Восстановить docs/ как зеркало для агентов (ревью МР #11)
ci / build-test (pull_request) Successful in 2m56s
ci / build-test (push) Successful in 2m52s

Ревью rust: перенос в вики не должен удалять из репозитория то, что нужно
агенту для работы (бэклог, статус, планы, код-стайл, спеки). Дублирование
вики и репы разрешено и обязательно: вики — актуальные версии для людей,
docs/ — зеркало для контекста агентов. README разведён по ролям.
This commit is contained in:
Rustam Khalimov
2026-09-12 23:54:28 +03:00
parent 53f8f8214f
commit 195faf1b1f
36 changed files with 10373 additions and 1 deletions
+4 -1
View File
@@ -2,7 +2,10 @@
SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов.
## Документация (актуальное) — в вики проекта
## Документация
Актуальные версии для людей — в [вики проекта](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Home);
в `docs/` — зеркала для работы агентов (дублирование разрешено и нужно).
- **ТЗ**: [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/Инструкция-пользователя)
+463
View File
@@ -0,0 +1,463 @@
# Дейл (Deal) — карта API (Python/FastAPI → .NET)
> Этап 9 «единая карточка»: карточки и колонки/стадии сведены в два домена — `/api/cards` и
> `/api/containers`; ручки `/api/leads`, `/api/projects`, `/api/boards`, `/api/columns` удалены, SSE
> `new_lead` переименован в `new_card`. Точный контракт — `docs/architecture/2026-09-10-unified-api-contract.md`.
Источники: `src/frontend/src/{api,store,data,utils}.js`, `src/frontend/src/store/*.js`, `src/frontend/src/views|components/*.vue`, `backend/app/main.py`, `backend/app/routers/*.py`, `backend/app/{auth,sse,constants}.py`, сервисы (`pipeline`, `processing`, `discovery`, `telegram`, `ml_client`, `rates`, `files`, `rules`, `suggest`). Фронт — высший авторитет по формам JSON; по карточкам/контейнерам источник истины — единый контракт этапа 9.
---
## 1. Общие правила
| Правило | Значение |
|---|---|
| Base path | Все API-роуты под префиксом `/api`; домены карточек/колонок — `/api/cards` и `/api/containers` (плюс `/api/auth/...`, `/api/tg/...` и т.д.) |
| Контент-типы | Запросы/ответы JSON (`application/json`), сериализация camelCase. Исключения: `POST /api/cards/{id}/files``multipart/form-data`, поле **`files`** (несколько файлов); `GET /api/tg/qr-image``image/svg+xml`; `GET /api/cards/{id}/files/{fileId}/download``application/octet-stream` (attachment); `GET /api/events``text/event-stream` |
| Сессия | httpOnly-кука **`deal_session`** (в прототипе — `leadradar_session`); `HttpOnly`, `SameSite=Lax`, `max-age` 30 дней, `secure` — по конфигурации (`Cookies__Secure`, в проде true). Запросы идут с `credentials: 'include'`. Токен сессии — случайный, хранится в БД. При logout кука удаляется; смена пароля инвалидирует старые сессии |
| Авторизация | Все роуты, кроме `POST /api/auth/login`, требуют валидной куки (`current_login`). Иначе **401** `{"detail": "Требуется авторизация"}`. Фронт на 401 разлогинивается (`setUnauthorizedHandler`) |
| Ошибки | Всегда **`{"detail": "<текст>"}`** (без `error`/`message`-обёртки). Коды: `400` (неверное тело/правила), `401` (нет сессии), `403` (тенант приостановлен), `404` (не найдено), `410` (файл не сохранён). Фронт парсит `data.detail \|\| data.message`. ⚠ «мягкие» ошибки отдаются HTTP 200 с полями (`generate-keywords``{keywords:[], error}`; `suggest-*``{ok:false, reason}`; `reset` ML → `{ok:false, error}`) |
| Оборачивание списков | `{"items": [...]}` — везде; исключение — `GET /api/containers/state` (объект `<containerId> → state`). Пагинация отсева: `{items, total, offset, limit}` |
| Успех-без-данных | `{"ok": true}` (+ опциональные поля) |
| Времена | epoch **миллисекунды** (int) в `receivedAt`, `createdAt`, `updatedAt`, `at`, `msgAt`, `queuedAt`, `rejectedAt`, `returnedAt`, `time` в превью-сообщениях; поле `card.time`**строка** «только что»/«5 мин»/«3 ч»/«2 дн» |
| Статические сегменты против `{id}` | Литералы объявляются до `{id}`: `/cards/counts`, `/cards/clear-col`, `/cards/clear-rejected`, `/cards/reclassify`, `/cards/mark-all-seen`, `/cards/mark-col-seen`, `/cards/take` — до `/cards/{cardId}`; `/containers/state`, `/containers/reorder` — до `/containers/{containerId}`; `/rejected/clear` — до `/rejected/{rejId}`. ASP.NET Core отдаёт приоритет литералам, порядок сохранён для читаемости |
| Prefix'ы id | `c_` — карточка (единый для всех дашбордов), `b_` — контейнер-колонка, `p_` — строка очереди, `r_` — запись отсева, `cm_` — комментарий, `h_` — запись истории, `pf_` — файл, `pl_*` — ссылка, `dt_` — discovery-задача, `dl_` — запись лога, `m_<dialog>_<msg>` — сообщение. Контейнеры-стадии/служебные зоны — без префикса (`planned``rejected`, `inbox`/`archive`/`trash`) |
| Служебные админ | `admin/tick`, `admin/fts/rebuild`, `admin/check-message` — служебные, фронтом не вызываются |
| Проверка фильтра/тестера | `POST /api/admin/check-message` — сухой прогон текста по всему конвейеру (стоп-правила → ML → ИИ) без создания карточки |
| Фоновые циклы (не API) | storage-тик (30 с): автоархив/очистка + напоминания; pipeline-воркер (2 с); discovery-воркер (5 с); ML outbox (10 с); suggest (180 с); tg sweep (30 с); rates (30 мин) |
---
## 2. SSE `GET /api/events`
Поток `text/event-stream`, заголовки `Cache-Control: no-cache`, `X-Accel-Buffering: no`; каждые 15 с без событий — комментарий-пинг `: ping`. Формат события: `event: <name>\ndata: <json>\n\n` (все данные — JSON).
**Что публикует бэкенд Дейла (4 именованных типа; прототип публиковал 7 — `pipeline_stats`/`boards_changed`/`leads_reclassified` в Дейле не реализованы):**
| event | Payload | Кто шлёт / когда |
|---|---|---|
| `new_card` | **полный объект карточки** (см. §4.1 — тот же объект, что элемент `GET /api/cards`) | pipeline-воркер при создании карточки (ML/ИИ-путь) |
| `toast` | `{"text": str, "icon": str}` — icon: `check`/`sparkles`/`clock`/`trash`/`x`/`send`/`logout`/`bell`/`refresh`/`restore` | автоархив/очистки, подключение/отключение Telegram, ИИ-предложения колонок, срабатывание ИИ-бюджета |
| `reminder_due` | `{"id": "<c_…>", "title": str, "containerId": "hold"}` | фоновый цикл правил хранения (30 с) — наступившие напоминания стадии `hold` при включённых напоминаниях |
| `system_status` | полный объект `tg.status()` (см. §4.9) | telegram-service при изменении подключения |
`new_lead` больше не публикуется (переименован в `new_card`). Фронтовый `openEvents()` (`api.js`) слушает `new_card`, `toast`, `reminder_due`, `system_status`.
---
## 3. Таблицы эндпоинтов
Сокращения: «→ карточка» = полный объект карточки §4.1; «→ контейнер» = §4.2; «→ settings» = §4.6; «→ задача/кандидат» = §4.8. `(фронт не вызывает)` — эндпоинт есть, UI его не дёргает; `(не используется фронтом)` — поле в ответе есть, UI не читает.
Счётчики в заголовках разделов — фактические строки таблиц (без строки-шапки); при добавлении/удалении ручки — обновлять.
### 3.1 Auth (auth_routes.py) — 4 эндпоинта
| METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---|
| `POST /auth/login` | Вход; ставит куку | `{login, password}` | `{ok: true, login: "<login>"}`. 401 `{"detail":"Неверный логин или пароль"}` |
| `POST /auth/logout` | Удалить сессию и куку | — | `{ok: true}` |
| `GET /auth/me` | Проверка живой сессии | — | `{login, ok: true}` |
| `POST /auth/change-password` | Смена пароля; перевыпуск куки | `{oldPassword, newPassword}` (min 8) | `{ok: true}`; 400 «Текущий пароль неверен»/«Пароль слишком короткий (минимум 8 символов)» |
### 3.2 Карточки, контейнеры, поиск, admin, ai (бывший Dashboard)
**Контейнеры (8; бывшие доски + колонки):**
| METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /containers?space=` | Список контейнеров пространства (`dashboard`/`selected`) — колонки/стадии/зоны со счётчиками | — | `{items: [→ контейнер]}` |
| `POST /containers` | Создать контейнер (колонку-фильтр) | `{name, description?, color?, space?, kind?, suggested?, note?, rules?}` | `{id: "<b_…>"}`; 400 «Укажите название колонки» |
| `PATCH /containers/{id}` | Правка (`name/description/color/collapsed/suggested/note/rules/policy`; null — «не менять») | `{…}` | `{id}`; 404 «Контейнер не найден» |
| `POST /containers/{id}/accept` | Принять ИИ-предложение (`suggested=false`) | — | → контейнер |
| `DELETE /containers/{id}` | Удалить; карточки → inbox новыми | — | `{ok: true, movedToInbox: <int>}` |
| `POST /containers/reorder` | Порядок контейнеров пространства | `{space, order: ["<b_…>", …]}` | `{ok: true}`; 400 «Не указан порядок колонок» |
| `GET /containers/state` | Состояние колонок (свёрнутость/ширина, `colState`) | — | `{ "<id>": {"collapsed": bool, "width": "sm"\|"md"\|"lg"} }` |
| `PATCH /containers/{id}/state` | Сменить состояние колонки | `{collapsed?, width?}` | состояние **только этой** колонки |
**Карточки (13; бывшие лиды + базовые операции Projects):**
| METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /cards?containerId=` | Карточки (`containerId`/алиас `col`; без параметра — весь дашборд), свежие сверху | — | `{items: [→ карточка]}`; 400 «Неизвестный контейнер» |
| `GET /cards/counts` | Плоские счётчики + счётчики обучения | — | см. §4.1 «counts» |
| `GET /cards/{cardId}` | Одна карточка | — | → карточка; 404 «Карточка не найдена» |
| `POST /cards/mark-all-seen` | Снять «новое» со всех | — | `{ok: true}` |
| `POST /cards/mark-col-seen` | Снять «новое» с контейнера | `{col}` | `{ok: true}` |
| `POST /cards/{cardId}/move` | Перенос карточки в контейнер; учит ML | `{to: "<containerId>"}` | → карточка; 400 «Переносить можно только…» |
| `POST /cards/{cardId}/trash` | В корзину; учит ML `spam` | — | `{ok: true}`; 404 |
| `POST /cards/{cardId}/restore` | Возврат из архива/корзины | — | `{ok: true, col: "<inbox\|b_…>"}` |
| `DELETE /cards/{cardId}` | Удалить навсегда | — | `{ok: true}` |
| `POST /cards/clear-col` | Очистить корзину/архив целиком | `{col: "trash"\|"archive"}` | `{ok: true, cleared: <int>}`; 400 |
| `POST /cards/{cardId}/comments` | Добавить комментарий | `{text}` | `{comments: [{id, by:"Вы", text, time:"только что"}]}`; 400 «Пустой комментарий» |
| `POST /cards/reclassify` | Переклассификация «Неразобранного» (реальный прогон; single-flight) | `{ids?: ["c_…"]}` (тело опционально; без `ids` — все `inbox`) | `{started, busy, attempted, reclassified, moved, kept, trashed, skipped, usedAi, reason}`; при занятом проходе `{started:false, busy:true}` |
| `POST /cards/{cardId}/reclassify` | Переклассификация одной карточки | — | тот же объект ответа; 404 «Карточка не найдена» |
**Поиск (1):**
| METHOD /api/… | Назначение | Request | Response |
|---|---|---|---|
| `GET /search?q=` | Полнотекстовый+LIKE поиск, `limit=12` | query `q` (min 2 симв.) | `{cards: [→ карточка], messages: []}` — в Дейле `messages` всегда пуст |
**Admin (3; в Дейле реализованы `tick`/`fts/rebuild`/`check-message`, остальные строки — только прототип, §6):**
| METHOD /api/… | Назначение | Response |
|---|---|---|
| `POST /admin/tick` | Ручной тик: хранение+напоминания+разбор очереди (фронт зовёт раз в 60 с) | `{storage: {archived, purgedArchive, purgedTrash, purgedRejected}, reminders: [{id, title, containerId}] (уже «выстрелившие», после SSE), pipeline: <dict pump_once>, queue: int}` |
| `POST /admin/fts/rebuild` | Пересобрать FTS-индекс | `{ok: bool, ready: bool}` |
| `POST /admin/check-message` | Сухой прогон текста по конвейеру (стоп-правила → ML → ИИ) | см. §4.10 |
| `POST /admin/wipe` | Полный сброс (карточки+ML+счётчики) | `{ok, cardsRemoved, ml: {ok}}` *(прототип)* |
| `POST /admin/clear-cards` | Очистить карточки/очереди без сброса ML | `{ok, cardsRemoved}` *(прототип)* |
| `POST /admin/pump-gate` | Шлагбаум воркера `{limit?}` | `{ok, limit, done}` *(прототип)* |
**AI-действия (2):**
| METHOD /api/… | Назначение | Response |
|---|---|---|
| `POST /ai/suggest-columns` | ИИ предлагает колонки по inbox (ручной запуск) | `{ok: true, created: int}` или `{ok: false, reason: str, cooldown?}`; при успехе шлёт `toast` |
| `POST /ai/suggest-keywords` | ИИ предлагает общие ключи сферы | `{ok: true, keywords: [str]}` (≤60 шт., длина ≤40) или `{ok: false, reason}` |
### 3.3 Telegram (tg_routes.py) — 14
| METHOD /api/tg/… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /status` | Статус аккаунта/фазы входа | — | §4.9 (status) |
| `POST /start-phone` | Вход по телефону | `{phone}` | `{phase: "code"}`; 400 с текстом причины |
| `POST /start-qr` | Начать QR-вход | — | `{phase: "qr", qrUrl: "https://t.me/…"}` |
| `POST /send-code` | Отправить SMS-код | `{code}` | `{phase: "password"\|"done"}`; 400 |
| `POST /send-password` | 2FA-пароль | `{password}` | `{phase: "done"}`; 400 |
| `POST /logout` | Отключить аккаунт, удалить сессию | — | `{ok: true}` (+toast/`system_status` по SSE) |
| `GET /qr-image` | SVG QR-кода (фаза qr) | — | `image/svg+xml`; 404 «QR не активен…». Фронт: `<img src="/api/tg/qr-image?t=N">` |
| `GET /dialogs` | Список диалогов из БД | — | `{items: [§4.11 диалог]}` |
| `POST /dialogs/refresh` | Синхронизировать диалоги из Telegram | — | `{ok: true, count: int}` или `{ok: false, reason: "not-connected", count: 0}` |
| `POST /dialogs/monitor-all` | Мониторинг всех каналов (первое включение → backfill в фоне) | `{enabled: bool}` | `{ok: true, count: int, enabled: bool}` |
| `POST /dialogs/backfill-all` | Перечитать последние ~10 сообщений включённых каналов (фон) | — | `{ok: true, count: int}` |
| `POST /dialogs/{dialog_id}/monitor` | Вкл/выкл мониторинг канала | `{enabled: bool}` | `{ok: true, enabled: bool}` |
| `POST /dialogs/{dialog_id}/backfill` | Догнать сообщения одного диалога | — | `{ok: true, processed: int}` *(фронт не вызывает — только сервер)* |
| `POST /dialogs/preview` | Последние сообщения диалога (свежие из TG, старые из БД) | `{dialogId, limit?=24 (clamp 1..50)}` | `{items: [§4.11 сообщение]}` |
### 3.4 Settings / rates / meta (settings_routes.py) — 6
| METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---|
| `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; `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}` |
| `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)* |
### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15
Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет.
| METHOD /api/cards… | Назначение | Request body | Response |
|---|---|---|---|
| `POST ""` | Создать локальную карточку | `{title="", summary="", containerId?="planned", stack?, budget?, contact="", tzText=""}` (алиас `stage`) | → карточка |
| `POST /take` | «Взять в работу»: карточка (**не клон**) → контейнер `planned` пространства `selected` | `{cardId}` (алиас `leadId`) | → карточка; 404 «Карточка не найдена» |
| `POST /clear-rejected` | Очистить стадию «Отклонено» | — | `{ok: true, cleared: int}` |
| `PATCH /{cardId}` | Правка полей (null — «не менять») | `{title?, summary?, stack?, budget?{from,to,cur}, contact?, tzText?}` | → карточка |
| `POST /{cardId}/move` | Перенос по контейнерам/стадиям (+история; сброс reminder при уходе с hold) | `{to}` | → карточка; 400 «Переносить можно только…» |
| `POST /{cardId}/comments` | Комментарий | `{text}` | `{comments: [...]}`; 400 «Пустой комментарий» |
| `POST /{cardId}/links` | Добавить ссылку (`url` без схемы → префикс https://) | `{name="", url}` | → карточка; 400 «Пустая ссылка» |
| `DELETE /{cardId}/links/{linkId}` | Удалить ссылку | — | → карточка |
| `POST /{cardId}/files` | Загрузить файлы (multipart, поле `files`) | FormData `files` | → карточка (с обновлённым `files`) |
| `GET /{cardId}/files/{fileId}/download` | Скачать (stream из MinIO/локального store) | — | `application/octet-stream`, `Content-Disposition: attachment`; 410/404 |
| `DELETE /{cardId}/files/{fileId}` | Открепить файл | — | → карточка |
| `GET /{cardId}/source` | Содержимое источника: провайдер по `source.kind` либо сохранённое в карточке | — | `SourceContent`; 404 «Карточка не найдена» |
| `POST /{cardId}/reminder` | Напоминание карточке | `{at: <epoch ms>}` | → карточка; 400 «Поле at (epoch-ms) обязательно» |
| `DELETE /{cardId}/reminder` | Снять напоминание | — | → карточка |
| `POST /{cardId}/reminder/snooze` | Отложить на +24 ч | — | → карточка |
### 3.6 Processing — очередь и отсев (processing_routes.py) — 6
| METHOD /api/pipeline… | Назначение | Request | Response |
|---|---|---|---|
| `GET /stats` | Сводка для синхронизации | — | `{queue: {new, ai, total}, rejected: int}` |
| `GET /queue?limit=` | Сырые сообщения очереди (`limit` ≤500, дефолт 100; фронт шлёт 120) | query `limit` | `{items: [§4.5 очередь], counts: {new, ai, total}, rejected: int}` |
| `GET /rejected?q=&offset=&limit=` | Отсев (поиск по q, страницы; лимит ≤500) | query | `{items: [§4.5 отсев], total: int, offset: int, limit: int}` |
| `POST /rejected/clear` | Очистить отсев | — | `{ok: true, cleared: int}` |
| `DELETE /rejected/{rej_id}` | Удалить запись отсева | — | `{ok: true}` |
| `POST /rejected/{rej_id}/return` | Вернуть в обработку (`{reason}` помечается на записи; снимает у ML вес спама; повтор/dup → 400) | `{reason=""}` | `{id, returned: true, returnedAt: ms}`; 404/400 |
### 3.7 ML (ml_routes.py) — 7
| METHOD /api/ml… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /status` | Статус ML-сервиса (форс-refresh) + локальная статистика | — | §4.10 (ml status) |
| `POST /reset` | Сброс модели + очистка outbox | — | `{ok: true}` или `{ok: false, error: str}` (⚠ ошибка — HTTP 200) |
| `POST /predict` | Проверка ML на тексте | `{text}` | `{text: <первые 200>, take: bool, label: str\|null, scores: {class: num}, hits, ready, margin, terms, type}`; 400 «Введите текст» |
| `POST /learn` | Ручная разметка в outbox | `{text, label}` | `{ok: true, outbox: int}` *(фронт не вызывает — использует apply)* |
| `POST /flush` | Немедленная отправка обучения | — | `{ok, flushed, outbox, service}` *(фронт не вызывает)* |
| `POST /candidates` | Последние сообщения канала + мнение ML | `{dialogId, limit?=10 (clamp 1..60)}` | `{items: [{id, dialogId, text(≤600), time, lead, pred: {take, label, scores}}]}` |
| `POST /apply` | Ручное решение: `action` = `spam` \| `board:<id>` \| `skip` | `{dialogId, msgId, action}` | `{ok, learned: bool, moved: "trash"\|"<board>"\|null, leadId: str\|null}`; `skip``{ok, learned: false, moved: null}`; 400/404 |
### 3.8 Discovery (discovery_routes.py) — 13
| METHOD /api/discovery… | Назначение | Request body | Response |
|---|---|---|---|
| `GET /tasks` | Список задач (старые первыми) | — | `{items: [→ задача]}` |
| `POST /tasks` | Создать (бюджет plan_joins ≤ discJoinLimit) | `{name, description?, keywords?[], minSubscribers?, lang? "ru"\|"any", threshold?, sampleSize?, planJoins?, autoJoin?}` | → задача; 400 (нет имени / бюджет) |
| `PATCH /tasks/{task_id}` | Обновить задачу | те же поля, все optional | → задача; 404/400 |
| `DELETE /tasks/{task_id}` | Удалить (с кандидатами и логом) | — | `{ok: true}` |
| `POST /tasks/{task_id}/start` | Запуск поиска (draft/paused/done/failed → running) | — | → задача; 400 «Нет ключевых слов…» |
| `POST /tasks/{task_id}/pause` | Пауза | — | → задача |
| `POST /tasks/{task_id}/generate-keywords` | ИИ-генерация ключей по description | — | `{keywords: [str≤30×60]}`, ошибка — `{keywords: [], error: str}` (HTTP 200, ⚠) |
| `GET /tasks/{task_id}/candidates?status=` | Кандидаты задачи, фильтр `new\|review\|joined\|rejected` | query `status` | `{items: [→ кандидат]}`; 404 |
| `POST /candidates/{dialog_id}/join` | Ручное вступление (+в мониторинг, +backfill, −чёрный список) | — | → кандидат; 400/404 |
| `POST /candidates/{dialog_id}/reject` | Отклонить → чёрный список | — | → кандидат; 400 (уже вступили)/404 |
| `GET /blacklist` | Чёрный список | — | `{items: [{dialogId, name, reason, createdAt}]}` |
| `DELETE /blacklist/{dialog_id}` | Убрать из чёрного списка | — | `{ok: true}` |
| `GET /tasks/{task_id}/log` | Лог задачи | — | `{items: [{id, taskId, event, text, createdAt}]}`, event ∈ `search\|skip\|review\|join_auto\|join_manual\|reject\|done\|flood\|error` |
### 3.9 Прочее (main.py / events_routes.py)
| METHOD /api/… | Назначение | Response |
|---|---|---|
| `GET /events` | SSE-поток (см. §2), авторизация обязательна | `text/event-stream` |
| `GET /health` | Healthcheck | `{ok: true, service: "deal"}` *(фронт не вызывает)* |
---
## 4. Сущности: поля JSON, которые реально читает фронт
### 4.1 Карточка (card) — `GET /api/cards`, `GET /api/cards/{cardId}`, ответы всех мутаций и payload SSE `new_card`
Единая сущность всех дашбордов (этап 9). Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание)
присутствуют всегда, но могут быть пустыми. Точный контракт — `docs/architecture/2026-09-10-unified-api-contract.md`.
```jsonc
{
"id": "c_1a2b3c4d5e6f", // string, префикс c_ — единый
"containerId": "inbox", // контейнер карточки
"col": "inbox", // алиас containerId (совместимость)
"isNew": true, // «новое» (точка на карточке)
"local": false, // создана локально, без внешнего источника
"title": "Разработка интернет-магазина", // string ≤140
"summary": "Компания: …\nЗадача: …",// блок «О заявке»
"source": { "kind": "telegram", "externalId": "4242", "displayName": "Канал заказов", "originRef": "123456789", "author": "…", "receivedAt": "2026-09-11T10:00:00+00:00", "extra": {"hue": "#8b8ff8"} },
"content": { "text": "Ищу разработчика…", "html": null, "author": "…", "subject": null, "data": [], "links": [], "contacts": [] },
"stack": ["vue", "dotnet"],
"budget": {"from": 100000, "to": 200000, "cur": "RUB"},
"converted": {"from": 100000, "to": 200000, "cur": "RUB"},
"contact": "@client",
"contacts": [{"type": "tg", "value": "@client"}],
"matchHits": [{"label": "Стек", "term": "vue", "word": null}],
"comments": [{"id": "cm_…", "by": "Вы", "text": "Позвонил", "time": "5 мин"}],
"links": [{"id": "pl_…", "name": "Бриф", "url": "https://example.com"}],
"files": [{"id": "pf_…", "name": "brief.pdf", "size": 10240, "kind": "document", "label": "Документ", "objectKey": "projects/c_…/pf_…_1726000000000_brief.pdf"}],
"history": [{"id": "h_…", "at": 1726000000000, "type": "created"}, {"id": "h_…", "at": 1726003600000, "stage": "planned"}],
"tzText": "Сделать каталог и корзину",
"reminder": {"at": 1727000000000},
"prevCol": "inbox", // предыдущий контейнер (возврат из archive/trash)
"isVacancy": false,
"isVacancyKnown": false,
"time": "5 мин", // human-метка от receivedAt
"receivedAt": 1726000000000, "createdAt": 1726000000000, "updatedAt": 1726000000000
}
```
Ключевые поля: `id/containerId/(col)` — принадлежность; `source` (`SourceRef`: вид, внешний id, подпись,
ссылка на оригинал, цвет в `extra.hue`) и `content` (`SourceContent`: текст, разметка, ссылки, контакты,
вложения `data`) — происхождение; `stack/budget/converted/contact/contacts/matchHits` — данные заявки; `comments/
links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/isVacancyKnown` — маркеры. Ровно
один из полей `history[].type`/`history[].stage` задан.
Строки очереди и отсева «Обработки» отдают тот же generic `source`/`content` (раньше — `ch`); решение
отсева — `decidedBy`/`decidedByLabel` (stop|ml|ai|stale|dup).
**counts** (`GET /api/cards/counts`): `{new: int, "<containerId>": {count: int, new: int}, learning: int, ml: int, ai: int}` — плоская форма (совместима с прежним `/api/leads/counts`).
### 4.2 Контейнер (container) — `GET /api/containers`, `POST/PATCH` тела
Единый реестр колонок/стадий/зон (этап 9): пользовательские колонки-фильтры (`kind: board`),
стадии «Выбранных» (`stage`), служебные зоны (`service`: inbox/archive/trash), терминальные (`terminal`).
```jsonc
{
"id": "b_1a2b3c4d5e6f", // b_... | planned…rejected | inbox/archive/trash
"name": "WPF", "description": "Заказы по WPF", "color": "#818cf8",
"order": 0,
"space": "dashboard", // dashboard | selected
"kind": "board", // board | stage | service | terminal
"collapsed": false, // свёрнута на дашборде
"suggested": false, // ИИ-предложение ждёт решения
"note": "", // заметка/обоснование ИИ
"rules": { // правила попадания (null — фильтра нет)
"mode": "any", "direction": [], "keywords": ["wpf"], "stack": [], "grade": [], "exclude": [],
"budget": {"from": 0, "to": 0, "cur": "RUB"}
},
"policy": {"canRestore": true, "isTerminal": false, "retentionDays": null},
"counts": {"total": 4, "new": 1} // счётчики карточек контейнера
}
```
`counts`/`policy` — только в ответе `GET`; `rules` — набор опциональных фильтров колонки (как раньше у доски).
### 4.3 Модульные поля карточки (бывшая «проектная карточка»)
Отдельной сущности/таблицы больше нет: модули (`comments`, `links`, `files`, `history`, `tzText`,
`reminder`, `budget`) — поля той же карточки §4.1. Формы элементов:
```jsonc
{
"comments": [{"id":"cm_…","by":"Вы","text":"…","time":"только что"}],
"links": [{"id":"pl_…","name":"сайт","url":"https://…"}],
"files": [{"id":"pf_…","name":"tz.pdf","size":12345,"kind":"document","label":"Документ","objectKey":"…"}],
"history": [{"id":"h_…","at":1757000000000,"type":"created"}, // type: "created"|"createdLocal" ИЛИ
{"id":"h_…","at":,"stage":"work"}], // stage — при переносе
"tzText": "", // техническое задание
"reminder": {"at": 1757000000000}, // object|null
"createdAt": , "updatedAt": // int ms
}
```
Загрузка файлов: multipart — ответ — обновлённая **карточка** (фронт берёт `files` из ответа). Скачивание: `GET /api/cards/{cardId}/files/{fileId}/download`.
### 4.4 Контейнеры по умолчанию (стадии «Выбранных» и зоны)
Стадии «Выбранных» (`space: selected`, `kind: stage/terminal`): `planned` Запланировано / `reply` Отклик /
`agree` Согласование / `work` В работе / `review` Проверка / `ready` Готово / `hold` Отложено (не terminal) /
`finished` Выполнено (terminal) / `rejected` Отклонено (terminal). Служебные зоны дашборда:
`inbox`, `archive`, `trash` (`space: dashboard`, `kind: service`).
### 4.5 Очередь и отсев (вкладка «Обработка»)
Очередь (`GET /pipeline/queue` item): `{id, source: SourceRef, content: SourceContent, text, status: "new"|"filtered", msgAt: ms, queuedAt: ms}` — UI показывает `text`, статус-бейдж и подпись источника (`source.displayName`, цвет `source.extra.hue`).
Отсев (`GET /pipeline/rejected` item): `{id, source: SourceRef, content: SourceContent, text, stage, stageLabel, reason, kw, decidedBy, decidedByLabel, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`.
- `stage``length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup`; `stageLabel` — подпись («короткое сообщение», «стоп-фраза», «спам (ML)», …).
- `decidedBy``stop|ml|ai|stale|dup`; `decidedByLabel` ∈ «правила|ML|ИИ|система».
- Фронт читает: `id, stageLabel, kw, reason, decidedBy, decidedByLabel, text, source, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `decidedBy==='dup'` или `returned`.
### 4.6 Настройки (settings) — все ключи ответа `GET/PATCH /api/settings` (camelCase; значения по умолчанию из `constants.DEFAULT_SETTINGS`)
```jsonc
{
"autoArchive": true, "archiveAfterDays": 14, "archiveClearDays": 90, "trashClearDays": 7,
"minLen": 24, "stopPhrases": ["взаимный пиар", "…"],
"mlEnabled": true, "aiEnabled": true, "aiFilterEnabled": true,
"aiPrompt": "Ты — классификатор…{domain}…{keywords}…", "aiFilterPrompt": "…", "cardPrompt": "…",
"wantedType": "both", // "both"|"vacancy"|"freelance"
"budgetRequiredHire": false, "budgetRequiredOrder": false,
"hireLabel": "вакансия", "orderLabel": "фриланс",
"domainDescription": "", "domainKeywords": [], "hireMarkers": [], "levelTerms": [], "resumeMarkers": [],
"blockResumes": true, "myPrompts": [{"id":"pp_…","name":"…","description":"…","prompt":"…"}],
"remindersEnabled": true,
"conversionOn": true, "targetCurrency": "RUB", "rateSource": "cbr", // cbr|mock
"autoMonitorNew": true,
"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
"discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом)
"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 (по одному/группами): `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`/`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`).
### 4.7 Промпты
- `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`.
- `myPrompts` — личная библиотека: `[{id, name(≤80), description(≤300), prompt}]`, ≤100; id генерирует и фронт (`pp_…`), и бэк при отсутствии.
### 4.8 Каналы/discovery
**Диалог** (`GET /api/tg/dialogs` item): `{id: string, name, handle, type, hue, on: bool, last: {text, time}}` — фронт читает `id/name/handle/type/hue/on` (`last` не читает). ⚠ `type` нестабилен по значению: «канал»/«группа»/«чат» (из `refresh_dialogs`) либо `channel`/`group`/`forum` (после discovery-вступлений `add_dialog_monitored`).
**Сообщение превью** (`POST /dialogs/preview` item): `{id, text, time: ms, lead: bool}`. ⚠ `id`: из Telegram — int; фолбэк из БД — string `m_<dialog>_<msg>`.
**Discovery-задача** (`GET/POST/PATCH …/tasks`, ответы start/pause): `{id:"dt_…", name, description, keywords[], minSubscribers: int, lang: "ru"|"any", threshold: int(1..100), sampleSize: int, planJoins: int, autoJoin: bool, status: "draft"|"running"|"paused"|"done"|"failed", searchIdx: int, searchDone: bool, found: int, evaluated: int, joined: int, rejected: int, createdAt: ms, updatedAt: ms}`.
**Кандидат** (`GET …/candidates` item, ответы join/reject): `{dialogId, taskId, name, username, kind: "channel"|"group"|"forum", hue, participants: int|null, langRu: bool|null, marks: string[], topics: [{topicId, title, fitCount, total, fitRatio, passed}] (форумы), fitRatio: 0..1|null, status: "new"|"review"|"joined"|"rejected", autoJoined: bool, joinFailures: int, createdAt: ms, updatedAt: ms}`.
### 4.9 Telegram-статус (`GET /api/tg/status`, payload `system_status`)
`{phase: "idle"|"phone"|"code"|"password"|"qr"|"ready", connected: bool, listener: bool, account: string, monitored: int, keysSet: bool, error: string|null, qrUrl: string|null}`. Фронт: `connected→tgConnected`, `account`, `phase``tgState` (ready→done), `qrUrl` при phase='qr', `keysSet`.
### 4.10 Прочее
- **`GET /api/ml/status`**: `{enabled: bool, service: {ready, classes: {label: n}, learned: int, eval: {count, correct, accuracy}}, reachable: bool, stats: {ml, ai, learning, ready, classes, learned, reachable, outbox}}`. Фронт читает: `reachable`, `service.ready/classes/learned/eval.{count,correct,accuracy}`, `stats.outbox`.
- **`POST /api/admin/check-message`** (сухой прогон конвейера): `{text}`
`{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` — этап выключен
настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится.
- **`POST /api/ai/check`**: `{ok: bool, message: string, local?, keySet?}`.
- Комментарии карточки: `{id, by: string, text, time: string}``by` всегда «Вы», `time` «только что».
---
## 5. Сводка
**Карточки и контейнеры (единый контракт этапа 9):** `GET/POST /api/cards`, `GET/DELETE
/api/cards/{cardId}`, `/move`, `/trash`, `/restore`, `/comments`, `/links`, `/files`, `/reminder`,
`/take`, `/clear-col`, `/clear-rejected`, `/mark-all-seen`, `/mark-col-seen`, `/reclassify`,
`GET /api/search`; `GET/POST /api/containers`, `PATCH/DELETE /api/containers/{id}`, `/accept`, `/reorder`,
`/state` — описаны в §3.2 и §3.5.
**Прочие домены (этап 9 их не менял):**
| Модуль (роутер) | Эндпоинты |
|---|---:|
| Auth `/api/auth` | 4 |
| Telegram `/api/tg` | 14 |
| Settings/rates/meta (`/api/settings`, `/api/ai/check`, `/api/rates`) | 5 |
| Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 |
| ML `/api/ml` | 5 |
| Discovery `/api/discovery` | 13 |
| Operator `/api/operator` + `/api/join` | 25 |
| Events `/api/events` | 1 |
| Health `/api/health` | 1 |
**SSE-события:** `new_card`, `toast`, `reminder_due`, `system_status` — 4 именованных типа (см. §2).
**Коды ошибок:** всегда `{"detail": "<текст>"}``400` (неверный ввод/правила), `401` (нет сессии),
`403` (вход приостановленного тенанта), `404` (объект не найден), `410` (файл не сохранён), `422` (тело
не разобрано). Исключения — «мягкие» ошибки в HTTP 200 с полями `error`/`reason` (см. п.1 ниже).
**Замечания (актуальные):**
1. Ответы PATCH `/api/settings`, `POST /api/ml/reset` и discovery `generate-keywords` «ошибочные» ветки: мягкие ошибки в HTTP 200 с полями `error`/`reason` вместо `{"detail"}` (см. §6 п.7).
2. Тип диалога (`tg/dialogs.type`/`kind`) хранится вперемешку («канал»/«группа»/«чат» после refresh против `channel`/`group`/`forum` после discovery-вступления) — UI показывает как есть.
3. Превью-сообщения: `id` — int (из Telegram) либо string `m_<dialog>_<msg>` (фолбэк из БД) — ключи рендера неустойчивы.
4. Контейнер: `POST`/`PATCH` отвечают `{id}` (не полный объект); после мутаций фронт перечитывает `GET /api/containers`.
---
## 6. Реализовано в Deal — расхождения с картой и SaaS-дополнения (этапы 7, 10)
Карта выше — контракт фронта Дейла (после этапа 9 — единый: карточки/контейнеры). Расхождения,
влияющие на HTTP-семантику, и SaaS-ручки вне карты — ниже (контракт фронта они НЕ ломают).
**Расхождения/решения этапа 7 (зафиксированы в коде; task-7-report.md):**
1. Вход приостановленного тенанта — **HTTP 403** `{detail: "Учётная запись приостановлена. Обратитесь к оператору"}`, а не 401: учётка существует, доступ запрещён; 401 остаётся только для неверных учётных данных (статус не раскрывается). В аудит пишется `tenant_login_failed` с tenantId.
2. Смена статуса тенанта — **не PATCH {status}**, а явные `POST /api/operator/tenants/{id}/suspend` и `POST …/unsuspend` (аудит `tenant_status_changed`, идемпотентно). Отклонение приёмочного текста плана «PATCH … status» — осознанное.
3. `POST /api/operator/tenants` (create) принимает `{name, email?}` **без `budget?`**: бюджет задаётся отдельно (`GET/PATCH …/tenants/{id}/limit`); у нового тенанта — ленивый дефолт-бюджет (константа `TokenBudgetDefaults`/env `DEAL_DEFAULT_AI_BUDGET`). Поле-заглушка «принять и не применить» не вводилась.
4. «Отсутствующие» эндпоинты карты не реализованы сознательно (экономия; список — §5): `/cards/{cardId}/seen` (снятие «новое» с одной карточки), `/meta/constants`, `admin/wipe|clear-cards|pump-gate`, `ml/learn|flush` (внутренние RPC/флашер MlOutbox), `/tg/dialogs/{id}/backfill` (сервер-only: backfill включается мониторингом/«Перечитать всё»). Демо-ручки `POST /api/demo/*` (флаг `DEAL_DEMO`) удалены. `POST /api/cards/reclassify`**реальный проход** (этап 12): переклассификация «Неразобранного» через тот же конвейер, что и пайплайн (ИИ-фильтр → классификация → правила колонок) с локальным фолбэком при выключенном/недоступном ИИ; single-flight (`{started:false, busy:true}` при занятом проходе), есть и одиночная ручка `POST /api/cards/{cardId}/reclassify`.
**Устойчивость и очистки (этап 12, пакет B).** Rate limiting и `LoginAttemptGuard` — store-backed на Postgres (таблица `public.rate_limit_counters`), т.е. работают при нескольких инстансах core; активные сессии приостановленного тенанта разлогиниваются сразу (проверка статуса в `AuthService.ResolveSessionAsync`, включая impersonation). Фоновый `DataRetentionScheduler` (раз в сутки) чистит `audit_log` по retention (дефолт 180 дней), сбрасывает накопительные поля `tenant_limits` прошедших периодов и удаляет завершившиеся окна счётчиков.
**SaaS-ручки (этапы 7, 10).** С этапа 10 у операторских ручек есть **UI**: экран оператор-консоли `#/operator` (разделы «Тенанты», «Приглашения», «Лимиты ИИ», «Аудит», «Аналитика», «Состояние системы») и публичная страница активации инвайта `#/join?code=…`; основное приложение — `#/`. Операторская кука — `deal_operator_session` (12 ч, httpOnly, SameSite=Lax; отдельная от `deal_session`); `/api/join` — публичная (без куки). 401 на всех `/operator/*` без операторской сессии — «Требуется вход оператора». Тенантные `/api`-ручки операторских сессий не видят и наоборот (разные middleware). Подробнее — техдок §13.8 (контур) и §13.10 (консоль/аналитика).
| METHOD /api/… | Назначение | Ответ |
|---|---|---|
| `POST /operator/auth/login` `{login,password}` | вход оператора (env `DEAL_OPERATOR_*`; dev-дефолт `operator`/`operator`) | `{ok, login}` + кука; 401; 429 (rate limit) |
| `POST /operator/auth/logout`; `GET /operator/auth/me` | выход / проверка сессии | `{ok}`; `{login, ok}`; 401 |
| `POST /join` `{code, email, name?, password}` | публичная активация инвайта (страница `#/join?code=…`): пользователь (Argon2id) и, при необходимости, тенант с провижинингом | `{ok: true, login}`; 400 `{detail}` |
| `GET /operator/tenants` | список тенантов + счётчики пользователей | `{items:[{id,name,status,createdAt,usersCount}]}` |
| `POST /operator/tenants` `{name, email?}` | создать тенанта (email → владелец с одноразовым паролем) | `{id,name,status,createdAt}` (+`ownerEmail`,`initialPassword`); 400/401 |
| `GET /operator/tenants/{id}` | детали + пользователи | тенант; 404 |
| `POST /operator/tenants/{id}/suspend`; `…/unsuspend` | приостановка/возобновление (см. п.2) | `{ok, status}`; 404 |
| `POST /operator/tenants/{id}/impersonate` `{login?}` | вход от имени пользователя тенанта; **ставит httpOnly-куку `deal_session` ответом** — оператор сразу в тенанте | `{sessionToken, expiresAt, tenantId, login}`; 404/400 |
| `GET /operator/invites`; `POST /operator/invites` `{email, tenantId?, name?}` | список / создание инвайта (код 16 симв., 72 ч) | `{items:[…]}`; `{code,email,tenantId,expiresAt,status}` |
| `POST /operator/invites/{code}/revoke` | отзыв инвайта | `{ok:true}` |
| `GET /operator/limits` | сводка ИИ-бюджетов по тенантам | `{items:[{tenantId,name,budget,period,used,percent,status}]}` |
| `GET/PATCH /operator/tenants/{id}/limit` | детали/смена бюджета `{budget?, period?}` (сброс флагов порогов, аудит) | лимит; 400/404 |
| `GET /operator/audit?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` | лента аудита (append-only, At DESC, limit ≤500, пагинация) | `{items, total}` |
| `GET /operator/analytics/overview?from=&to=` | сводка за период: тенанты, токены, события, входы/выходы/неудачные входы | `{tenantsTotal,tenantsActive,promptTokens,completionTokens,totalTokens,tokenEvents,events,logins,logouts,failedLogins,from,to}` |
| `GET /operator/analytics/tokens?groupBy=&tenantId=&from=&to=` | агрегаты расхода токенов (`groupBy=day\|tenant\|provider\|model`) | `{groupBy,from,to,items:[{key,…}],total}`; 400 (неизвестная группировка) |
| `GET /operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` | лента действий (аудит) с фильтрами и пагинацией | `{items,total,limit,offset}` |
| `GET /operator/health` | health core/БД + сервисы ml/ai/telegram (UseLocal → `mode:local`); этап 12: глубины очередей и активные сессии | `{ok, core:{db}, services:[…], queues:{pipeline,mlOutbox}, sessions:{active}}` (всегда 200) |
| `POST /operator/maintenance/tenants/migrate` | Пакетная миграция схем всех тенантов (идемпотентно, шардированный обход страницами + ограниченный параллелизм; этап 12, пакет C / BL-SCALE-1000) | `{ok,total,migrated,failed,failedSchemas,durationMs}` (`ok=false`, если хотя бы одна схема не мигрирована); 401 без операторской сессии |
| `GET /operator/analytics/suspicious?from=&to=` | подозрительная активность по аудиту (всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; этап 12) | `{scanned,truncated,items:[…]}` |
| `GET /operator/settings/telegram-keys` | глобальные ключи Telegram (задаёт оператор; тенант их не видит) | `{apiId, apiHash (маска), keysSet}` |
| `PUT /operator/settings/telegram-keys` `{apiId?, apiHash?}` | задать/обновить ключи (частично: можно одно поле, второе сохраняется); `api_id` 59 цифр, `api_hash` непустой; hash шифруется | маска-форма; 400 `{detail}`; 401 |
@@ -0,0 +1,272 @@
# Дейл (Deal) — архитектурный дизайн-док
> Исторический документ (архитектурный дизайн-черновик, 2026-09-05). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> Версия: 0.1 (черновик для согласования)
> Дата: 2026-09-05
> Статус: фиксирует согласованные решения по переписыванию LeadRadar в новый продукт «Дейл»
---
## 1. Контекст и цели
**LeadRadar** — рабочий прототип (Python/FastAPI/DuckDB/Vue), проверенный на тестовых данных.
**«Дейл»** — новая реализация: SaaS-продукт, который мониторит Telegram-каналы и группы клиентов,
отсеивает рекламу/скам/дубликаты и показывает **реальные заказы и клиентов**, совпадающих с
профилем пользователя (сфера, стек, бюджет). Клиенты подключают свои Telegram-аккаунты.
### Цели переписывания
1. Код, который владелец продукта может поддерживать сам (типизированный .NET вместо Python).
2. Стабильность и строгость типов, интерфейсов, слоёв — «как сеньор-архитектор».
3. Мультитенантный SaaS (схема на тенанта) — фундамент для роста до сотен/тысяч клиентов.
4. Безопасность «с первого дня» (публичный продукт).
5. Полная документация: ТЗ, инструкция пользователя, техдок — параллельно с кодом.
### Не-цели (сейчас)
- Переписывание фронтенда (Vue остаётся как есть).
- Kafka/кубер (отложены до реального масштаба; архитектура готова к ним).
- Биллинг-провайдер (лимиты в ядре, биллинг — позже).
- Саморегистрация тенантов (только инвайты).
---
## 2. Решения верхнего уровня (зафиксированы)
| # | Решение | Выбор |
|---|---|---|
| 1 | Стратегия | Big Bang: пишем новый бэкенд целиком; старые данные не мигрируем (тестовые) |
| 2 | Фронтенд | Vue не трогаем; HTTP-контракт `/api/...` — замороженная спецификация миграции |
| 3 | Архитектура | Модульный монолит в `core` (один процесс, одно sln); сервисы — отдельные процессы/sln |
| 4 | Стек | .NET (актуальная LTS), C# современный, Postgres |
| 5 | Мультитенантность | Одна Postgres-БД, **схема на тенанта** (`tenant_<id>.*`), системное в `public` |
| 6 | Владение данными | Каждый модуль владеет своими таблицами; межмодульно — интерфейсы/доменные события |
| 7 | Межпроцессно | gRPC + mTLS; шина событий за портом `IEventBus` (outbox → Kafka позже) |
| 8 | Telegram | Отдельный `telegram-service`: ферма сессий, 1 аккаунт/тенант, анти-бан; исполняет команды ядра, ничего не знает о бизнес-логике |
| 9 | ML | Отдельный `ml-service`: .NET + ONNX, пул моделей per-tenant, обучение на действиях |
| 10 | AI (LLM) | Отдельный `ai-service`: фасад провайдеров, промпты, учёт токенов |
| 11 | Клиенты SaaS | Подключают свои Telegram-аккаунты и настраивают обработку под свою сферу |
| 12 | Доступ тенантов | Инвайты: тенанта создаёт оператор, клиент по ссылке задаёт пароль |
| 13 | Аутентификация | Логин = email + пароль (email уникален глобально); `tenantId` в сессии/JWT |
| 14 | Название | «Дейл» (бренд), namespace `Deal` |
| 15 | Код-стайл | Документ пользователя + 5 адаптаций; 1 тип = 1 файл; `.editorconfig` + анализаторы |
| 16 | Наблюдаемость | Serilog + OpenTelemetry → Grafana + Loki + Promtail |
| 17 | Админка | Операторская (A): тенанты, лимиты, health, impersonation, аудит |
| 18 | Бэкапы | Ежедневные: Postgres + minio + сессии |
| 19 | Лимиты | Бюджет токенов на тенанта (LLM); fallback на ML/локальную обработку |
| 20 | Деплой | docker compose на своём VPS; Cloudflare перед origin; k8s позже |
---
## 3. Структура репозитория
```
src/
core/ # МОДУЛЬНЫЙ МОНОЛИТ — один процесс, один sln
Deal.sln
Deal.Api/ # host: Web API (/api-контракт), gRPC-сервер, SSE, DI
Deal.Modules.Pipeline/ # очередь → стоп-лист → дедуп → ML/ИИ → карточка
Deal.Modules.Kanban/ # карточки, колонки, правила, архив/корзина
Deal.Modules.Projects/ # «Выбранные» (проектный канбан)
Deal.Modules.Discovery/ # поиск каналов, вступление, чёрный список
Deal.Modules.Settings/ # настройки тенанта, промпты, валюты
Deal.Modules.Tenants/ # тенанты, инвайты, лимиты, админка
Deal.SharedKernel/ # Result, доменные события, время, tenant-контекст
Deal.Infrastructure/ # Postgres, миграции, outbox, IEventBus, файлы (MinIO)
Deal.Contracts/ # DTO для /api + gRPC-контракты наружу
tests/ # Deal.Tests.* (unit/integration модулей)
ml-service/ # Deal.Ml.sln — .NET + ONNX, обучение/предсказание per-tenant
ai-service/ # Deal.Ai.sln — LLM-фасад, промпты, учёт токенов
telegram-service/ # Deal.Telegram.sln — ферма сессий, анти-бан
contracts/ # общие .proto (gRPC): ml.proto, ai.proto, telegram.proto
frontend/ # Vue — переезжает как есть
docker-compose.yml # dev-подъём всех процессов
```
Правила:
- `core` — единственное место с бизнес-логикой и БД.
- Каждый сервис самодостаточен: свой sln, свой контейнер.
- `.proto` — единственный общий «язык» между процессами, лежит в `contracts/`.
---
## 4. Мультитенантность
### Модель БД
- Одна Postgres-БД, **схема на тенанта**: `tenant_<id>.*`.
- Системные таблицы (реестр тенантов, пользователи, инвайты, глобальные настройки,
ключи приложения Telegram) — в схеме `public`.
- DAL получает схему из tenant-контекста (claim в JWT / gRPC-метаданные);
пул соединений переключает `search_path`.
- Миграции применяются ко всем схемам тенантов (специальный механизм, см. §10).
- «Золотым» клиентам позже — выделенный инстанс: стратегия выбора схемы/БД в одном месте.
### Изоляция (критично)
- `tenantId` **только из сессии/JWT**, никогда из тела запроса.
- Каждый SQL-запрос исполняется в контексте схемы тенанта; модуль проверяет
принадлежность объекта тенанту (IDOR-защита).
- Интеграционные тесты на перекрёстный доступ тенантов — обязательны.
### Обработка per-tenant
- Настройки обработки (стоп-фразы, промпты, колонки/правила, ключи) — per-tenant.
- **ML-модель — per-tenant** (модель дизайнера не учится на действиях кровельщика):
`ml-service` держит пул моделей, core передаёт `tenantId` в каждом вызове.
---
## 5. Модули core и их границы
Модули заводятся сразу как отдельные проекты; **внутренние интерфейсы между ними
не выдумываются заранее** — появляются в момент реальной зависимости.
| Модуль | Ответственность | Владеет таблицами (в схеме тенанта) |
|---|---|---|
| Pipeline | очередь входящих → стоп-лист → дедуп → ML/ИИ → карточка; отсев; обработка | очередь, отсев, dedup |
| Kanban | карточки, колонки, правила, архив/корзина, комментарии, файлы | карточки, колонки |
| Projects | «Выбранные»: свой канбан, стадии, история, напоминания | проекты |
| Discovery | задачи поиска каналов, кандидаты, чёрный список, квоты | discovery-таблицы |
| Settings | настройки тенанта, промпты, валюты | настройки |
| Tenants | тенанты, пользователи, инвайты, лимиты, аудит, операторская админка | tenant-реестр (в `public`) |
Общие справочники (например, «колонки» нужны и Pipeline при создании карточки, и Kanban
при отрисовке) живут в модуле-владельце (Kanban); доступ — через его публичный интерфейс.
---
## 6. Контракты
### 6.1 `/api` — замороженный контракт миграции
- Фронтенд Vue продолжает ходить в `/api/...` без изменений.
- Снимаем точную карту с работающего LeadRadar (эндпоинты + формы ответов, которые
реально потребляет фронт) → фиксируем как OpenAPI-спецификацию.
- Новый `Deal.Api` обязан воспроизводить её 1:1.
- Ведём реестр «кривых мест»: если правка фронта на 1 строку убирает слой костылей —
выносим на решение владельца по одному (не молча).
### 6.2 gRPC-контракты (`contracts/`)
- `telegram.proto`: команды ядра (подключить аккаунт, слушать канал, перечитать,
вступить/выйти) + поток сырых сообщений → ядро.
- `ml.proto`: predict (текст → решение), train (действие → обучение), health.
- `ai.proto`: classify/filter/generate (текст → структура), учёт токенов.
- Каждый вызов несёт `tenantId`; сервисы проверяют принадлежность по своей модели
(сессии/модели), не доверяя полю на слово.
### 6.3 Шина событий
- Порт `IEventBus` в SharedKernel.
- Реализация сейчас: outbox в Postgres (транзакционно событие + эффект, фоновый диспетчер).
- Kafka — позже, сменой реализации без правки бизнес-логики.
---
## 7. Сервисы
### 7.1 telegram-service
- Отдельный процесс, свой sln. Ничего не знает о данных и бизнес-логике.
- **Сессии привязаны к тенанту** (`tenantId → session`, 1:1): команды исполняются только
на сессии своего тенанта; нет сессии для tenantId → отказ.
- Проверка принадлежности диалога: read/subscribe только для диалогов аккаунта тенанта.
- Join — только от имени тенанта, под его квотами и анти-баном.
- Исходящий поток сообщений помечен `tenantId` (источник определён на входе, в сервисе).
- Сервисная аутентификация (mTLS) + аудит команд `(tenantId, действие, диалог, результат)`.
- Один аккаунт на тенанта на старте (связь тенант→аккаунты уже таблицей — расширение позже).
### 7.2 ml-service
- .NET + ONNX (не ML.NET для онлайн-обучения): пул моделей по тенантам, обучение на
реальных действиях пользователя и результатах ИИ.
- Ничего не знает о домене: получает текст, отдаёт решение; обучение — по контракту.
- Экспорт/импорт моделей — по контракту (для переноса между инстансами).
### 7.3 ai-service
- Фасад LLM-провайдеров (DeepSeek и др., включая локальные OpenAI-совместимые),
библиотека промптов, классификация, генерация.
- **Учёт токенов**: каждый вызов оценивается в токенах и списывается с бюджета тенанта.
- При исчерпании бюджета — fallback на ML/локальную обработку + уведомление
(приём сообщений не блокируется).
---
## 8. Безопасность
### Слой приложения (core)
- SQL-инъекции: запрет конкатенации SQL; только параметризация (EF Core/Dapper);
анализаторы; Postgres-роль без DDL.
- Tenant-изоляция (IDOR): tenantId из сессии; проверка принадлежности; тесты.
- Аутентификация: Argon2id, лимит попыток, одноразовые инвайты с expiry.
- Сессии: httpOnly cookie + CSRF (не localStorage).
- XSS: экранирование на фронте (renderSourceMessage), CSP, запрет v-html без санитайзера.
- SSRF: ai/telegram не тянут произвольные URL от имени тенанта (allowlist).
- Валидация входа: DTO + FluentValidation, лимиты размеров.
- Аудит: входы, инвайты, impersonation, действия оператора — неизменяемый поток.
### Транспорт/сервисы
- TLS везде; mTLS между сервисами; service-token второй фактор.
### Инфраструктура
- Cloudflare (DDoS/WAF) → reverse proxy (TLS, rate limit по IP, security-заголовки).
- Rate limiting в приложении по тенанту (защита от «шумного соседа»).
- Docker: сервисы в изолированной сети, наружу — только прокси; non-root, read-only FS.
- Секреты: env/secret-хранилище; шифрование (enc); ничего в коде/репозитории.
### Процессы
- CI: сканирование зависимостей (NuGet/npm), SAST, trivy-скан образов.
- Обновления и алерты на CVE.
- Postgres: бэкапы ежедневные, тест восстановления.
---
## 9. Наблюдаемость, админка, бэкапы
### Observability
- Serilog (структурированные логи) + OpenTelemetry (метрики/трейсы) → Promtail → **Grafana + Loki**.
- Дашборды: health сервисов, pipeline, ML-качество, расход токенов по тенантам.
- За абстракцией экспорта — смена стека без правки кода.
### Операторская админка (только оператору)
- Создание тенантов и инвайтов, лимиты, health, impersonation (с полным аудитом),
подозрительная активность. Отдельный защищённый вход (оператор ≠ тенант).
### Бэкапы
- Ежедневно: Postgres (pg_dump), файлы MinIO, сессии telegram.
- Retention и внешняя выгрузка — уточнить на этапе деплоя.
---
## 10. Деплой
- docker compose на одном VPS: core, ml-service, ai-service, telegram-service,
postgres, minio, grafana/loki/promtail, reverse proxy.
- Сервисы compose = будущие k8s-деплойменты (никаких завязок на compose в коде).
- Миграции схем тенантов: механизм «миграция ко всем схемам» (список схем в `public`,
применение по очереди, версия миграции на схему) — детализировать в плане реализации.
---
## 11. Стандарты кода
- Код-стайл: `C:\telbase\Стиль_кода.docx` + согласованные адаптации
(без snake_case-хелперов и регионов, public-поля → свойства, XML-doc для public-контрактов,
настройки через `IOptions<T>`, комментарии на русском).
- 1 тип = 1 файл (класс/record/struct/enum/interface — отдельный файл).
- `.editorconfig` + Roslyn-анализаторы с ошибками на нарушения.
- Второй слой правил: скилы `agent-rules-books` (Clean Code, DDD, DDIA).
- .NET-эталоны: скил `dotnet-clean-architecture-skills` (адаптировать под проект).
---
## 12. Открытые вопросы / следующие шаги
1. **Карта `/api`**: снять точную спецификацию с работающего LeadRadar (отдельная задача).
2. **Детали лимитов**: механика «бюджет токенов» (период, пороги, уведомления) — спроектировать.
3. **Бэкапы**: точная схема retention/внешнего хранилища.
4. **Миграции на 1000 схем**: детальный механизм.
5. Порядок реализации: этап 0 (каркас) → Pipeline+Kanban → ai/ml/telegram → Projects/Discovery.
---
## Приложение: глоссарий
- **Тенант** — клиент SaaS (одна организация/пользователь), владеет схемой БД и настройками.
- **Канал/источник** — Telegram-канал/группа, который слушает аккаунт тенанта.
- **Карточка** — структурированная заявка (заказ/вакансия), созданная пайплайном.
- **Outbox** — паттерн надёжной доставки событий через таблицу в той же транзакции.
@@ -0,0 +1,92 @@
# Дейл — единая модель карточки (unified card)
> Исторический документ (дизайн этапа 9, 2026-09-09). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> Дата: 2026-09-09
> Статус: дизайн согласован с владельцем продукта (в чате), начало реализации.
> Связанные документы: `docs/spec/ТЗ-дейл-новая-архитектура.md`, `docs/architecture/2026-09-05-deal-architecture-design.md`.
## Проблема
Сейчас в системе **два «домена» карточек**, хотя по смыслу это одна сущность:
| | Канбан (`/api/leads`) | «Выбранные» (`/api/projects`) |
|---|---|---|
| Таблица | `Cards` | `ProjectCards` |
| Контейнер | колонка `inbox/board/archive/trash/taken` | стадия `planned…finished/rejected` |
| «Взять в работу» | `col=taken` + **копия полей** в `ProjectCards` | создание второй записи |
| Драйвер/карточка | `CardDto` | `ProjectCardDto` |
Переход «лид → проектная карточка» — это **клонирование в другую сущность**: у карточки меняется id, теряется связность истории, третий вид карточки/дашборда потребует третьей таблицы и третьего конвейера.
**Решение (согласовано):** карточка — **один агрегат** во всех дашбордах. Понятие «лид» упраздняется: сообщение из канала — это *входные данные*, из которых создаётся карточка. «Взял в работу» — это **переход карточки в другой контейнер** той же доски пространства «Выбранные», а не создание новой записи.
## Модель (C#)
### Ядро
```csharp
/// Единственное, что есть у любой карточки.
public interface ICard
{
string Id { get; }
string Title { get; }
ISource Source { get; } // откуда пришла (см. ниже)
}
/// Типизированная проекция для сценариев, которым нужен конкретный источник.
public interface ICard<TSource> : ICard where TSource : ISource
{
new TSource Source { get; }
}
```
### Источники (ISource) — иерархия, а не enum-свойство
- `ISource` — общее: `DisplayName`, `OriginRef`, `RawPayload`, `ReceivedAt`.
- Простые: `ILocalSource`, `IWebSource`, `IFileSource`.
- Сложные: `ITelegramSource` (dialogId/messageId/peer/topic), `IRowSource` (импорт колонки/строки), `IApiSource`, `IAiSource` (провайдер+модель+агент), `ICompositeSource { Origin, Pipeline[] }`.
### Модули-роли карточки (опциональные части одного агрегата)
`IContentCard` (блок «О заявке»), `IBudgetedCard`, `IContactCard`, `IAttributedCard` (стек/грейд/локация — настраиваемые атрибуты тенанта), `ICommentableCard`, `ILinkCard`, `IFileCard`, `ITzCard`, `ITraceableCard` (история), `IRemindableCard`, `ILocatedCard` (контейнер + prev + isNew).
Вид карточки = композиция модулей, **не класс-наследник**. Новый дашборд/вид — новая композиция + при необходимости новый модуль.
### Контейнеры (общая база колонок/стадий/зон)
```csharp
public interface IContainer
{
string Id { get; }
string Name { get; }
string Color { get; }
int Order { get; }
IContainerRules? Rules { get; } // фильтры попадания (пользовательские колонки)
IContainerPolicy Policy { get; } // поведение (роль, не enum)
}
```
Политики: возврат/очистка (корзина 7д, архив 90д), терминальность («Отклонено/Выполнено» — только ручная очистка), «выбранные не попадают в архив дашборда». Отсев пайплайна — **не карточка**, вне этой модели.
### Переходы
Один `ICardMover.MoveAsync(card, toContainerId, ctx)`; правила — в политиках контейнеров и «воротах» между пространствами; побочные эффекты карточка делает через свои модули (`ITraceableCard` пишет историю, `IRemindableCard` сбрасывает напоминание, `ILocatedCard.IsNew=false`).
## Терминология
- ~~лид, lead~~ → **карточка (card)**; входное сообщение → **сообщение-источник**.
- ~~проектная карточка~~ → карточка в контейнерах пространства «Выбранные».
- «Взять в работу» → переход в контейнер `planned`.
## Что меняется
- **БД**: таблицы `Cards` + `ProjectCards` → одна `Cards` (+ модульные данные); доски и стадии — единый реестр контейнеров; удаляется `ProjectCards`, перенос `LeadComments` в модуль карточки.
- **Бэк**: модули Kanban и Projects объединяются в один модуль карточки/контейнеров; порты/сервисы/адаптеры/DTO — единые.
- **Pipeline**: создаёт карточку (не «лид»), кладёт в контейнер по правилам.
- **API**: единый контракт `/api/cards` + `/api/containers`; `/api/leads`, `/api/projects` упраздняются (фронт переписывается).
- **Фронт**: один state-слайс карточек, один рендер карточки/драйвера, один канбан-компонент.
## Границы этапа
Данные тестовые — схема пересоздаётся, миграции данных нет. Вне рамок: Kafka, «третьи» дашборды (архитектура готова), разовые миграции.
@@ -0,0 +1,252 @@
# Дейл — контракт операторской аналитики и аудита действий (этап 10, T1–T3)
> Дата: 2026-09-10
> Статус: контракт для фронта (оператор-консоль, T4). Источник истины для `src/frontend`.
> Связанные документы: `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`,
> `docs/architecture/2026-09-10-unified-api-contract.md`.
## Общие правила
- **Только операторская сессия.** Все ручки `/api/operator/*` (включая аналитику) требуют разрешённой
операторской сессии (кука `deal_operator_session`). Без неё — `401 { "detail": "Требуется вход оператора" }`.
- Все ответы — JSON **camelCase**.
- **Время на wire в этих ручках — ISO-8601** (`DateTimeOffset`, UTC, напр. `2026-09-10T15:22:46.123Z`).
Query-параметры `from`/`to` и поля `at`/`from`/`to` используют ISO-8601 (как уже принято в
`GET /api/operator/audit`). Ключи агрегатов `groupBy=day` — строки `ГГГГ-ММ-ДД`.
- Диапазоны `from`/`to`**включительно**; не заданы — без границы.
- Ошибки — `{ "detail": "текст" }`. Коды: `400` (некорректный ввод, напр. неизвестный `groupBy`),
`401` (нет операторской сессии).
- Все ручки аналитики — **read-only** (ничего не меняют).
## Каталог событий аудита (для фильтров `eventType` / ленты действий)
К SaaS-событиям этапа 7 добавлены (этап 10, T1; актор `tenant` — действия пользователя тенанта):
| `eventType` | Когда |
|---|---|
| `tenant_logout` | выход пользователя тенанта (`POST /api/auth/logout`) |
| `operator_logout` | выход оператора (`POST /api/operator/auth/logout`) |
| `invite_joined` | активация инвайта (`POST /api/join`) |
| `card_created` | создание карточки |
| `card_moved` | перенос карточки между контейнерами |
| `card_trashed` | карточка отправлена в корзину |
| `card_restored` | карточка возвращена из корзины/архива |
| `card_deleted` | карточка удалена навсегда |
| `card_comment_added` | добавлен комментарий к карточке |
| `container_created` | создан контейнер/колонка |
| `container_updated` | изменён контейнер/колонка |
| `container_deleted` | удалён контейнер/колонка |
| `settings_updated` | сохранены настройки тенанта |
| `channel_enabled` | включён мониторинг канала Telegram |
| `channel_created` | канал добавлен в каталог (резерв каталога) |
| `telegram_linked` | аккаунт Telegram привязан (фаза `ready`) |
| `telegram_keys_changed` | оператор изменил глобальные ключи Telegram (`PUT /api/operator/settings/telegram-keys`) |
Типы акторов (`actorType`): `tenant`, `operator`, `system`. Секреты (пароли, токены, api-ключи) в
`detailJson` **не пишутся**.
---
## GET /api/operator/analytics/overview
Сводка за период: тенанты, расход токенов, события, входы/выходы/неудачные входы.
**Query**
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
| `from` | ISO-8601 | нет | начало периода (включительно) |
| `to` | ISO-8601 | нет | конец периода (включительно) |
**200**
```json
{
"tenantsTotal": 12,
"tenantsActive": 10,
"promptTokens": 1250000,
"completionTokens": 320000,
"totalTokens": 1570000,
"tokenEvents": 842,
"events": 5012,
"logins": 320,
"logouts": 288,
"failedLogins": 17,
"from": "2026-09-01T00:00:00Z",
"to": "2026-10-01T00:00:00Z"
}
```
- `tokens*`/`tokenEvents` — сумма по событиям `public.token_usage_events` за период.
- `events` — число записей аудита за период.
- `logins` = `tenant_login_ok` + `operator_login_ok`; `logouts` = `tenant_logout` + `operator_logout`;
`failedLogins` = `tenant_login_failed` + `operator_login_failed`.
**Коды**: `200`, `401`.
---
## GET /api/operator/analytics/tokens
Серия/агрегаты расхода токенов.
**Query**
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
| `groupBy` | enum | нет | `day` (дефолт) \| `tenant` \| `provider` \| `model` |
| `tenantId` | uuid | нет | фильтр по тенанту |
| `from` | ISO-8601 | нет | начало периода (включительно) |
| `to` | ISO-8601 | нет | конец периода (включительно) |
**200**
```json
{
"groupBy": "day",
"from": "2026-09-01T00:00:00Z",
"to": "2026-10-01T00:00:00Z",
"items": [
{ "key": "2026-09-10", "promptTokens": 1200, "completionTokens": 300, "totalTokens": 1500, "eventCount": 42 }
],
"total": { "key": "total", "promptTokens": 1250000, "completionTokens": 320000, "totalTokens": 1570000, "eventCount": 842 }
}
```
- `key` группы: `day``ГГГГ-ММ-ДД` (сутки UTC); `tenant` — Guid `D`; `provider` — id провайдера
(`deepseek`/`openai`/…, для ML — `local`); `model` — модель (`ml` для локальной ML-модели).
- Порядок `items`: `day` — по возрастанию даты; `tenant`/`provider`/`model` — по убыванию `totalTokens`.
- `total` — итог по всем строкам.
**Коды**: `200`; `400 { "detail": "Неизвестная группировка (day|tenant|provider|model)" }`; `401`.
---
## GET /api/operator/analytics/activity
Лента действий (аудит) с фильтрами и пагинацией.
**Query**
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
| `eventType` | string | нет | тип события (см. каталог) |
| `actorType` | enum | нет | `tenant` \| `operator` \| `system` |
| `actorId` | uuid | нет | идентификатор актора |
| `tenantId` | uuid | нет | тенант |
| `from` | ISO-8601 | нет | нижняя граница `at` (включительно) |
| `to` | ISO-8601 | нет | верхняя граница `at` (включительно) |
| `limit` | int | нет | размер страницы (дефолт 100, кламп 1..500) |
| `offset` | int | нет | смещение (≥0) |
**200**
```json
{
"items": [
{
"eventType": "card_moved",
"actorType": "tenant",
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
"ip": "203.0.113.7",
"detailJson": "{\"cardId\":\"c_1a2b3c4d5e6f\",\"to\":\"planned\"}",
"at": "2026-09-10T15:22:46.123Z",
"id": 1042
}
],
"total": 5012,
"limit": 100,
"offset": 0
}
```
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
- `detailJson`**строка** JSON деталей события (без секретов), может быть `null`.
**Коды**: `200`, `401`.
---
## Расширение GET /api/operator/audit
К прежним фильтрам (`eventType`, `actorType`, `tenantId`, `from`, `to`, `limit`) добавлены:
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
| `actorId` | uuid | нет | фильтр по идентификатору актора |
| `offset` | int | нет | смещение страницы (≥0, дефолт 0) |
Ответ — прежний `{ "items": [...], "total": n }` (поля `items`/`total` без изменений; форма записи —
как в ленте действий выше). **Коды**: `200`, `401`.
---
## Операторские настройки: глобальные ключи Telegram
Ключи приложения Telegram (`api_id`/`api_hash`) задаются оператором **глобально** (ТЗ §4.1/§8.1),
едины для всех тенантов. Тенант их не видит и не задаёт (ключ `tgKeys` удалён из `GET/PATCH /api/settings`).
Хранилище — системная таблица `public.global_settings` (ключ `telegramKeys`), `apiHash` хранится
зашифрованным и наружу не отдаётся.
### GET /api/operator/settings/telegram-keys
Маскированный снимок глобальных ключей.
**200**
```json
{
"apiId": "1234567",
"apiHash": "abcd…mnop",
"keysSet": true
}
```
- `apiId` — открыт (не секрет; пусто — ключи не заданы оператором).
- `apiHash`**маска** (пусто / `x…` / `1234…5678`); открытый секрет не возвращается никогда.
- `keysSet``true`, если заданы оба ключа; в `GET /api/tg/status` это же значение в поле `keysSet`.
**Коды**: `200`, `401`.
### PUT /api/operator/settings/telegram-keys
Сохранение/смена глобальных ключей. Поля можно передавать **по отдельности** (частичное обновление):
непереданное поле (`null` или отсутствие в JSON) сохраняет текущее значение. Если ключей ещё нет,
оба поля обязательны.
**Тело**
```json
{ "apiId": "1234567", "apiHash": "abcdefghijklmnop" } // полное обновление
```
```json
{ "apiId": "7654321" } // только apiId — apiHash сохраняется
```
```json
{ "apiHash": "newsecrethash12" } // только apiHash — apiId сохраняется
```
- `apiId` — если передан, строго 5–9 цифр; если не передан, берётся текущий (`null` = «не менялось»).
- `apiHash` — если передан, непустой секрет (не маска и без префикса `enc:`), шифруется перед сохранением;
если не передан, берётся текущий зашифрованный секрет.
- Явное пустое значение (`""`) считается невалидным, а не «не менялось».
**200** — маскированный снимок (форма как у GET).
**Ошибки**
- `400 { "detail": "Укажите api_id и api_hash" }` — не передано ни одного поля.
- `400 { "detail": "Ключи ещё не заданы — укажите и api_id, и api_hash" }` — частичное обновление,
но ключей ещё нет (нельзя дополнить отсутствующее значение).
- `400 { "detail": "api_id должен состоять из 5–9 цифр" }`
- `400 { "detail": "Укажите непустой api_hash" }`
- `401 { "detail": "Требуется вход оператора" }`
**Аудит**: событие `telegram_keys_changed` (актор `operator`, `tenantId: null`, детали `{apiId, apiHashSet}` — без секрета).
> Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта),
> поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают
> `400 { "detail": "Ключи Telegram не заданы оператором" }`.
@@ -0,0 +1,434 @@
# Дейл — единый API-контракт этапа 9 (cards + containers)
> Дата: 2026-09-10
> Статус: контракт для портирования фронта (T6). Источник истины для `src/frontend`.
> Связанные документы: `docs/architecture/2026-09-09-unified-card.md`,
> `docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md` (T4T6, R5).
## Общие правила
- **Только два домена API**: `/api/cards` (карточки) и `/api/containers` (колонки/стадии/зоны).
Старые ручки `/api/leads`, `/api/projects`, `/api/boards` **удалены**.
- Все ответы и тела запросов — JSON **camelCase**.
- Время на wire — **epoch-ms** (`int64`, UTC). Внутри — `DateTimeOffset` (UTC).
- Ошибки — объект `{ "detail": "текст" }`. Коды: `400` (некорректный ввод), `401` (нет сессии),
`404` (объект не найден), `422` (тело не разобрано).
- Аутентификация — сессионная кука (как раньше). Без сессии — `401 {detail}`.
- Контейнер — единый реестр колонок/стадий/зон. Карточка ссылается на контейнер полем
`containerId` (алиас прежнего `col`). Пространства: `dashboard` (дашборд) и `selected`
(«Выбранные»). Карточка живёт в одном пространстве: её `containerId` однозначно определяет,
где она показана.
- Виды контейнеров (`kind`): `board` (пользовательская колонка-фильтр), `stage` (стадия
«Выбранных»), `service` (inbox/archive/trash), `terminal` (finished/rejected).
## SSE (`GET /api/events`)
Поток `text/event-stream`, канал тенанта сессии. Типы событий:
| `event` | `data` | Когда |
|---|---|---|
| `new_card` | объект **Card** (см. ниже) | создана карточка (пайплайн, демо, тик) |
| `reminder_due` | `{ "id", "title", "containerId" }` | наступило напоминание |
| `toast` | `{ "text", "icon" }` | статистика тика / служебное уведомление |
| `cards_reclassified` | промежуточный — `{ "progress": true, "done", "total", "moved", "kept", "trashed", "skipped" }`; финал — `{ "progress": false, "reclassified", "moved" }` | прогресс/завершение переклассификации «Неразобранного» |
`new_lead` больше не публикуется (переименован в `new_card`).
---
## Card (карточка)
Единая сущность во всех дашбордах. Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание)
присутствуют всегда, но могут быть пустыми.
```json
{
"id": "c_1a2b3c4d5e6f",
"containerId": "inbox",
"col": "inbox",
"isNew": true,
"local": false,
"title": "Разработка интернет-магазина",
"summary": "Компания: ...\nЗадача: ...",
"source": {
"kind": "telegram",
"displayName": "Канал заказов",
"originRef": "123456789",
"receivedAt": 1726000000000
},
"sourceMsg": "Ищу разработчика...",
"sourceDialogId": "123456789",
"sourceMsgId": 4242,
"stack": ["vue", "dotnet"],
"budget": { "from": 100000, "to": 200000, "cur": "RUB" },
"converted": { "from": 100000, "to": 200000, "cur": "RUB" },
"contact": "@client",
"contacts": [{ "type": "tg", "value": "@client" }],
"channel": { "name": "Канал заказов", "handle": "@orders", "hue": "#8b8ff8" },
"matchHits": [{ "label": "Стек", "term": "vue", "word": null }],
"comments": [{ "id": "cm_...", "by": "Вы", "text": "Позвонил", "time": "5 мин" }],
"links": [{ "id": "pl_...", "name": "Бриф", "url": "https://example.com" }],
"files": [
{ "id": "pf_...", "name": "brief.pdf", "size": 10240, "kind": "document",
"label": "Документ", "objectKey": "cards/c_.../pf_..." }
],
"history": [
{ "id": "h_...", "at": 1726000000000, "type": "created", "stage": null },
{ "id": "h_...", "at": 1726003600000, "type": null, "stage": "planned" }
],
"tzText": "Сделать каталог и корзину",
"reminder": { "at": 1727000000000 },
"prevCol": "inbox",
"isVacancy": false,
"isVacancyKnown": false,
"time": "5 мин",
"receivedAt": 1726000000000,
"createdAt": 1726000000000,
"updatedAt": 1726000000000
}
```
Поля:
| Поле | Тип | Описание |
|---|---|---|
| `id` | string | короткий id карточки, префикс `c_` |
| `containerId` | string | контейнер карточки (`inbox`/`archive`/`trash`/стадия/`b_...`) |
| `col` | string | **алиас** `containerId` (совместимость со старым фронтом) |
| `isNew` | bool | точка «новое» (снимается просмотром/переносом) |
| `local` | bool | карточка создана локально (без внешнего источника) |
| `title` / `summary` | string | заголовок / блок «О заявке» |
| `source` | object | происхождение: `kind` (`local`/`telegram`/`web`/`file`/`row`/`api`/`ai`/`composite`/`other`), `displayName`, `originRef`, `receivedAt` |
| `sourceMsg` / `sourceDialogId` / `sourceMsgId` | string / string / int64? | исходное сообщение (текст, диалог, id) |
| `stack` | string[] | стек/направления |
| `budget` | object? | `{from,to,cur}` по исходному сообщению |
| `converted` | object? | `{from,to,cur}` бюджет в целевой валюте |
| `contact` | string | «быстрый» контакт |
| `contacts` | object[] | `{type,value}` |
| `channel` | object | `{name,handle,hue}` (прежний `ch`) |
| `matchHits` | object[] | `{label,term,word?}` — почему карточка в контейнере |
| `comments` | object[] | `{id,by,text,time}` |
| `links` | object[] | `{id,name,url}` |
| `files` | object[] | `{id,name,size,kind,label,objectKey}` |
| `history` | object[] | `{id,at,type\|stage}` — ровно один из `type`/`stage` |
| `tzText` | string | техническое задание |
| `reminder` | object? | `{at}` (epoch-ms) |
| `prevCol` | string | предыдущий контейнер (возврат из archive/trash) |
| `isVacancy` / `isVacancyKnown` | bool | маркер/подтверждение «найм» |
| `time` | string | human-метка от `receivedAt` |
| `receivedAt` / `createdAt` / `updatedAt` | int64 | epoch-ms |
### `GET /api/cards?containerId=`
Список карточек. `containerId` — фильтр по контейнеру; алиас `col` принят для совместимости; без
параметра — все карточки дашборда (кроме стадий «Выбранных»).
```json
{ "items": [ /* Card... */ ] }
```
`400 {detail:"Неизвестный контейнер"}` — если контейнер не существует.
### `GET /api/cards/counts`
Плоские счётчики (совместимо с прежним `/api/leads/counts`).
```json
{ "new": 3, "inbox": { "count": 5, "new": 2 }, "learning": 12, "ml": 0, "ai": 0 }
```
### `GET /api/cards/{cardId}`
Карточка. `404 {detail:"Карточка не найдена"}`.
### `POST /api/cards`
Создание локальной карточки. Тело:
```json
{ "title": "Новый заказ", "summary": "", "containerId": "planned",
"stack": [], "budget": null, "contact": "", "tzText": "" }
```
Алиас `containerId``stage`. Ответ — созданная **Card**.
### `PATCH /api/cards/{cardId}`
Частичная правка. Null-поле = «не менять». Тело:
```json
{ "title": "...", "summary": "...", "contact": "...", "tzText": "...",
"stack": ["..."], "budget": { "from": 1, "to": 2, "cur": "RUB" } }
```
Ответ — обновлённая **Card**.
### `POST /api/cards/{cardId}/move` `{ "to": "<containerId>" }`
Перенос карточки. Ответ — обновлённая **Card**.
`400 {"Переносить можно только в существующий контейнер или в «Неразобранное»"}` (несуществующий
контейнер/служебный источник), `404`.
### `POST /api/cards/{cardId}/trash` → `{ "ok": true }`
### `POST /api/cards/{cardId}/restore` → `{ "ok": true, "col": "inbox" }`
### `DELETE /api/cards/{cardId}` → `{ "ok": true }`
### `POST /api/cards/clear-col` `{ "col": "trash"|"archive" }` → `{ "ok": true, "cleared": 4 }`
### `POST /api/cards/clear-rejected` → `{ "ok": true, "cleared": 0 }`
### `POST /api/cards/mark-all-seen` → `{ "ok": true }`
### `POST /api/cards/mark-col-seen` `{ "col": "<containerId>" }` → `{ "ok": true }`
### `POST /api/cards/take` `{ "cardId": "<cardId>" }`
«Взять в работу»: карточка (не клон) переносится в контейнер `planned` пространства
`selected`. Ответ — обновлённая **Card**. Алиас поля — `leadId`. `404` — карточки нет.
### Комментарии
`POST /api/cards/{cardId}/comments` `{ "text": "..." }``{ "comments": [ /* ... */ ] }`
`400 {detail:"Пустой комментарий"}`, `404`.
### Ссылки
- `POST /api/cards/{cardId}/links` `{ "url": "...", "name": "..." }` → обновлённая **Card**
- `DELETE /api/cards/{cardId}/links/{linkId}` → обновлённая **Card**
### Файлы
- `POST /api/cards/{cardId}/files``multipart/form-data`, поле `files` (одно или несколько)
→ обновлённая **Card**
- `GET /api/cards/{cardId}/files/{fileId}/download` → бинарный поток
- `DELETE /api/cards/{cardId}/files/{fileId}` → обновлённая **Card**
### Напоминания
- `POST /api/cards/{cardId}/reminder` `{ "at": 1727000000000 }` → обновлённая **Card**
`400 {detail:"Поле at (epoch-ms) обязательно"}`
- `DELETE /api/cards/{cardId}/reminder` → обновлённая **Card**
- `POST /api/cards/{cardId}/reminder/snooze` → обновлённая **Card**
### `POST /api/cards/{cardId}/reclassify` и `POST /api/cards/reclassify`
Переклассификация карточки/«Неразобранного»: повторный прогон через тот же конвейер, что и пайплайн
(ИИ-фильтр → классификация → сборка контента → правила колонок; без создания новой карточки).
- Single: `{cardId}` — любая карточка с исходным текстом; `404 {detail:"Карточка не найдена"}`.
- Batch: тело `{ "ids": ["c_..."] }` опционально; без `ids` — все карточки `inbox`.
- При включённом ИИ используется порт `IAiClassifier`; при выключенном (`aiEnabled=false`) или недоступности
сервиса — детерминированный локальный разбор (без кредов сервис не падает). `usedAi` показывает путь.
- Одна переклассификация за раз (single-flight): при занятом проходе `{ "started": false, "busy": true }`.
- Аудит — событие `card_reclassified` (только при `reclassified > 0`).
- Во время пакетного прохода публикуются промежуточные SSE `cards_reclassified` с `{ "progress": true,
"done", "total", "moved", "kept", "trashed", "skipped" }` (каждые 5 карточек и на последней); по завершении —
финальное `{ "progress": false, "reclassified", "moved" }`; фронт показывает `done/total` и перечитывает
доску только по финальному событию.
```json
{
"started": true,
"busy": false,
"attempted": 3,
"reclassified": 3,
"moved": 1,
"kept": 1,
"trashed": 1,
"skipped": 0,
"usedAi": false,
"reason": null
}
```
| Поле | Тип | Описание |
|---|---|---|
| `started` | bool | Проход выполнен (target непуст); `false` — пусто/занято |
| `busy` | bool | Проход уже выполняется другим запросом |
| `attempted` | int | Сколько карточек отобрано (batch — inbox, либо `ids ∩ inbox`) |
| `reclassified` | int | Успешно обработано (`moved + kept + trashed`) |
| `moved` | int | Ушло в смысловую колонку |
| `kept` | int | Осталось в «Неразобранном» |
| `trashed` | int | Отправлено в корзину (спам/не прошло ИИ-фильтр) |
| `skipped` | int | Пропущено (нет исходного текста) |
| `usedAi` | bool | True — разбор хотя бы одной карточки через порт ИИ; false — локальный разбор |
| `reason` | string? | Причина, если проход не выполнен/пусто; иначе `null` |
### `GET /api/search?q=`
```json
{ "cards": [ /* Card... */ ], "messages": [] }
```
---
## Container (колонка/стадия/зона)
```json
{
"id": "b_1a2b3c4d5e6f",
"name": "WPF",
"description": "Заказы по WPF",
"color": "#818cf8",
"order": 0,
"space": "dashboard",
"kind": "board",
"collapsed": false,
"suggested": false,
"note": "",
"rules": {
"mode": "any",
"direction": [],
"keywords": ["wpf"],
"stack": [],
"grade": [],
"exclude": [],
"budget": { "from": 0, "to": 0, "cur": "RUB" }
},
"policy": { "canRestore": true, "isTerminal": false, "retentionDays": null },
"counts": { "total": 4, "new": 1 }
}
```
| Поле | Тип | Описание |
|---|---|---|
| `id` | string | `b_...` (board), `planned…rejected` (stage/terminal), `inbox`/`archive`/`trash` (service) |
| `name` | string | имя для отображения |
| `description` | string | описание (подсказка ИИ/ML) |
| `color` | string | hex |
| `order` | int | позиция в пространстве |
| `space` | string | `dashboard` / `selected` |
| `kind` | string | `board` / `stage` / `service` / `terminal` |
| `collapsed` | bool | свёрнутость колонки на дашборде |
| `suggested` | bool | ИИ-предложение, ждёт решения пользователя |
| `note` | string | заметка/обоснование ИИ |
| `rules` | object? | правила попадания (null — фильтра нет) |
| `policy` | object | `{canRestore,isTerminal,retentionDays}` |
| `counts` | object | `{total,new}` — счётчики карточек контейнера |
`rules` (объект фильтров колонки): `mode` (`all`/`any`), `direction`, `keywords`, `stack`, `grade`,
`exclude`, `budget` (`{from,to,cur}`) и добавленные этапом 12 группы `levels` (уровень), `locations`
(локация/язык), `types` (`vacancy`/`freelance`/`announcement`), `prices` (`{from,to,cur}`). Все группы
опциональны; старый сохранённый `rules` без новых групп разбирается как прежде (обратная совместимость).
---
### `GET /api/containers?space=`
```json
{ "items": [ /* Container... */ ] }
```
`space` (`dashboard`/`selected`) — опциональный фильтр.
### `POST /api/containers`
```json
{ "name": "WPF", "description": "", "color": null,
"space": "dashboard", "kind": "board", "suggested": false, "note": "",
"rules": { "mode": "any", "keywords": ["wpf"] } }
```
`400 {detail:"Укажите название колонки"}` при отсутствующем/null `name`.
Ответ — `{ "id": "b_..." }`.
### `PATCH /api/containers/{containerId}`
Null-поле = «не менять». Тело: `name`, `description`, `color`, `collapsed`, `suggested`,
`note`, `rules`, `policy`. Ответ — `{ "id": "..." }`, `404 {detail:"Контейнер не найден"}`.
### `POST /api/containers/{containerId}/accept`
Принять ИИ-предложение (`suggested=false`), ответ — обновлённый **Container**.
### `DELETE /api/containers/{containerId}`
Удаление контейнера; его карточки переносятся в `inbox` новыми.
Ответ — `{ "ok": true, "movedToInbox": 4 }`.
### `POST /api/containers/reorder`
```json
{ "space": "dashboard", "order": ["b_...", "b_...", "inbox"] }
```
Ответ — `{ "ok": true }`.
### Состояние колонок (UI)
- `GET /api/containers/state` → `{ "<containerId>": { "collapsed": true, "width": "md" } }`
- `PATCH /api/containers/{containerId}/state` `{ "collapsed": true }` → `{ "collapsed": true }`
(только не-null поля после merge).
---
## ML (проверка на сообщении/канале, §8)
Все ручки — под сессией тенанта (`401 {detail:"Требуется авторизация"}`).
### `POST /api/ml/candidates`
Тело: `{ "dialogId": "d_...", "limit": 10 }` — `limit` клампится `1..60` (дефолт 10);
пустой `dialogId` — выборка по всем источникам тенанта (очередь/отсев/карточки).
```json
{ "items": [
{ "id": 12345, "dialogId": "d_...", "text": "исходный текст (до 600 симв.)",
"time": 1757500000000, "lead": true, "verdict": "card", "col": "b_...",
"stage": null, "reason": null,
"pred": { "take": true, "label": "b_...", "scores": { "b_...": 0.83 } } }
] }
```
| Поле | Тип | Описание |
|---|---|---|
| `id` | int | id исходного сообщения (`msgId`) — его принимает `/apply` |
| `dialogId` | string | id диалога-источника |
| `text` | string | исходный текст (до 600 символов) |
| `time` | int? | время сообщения, epoch-ms (null — неизвестно) |
| `lead` | bool | по сообщению уже есть карточка |
| `verdict` | string | `card` / `rejected` / `queued` — текущее состояние |
| `col` | string? | колонка карточки (для `verdict=card`) |
| `stage` | string? | этап отсева / статус очереди |
| `reason` | string? | причина отсева (для `verdict=rejected`) |
| `pred` | object? | мнение ML `{take,label,scores}` (null — не ответил/не готов) |
### `POST /api/ml/apply`
Тело: `{ "dialogId": "d_...", "msgId": 12345, "action": "spam" }` —
`action`: `skip` | `spam` | `board:<containerId>`.
```json
{ "ok": true, "learned": true, "moved": "trash", "leadId": "c_..." }
```
- `skip` — ничего не меняет (`learned:false`, `moved:null`);
- `spam` — учит ML; карточку → в корзину (`moved:"trash"`), сообщение из очереди → в отсев;
- `board:<id>` — учит ML; карточку переносит в колонку (`moved:"<id>"`), уже в колонке — только учит.
Ошибки: `404 {detail:"Исходное сообщение не найдено"}` — сообщение не найдено ни в карточках, ни в
отсеве, ни в очереди; `400 {detail:"Неизвестная доска"}` (нет такого контейнера);
`400 {detail:"Неизвестное действие"}`.
---
## Операторский health (глубины очередей, §10.2)
`GET /api/operator/health` дополнен числовыми полями:
```json
{ "ok": true, "core": { "db": "ok" },
"services": [ /* ... */ ],
"queues": { "pipeline": 12, "mlOutbox": 3 },
"sessions": { "active": 5 } }
```
`queues.pipeline` — суммарная глубина очереди обработки (new+filtered), `queues.mlOutbox` — очередь
обучения ML по всем тенантам; `sessions.active` — активные непросроченные сессии.
---
## Удалённые ручки
| Было | Стало |
|---|---|
| `GET/POST /api/leads`, `/api/leads/{id}`, `/counts`, `/move`, `/trash`, `/restore`, `/comments`, `/mark-*-seen`, `/clear-col`, `/reclassify` | `/api/cards...` |
| `GET/POST /api/projects`, `/api/projects/{id}`, `/take`, `/move`, `/comments`, `/links`, `/files`, `/reminder`, `/clear-rejected` | `/api/cards...` |
| `GET/POST/PATCH/DELETE /api/boards`, `/reorder` | `/api/containers...` |
| `GET /api/columns/state`, `PATCH /api/columns/{id}/state` | `/api/containers/state`, `/api/containers/{id}/state` |
| SSE `new_lead` | SSE `new_card` |
+276
View File
@@ -0,0 +1,276 @@
# Дейл — код-стайл (действующие правила)
> Единый свод правил стиля кода для всего репозитория (core, telegram/ai/ml-сервисы, тесты).
> Составлен на основе исходного `Стиль_кода.docx` (перенесён в `archive/style-guide-original/`),
> дополнен действующими правилами проекта и `.editorconfig`. Правила обязательны для нового кода;
> приведение существующего — в `docs/superpowers/backlog.md` (`TD-COMMENTS-IFACE`, `TD-PROTO-COMMENTS`).
Пометки:
- **[изм.]** — правило дополнено/уточнено относительно исходного документа.
- **[отмена]** — правило исходного документа, которое в этом проекте не применяется.
---
## 1. Именование
Используются стандартные соглашения .NET. Венгерская нотация и префиксы типов в именах не применяются.
- **Классы** — Pascal: `User`.
- **Интерфейсы** — Pascal с префиксом `I`: `IDisposable`, `ICardStore`.
- **Generic-параметры** — Pascal с `T`: `T`, `TKey`, `TValue`.
- **Публичные функции/методы** — Pascal: `Authenticate`.
- **Приватные функции/методы** — тоже Pascal: `Authenticate` (не camel).
- **Параметры функций** — camel: `userId`.
- **Свойства (public/private)** — Pascal: `FirstName`.
- **Public-поля** — Pascal: `FirstName`. **[изм.]** Публичное состояние — свойство (§4); публичное поле допускается
только для данных-контейнеров без логики и именуется Pascal.
- **Private-поля — обязательный префикс `_` + camelCase: `_firstName`.** **[изм.]** Без `_` запрещено.
Исключения — только для константоподобных полей: `const` и `static readonly` именуются PascalCase
(`MaxRetryCount`, `DefaultTimeout`).
- **Локальные переменные** — camel: `user`.
- **Константы** — Pascal: `MaxRetryCount` (приватные `const` и `static readonly` — тоже Pascal, без `_`).
- **Enum** — Pascal: `UserStatus`; **значения enum** — Pascal: `Active`.
- **Exception** — Pascal с суффиксом `Exception`: `UserAuthenticationException`.
- **Event** — Pascal: `StatusChanged`.
- **Namespace** — Pascal.
Не использовать сокращения, кроме общепринятых (`id`, `ui`, `http`, `grpc`, `json`, `api`).
## 2. Организация кода и файлов
- Один публичный тип — один файл; имя файла = имя типа. **[изм.]** Правило усилено: смешивать типы в
одном файле нельзя (небольшие вспомогательные private-классы — исключение).
- **`namespace` строго соответствует пути папки** (для тестов — тоже). Файлы группируются по назначению:
`Abstractions` (интерфейсы `I*`), `Services` (сервисы/воркеры/исполнители), `Models` (доменные типы,
enum/статусы/константные реестры), `Dtos` (`*Dto`/`*Request`/`*Response`/`*Patch`), `Extensions`
(`*Extensions`), `Options` (`*Options`), `Exceptions` (`*Exception`), `Registrars` (`*ModuleRegistrar`),
`Configurations` (EF-конфигурации), `Entities`, `Repositories`. Feature-папки допустимы и сохраняются
(`Endpoints`, `Middleware`, `Hosting`, `Parsing`, `ColumnRules` и т.п.).
- **Тестовые проекты** группируются по областям (`Modules/<X>`, `Api`, `Infrastructure`, `Contracts`,
`Grpc`, …), общие фейки/хелперы — в `Support`; `namespace` = `<ПроектТестов>.<Область>`.
- В одном файле — один `namespace`. File-scoped namespace допустим.
- Все `using` — в начале файла; сначала системные, затем сторонние/project.
- `using` внутри `namespace` не используются (внешние `using`).
- Порядок членов внутри типа: константы → поля → конструкторы → свойства → методы. Члены группируются
по назначению.
- **[отмена]** Регионы (`#region`) **не используются** — вместо них осмысленный порядок и декомпозиция.
- Если у свойства есть backing-поле, поле объявляется **над** свойством:
```csharp
private User _user;
public User User { get; set; }
```
## 3. Форматирование
- Стандартные настройки форматирования Visual Studio / `.editorconfig`.
- Фигурные скобки — всегда на отдельной строке (Allman).
- В `if`/`else` фигурные скобки используются **всегда**, даже для одной инструкции.
- Отступ — 4 пробела (символ табуляции в историческом документе; в проекте — пробелы).
- Длина строки — желательно не более 100 символов; при переносе продолжение сдвигается вправо на один
уровень отступа.
- Каждая переменная объявляется на отдельной строке.
- Если `get`/`set` свойства состоит из одной операции, допускается размещение на одной строке:
```csharp
public User
{
get { return user; }
}
```
- Модификаторы доступа указываются **всегда**, включая явный `private`.
## 4. Проектные соглашения .NET
Машиночитаемая часть правил форматирования/анализа — в `.editorconfig` и `Directory.Build.props`
(`Nullable=enable`, `TreatWarningsAsErrors=true`, `EnforceCodeStyleInBuild=true`). Ниже — соглашения
уровня кода, которые этими файлами не выражаются.
- **Публичные члены — только свойства** (`{ get; init; }` / `{ get; set; }`), **не публичные поля**.
**[изм.]** Отменяет исходное правило о публичных полях: публичное состояние — свойство.
- Приватное/внутреннее состояние без дополнительной логики — поле (см. §6); с логикой — свойство.
- Зависимости — через конструктор (DI). Настройки — через `IOptions<T>` / `IOptionsSnapshot<T>`;
прямое чтение `IConfiguration` в бизнес-коде не допускается.
- `var` не использовать для встроенных типов и когда тип неочевиден — предпочитать явный тип
(см. `.editorconfig`, `csharp_style_var_* = false`).
- **Время**: `DateTimeOffset` в UTC внутри домена; на wire — epoch-миллисекунды. Локальное время —
только на границе представления (UI).
- **JSON на wire** — camelCase; ошибки API — объект `{ "detail": ... }`.
- **Идентификаторы** — с префиксом сущности/типа (напр. `card_...`, `board_...`), без «сырых» чисел.
- `this.` для обращения к членам **запрещён** (`dotnet_style_qualification_* = false:warning`).
К приватным полям обращаемся по имени с `_` (`_logger.Info(...)`), к свойствам/методам — без
квалификации. Запрет распространяется на поля, свойства, методы и события. **[изм.]**
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
запрещены — только `await`.
## 5. Комментирование кода
Все комментарии — на русском языке.
- **Комментируем то, что видно снаружи.** XML-doc (`///`) — на **public/protected** члены, типы и
интерфейсы. **[изм.]** Приватные/внутренние детали реализации комментариями не «обвешиваем» — только
там, где неочевидна причина/ограничение (короткий обычный комментарий).
- **Кратко.** Комментарий объясняет **зачем и что**, а не пересказывает код. Не дублировать имя и
сигнатуру словами.
- **`<summary>` — короткое описание (одна фраза).** Это назначение типа/члена, а **не** «как оно
работает» и не пояснения/детали реализации. Несколько предложений в summary не пишем.
- **`<remarks>` не используем** — подробные пояснения «как устроено» не нужны; rationale — только если
поведение действительно неочевидно, коротким обычным комментарием.
- **Никаких упоминаний процесса:** в комментариях запрещены ссылки на таски/этапы/рулинги/планы и
прототип (`Task N`, `Ruling N`, `этап N`, `python L…`, `main.py`, `прототип`, `LEADRADAR_*` и т.п.).
- **Внутренние `//`-комментарии — только для неочевидного поведения** (причина, ограничение, подвох).
Пересказ кода, пошаговая навигация и «что делает следующая строка» — удалять.
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
Правильно:
```csharp
/// <summary>
/// Краткое описание назначения.
/// </summary>
public void DoWork() { }
```
Неправильно:
```csharp
/// <summary>Краткое описание.</summary>
public void DoWork() { }
```
- Прочие теги (`<param>`, `<returns>`, `<remarks>`, `<inheritdoc/>`) — по необходимости; `<param>`/`<returns>`
можно однострочно, `<remarks>` — блоком.
- Для функций, создающих исключения, возможные исключения указывать в `<exception>`.
- Для примеров использования — `<example>`, `<remarks>`, `<code>`.
- Для ссылок в документации — `<see cref="..."/>`, `<seeAlso cref="..."/>`.
- Спецсимволы XML в тексте комментария — через `CDATA`.
- Для сложных/неочевидных алгоритмов — пояснение каждого шага прямо в коде.
- **[изм.]** При изменении критичных участков/ядра — комментарий: кто, когда, почему.
- Временные заплатки — с `//TODO:` и указанием, что и когда должно быть исправлено.
- Неочевидные межкомпонентные зависимости (не ловятся компилятором) — описывать подробно.
## 6. Переменные и типы
- Свойство использовать только когда есть смысл. Если при получении/сохранении дополнительной логики
нет — использовать поле.
- Использовать максимально простой достаточный тип (`int`, а не `long`, когда `int` хватает).
- Константы — только для простых типов; для сложных — `static readonly`-поля.
- `object` — только когда действительно необходимо; в остальных случаях generic-и. `Hashtable` → `Dictionary<>`,
`ArrayList` → `List<>`.
- Boxing/unboxing value-типов — только при необходимости.
- При задании нецелых значений — минимум одна цифра до и после точки.
- Использовать имена типов C# (`int`, `string`), а не CTS (`Int32`, `String`).
- Поля и переменные инициализировать при объявлении, когда возможно.
- Конструктор по умолчанию, если класс требует параметров инициализации, делать `private`, чтобы клиент
не создал неинициализированный объект.
- Magic numbers для статусов/состояний запрещены — только константы/enum:
```csharp
// плохо
public User GetUserByStatus(int statusId);
// хорошо
public User GetUserByStatus(UserStatus userStatus);
```
- Если `get`/`set` содержит сложные вычисления, преобразование, побочный эффект или долго выполняется —
заменить свойством на метод.
- Свойство не должно менять значение от вызова к вызову при неизменном состоянии объекта.
- Внутри `get`/`set` не должно быть обращений к коду, не связанному напрямую с получением/сохранением значения.
- Настройки, влияющие на работу приложения, не хардкодить — выносить в конфигурацию. Значения по умолчанию
прописывать; если default невозможен и ключ отсутствует — выбрасывать исключение.
## 7. Функции
- Функции, возвращающие массив/коллекцию, всегда возвращают массив/коллекцию: если данных нет — пустой
экземпляр, но не `null`.
- Не более 7 параметров у функции. Больше — объединять в класс/DTO.
- **Перенос параметров:** если параметров **больше двух** — каждый на **отдельной строке** (открывающая `(` — в конце первой строки, закрывающая `)` — на отдельной строке с отступом объявления); если **два или меньше** — все параметры **в одну строку**.
Больше двух:
```csharp
public async Task<CardMoveResultDto> MoveAsync(
string cardId,
string toContainerId,
TransitionContext ctx,
CancellationToken ct)
```
Два или меньше:
```csharp
public User FindUser(string login, CancellationToken ct) { }
```
## 8. Управление выполнением программы
- При `foreach` по коллекции саму коллекцию модифицировать нельзя (не добавлять и не удалять элементы).
- Если задача решается и рекурсией, и циклом — предпочитать цикл; рекурсия — только когда цикл сложнее.
- Тернарный оператор — только для простых проверок; сложные условия — через `if`/`else`.
- Сложные составные условия разбивать на простые, сохраняя промежуточные результаты в `bool`-переменные.
- Типы, реализующие `IDisposable`, создавать в `using`:
```csharp
using (SqlConnection sqlConnection = new SqlConnection(...)) { }
```
## 9. События, делегаты, потоки
- Перед вызовом делегата/события — всегда проверка на `null`.
- Для простых event-ов использовать `EventHandler`/`EventArgs`.
- Для сложных event-ов — наследники `EventArgs`.
- Для блокировок использовать `lock`, а не класс `Monitor`.
## 10. Исключения и их обработка
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
- Свои исключения наследовать от `Exception`.
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
БД, неизвестные идентификаторы и т.п.).
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены.
- В лог об ошибке, как правило, писать `StackTrace`.
## 11. Интерфейсы
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
Текст описания пишется **один раз** — у интерфейса.
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
реализации (codemod `scripts/make_explicit.py`, идемпотентный).
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
- **Тесты: моки — через NSubstitute** (`Substitute.For<IPasswordHasher>()`), тестовые переменные
типизируются интерфейсом. Новые hand-written фейк-классы не заводить; существующие мигрируются
поэтапно (план — `backlog.md`, `TD-TESTS-NSUBSTITUTE`). **[изм. 2026-09-11]**
## 12. Приложение: сводная таблица правил именования
| Идентификатор | Регистр | Пример |
| --- | --- | --- |
| Класс | Pascal | `User` |
| Локальная переменная | camel | `user` |
| Интерфейс | Pascal (`I`) | `IDisposable` |
| Generic | Pascal (`T`) | `T`, `TKey`, `TValue` |
| Публичная функция | Pascal | `Authenticate` |
| Приватная функция | Pascal | `Authenticate` |
| Параметр функции | camel | `userId` |
| Публичное свойство | Pascal | `FirstName` |
| Приватное свойство | Pascal | `FirstName` |
| Публичное поле | Pascal | `FirstName` |
| Приватное поле | `_` + camel | `_firstName` |
| Приватное `const` / `static readonly` | Pascal | `MaxRetryCount` |
| Константа | Pascal | `MaxRetryCount` |
| Enum | Pascal | `UserStatus` |
| Значение enum | Pascal | `Active` |
| Exception | Pascal (+`Exception`) | `UserAuthenticationException` |
| Event | Pascal | `StatusChanged` |
| Namespace | Pascal | `Deal.Core.Cards` |
## 13. Автоматизация
- **Служебные скрипты (codemod'ы, скрипты сборки/тестов/бэкапов) в репозиторий не входят** —
правило владельца: в репе только код. Актуальные копии живут локально, вне кода.
- **Проверка на новом коде**: правила `<summary>`-блока и «комментарии только на public» проверяемы
статически; задел — линтер (по аналогии с `scripts/i18n-lint.mjs`) и/или анализаторы Roslyn/StyleCop в
`Directory.Build.props`.
- Открытые пункты аудита и решения по ним — `docs/spec/Код-стайл-аудит-2026-09-11.md`.
@@ -0,0 +1,62 @@
# Аудит кода на соответствие код-стайлу «Дейл» (2026-09-11)
> Отчёт прохода по всему C#-коду (`src/**/*.cs`, 928 файлов, без `bin/obj`).
> Правила — `docs/spec/Код-стайл-Дейл.md`. Проверка: сборка 4 решений + все тесты.
## 1. Исправлено (применено и проверено)
| Пункт | Правило | Было | Стало | Инструмент |
| --- | --- | --- | --- | --- |
| Блочный `<summary>` | §5 | 5286 однострочных/инлайн (833 файла) | 0 | `scripts/fix_summary_blocks.py --apply` |
| XML-док на private/internal | §5 | 2028 блоков (359 файлов) | 0 (понижены до `//`) | `scripts/fix_private_docs.py --apply` |
| Квалификация `this.` | §4 | 124 | **0** | разовый Roslyn-инструмент (семантический) |
| Приватные instance-поля | §1 | camelCase (`logger`) | `_camelCase` (`_logger`) | разовый Roslyn-инструмент |
| Приватные `static readonly`/`const` | §1 | — | Pascal (`DefaultTimeout`) | разовый Roslyn-инструмент |
Дополнительно в `.editorconfig` включены машинные правила, теперь ломающие сборку при нарушении
(`TreatWarningsAsErrors=true`, `EnforceCodeStyleInBuild=true`):
- `dotnet_style_qualification_for_{field,property,method,event} = false:warning` — запрет `this.`;
- правила именования `IDE1006`: приватные instance-поля `_camelCase`, `const`/`static readonly` — Pascal.
Также проверено и **не требует правок**: `#region` нет; trailing whitespace нет; все файлы заканчиваются
переводом строки; кодировка UTF-8; настоящих public-полей нет (публичные члены — свойства); явные
модификаторы доступа соблюдены.
### Проверка после правок
- `dotnet build``Deal.sln`, `Deal.Telegram.sln`, `Deal.Ai.sln`, `Deal.Ml.sln`: 0 ошибок / 0 предупреждений.
- Тесты: core **1275/1275**, telegram **125/125**, ai **52/52**, ml **38/38** — все пройдены.
- Повторный прогон renamer: `this.` — 0, полей к переименованию — 0 (идемпотентно).
## 2. Остатки — решения (закрыто 2026-09-11, вечер)
1. **`var` — закрыто.** В `.editorconfig` включён ломающий сборку гейт `csharp_style_var_for_built_in_types = false:warning`
(запрет только для встроенных типов — как в §4); режимы «очевидный тип» и «прочие» оставлены `silent`
осознанно: правка субъективна и потребовала бы ~1600 механических замен. Остаток встроенных типов
выправлен `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, вечер)
- Повторный прогон `scripts/fix_private_docs.py --apply`: понижено 12 XML-доков на private/internal (extension-файлы).
- Добавлены недостающие `<summary>`: `IContainerRules.Keywords`/`Stack`, `ITenantContext.TenantId`/`HasTenant`.
- Переведены на русский англоязычные `//`-комментарии (3 шт. из 18 найденных; остальные — имена
сущностей/заголовки секций тестов, не англоязычный текст).
- Из индекса убраны случайно закоммиченные `archive/**/__pycache__/*.pyc` (2 шт., уже в `.gitignore`).
- STATUS.md: удалён устаревший блок «Осталось (в backlog)» в шапке (пункты закрыты generic-контрактом источника).
- Дочистка по контрольному скану краткости: удалены 73 очевидных `<param name="ct|cancellationToken">`
(«Токен отмены.» — пересказ сигнатуры, §5) в 17 файлах; ужаты 3 summary (2 многосентенционных, 1 длинное).
Контроль: `<remarks>` — 0, inline-`<summary>` — 0, многосентенционных summary — 0, TODO — 0.
@@ -0,0 +1,257 @@
# Дейл (Deal) — Техническое задание на новую архитектуру
> Версия: 1.0 (отражает этапы 0–12)
> Дата: 2026-09-10
> Связанные документы: `docs/architecture/2026-09-05-deal-architecture-design.md`,
> `docs/architecture/2026-09-10-unified-api-contract.md`,
> `docs/architecture/2026-09-10-operator-analytics-contract.md`,
> исходное ТЗ прототипа LeadRadar V1.2 — `archive/leadradar-legacy/ТЗ-LeadRadar-v1.2.md`.
---
## 1. О продукте
«Дейл» — SaaS-сервис мониторинга Telegram-каналов и групп. Клиент подключает свой
Telegram-аккаунт, выбирает каналы/группы для мониторинга, а система:
1. получает сообщения из источников в реальном времени;
2. отсеивает мусор: рекламу, скам, служебные сообщения, дубликаты, устаревшее;
3. структурирует оставшееся в **карточки** (заказ/вакансия/услуга) по профилю клиента
(сфера, стек, бюджет, локация);
4. раскладывает карточки по **колонкам-фильтрам** клиента;
5. обучается на действиях клиента (ML) и всё больше обрабатывает поток сама;
6. помогает искать и подключать новые источники (Discovery).
**Целевая аудитория:** специалисты и мастера в разных сферах (разработчики, дизайнеры,
риелторы, строители и т.д.), которые ищут реальные заказы и клиентов в Telegram.
**Ключевая ценность:** видеть реальные заказы и клиентов, а не кучу дубликатов и рекламы.
---
## 2. Термины
- **Тенант** — клиент SaaS. Владеет схемой БД, настройками обработки, ML-моделью.
- **Аккаунт (Telegram)** — личный Telegram-аккаунт тенанта, подключённый к системе.
- **Источник** — откуда система получает записи. Сейчас это Telegram-канал/группа/чат (тема форума);
контракт источника универсален, поэтому позже сюда добавляются другие сервисы (WhatsApp, сайты,
файлы/таблицы) без изменения ядра.
- **Сырое сообщение** — оригинальное сообщение из источника до обработки (входные данные пайплайна).
- **Карточка** — единая сущность системы: ядро (id, заголовок, источник) + опциональные модули
(содержимое, бюджет, контакты, атрибуты, комментарии, ссылки, файлы, ТЗ, история, напоминания,
размещение в контейнере). Создаётся из прошедшего фильтры сообщения либо вручную; переезжает между
дашбордами/контейнерами без смены сущности. Термин «лид» не используется — это лишь входное сообщение.
- **Источник (Source)** — откуда пришла карточка: локально/вручную, ссылка на сайт, файл, Telegram
(канал/группа/чат, тема форума), колонка импортированных данных, внешний API, ИИ (провайдер+модель),
составной «первоисточник + цепочка обработки».
- **Контейнер** — общая база колонок/стадий/зон: пользовательские колонки дашборда (набор фильтров),
стадии «Выбранных», «Неразобранное», архив, корзина, терминальные зоны. У каждого контейнера —
политика (что можно/нельзя, автоочистка, терминальность).
- **Отсев** — сообщения, отклонённые пайплайном (с причиной).
---
## 3. Роли и доступ
| Роль | Возможности |
|---|---|
| **Оператор (владелец SaaS)** | Создаёт тенантов и инвайты; управляет лимитами; видит health; impersonation с аудитом |
| **Тенант (клиент)** | Входит по инвайту, задаёт пароль; подключает свой Telegram-аккаунт; настраивает обработку; работает с дашбордом |
- Регистрация — **только по инвайту** (ссылка/код от оператора).
- Логин: email + пароль; email уникален в масштабе SaaS; `tenantId` — в сессии/JWT.
- Вход оператора — отдельный, изолированный от тенантов.
---
## 4. Подключение Telegram-аккаунта
1. Оператор один раз задаёт ключи приложения Telegram (`api_id`/`api_hash`) — глобально.
2. Тенант в UI: «Добавить аккаунт» → QR-код (или телефон + код подтверждения).
3. Система сохраняет сессию аккаунта (в telegram-service) и показывает статус подключения.
4. **1 аккаунт на тенанта** на старте (схема допускает расширение).
5. При первом подключении система подтягивает список диалогов аккаунта (каналы/группы/чаты)
и обновляет его при каждом входе на экран каналов и в фоне (появление/исчезновение
источников отслеживается автоматически).
### Мониторинг источников
- Тенант включает/выключает мониторинг по каждому источнику из списка его диалогов.
- Настройка «новый чат → мониторинг автоматически» (вкл/выкл).
- Источники, удалённые/покинутые вне системы, исчезают из списка.
- Кнопка «Перечитать»: догон последних ~10 сообщений всех включённых источников
(с паузами, анти-бан).
- Полученные сообщения **сразу помечаются прочитанными** в Telegram.
### Discovery (поиск и подключение источников)
- Тенант создаёт **задачу поиска**: описание цели → ИИ генерирует ключевые слова.
- Система ищет каналы/группы/форумы, в которых аккаунт **не состоит** (глобальное правило).
- Каскад фильтров: участники → язык → содержание (по темам, порог ≥40%).
- Кандидаты показываются «на рассмотрение» с метаданными (тип, участники, fit «X из N»,
темы форума, метки: закрытая группа и т.п.).
- Действия: «Вступить и мониторить» (вручную) или авто-вступление с квотами
(50/сутки общий, паузы 50–70 с), «Отклонить» → чёрный список.
- Чёрный список исключает источник во всех задачах; снимается вручную.
---
## 5. Обработка входящих (пайплайн)
Путь сообщения: **источник → очередь → стоп-лист → дедуп → ML → ИИ → карточка**.
Всё, что отсеяно, — в «Отсеве» с причиной. Настройки обработки — **per-tenant**.
### Этап 1 (без ИИ, дёшево)
1. Минимальная длина текста.
2. **Стоп-фразы** (настраиваемый список).
3. Отсев резюме соискателей (настройка).
4. Тип заявки (только вакансии / только заказы) по контексту.
5. **Дедуп**: одинаковый текст (нормализованный хэш) уже в системе → отсев «повтор».
6. Устаревшее сообщение (старше срока архивации) → отсев.
### ML-слой
- Если ML-модель тенанта уверена — решает сама: спам → отсев; колонка → карточка сразу.
- Не уверена → сообщение уходит на ИИ.
- Возврат из отсева (force) идёт мимо ML к ИИ-классификации.
### ИИ-слой (если включён)
- ИИ-фильтр: сообщение не про заявки/интересы тенанта → отсев.
- Классификация: структурированный разбор (компания, формат, о задаче, требования,
плюсы, условия, бюджет, стек, контакты, тип заявки).
- Назначение колонки с проверкой её правил.
### Глобальные фильтры
- «Не создавать карточку без суммы» — отдельно для вакансий и для заказов.
- Исключения по ключевым словам/технологиям/бюджету/локации (стоп на уровне фильтров).
### Карточка
- Единая сущность: ядро (id, заголовок, источник) + опциональные модули. Вид карточки — композиция
модулей, не отдельный класс/таблица; третий дашборд работает с той же карточкой.
- Реализация (этап 9): карточка — **одна строка одной таблицы `Cards`** во всех дашбордах; таблица
`ProjectCards` упразднена. Модули — колонки той же строки (`StackJson`/`LinksJson`/`FilesJson`/
`HistoryJson`/`TzText`/напоминание), комментарии — общая таблица `LeadComments`. Колонки/стадии/зоны —
единый реестр контейнеров; пространства не пересекаются (карточка не может быть одновременно
в дашборде и в «Выбранных»), «взять в работу» — смена контейнера, а не клон.
- Модули: содержимое (единая структура «О заявке»: Компания → Формат → О задаче → Требования →
Будет плюсом → Условия), бюджет (from/to/валюта), контакты (квалифицированные: tg/phone/email/
linkedin/site), атрибуты (стек/грейд/локация/сроки — настраиваются тенантом в UI, не зашиты),
комментарии, ссылки, файлы, ТЗ, история движения, напоминание, размещение в контейнере.
- Исходное сообщение карточки хранится и доступно: текст структурируется и показывается в карточке,
вложения/ссылки/контакты — отдельными блоками; кнопка «Обновить из источника» догружает оригинал
у сервиса-владельца источника (для Telegram — по id сообщения), если он доступен.
---
## 6. Дашборд (канбан)
- Колонки: «Неразобранное», пользовательские колонки (набор фильтров), «Архив», «Корзина».
- Пользовательские колонки создаёт пользователь; ИИ может **предлагать** колонки с
обоснованием (по каким критериям), пользователь принимает/отклоняет/переименовывает.
- Колонка = сложный набор опциональных фильтров: ключевые слова/стек/грейд/уровень/цена/
бюджет/локация/тип + отрицательные фильтры («чтобы не попадало»).
- При помещении карточки в колонку указывается, **по каким критериям** она попала.
- Карточки в колонке: свежие сверху. Drag&drop между колонками (с обучением ML).
- Быстрые действия на карточке: комментарий, корзина, контакт, «открыть исходник».
- Виджеты-счётчики свёрнутых колонок; колонки можно двигать, менять размер/ширину.
- **Архив**: карточки старше N дней (настройка 1–30); очистка архива через 90 дней.
- **Корзина**: очистка раз в 7 дней; из архива/корзины карточку можно вернуть (на канбан).
### «Выбранные» (пространство стадий)
- То же пространство карточек: **те же карточки** в контейнерах-стадиях
(Запланировано → Отклик → Согласование → В работе → Проверка → Готово / Отложено и др.).
«Взять в работу» — переход карточки в контейнер, а не создание второй сущности.
- У карточки наполняются модули работы: комментарии, изменение суммы/стека/контактов,
прикрепление ссылок, ТЗ, **файлов** (медиа/документы; тип определяется автоматически;
хранение в S3/MinIO; на карточке значки количества файлов и ссылок).
- Отложенные: напоминания (через срок + в заданное время, выбор даты в календаре);
настройка в общих настройках; если напоминания выключены — окно не показывается и
установленные не срабатывают.
- История движения карточки (статус, дата, время) — под спойлером в карточке.
- Ручное создание карточки с тем же набором полей (пометка «создано локально»).
- В архив/корзину дашборда карточки «Выбранных» не попадают; свои терминальные зоны:
«Отклонено», «Выполнено» (политики контейнеров).
---
## 7. Вкладка «Обработка»
- **Очередь**: сырые сообщения, ожидающие обработки (этап 1 / ожидают ИИ), с автопрокруткой.
- **Отсев**: отклонённые сообщения с причиной и источником решения
(правила / ML / ИИ / система), включая конкретное стоп-слово/фразу.
- У записи: метаданные (источник, подпись, вид, время), «показать исходное сообщение» с форматированием,
кнопка обновления исходника у сервиса-владельца источника.
- Поиск по отсеву — полнотекстовый.
- Возврат из отсева в обработку: причины отсева игнорируются, ML/ИИ обучаются на решении;
можно указать причину возврата.
- Автоочистка отсева: раз в 3 дня; ручная очистка.
- Вкладка показывает счётчик обработки (в боковой панели отсев не показывается).
---
## 8. Настройки тенанта
- Telegram: ключи приложения (оператор), подключение аккаунта, авто-мониторинг новых.
- ИИ: провайдер (один; включая локальные), модель, ключ (хранится зашифрованно),
промпты (базовый + свой), библиотека готовых промптов по сферам + «мои промпты»,
вкл/выкл ИИ, вкл/выкл ИИ-фильтр.
- ML: вкл/выкл, обучение на действиях, проверка на сообщении/канале, сброс, самооценка
(«ML справляется с последними N сообщениями — ИИ можно отключить»).
- Обработка: стоп-фразы, длина, резюме, тип заявки, домен/ключи, маркеры найма/заказа.
- Проверка текста: сухой прогон по цепочке (стоп-правила → глобальные исключения → ML → ИИ →
«без суммы») без создания карточки — показывает этапы, причину отсева и куда попала бы карточка.
- Колонки: набор, правила, отрицательные фильтры, исключения.
- Валюта: целевая валюта отображения, источник курсов (4 запроса/сутки), конвертация
при приходе данных + пересчёт старых карточек (кроме архива/корзины); USDT = USD.
- Хранение: срок архивации (1–30 дней), очистка архива/корзины.
- Уведомления и напоминания (общие; отложенные — отдельно).
- Звук, внешний вид.
---
## 9. Лимиты (бюджет токенов)
- Каждый тенант имеет **бюджет токенов** на LLM-вызовы (период — настраивается).
- ai-service оценивает каждый вызов в токенах и списывает с бюджета.
- При исчерпании: AI-обработка переключается на fallback (ML/локальный разбор),
тенант получает уведомление; приём и базовая обработка сообщений не блокируются.
- Оператор видит расход по тенантам в админке и может менять бюджет.
---
## 10. Админка оператора
- Тенанты: создание, инвайты, статус, лимиты/бюджеты, приостановка.
- Health всех сервисов и очередей.
- Аудит: входы/выходы, инвайты, impersonation, действия оператора и пользователей тенанта
(создание/перенос/удаление карточек, комментарии, контейнеры, настройки, каналы).
- Аналитика: расход токенов (по дню/тенанту/провайдеру/модели) и лента действий с фильтрами.
- Подозрительная активность (по логам безопасности) и метрики сервисов (Prometheus/Grafana).
- UI: оператор-консоль (`#/operator`) и страница активации инвайта (`#/join`).
---
## 11. Нефункциональные требования
- **Безопасность**: TLS, mTLS между сервисами, параметризованный SQL, защита от
IDOR/XSS/SSRF/CSRF, Argon2id, rate limiting (прокси + приложение; счётчики — распределённые,
в БД, работают при нескольких инстансах), Cloudflare.
- **Надёжность**: ежедневные бэкапы (Postgres, файлы, сессии), outbox для событий;
авто-очистки (retention аудита, лимитов, окон rate-limit); мгновенный разлогин suspended-сессий.
- **Наблюдаемость**: структурированные логи → Loki, метрики (OpenTelemetry → Prometheus) → Grafana
+ правила алертов; история расхода токенов (`token_usage_events`).
- **Масштабируемость**: модульный монолит + отдельные сервисы (ml/ai/telegram);
горизонтальное масштабирование сервисов; k8s — позже.
- **Производительность**: пайплайн обрабатывает поток без потерь; анти-бан-паузы
Telegram не блокируют обработку.
- **Локализация (i18n)**: весь интерфейс — на русском; все пользовательские строки вынесены в ресурсы
(без хардкода в компонентах), включая тексты ошибок; фолбэк — русский. Переключатель языка и второй
язык — **в бэклоге**: делаем, когда возникнет потребность (основа в ресурсах уже готова).
Область — основное приложение и оператор-консоль. (Этап 11 roadmap.)
---
## 12. Ограничения и допущения
- Фронтенд (Vue 3 + Vite + Tailwind) переезжает из LeadRadar; с этапа 9 контракт карточек/колонок — единый
(`/api/cards` + `/api/containers`, см. `docs/architecture/2026-09-10-unified-api-contract.md`).
- Данные текущего LeadRadar тестовые — не мигрируются.
- Kafka, k8s, биллинг-провайдер, саморегистрация — вне рамок текущего этапа.
- 1 Telegram-аккаунт на тенанта; несколько аккаунтов — позже (схема готова).
+241
View File
@@ -0,0 +1,241 @@
# Дейл (Deal) — Статус разработки и прогресс
> Обновляется в конце каждого захода. Проект в git (ветка `main`, коммиты на русском) — борд состояния + git-история.
> Дата последнего обновления: 2026-09-11.
>
> **2026-09-11 — единый контракт источника (generic source).** Ядро (домен Cards, Storage-сервис,
> персистентность, конвейер, wire, фронт) переведено с Telegram-полей карточки на generic-тип
> `SourceItem` (`SourceRef` + `SourceContent`, вложения — `DataRef` → общий Storage). Дашборды/канбан/конвейер
> больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции
> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и
> `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`,
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`; actor/tenant в access-логе; прогресс переклассификации
> через SSE. Hardening контейнеров (non-root/read-only/limits), шардированная пакетная миграция схем,
> единый 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` зелёные.
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
>
> **2026-09-11 (вечер) — закрыты остатки код-стайла (TD-COMMENTS-IFACE, TD-STYLE-ANALYZERS).** Дедупликация
> `<summary>`: дублей нет (сканы по тексту и по имени члена — 39 интерфейсов/229 членов). `var`: гейт
> `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
> `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10
> `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md` и ledgers в `.superpowers/sdd/`.
> Live-приёмки на Docker Desktop выполнены: dev-smoke 14/14, SaaS-контур 15/15, prod-контур+mTLS+
> observability PASS, backup/restore на копии PASS, runtime-приёмка этапа 10 (оператор-консоль,
> аналитика, аудит) PASS (см. чек-лист ниже). Осталось Manual: реальные Telegram/LLM-креды (п.5).
## Общий прогресс по этапам
| Этап | Статус | Задач | Тесты (unit, накопительно) | Приёмка |
|---|---|---|---|---|
| 0. Каркас | ✅ готов | 9/9 | 6 | health 200 |
| 1. Доступ и мультитенантность | ✅ готов | 6/6 | 25 | auth 1:1 |
| 2. Settings (настройки) | ✅ готов | 11/11 | 175 | 60/60 |
| 3. Kanban (дашборд) | ✅ готов | 15/15 | 410 | 94/94 |
| 4. Pipeline/«Обработка» | ✅ готов | 13/13 | 535 | 74/74 |
| 5. Projects («Выбранные») | ✅ готов | 13/13 | 620 | 75/75 |
| 6. Сервисы telegram/ml/ai + Discovery | ✅ готов | 20/20 | 830 | 20/20 + 37/37 |
| 7. SaaS-контур (оператор/инвайты/лимиты/аудит/безопасность/prod-деплой/бэкапы/доки) | ✅ готов | 16/16 | 1123 | ✅ live SaaS 15/15 (остальное — ⚠ Manual) |
| 8. Code-quality rework (ревью 5 зон) | ✅ готов | 5/5 фаз | 1139 | build 4 sln 0/0; фронт build OK |
| 9. Единая карточка (слияние Kanban/Projects, `/api/cards`+`/api/containers`) | ✅ готов | 11/11 | 1138 | ✅ live dev-smoke PASS=14 FAIL=0 |
| 10. Оператор-консоль, аналитика, аудит действий, Grafana/Loki-дашборды | ✅ готов | 7/7 | 1173 | ✅ live runtime (Docker dev) |
| 11. Локализация UI (вынос строк в ресурсы) | ✅ готов | 7/7 | 1173 | build + `lint:i18n` зелёные |
| 12. Наблюдаемость/устойчивость/перф + добивка ТЗ | ✅ готов | 4/4 пакетов + добивка | 1275 | build 4 sln 0/0; telegram 125/125 |
| **Итого** | **этапы 012 = 100%** | **137/137** | **1275 (core)** + 125/52/38 (сервисы) | — |
Финальный прогон этапа 12 (2026-09-10, автономный заход A–D): build `Deal.sln` 0/0; core **1275/1275 PASS**;
telegram **125/125**; фронт `npm run build` зелёный (main-чанк 309 kB, словарь в отдельном чанке i18n),
`npm run lint:i18n` зелёный; метрики: `/metrics` (OTel→Prometheus) во всех 4 процессах, Prometheus targets 5/5 UP;
пакеты B (rate-limit/LoginAttemptGuard на Postgres, разлогин suspended, purge) и D (reclassify + токены ML)
с зелёными тестами. Всё остановлено (правило «без хвостов»). LEDGER: `.superpowers/sdd/deal-stage12-observability-hardening/`.
Финальный прогон этапа 10 (2026-09-10): build `Deal.sln` 0/0; core **1173/1173 PASS**; фронт
`npm run build` зелёный; `GET /api/operator/analytics/{overview,tokens,activity}` и
`GET /api/operator/audit` живьём на dev-Postgres (`:5433`, миграция `AddTokenUsageEvents` применена),
`deal-core` healthy; страницы `#/operator` и `#/join` отдаются dev-сервером. Детали — ledger
`.superpowers/sdd/deal-stage10-operator-analytics/progress.md`.
Финальный прогон (Task 16, 2026-09-08, docker выключен): build 0 warnings / 0 errors всех четырёх sln
(core/telegram/ai/ml); core 1123/1123 PASS, telegram 114/114, ai 50/50, ml 36/36 PASS;
`docker compose -f deploy/compose.prod.yml config` rc=0 (+ профиль observability);
`sh -n` scripts/dev-smoke.sh/backup.sh/restore.sh/mtls-certs.sh rc=0.
> Накопительный счётчик core в таблице — по состоянию на конец этапа: этап 8 — 1139, этап 9 — 1138
> (часть тестов удалена вместе с доменом Projects), этап 10 — 1173.
## Что система умеет СЕЙЧАС (проверяемо)
- **Ядро (этапы 06)**: вход admin/admin (dev-seed, dev-only), сессии, схемы на тенанта (Postgres :5433);
дашборд (колонки/карточки/drag&drop/архив/корзина/FTS-поиск), «Обработка»
(очередь→стоп-лист→дедуп→ML/ИИ→карточка), «Выбранные» (стадии/напоминания SSE/файлы Local/MinIO),
Настройки (ключи AI enc:, промпты, валюты; Telegram-ключи — глобально у оператора), Каналы/Discovery; полный dev-стек этапа 6 —
`deploy/compose.dev.yml` (`Services__*__UseLocal=false`, gRPC-режим telegram/ai/ml + ингресс core).
- **SaaS-контур (этап 7)**: оператор (`public.operators/operator_sessions`, кука `deal_operator_session`,
bootstrap env `DEAL_OPERATOR_*`; dev-дефолт operator/operator) и ручки `/api/operator/*`
(auth/tenants/invites/limits/audit/health — с этапа 10 у них есть UI, см. ниже); инвайты (код 16 симв., 72 ч) и активация
`POST /api/join` (пользователь + провижининг тенанта); лимиты ИИ-бюджета (`tenant_limits`,
`TokenUsageRecorder`, гейт-декораторы → Local-фолбэк, SSE-тосты 80/100%); append-only аудит;
rate limiting (auth 10/мин·IP, api 600/мин·тенант, gRPC-ингресс 600/мин·тенант, `LoginAttemptGuard`
5/15 мин); Origin-проверка мутаций + security-заголовки + ForwardedHeaders за Caddy;
mTLS за флагом `DEAL_MTLS_*` (`scripts/mtls-certs.sh``deploy/certs/`); Serilog JSON во всех
4 процессах (+ access-логи HTTP/gRPC); prod-деплой `deploy/compose.prod.yml` (caddy 80/443,
postgres/minio без host-портов, профиль observability: promtail/loki/grafana, `.env.prod.example`);
бэкапы `scripts/backup.sh`/`restore.sh`/`deal-backup-lib.sh` (pg_dump -Fc + MinIO + tar; retention 14).
- **Оператор-консоль и аналитика (этап 10)**: hash-роутер фронта (`#/` приложение, `#/operator` консоль,
`#/join?code=…` активация инвайта); разделы консоли (вход, тенанты+suspend/resume/impersonate,
инвайты, лимиты, аудит с фильтрами/пагинацией, аналитика, health); impersonation ставит httpOnly-куку
`deal_session` тем же ответом (оператор сразу в тенанте). Сквозной аудит действий (`public.audit_log`):
выходы, `invite_joined`, действия карточек/комментариев, CRUD контейнеров, настройки, каналы, Telegram.
История расхода токенов `public.token_usage_events` + операторская аналитика
(`/api/operator/analytics/{overview,tokens,activity}`, `groupBy=day|tenant|provider|model`).
Grafana provisioning (datasource Loki + дашборды `Deal-Auth/Errors/Rps/Logs`) и promtail-лейблы.
- **Что увидеть глазами**: полный dev-стек — `docker compose -f deploy/compose.dev.yml up -d --build`
→ фронт `cd src/frontend && npm run dev` → логин `admin/admin` (dev-seed);
либо сквозной smoke одной командой: `sh scripts/dev-smoke.sh`. Prod-контур — §13.8 техдока
(оператор → тенант → инвайт → `/api/join`). Host-режим (Local-заглушки): postgres/minio +
`dotnet run --project src/core/Deal.Api --urls http://localhost:5080`.
- Полная карта «что/где» — техдок `docs/technical/Техническая-документация-Дейл.md` (§5, §7–§11,
§13.1–§13.10), api-map `docs/api/api-map.md` (раздел «Реализовано в Deal»), инструкция пользователя
`docs/user-guide/Инструкция-пользователя-Дейл.md`.
## Manual-чек-лист (остаток после live-приёмок)
Выполнено живьём на Docker (автономно от авто-прогонов Task 16, build/test/config/syntax — там же):
1.**SaaS-сквозная приёмка (live, 15/15 PASS)**`run-live-saas.sh` + `live-saas-check.sh` на поднятом
dev-Postgres (:5433, миграции SystemSaaS + SessionsImpersonationMark применены): оператор login →
создать тенанта → инвайт → `POST /api/join` → вход пользователя → settings/cards (на тот момент —
`boards`/демо-карточка) →
IDOR-негатив 401 (пользователь к операторским ручкам) → suspend (вход 403) → resume (вход 200) →
лимиты (tenant_limits) → аудит-лента. Core погашен, :5080 свободен.
2.**dev-smoke 14/14 PASS** — полный gRPC-стек (`scripts/dev-smoke.sh`): подъём, health, login
admin/admin, `/api/tg/status` idle, `POST /api/cards` (карточка `planned`) → trash → обучающий сигнал spam,
ML-флашер выгрузил outbox. Стек погашен скриптом (trap).
3.**Prod-контур + mTLS + observability (live)** — сертификаты перегенерированы (`scripts/mtls-certs.sh -f`,)
полный набор в deploy/certs; подъём `compose.prod.yml` + `--profile observability` с фиктивными
env-секретами (`DEAL_MTLS_ENABLED=1`, endpoint'ы https://): core/telegram/ai/ml **healthy** под mTLS;
исходящее mTLS подтверждено живьём — `/api/tg/status` (idle) и `/api/ml/status` (reachable:true) через
Caddy; фронт и `/api/health` через Caddy 200; promtail→loki (логи пишутся), Grafana 200. Исправлен
дефект `deploy/observability/loki.yml` (Loki 3.x: `delete_request_store`). `.env.prod` тестовый удалён.
4.**backup/restore (live, на копии)**`backup.sh`: pg (-Fc) + minio (docker-mc) + data (tar docker-томов)
+ retention; `restore.sh pg` в копию-БД — 43 таблицы/3 схемы идентичны, данные сошлись (users=2,
tenants=2, sessions=30); `restore.sh minio` с реальным объектом (залит→бэкап→удалён→восстановлен);
`restore.sh data`. Исправлены дефекты скриптов, проявившиеся живьём: двойная схема в MC_HOST_deal
(`deal-backup-lib.sh`), пустой бакет → mv (`backup.sh`), Windows/MSYS docker-пути (`host_docker_path`).
5. ❌ Реальный Telegram-вход (api_id/api_hash/QR) и LLM-вызовы — **нужны живые креды**.
6. ✅ Прогон `scripts/backup.sh` и restore-тест — см. п.4 (полный цикл на dev-хранилищах и копии-БД).
7.**Этап 10 — runtime-приёмка (Docker dev-стек)** — миграция `AddTokenUsageEvents` применена к
dev-Postgres (`:5433`), `deal-core` пересобран/healthy; операторский вход `operator`/`operator` → 200;
`GET /api/operator/tenants`, `/audit`, `/analytics/overview`, `/analytics/tokens?groupBy=day|provider`,
`/analytics/activity?limit=3` → 200 (реальные лента/агрегаты); фронт `npm run build` зелёный, dev-сервер
отдаёт `#/operator` и `#/join`; core-тесты 1173/1173. Детали — ledger этапа 10.
## Заделы (этап 13+; подробно — техдок §11 и roadmap)
> **Единый источник отложенного и техдолга — `docs/superpowers/backlog.md`.** Ниже — краткая выжимка.
- **Этап 11 — Локализация интерфейса (i18n)** — **выполнен** (2026-09-10, урезанный объём): все
пользовательские строки фронта в ресурсах (`src/frontend/src/i18n/`, 1039 ключей в 13 областях),
линтер `npm run lint:i18n`. Переключатель языка и второй язык — **в бэклоге**: делаем, когда появится
потребность (ядро i18n/`registerLocale` к этому готово).
- **Этап 12 — Наблюдаемость/устойчивость/перф** — **выполнен** (2026-09-10, автономно, пакеты A–D):
метрики Prometheus+Grafana (`/metrics` :9464 во всех процессах); распределённый rate-limit и
`LoginAttemptGuard` на Postgres; мгновенный разлогин suspended-сессий; авто-purge `audit_log`/`tenant_limits`;
разбиение бандла фронта + прогрессивный рендер колонок; LRU-кэши WTelegram; пакетная миграция схем тенантов;
реальный `reclassify` с Local-фолбэком. LEDGER: `.superpowers/sdd/deal-stage12-observability-hardening/`.
- **Остатки этапа 12 (закрыто 2026-09-10):** доки под этап 12 (real-reclassify, maintenance-migrate, без
демо), Prometheus alert rules (`deploy/observability/prometheus-rules.yml` + провижининг), устранена гонка
`FreeTcpPort()` в тест-харнессе (единый `TestPort`), SSE `cards_reclassified` (бэк+фронт), нагрузочные
скрипты `scripts/loadtest/`, скан уязвимостей — **чисто** (core: 0 уязвимых пакетов; frontend `npm audit`: 0).
- Прочее (требует владельца/кредов): биллинг/провайдер планов и саморегистрация; мультиаккаунтность Telegram;
k8s/Cloudflare; Kafka; экспорт/импорт ML; переключатель языка/второй язык (в бэклоге — по потребности);
реальный Telegram-вход и живые LLM-вызовы;
legacy-прототип `docker-compose.yml` перенесён в `archive/leadradar-legacy/` (2026-09-10).
- **Добивка по ТЗ (2026-09-10, автономно)** — закрыты найденные аудитом частично/незакрытые пункты:
ML-проверка на канале/сообщении (`/api/ml/candidates`+`/apply` — реальные, не заглушки); глобальные
исключения до ML/ИИ (§5.14); новые группы фильтров колонки `levels/locations/types/prices` (§6.3);
«открыть исходник» как быстрое действие на карточке (§6.6); глубины очередей/сессии в `/api/operator/health` (§10.2);
детектор подозрительной активности `/api/operator/analytics/suspicious` (§10.5); раздел настроек и
поддержка тем «Внешний вид» — тёмная (дефолт) / светлая / системная (§8.12). Отчёт аудита:
`docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md`. Core-тесты **1275/1275**; фронт build+`lint:i18n` зелёные.
- **Telegram-ключи — вариант A (2026-09-10, по решению владельца):** `api_id`/`api_hash` задаёт
**оператор глобально** (таблица `public.global_settings`, ручки `GET/PUT /api/operator/settings/telegram-keys`,
hash шифруется; раздел «Telegram» в оператор-консоли). У тенанта ключи убраны — только подключение
аккаунта; без ключей подключение недоступно (`/api/tg/status → keysSet:false`). Миграция `GlobalSettings`
**применена**; `PUT` поддерживает частичное обновление (можно сменить одно поле). Core-тесты **1275/1275**.
## Code-quality rework (2026-09-08, после ревью 5 зон ~1100 файлов)
Проведено многоосевое ревью (безопасность/корректность/архитектура/перф) бэкенда и фронта; findings —
`docs/superpowers/reviews/2026-09-08-code-quality-review.md`. Все исправления закрыты и проверены:
**core 1135/1135, telegram 118/118, ai 52/52, ml 38/38**, фронт build OK, 4 sln 0/0. Главное:
- **Безопасность**: SSRF-гейт (baseUrl каталоговых провайдеров фиксирован + private-IP-блок в проверке),
fail-closed Production (rate limit/CORS/conn-string обязательны), код инвайта в аудите — SHA-256, пароль ≥8,
маска ключа не перезаписывает ключ и короткие секреты скрыты, TenantId = 32-hex, mTLS fail-closed,
gRPC-лимиты входных данных, join проверяет целевого тенанта, атомарный инкремент токенов (Npgsql).
- **Корректность**: атомарные append комментариев/ссылок/файлов (1 SQL), уникальный objectKey, атомарный
дедуп-pump, запрет move из trash/archive/taken, логирование «немых» catch, очистка сессий вне hot-path;
фронт: смена пароля с текущим паролем, boot не падает от одного 500, автосейв не затирает промпты,
гонки поиска закрыты seq-токенами.
- **Архитектура**: `Deal.Grpc.Hosting` (общая обвязка 3 сервисов), `TenantSettingsSnapshot` (9 копий чтения
настроек → одна), декомпозиция 7 крупных файлов на partial (<350 строк), фронт store.js → слайсы `store/`,
вынесены компоненты Settings/Discovery; мёртвый код удалён.
- **Реестры констант (C35, 2026-09-09)**: общие `Deal.Contracts.Integrations.MlLearningLabels`
(spam/t:hire/t:order) и `SourceDefaults` (DefaultHue) вместо дублей в 5 модулях; единый предикат «активные
правила» (Kanban `ColumnRules.HasActiveRules`); реестр id-стадий `ProjectStages` (с этапа 9 — `CardsDefaultContainers`); общий
`CardsService.JustNowLabel`; TTL-эвикция в DiscoverySearchErrorCounter (+4 теста, core 1139).
- **Заделы** (не рисковали без e2e/не успели): вынос оставшихся вкладок SettingsView, Optional-пункты
(пагинация колонок, виртуализация, LRU-кэши WTelegram и др.). Подробности — в отчёте ревью и
`.superpowers/sdd/deal-stage8-quality-rework/`.
## Процесс (обязательства, чтобы не жрать память/хосты)
- Acceptance-серверы — только через враппер с гарантированным kill (taskkill //T //F по PID-файлу) +
проверка освобождения порта; в конце каждой приёмки — шаг очистки.
- Контейнеры — по требованию (`docker compose ... up|down`); между заходами ничего не держать;
`scripts/cleanup-dev.sh` (kill висящих Deal.*/тест-хостов, `dotnet build-server shutdown`,
остановка deal-контейнеров) — в конце захода.
- **Хвостов не оставлять (правило владельца, 2026-09-10):** по завершении работы все сервисы и процессы
должны быть остановлены — включая Docker-контейнеры и dev-серверы (frontend/Vite, dotnet) — если
владелец явно не попросил оставить их запущенными. Проверка в конце: `docker ps` без `deal-*`,
свободные порты (5173/5080/5082/5101/5102/5103/5433/9000/9001/9464), `dotnet build-server shutdown`.
- **Разовые решения — одним списком в начале (правило владельца, 2026-09-10):** все вопросы, требующие
выбора владельца, собираются и задаются **сразу, до начала работы**, а не по ходу/в конце. **Не
спрашивать о том, что уже определено ТЗ/принятыми решениями** — это делать без вопросов; вопрос —
только при реальном противоречии в требованиях. При неоднозначности без противоречий — выбирать
безопасный обратимый дефолт и делать (напр. перенос, а не удаление).
- **Легаси-прототип LeadRadar** перенесён из корня в `archive/leadradar-legacy/` (2026-09-10; обратимо,
на сборку/запуск не влияет).
+95
View File
@@ -0,0 +1,95 @@
# Бэклог (техдолг и отложенные задачи) — «Дейл»
> Назначение: единый источник отложенного/запланированного. Роудмап черпается отсюда.
> Статусы: **BACKLOG** (сделаем при потребности), **DEFERRED** (отложено осознанно, вне текущих рамок),
> **MANUAL** (нужны внешние условия: креды, хост, прод), **TECHDEBT** (качество/архитектура).
> Приоритет: P1 (важно), P2 (полезно), P3 (когда-нибудь).
> Обновлять при каждом заходе; выполненные пункты переносить в `docs/superpowers/STATUS.md` и вычёркивать здесь.
## 1. Продуктовые фичи (по потребности)
| ID | Пункт | Источник | Приоритет | Статус |
|---|---|---|---|---|
| BL-I18N | Переключатель языка в UI + второй язык (en) + locale-aware форматирование (`Intl`), плюрализация. Основа (вынос строк в ресурсы, `registerLocale`) готова | ТЗ §11, этап 11 | P3 | BACKLOG |
| BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED |
| 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 |
| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto`. **Решение (2026-09-11): DEFERRED.** Наружный контракт единый; внутренние DTO (read/write/DB/patch) намеренно разделены по слоям, слияние — риск без пользы | этап 9/11 | P3 | DEFERRED |
| 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-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 | Остаток мягких правил код-стайла. **Закрыто (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. Инфраструктура и эксплуатация
| ID | Пункт | Источник | Приоритет | Статус |
|---|---|---|---|---|
| BL-K8S | Kubernetes-манифесты (сейчас docker-compose; k8s — при масштабировании) | ТЗ §11/§12, R6 | P3 | DEFERRED |
| BL-KAFKA | Kafka как шина данных между сервисами (сейчас gRPC + БД-outbox) | ТЗ §12, обсуждение | P3 | DEFERRED |
| BL-CF | Cloudflare / внешний периметр (сейчас Caddy, mTLS; конфиг вне кода) | ТЗ §11, техдок §10 | P2 | MANUAL |
| BL-CI | **Сделано (2026-09-11):** `scripts/ci.sh` — сборка всех 5 решений, тесты всех сервисов, скан уязвимых NuGet-зависимостей (`dotnet list package --vulnerable --include-transitive`), сборка+линтер фронта; `build.sh`/`test.sh` расширены на все решения/сервисы; `.github/workflows/ci.yml`. Первый прогон в удалённом CI — при публикации репозитория | техдок §8 | P2 | DONE (remote — MANUAL) |
| BL-IMG-HARDEN | **Сделано (2026-09-11):** non-root USER (deal, UID 10001, HOME=/tmp) во всех прикладных образах; в compose — read_only root FS + tmpfs /tmp, no-new-privileges, cap_drop ALL, mem_limit/cpus; логи stateless-сервисов в /tmp/logs. Проверено `compose config` (dev/prod/observability); живой прогон — MANUAL | техдок §10 | P2 | DONE (live — MANUAL) |
| BL-BACKUP-CRON | Автоматизация бэкапов (cron/systemd-примеры есть, реальный прогон — MANUAL) | техдок §9 | P2 | MANUAL |
## 3. SaaS / мультитенантность
| ID | Пункт | Источник | Приоритет | Статус |
|---|---|---|---|---|
| BL-BILLING | Биллинг и тарифные планы, провайдер платежей | ТЗ §12 | P3 | DEFERRED |
| BL-SIGNUP | Саморегистрация тенантов (сейчас инвайты/оператор) | ТЗ §12 | P3 | DEFERRED |
| BL-SCALE-1000 | Механизм миграций/провижининга на 1000+ схем. **Сделано (2026-09-11):** пакетная миграция шардирована — `ITenantRepository.ListPageAsync` + обход страницами в `TenantSchemaMigrationService` (параллелизм внутри страницы, `DefaultPageSize=200`, границы 1..32 / 1..5000), сбои изолированы. Осталось при росте: вынести параллелизм/размер в конфиг и кэш прогресса (при необходимости) | roadmap этап 0, этап 12 | P2 | DONE |
## 4. Безопасность и наблюдаемость (доработки)
| ID | Пункт | Источник | Приоритет | Статус |
|---|---|---|---|---|
| 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-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 |
## 5. Технический долг (качество/архитектура)
| ID | Пункт | Источник | Приоритет | Статус |
|---|---|---|---|---|
| TD-SETTINGS-UI | Вынос вкладок `SettingsView` в компоненты. **Сделано (2026-09-11):** `SettingsView.vue` — только набор вкладок/QR-опрос, все 10 вкладок — отдельные компоненты (`components/settings/*`) | ревью 2026-09-08 | P3 | DONE |
| TD-VIRT | Полная виртуализация длинных колонок. **Решение (2026-09-11): DEFERRED** — прогрессивный рендер «Показать ещё» покрывает текущие объёмы; виртуализация — при росте списков | ревью, этап 12 | P3 | DEFERRED |
| 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-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-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-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-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-проверки (нужны внешние условия)
| ID | Пункт | Источник | Приоритет | Статус |
|---|---|---|---|---|
| MN-E2E-TG | Реальный Telegram-вход (QR) + приём сообщений, backfill, «Перечитать каналы» | ТЗ §4, STATUS | P1 | MANUAL (креды/аккаунт) |
| MN-E2E-LLM | Живые LLM-вызовы (классификация/фильтр/reclassify/расход токенов) | ТЗ §89 | P1 | MANUAL (LLM-ключ) |
| MN-PROD | Прод-развёртывание (хост/домен, Caddy+mTLS, observability-профиль) | техдок §13.8 | P2 | MANUAL (данные хоста) |
| MN-GRAFANA | Живая проверка Grafana-дашбордов метрик/алертов и логов | этап 12, A/T6 | P2 | MANUAL |
| MN-LOADTEST | Живой нагрузочный прогон (`scripts/loadtest/`) и baseline | этап 12, C | P2 | MANUAL |
| MN-BACKUP | Живой прогон `backup.sh`/restore на реальных данных | техдок §9 | P2 | MANUAL |
| MN-THEME | Визуальная приёмка светлой темы в браузере | этап 12, «Внешний вид» | P3 | MANUAL |
## 7. Отложено/решено «не делать» (для истории)
| ID | Пункт | Решение |
|---|---|---|
| DEC-DEMO | Демо-эндпоинты (`DEAL_DEMO`, `simulate-lead`) | Удалены (этап 9/12) |
| DEC-LEGACY | Легаси-прототип LeadRadar (`backend/`, `mlservice/`, корневой compose) | Перенесён в `archive/leadradar-legacy/` (2026-09-10); 2026-09-11 убран и из репозитория — лежит только локально |
| DEC-ML-EXP | Экспорт/импорт ML | Отложено владельцем (перенесено в `BL-ML-EXP`) |
---
## Как пользоваться
- Для нового захода: выбрать пункты по приоритету/теме, оформить SDD-план в `docs/superpowers/plans/`
и ledger `.superpowers/sdd/<этап>/`, после приёмки — перенести факт в `docs/superpowers/STATUS.md`,
а пункт здесь пометить выполненным/удалить.
- Открытые пункты (BACKLOG/MANUAL) зеркалятся задачами в Gitea (`rust/Deal`, метки P1/P2/P3/manual/techdebt);
при закрытии пункта закрывать задачу и наоборот.
- Пункты `MANUAL` не блокируют разработку; выполняются, когда владелец даёт креды/хост.
@@ -0,0 +1,341 @@
# Поиск и подключение каналов (Discovery) — Implementation Plan
> Исторический документ (план Discovery прототипа LeadRadar, 2026-09-04). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Дать пользователю возможность создавать «задачи поиска»: система находит по описанию/ключам Telegram-каналы и группы (в которых мы не состоим), оценивает их (метаданные → язык → контент по темам), показывает «на рассмотрение», а человек вступает сам или включает авто-вступление в рамках суточных квот с анти-бан паузами.
**Architecture:** Дочерняя система Discovery поверх существующего стека (FastAPI + DuckDB + TelegramManager/Telethon + Vue 3). Отдельный сервис `discovery` (хранилище+оркестрация), новые методы Telegram-действий в `TelegramManager`, общий BanGuard для квот/пауз, отдельный фоновый воркер в `main.py`. Оценка сообщений переиспользует правила/ML/ИИ, но с профилем задачи и БЕЗ создания карточек. UI — подвкладка «Поиск» на экране «Каналы».
**Tech Stack:** Python 3.12 / FastAPI / DuckDB / Telethon / Vue 3 + Tailwind (Vite). Новых зависимостей нет.
## Global Constraints
- Правило «мы не состоим» — глобальное и безусловное: источники из `dialogs`, чёрного списка или уже в другой задаче исключаются сразу (проверка повторяется и при вступлении).
- Спека: `docs/superpowers/specs/2026-09-04-channel-discovery-design.md` (читать при каждом задании).
- Проект НЕ git-репозиторий: вместо `git commit` — проверка через `docker compose`/`npm run build`, фиксация результата в тексте шага.
- Все тексты UI — по-русски, в стиле существующего интерфейса (без канцелярита, короткие подписи).
- Все настраиваемые числа (лимиты, паузы, пороги, размеры выборок) — настройки в БД (`store.get_setting`), НЕ в коде; дефолты в `constants.DEFAULT_SETTINGS`.
- Новые таблицы добавлять только через `db.py` (`_SCHEMA`, `CREATE TABLE IF NOT EXISTS`), при необходимости — миграции в `_MIGRATIONS`.
- Запуск/проверка: контейнеры `docker compose up -d`, бэкенд на :8000, ML на :8100; пересборка `docker compose build app`.
- JSON-поля (keywords/marks/topics) хранить как VARCHAR с `json.dumps(..., ensure_ascii=False)`, читать через `json.loads` — как в остальном коде.
---
### Task 1: Схема БД и настройки по умолчанию
**Files:**
- Modify: `backend/app/db.py` (добавить 4 таблицы в `_SCHEMA`)
- Modify: `backend/app/constants.py` (`DEFAULT_SETTINGS`)
- Modify: `backend/app/routers/settings_routes.py` (`_PUBLIC_INT`)
**Interfaces:**
- Produces: таблицы `disc_tasks`, `disc_candidates`, `disc_blacklist`, `disc_log`; настройки `discJoinLimit` (50), `discJoinDelayMin` (50), `discJoinDelayMax` (70), `discEvalSample` (10), `discEvalThreshold` (40).
- [ ] **Step 1: Добавить таблицы в `_SCHEMA`** (перед таблицей `settings`)
```sql
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
);
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,
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);
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
);
CREATE TABLE IF NOT EXISTS disc_log (
id VARCHAR PRIMARY KEY,
task_id VARCHAR NOT NULL,
event VARCHAR NOT NULL, -- search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done
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);
```
- [ ] **Step 2: Добавить настройки в `constants.py` → `DEFAULT_SETTINGS`**
```python
# поиск каналов (Discovery)
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
"discJoinDelayMax": 70, # сек, верхняя граница
"discEvalSample": 10, # размер выборки сообщений при оценке
"discEvalThreshold": 40, # % подходящих сообщений
```
- [ ] **Step 3: Открыть настройки наружу в `settings_routes.py`**
В `_PUBLIC_INT` добавить `discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold`. В `patch_settings` наложить границы: limit 1..200; паузы 5..600; sample 3..30; threshold 1..100.
- [ ] **Step 4: Проверить**
```bash
docker compose build app && docker compose up -d app
```
Затем `GET /api/settings` (после логина) — в ответе присутствуют `discJoinLimit: 50` и остальные ключи. `python -m py_compile` всех изменённых файлов — без ошибок.
---
### Task 2: BanGuard (квоты, паузы, flood)
**Files:**
- Create: `backend/app/services/ban_guard.py`
**Interfaces:**
- Consumes: `store`, настройки из Task 1.
- Produces:
- `def joins_today_auto() -> int` — авто-вступления за текущие UTC-сутки (считает `disc_log` event='join_auto', `created_at >= начало суток`).
- `def can_auto_join() -> bool` — лимит не исчерпан И нет flood-блокировки на сегодня И нет глобальной паузы.
- `async def wait_join_delay() -> None``asyncio.sleep(random.uniform(min, max))`.
- `def note_flood() -> None``store.set_setting("discFloodDay", <start_of_day_ms>)`.
- `def flood_today() -> bool`
- `def global_paused() -> bool` / `def set_global_pause(v: bool) -> None` (setting `discPaused`)
- `def search_pause() -> float``random.uniform(2.0, 4.0)`.
- [ ] **Step 1: Реализовать модуль** (~60 строк; начало суток — UTC: `datetime.now(timezone.utc).replace(hour=0,minute=0,second=0,microsecond=0)` → ms).
- [ ] **Step 2: Проверить на временной БД в контейнере**
```bash
docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_bg --entrypoint python app -c "
from app.db import store; store.init()
from app.services import ban_guard as bg
assert bg.can_auto_join() is True
assert bg.joins_today_auto() == 0
bg.note_flood(); assert bg.flood_today() is True
bg.set_global_pause(True); assert bg.can_auto_join() is False
print('BANGUARD OK')
"
```
---
### Task 3: Хранилище Discovery (задачи/кандидаты/чёрный список/лог)
**Files:**
- Create: `backend/app/services/discovery.py`
**Interfaces:**
- Consumes: `store` (таблицы Task 1).
- Produces (все синхронные):
- `list_tasks() -> list[dict]`, `get_task(id) -> dict | None` (keywords — список)
- `create_task(payload: dict) -> dict` — валидация: name непустое; `plan_joins` 1..limit; **правило бюджета**: `sum(plan_joins задач, где status NOT IN ('done','failed')) + plan_joins <= discJoinLimit`, иначе `raise ValueError(...)`.
- `patch_task(id, patch: dict) -> dict` (name/description/keywords/min_subscribers/lang/threshold/sample_size/plan_joins/auto_join; увеличение plan_joins — с той же проверкой)
- `delete_task(id) -> None` (удалить задачу и её кандидатов)
- `start_task(id) -> dict` — требует непустой keywords; status=running; `pause_task(id) -> dict` — paused
- `list_candidates(task_id, status: str | None) -> list[dict]` (декод marks/topics)
- `add_candidate(task_id, dialog_id, name, username, kind, hue) -> dict | None``None`, если: в `dialogs`, в `disc_blacklist`, либо уже есть `disc_candidates` со статусом new/review/joined. Лог `skip` с причиной.
- `bump_counter(task_id, field: str, n: int = 1)` — found/evaluated/joined/rejected
- `set_candidate(task_id, dialog_id, patch: dict)` — обновление полей кандидата
- `set_candidate_status(dialog_id, status)` + лог
- `delete_candidate(dialog_id) -> None` — удалить кандидата (skip-ветки)
- `advance_search(task_id) -> None``search_idx += 1`; когда индекс >= len(keywords) → `search_done=True`
- `mark_joined(dialog_id, auto: bool)` — статус joined + `bump_counter('joined')` + лог `join_auto`/`join_manual`
- `mark_rejected(dialog_id, reason="")` — статус rejected + `bump_counter('rejected')` + лог `reject` + `add_blacklist`
- `add_blacklist(dialog_id, name, reason) / remove_blacklist(dialog_id) / list_blacklist() -> list[dict]`
- `add_log(task_id, event, text="")`; `task_log(task_id, limit=100) -> list[dict]`
- [ ] **Step 1: Реализовать модуль** (json-поля по конвенции проекта; все `store.execute/query` с параметрами).
- [ ] **Step 2: Проверить на временной БД** (сценарий): создать задачу plan=25 → ок; создать вторую plan=30 → `ValueError`; кандидат, совпадающий с `dialogs``add_candidate` вернул None + лог skip; `mark_rejected` → в чёрном списке; повторный `add_candidate` того же источника → None.
---
### Task 4: Telegram-действия поиска (методы TelegramManager)
**Files:**
- Modify: `backend/app/services/telegram.py` (класс `TelegramManager`)
**Interfaces:**
- Consumes: `self.client`, `ban_guard`.
- Produces (async-методы):
- `async def discovery_search(q: str, limit: int = 30) -> list[dict]``client(functions.contacts.SearchRequest(q=q, limit=limit))`; вернуть `[{id(str), name, username, kind, hue}]` (kind через `_kind_of`, hue через `dialog_hue`); между вызовами — `await asyncio.sleep(ban_guard.search_pause())`.
- `async def discovery_info(dialog_id: str) -> dict``{id, name, username, kind, hue, participants: int | None, is_forum: bool}` (participants из `full_chat` где возможно; иначе None).
- `async def discovery_read(dialog_id: str, limit: int) -> dict` — последние сообщения: `{"ok": bool, "error": str | None, "messages": [{"id", "text", "date_ms", "topic_id"}]}`; `topic_id``getattr(getattr(m,'reply_to',None),'reply_to_top_id',None)`. История недоступна → `{"ok": False, "error": "no_history", "messages": []}`.
- `async def discovery_join(username: str) -> None``client(functions.channels.JoinChannelRequest(...))`; ПЕРЕД вызовом `await ban_guard.wait_join_delay()`; `FloodWaitError``ban_guard.note_flood()` и проброс.
- `async def discovery_leave(dialog_id: str) -> None``channels.LeaveChannelRequest`.
- `def add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — INSERT/UPDATE `dialogs` с `monitor=TRUE, backfilled=FALSE` (как в `set_monitor`, но без авто-join-логики).
- [ ] **Step 1: Реализовать методы** (импорт `telethon.tl.functions`, `telethon.errors.rpcerrorlist.FloodWaitError`).
- [ ] **Step 2: Проверить компиляцию** `py_compile`. Полная проверка — после Task 6/7 на живом аккаунте (ручные шаги в Task 10).
---
### Task 5: Оценка контента (язык, темы, fit по профилю задачи)
**Files:**
- Create: `backend/app/services/discovery_eval.py`
**Interfaces:**
- Consumes: `store`, `ml_client`, `ai_service` (chat_json), `pipeline.clean_short`.
- Produces:
- `def detect_lang_ru(texts: list[str]) -> bool | None` — доля кириллических букв от всех букв в сумме: `>=0.15 → True`; `<=0.03 → False`; между порогами → `None` (неопределённо).
- `def group_by_topic(messages: list[dict]) -> list[dict]` — группировка по `topic_id` (None → "main"); возвращает `[{"topic_id", "title", "messages": [...]}]`, title = сниппет первого текста темы (≤60 симв.), сортировка по количеству сообщений (убыв.).
- `async def evaluate_message(task: dict, text: str) -> dict``{"fit": bool, "reason": str, "source": "heuristic"|"ml"|"ai"}`:
1) текст пустой/длина <10 → fit False «слишком короткое»;
2) ML: если `ml_client.is_enabled()` и прогноз `take` и `label=='spam'` → fit False «ML: спам»;
3) ИИ: если `aiEnabled` → один JSON-вызов `ai_service.chat_json(промпт, user=text)` с промптом из описания задачи и ключей (`{fit, reason}`); ошибка → шаг 4;
4) эвристика: fit = любой ключ входит в `clean_short(text)` casefold; reason «совпал ключ "…"» / «нет совпадений с ключами».
- `async def evaluate_sample(task: dict, messages: list[dict]) -> dict` — последовательно по каждому сообщению; вернуть `{"fit_count": int, "total": int, "fit_ratio": float, "per_message": [{"text": …, "fit", "reason", "topic_id"}]}`.
- `def passed(ev: dict, task: dict) -> bool``ev["total"] >= 3 and ev["fit_ratio"]*100 >= task["threshold"]`.
- [ ] **Step 1: Реализовать модуль**. Промпт ИИ (внутри модуля, константа):
`Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. Ключи: {keywords}. Верни JSON {"fit": 0|1, "reason": "краткая причина"}.`
- [ ] **Step 2: Проверить на временной БД** (без сети): `detect_lang_ru(["Ищем python разработчика"]) is True`; `detect_lang_ru(["we need a python developer"]) is False`; `group_by_topic` объединяет по topic_id и сортирует; `evaluate_message` на задаче без ИИ/ML возвращает эвристический fit по ключу.
---
### Task 6: Воркер Discovery (поиск → оценка → авто-вступление)
**Files:**
- Create: `backend/app/services/discovery_worker.py`
- Modify: `backend/app/main.py` (фоновый цикл `_discovery_loop`, каждые 5 c)
**Interfaces:**
- Consumes: `discovery` (Task 3), `tg.discovery_*` (Task 4), `discovery_eval` (Task 5), `ban_guard` (Task 2).
- Produces: `async def tick() -> dict` — выполняет ОДНО действие и возвращает `{"action": ..., "taskId": ...}` (или `{"action": "none"}`).
Логика tick (по одной задаче за вызов, начиная с самой старой running):
1. Если задача `search_done=False`: взять ключ `keywords[search_idx]`, вызвать `tg.discovery_search`; для каждого результата `discovery.add_candidate`; `discovery.advance_search(task_id)`; если `search_done` стал True — лог `search` «поиск завершён: N кандидатов». Возврат.
2. Иначе взять первого кандидата статуса `new` задачи:
- `info = tg.discovery_info`; `participants`, `kind` (forum если `is_forum`); при заданном `min_subscribers` и participants НЕ None и меньше минимума — `set_candidate_status(...)` нет: просто `discovery.delete_candidate` + лог `skip`; если participants None — метка «участники не подтверждены» (идём дальше).
- `read = tg.discovery_read(dialog_id, sample_size)`.
- Если `read.ok=False` (история недоступна без членства): kind==channel → `review` с меткой «канал: история недоступна»; группа/форум → `review` с меткой «закрытая группа (история скрыта) — вступите сами»; оценка контента не производится, неподтверждённые фильтры помечаются.
- Язык: если прочитано и `task.lang=='ru'`: `lang_ru=detect_lang_ru(...)`; False → удалить кандидата, лог `skip` «язык не русский»; None → метка «язык не подтверждён».
- Оценка: `evaluate_sample`; `passed` → метки topics/fit → `review` + лог `review`; иначе удалить кандидата, лог `skip` «мало подходящих (X из N)».
3. Авто-вступление (отдельный проход tick, приоритет ниже оценки): если у running-задачи `auto_join` и есть кандидат `review` и `ban_guard.can_auto_join()`:
- повторная проверка «мы не состоим» (`dialogs`/blacklist) → если вступили уже → `mark_rejected` с логом;
- `await ban_guard.wait_join_delay()` (рандом 50–70 с — спейсинг авто-вступлений; ручные join из API паузу не делают);
- `tg.discovery_join(username)``discovery.mark_joined(dialog_id, auto=True)``tg.add_dialog_monitored(...)`; при FloodWaitError → `ban_guard.note_flood()` + лог `flood`.
4. Если `task.joined >= task.plan_joins` → статус `done`, лог `done`.
- [ ] **Step 1: Реализовать** `discovery_worker.py` и цикл в `main.py`.
- [ ] **Step 2: Проверить компиляцию** и запуск без падений (воркер с пустыми таблицами делает `none`). Полный прогон — Task 10 вручную.
---
### Task 7: API Discovery
**Files:**
- Create: `backend/app/routers/discovery_routes.py`
- Modify: `backend/app/main.py` (регистрация роутера)
**Interfaces:**
- Prefix `/api/discovery`, auth `current_login`:
- `GET /tasks`, `POST /tasks`, `PATCH /tasks/{id}`, `DELETE /tasks/{id}`, `POST /tasks/{id}/start`, `POST /tasks/{id}/pause`
- `POST /tasks/{id}/generate-keywords` — ИИ: промпт по description → JSON `{"keywords": [...]}` (816 строк RU+EN); ИИ недоступен/выключен → `{"keywords": [], "error": "..."}`.
- `GET /tasks/{id}/candidates?status=`
- `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот): `tg.discovery_join` + `add_dialog_monitored` + `mark_joined(auto=False)`; 400 при ошибке.
- `POST /candidates/{dialog_id}/reject``mark_rejected` (добавляет в чёрный список). Если кандидат уже `joined` — 400.
- `GET /blacklist`, `DELETE /blacklist/{dialog_id}`
- `GET /tasks/{id}/log`
Pydantic-модели: `TaskCreate` (name, description, keywords, minSubscribers, lang, threshold, sampleSize, planJoins, autoJoin), `TaskPatch` (все optional), `GenKeywordsBody` не нужен (id в пути).
- [ ] **Step 1: Реализовать роутер** (ValueError → HTTPException 400; KeyError → 404).
- [ ] **Step 2: Зарегистрировать в main.py**.
- [ ] **Step 3: Проверить API на живом контейнере**: логин, создание задачи plan=1, list, delete; `generate-keywords` вернёт error-ветку без настроенного ИИ (не падает).
---
### Task 8: Фронтенд — store + каркас подвкладки «Поиск»
**Files:**
- Modify: `frontend/src/store.js`
- Create: `frontend/src/views/DiscoveryView.vue`
- Modify: `frontend/src/views/ChannelsView.vue`
**Interfaces:**
- state: `channelsTab: 'list' | 'search'`, `discTasks: []`, `discCandidates: []`, `discBlacklist: []`, `discLog: []`, `discActiveTaskId: null`, `discCandidateStatus: 'review'`, `discBusy: false`.
- store-функции: `gotoChannelsTab(tab)`, `loadDiscTasks()`, `saveDiscTask(form, id=null)` (create/patch), `deleteDiscTask(id)`, `startDiscTask(id)`, `pauseDiscTask(id)`, `generateDiscKeywords(taskId)`, `loadDiscCandidates(taskId, status)`, `joinDiscCandidate(c)`, `rejectDiscCandidate(c)`, `loadDiscBlacklist()`, `removeDiscBlacklist(id)`, `loadDiscLog(taskId)`.
- [ ] **Step 1: store.js** — состояние + функции (паттерны: `api.get/post/patch/delete`, `toast`, `errMsg`).
- [ ] **Step 2: ChannelsView.vue** — в шапке сегмент: «Каналы | Поиск» (`state.channelsTab`), содержимое по табу.
- [ ] **Step 3: DiscoveryView.vue (каркас)**: левая колонка — список задач (+ «Новая задача»); правая — панель задачи: мастер (name, description, «Сгенерировать ключи ИИ», чипы ключей редактируемые, minSubscribers, lang select ru/any, threshold, sampleSize, planJoins, autoJoin toggle, кнопки «Запустить/Пауза/Удалить»).
- [ ] **Step 4: `npm run build`** — без ошибок.
---
### Task 9: Фронтенд — кандидаты, действия, чёрный список, история
**Files:**
- Modify: `frontend/src/views/DiscoveryView.vue`
**Interfaces:**
- Consumes: Task 8 (store).
- [ ] **Step 1: Табы панели задачи**: «В обработке» (`new`) / «На рассмотрении» (`review`) / «Вступили» (`joined`) / «Отклонены» (`rejected`) + «История» (лог). Бейджи счётчиков скрыты при 0.
- [ ] **Step 2: Карточка кандидата**: название, @username, kind-иконка/метка (канал/группа/форум), метки marks (чипы: закрытая, не прочитан, участники/язык не подтверждены, мало сообщений), участники, «подходит X из N», кнопки «Вступить и мониторить» / «Отклонить» (только для review). Форум → раскрывающийся список topics («тема — подходит X из N»).
- [ ] **Step 3: Чёрный список** (под списками или отдельный таб) — снять источник; «Настройки квот» — popover/inline с `discJoinLimit/discJoinDelayMin/discJoinDelayMax` + стоп-кран (PATCH /api/settings).
- [ ] **Step 4: История** — лог задачи.
- [ ] **Step 5: `npm run build`** — без ошибок; визуальная проверка основных сценариев (Task 10).
---
### Task 10: ТЗ, сборка и end-to-end проверка
**Files:**
- Modify: `ТЗ.md` (раздел «Поиск и подключение каналов»)
- [ ] **Step 1: Дополнить ТЗ** — кратко: задачи поиска, каскад фильтров, глобальное правило «мы не состоим», метки, оценка по темам, review/join/reject + чёрный список, авто-вступление и квоты (50/сутки, 50–70 с), подвкладка «Поиск» на «Каналах».
- [ ] **Step 2: Сборка и рестарт**:
```bash
docker compose build app && docker compose up -d app
cd frontend && npm run build
```
- [ ] **Step 3: E2E вручную (нужен подключённый Telegram-аккаунт)**:
1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
2. Дождаться кандидатов; проверить, что текущие подписки и отклонённые не появляются.
3. Открыть кандидата: метки, участники, fit «X из N», темы форума.
4. «Вступить и мониторить» → источник появляется в «Каналах» (monitor on) и начинает давать карточки.
5. «Отклонить» → уходит в чёрный список; повторно не находится.
6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита.
---
## Self-Review
- **Покрытие спеки:** Task 1 (хранилище+настройки), Task 2 (квоты/анти-бан), Task 3 (задачи/бюджет планов/чёрный список), Task 4 (поиск/инфо/чтение/join), Task 5 (язык/темы/fit), Task 6 (воркер+авто-join), Task 7 (API), Task 89 (UI), Task 10 (ТЗ+E2E). Правило «мы не состоим» — Task 3 `add_candidate`, Task 6 шаг 3 (повторная проверка перед join), UI Task 9. Разделы спеки §4–§12 покрыты; «вне рамок» (§13) не реализуются.
- **Плейсхолдеры:** нет; у каждого шага есть конкретный код/поведение и способ проверки.
- **Согласованность:** единые статусы задач `draft|running|paused|done|failed`, кандидатов `new|review|joined|rejected`; события лога `search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done`; все имена настроек и функций совпадают между задачами.
@@ -0,0 +1,173 @@
# Дейл (Deal) — Roadmap этапов (все этапы 0–7 выполнены; 2026-09-08)
> Исторический документ (roadmap этапов 07, 2026-09-08). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> Назначение: зафиксировать план продолжения разработки «Дейл» по этапам. Каждый этап исполняется
> как отдельный SDD-план (файл в `docs/superpowers/plans/`, ledger в `.superpowers/sdd/<plan>/`),
> задача-за-задачей с ревью. Проект НЕ git — фиксация в отчётах и ledgers.
## Выполнено
- **Этап 0 — Каркас** (`2026-09-05-deal-scaffold.md`): структура `src/`, фронт переехал в `src/frontend/`,
`.editorconfig`+`Directory.Build.props`, `Deal.sln` (11 проектов), тесты, dev-Postgres (:5433),
tenant-контекст (`TenantId`/`ITenantContext`/`TenantContext` AsyncLocal/`ConnectionStringProvider` search_path),
EF (public), `TenantSchemaMigrator`, CI-скрипты.
- **Этап 1 — Доступ и мультитенантность** (`2026-09-05-deal-stage1-tenancy.md`): карта `/api`
(`docs/api/api-map.md`, 101 эндпоинт, 87 использует фронт), два DbContext (системный `public` + бессхемный
tenant: таблица `settings`), миграции InitialSystem/InitialTenant, модуль Tenants (порты+адаптеры,
Argon2id, сессии 30 дней), auth-эндпоинты 1:1 (login/logout/me/change-password), SessionMiddleware +
`ITenantContext.Reset`, `TenantProvisioningService` (схема `tenant_<32hex>` + Migrate c
`MigrationsHistoryTable("__TenantMigrationsHistory", schema)`), `TenantBootstrapService` (seed: тенант
id `000…001` + admin/admin из env; идемпотентно). 25 тестов PASS.
- **Этап 2 — Settings (настройки тенанта)** (`2026-09-05-deal-stage2-settings.md`): модуль
`Deal.Modules.Settings` (каталог ключей/дефолты 1:1 с прототипом, ISettingsStore, SettingsService
снимок+PATCH 1:1, IncomingRules, PromptFiller, RatesService) + адаптеры (SettingsStore на `settings`,
AesGcmSecretCipher), эндпоинты GET/PATCH `/api/settings`, POST `/api/ai/check`, GET `/api/rates`,
POST `/api/rates/refresh`, `/api/ml/*` (заглушка LocalMlClient), POST `/api/admin/check-message`
(тестер фильтров). Секреты AI/Telegram — AES-GCM (`enc:` в БД, ключ env `DEAL_ENCRYPTION_KEY`/файл).
Задачи 1–11 приняты: 175 unit-тестов PASS, build 0/0, сквозная curl-приёмка :5080 PASS=60 FAIL=0 +
psql (шифрование, внутренние ключи не публикуются). **Ограничение:** Settings-экран обслуживается
бэкендом, но Vue-фронт полностью оживает только с этапом 3 (его `boot()` требует `/api/boards`,
`/api/leads`, `/api/projects`, `/api/tg/status`, `/api/columns/state`; Telegram-вкладка, кнопки
«Проверить правила сейчас»/«Пересобрать индекс», «Предложить ключи» и канбан-фронт — этапы 3–6).
- **Этап 3 — Kanban (дашборд): колонки, карточки, архив/корзина** (`2026-09-05-deal-stage3-kanban.md`):
миграция TenantKanban (Boards/Cards/LeadComments/CardMoves/MlOutbox в схеме тенанта), модуль
`Deal.Modules.Kanban` (доски/карточки/правила `ColumnRules` с matchHits, StorageTickService +
фоновый StorageTickScheduler 30 с, ConversionRecomputer, демо-фабрика, эвристика ИИ-предложений),
эндпоинты boards/columns/leads/search/events(SSE)/admin/demo/ai-suggest, boot-заглушки /projects и
/tg/status, LocalMlClient+PushAsync. Задачи 115 приняты: 410 unit-тестов PASS, build 0/0, сквозная
curl-приёмка :5080 PASS=94 FAIL=0 + psql. **Ограничения:** pipeline/очередь/отсев/FTS — этап 4;
projects/файлы/reminder_due — этап 5; реальные ai/telegram/ml и discovery — этап 6; reclassify и
/admin/fts/rebuild — контракт-заглушки. Фронт теперь boot'ится полностью и канбан-дашборд работает
на демо-данных (реальные данные появятся с pipeline этапа 4).
- **Этап 4 — Pipeline + вкладка «Обработка»** (`2026-09-05-deal-stage4-pipeline.md`): миграция
TenantPipeline (QueueItems/RejectedItems/DedupEntries + FTS-колонки SearchTsv на Cards/RejectedItems),
модуль `Deal.Modules.Pipeline` (ядро разбора 1:1, Ingest/ProcessingService/PipelineWorkerService
pump 1:1, CardComposer через `IKanjStore.AddCardAsync`, dedup-связь), порт `IAiClassifier` +
детерминированный `LocalAiClassifier`, эндпоинты `/api/pipeline/*` + демо-ingest, реальные
admin/tick и admin/fts/rebuild (+SSE new_lead/тост очистки отсева), фоновые PipelineWorkerScheduler
(2 с) и purge-отсева 3 дня в StorageTickScheduler, FTS-поиск `/api/search`. Задачи 1–13 приняты:
**535 unit-тестов PASS**, build 0/0, сквозная curl-приёмка :5080 (Task 13 — финал) PASS=74 FAIL=0
+ psql. **Ограничения:** projects/reminder_due/файлы — этап 5; реальные ai/telegram/ml-сервисы и
их gRPC-ингресс, discovery, ИИ-предложения колонок на реальных данных — этап 6; оператор/лимиты/
аудит — этап 7. Вкладка «Обработка» и канбан-дашборд работают на реальном конвейере (демо-ingest
до telegram-этапа 6).
- **Этап 5 — Projects («Выбранные»): стадии, напоминания, файлы/ссылки, история**
(`2026-09-05-deal-stage5-projects.md`): миграция TenantProjects (`ProjectCards` с partial UNIQUE
LeadId), модуль `Deal.Modules.Projects` (стадии ProjectStages 1:1, ProjectsService: take из дашборда с
уходом лида в `col='taken'`/ручное создание/patch presence-aware/move+история/clear-rejected/
комментарии/ссылки; ProjectReminderService; ProjectFilesService), порт `IFileStorage` + адаптеры
`LocalFileStorage`/`MinioFileStorage` (deal-minio в compose.dev.yml), эндпоинты `/api/projects*`
(16 шт., файлы и напоминания включены), boot-заглушка /projects снята, напоминания в admin/tick +
фоновый 30-с цикл StorageTickScheduler + SSE `reminder_due`. Задачи 1–13 приняты: **620 unit-тестов
PASS**, build 0/0, сквозная curl-приёмка :5080 (Task 13 — финал) PASS=75 FAIL=0 + psql (take-
семантика, история, clear-rejected, UNIQUE LeadId, файлы на диске, reminder_due фоновым циклом).
**Ограничения:** реальные ai/telegram/ml-сервисы и их gRPC-ингресс, discovery, telegram-вкладка и
`/tg/status` — этап 6; оператор/лимиты/админка, мульти-аренда MinIO-бакетов — этап 7.
- **Этап 6 — Сервисы telegram/ai/ml (отдельные процессы) + Discovery** (`2026-09-05-deal-stage6-services.md`):
gRPC-контракты в `src/contracts/*.proto` (общий `Deal.Proto`); три автономных процесса — telegram-service
(:5101: ферма сессий 1 акк/тенант, QR-вход, AES-GCM-сессии `/data/sessions`, диалоги/мониторинг/backfill
с анти-бан-паузами), ai-service (:5102: LLM-фасад OpenAI-совместимых+Anthropic, Filter/Classify/
GenerateKeywords/EvaluateFit, usage), ml-service (:5103: инкрементальный наивный Байес 1:1 с python
`mlservice/model.py`, SQLite на тенанта); core — gRPC-ингресс telegram :5082 (PushMessage→очередь,
SyncDialogs, ReportStatus→SSE), модуль Telegram (Dialogs/TgMessages) + эндпоинты /api/tg (14 шт., реальный
статус, QR-SVG) вместо boot-заглушки, ai/ml-gRPC-адаптеры за флагами `Services:*:UseLocal` (код-дефолт
Local, compose.dev.yml — false), MlOutboxFlushScheduler (10 с), модуль Discovery (воркер 5 с: поиск/
каскад оценки/авто-join с квотами и бан-гардом; /api/discovery 13 шт.); полный dev-стек —
`deploy/compose.dev.yml` (postgres/minio/3 сервиса/core, secrets, healthcheck), smoke-скрипт
`scripts/dev-smoke.sh` (отложенный живой прогон — Docker Desktop был выключен). Задачи 1–20 приняты:
**830 unit-тестов PASS**, build 0 warnings / 0 errors всех четырёх sln, curl-приёмки Task 14 (/api/tg
PASS=20 FAIL=0) и Task 19 (/api/discovery PASS=37 FAIL=0), in-proc gRPC-приёмки. Ledger:
`.superpowers/sdd/deal-stage6-services/`. **Ручные проверки (с кредами):** Telegram-вход
(api_id/api_hash/QR) и реальные LLM-вызовы; живой smoke `scripts/dev-smoke.sh` — после поднятия Docker.
**Ограничения этапа 6 (переходят в этап 7):** mTLS-сертификаты и prod-compose; лимиты/бюджеты токенов
(учёт `aiTokenUsage` уже есть); оператор/админка/аудит-поток; rate limiting gRPC; экспорт/импорт
ML-моделей; ротация/бэкап ключей сессий; reclassify на реальном ИИ (контракт-заглушка остаётся).
- **Этап 7 — SaaS-контур (Tasks 116, 2026-09-08)** (`2026-09-05-deal-stage7-saas.md`): оператор
(`public`-таблицы, кука `deal_operator_session`, bootstrap env, ручки `/api/operator/*` — API-only),
инвайты + активация `POST /api/join`, лимиты ИИ-бюджета с fallback-декораторами и SSE-тостами,
append-only аудит-поток, rate limiting (приложение + gRPC-ингресс + защита входа), Origin-проверка/
security-заголовки/ForwardedHeaders, mTLS за флагом DEAL_MTLS_* + `scripts/mtls-certs.sh`, Serilog JSON
во всех 4 процессах, prod-деплой `deploy/compose.prod.yml` (caddy 80/443, mTLS-env, healthcheck'и
grpc_health_probe, профиль observability: promtail/loki/grafana) + `deploy/.env.prod.example`,
бэкапы `scripts/backup.sh`/`restore.sh` (+ `deal-backup-lib.sh`). **Task 16 (финал)**: актуализированы
техдок §5/§7–§11/§13 (фактический стек, Manual-пометки), api-map (раздел «Реализовано в Deal»),
user-guide, STATUS.md (этапы 07 = 100%). Финальный прогон: core **1123/1123 PASS**, telegram 114/114,
ai 50/50, ml 36/36 PASS, build 0/0 всех четырёх sln, `compose.prod.yml config` rc=0, `sh -n`
скриптов rc=0. Ledger: `.superpowers/sdd/deal-stage7-saas/`.
**Manual (нужен docker/живые креды):** применение system-миграции + сквозная SaaS-curl-приёмка,
подъём compose.prod и dev-smoke `scripts/dev-smoke.sh`, mTLS-рукопожатие контейнеров, реальные
Telegram/LLM-вызовы, прогон `scripts/backup.sh` и restore-тест — чек-лист в task-16-report.md.
## Эталонные конвенции (уже в коде — их придерживаться дальше)
- Модуль = чистый проект (SharedKernel/Contracts): порты (интерфейсы) + record-DTO, без EF.
Регистрация: `AddTenantsModule()` (модуль), адаптеры EF — в `Deal.Infrastructure` через
`AddDealPersistence()` (scoped). HTTP-эндпоинты — в `Deal.Api/Endpoints/*` (`MapXxxEndpoints`).
- Два EF-контекста: системный (public, явная схема) и tenant (бессхемный; новые таблицы модулей —
DbSet в `TenantDbContext` + `dotnet ef migrations add X --context TenantDbContext`; применяются
провижинером ко всем схемам). Ошибки API — `{detail}`; JSON camelCase; кука `deal_session`.
- Константы/настройки: `IOptions<T>`; без магических чисел; 1 тип=1 файл; XML-doc на public.
## Следующие этапы (после этапов 0–7; порядок из архитектуры §12.5)
> **Актуальный источник отложенного и техдолга — `backlog.md` в корне** (роудмап черпается оттуда).
> Ниже — историческая секция роудмапа.
> Этапы 0–7 выполнены (см. «Выполнено»). Ниже — следующие инкременты: заделы этапа 7 (сознательно
> вынесены, подробно — техдок §11) и пункты архитектуры, не входившие в этапы.
### Этап 8+ — следующие инкременты (заделы этапа 7, подробно — техдок §11):
- **Этапы 8–10 выполнены** (2026-09-10): ревью/качество; единая карточка (unified card);
оператор-консоль + активация инвайта (UI) + аудит действий и аналитика расхода токенов + ELK/Loki-дашборды.
- Остаются заделы: OTel-метрики/Prometheus и дашборды метрик (сейчас Serilog-логи → Loki);
multi-instance rate-limit и бэкенд попыток входа; экспорт/импорт ML-моделей; reclassify на реальном ИИ;
мультиаккаунтность Telegram; биллинг/планы; k8s/Cloudflare-конфигурация; purge-автоматика audit_log.
### Этап 11 — Локализация интерфейса (i18n)
**Требование владельца (2026-09-10).** Весь интерфейс — на русском; все тексты вынесены в ресурсы,
чтобы можно было добавлять новые языки и менять язык **на лету**.
- **Русский — язык по умолчанию.** Все пользовательские строки UI (экраны, кнопки, подписи, пустые
состояния, подсказки, подтверждения, уведомления/тосты, страницы оператора и активации) — на русском.
- **Никакого хардкода строк в компонентах.** Все тексты — в словарях ресурсов (ключ → значение),
включая сообщения об ошибках, которые сейчас формируются на бэке (`{detail}`), — они должны быть
локализуемы (ключ + параметры) или переводимы по коду.
- **Переключение языка на лету**, без перезагрузки страницы; выбранный язык сохраняется (localStorage/настройки).
- **Расширяемость:** добавление нового языка = новый файл словаря, без правок компонентов.
- **Форматирование** дат/времени/чисел/валют — через i18n-форматтеры (не вручную), плюрализация —
через правила языка.
- Ключи — стабильные, сгруппированные по областям (nav/cards/settings/operator/…); отсутствующий
ключ в языке → фолбэк на русский.
- Бэк: ответы API остаются с `{detail}`/кодами; фронт отображает локализованный текст по коду/ключу
(при необходимости — расширяемый словарь ошибок).
UI-область, к которой это применяется: основное приложение (дашборд, «Выбранные», настройки, каналы,
обработка) и оператор-консоль (этап 10).
## Открытые точки согласования (накопились к концу этапа 1)
> Решения владельца (2026-09-06): 1 — бренд меняем (сделано точечно: index.html, LoginView, Sidebar, DiscoveryView, SettingsView); 2 — инвайты/оператор остаются на SaaS-этап, dev-seed admin/admin; 3 — PascalCase — конвенция БД; 4 — кука `deal_session` остаётся; 5 — заглушки сервисов допустимы (порты с детерминированными локальными реализациями до этапов 6+); 6 — идём по roadmap все этапы.
1. **Бренд во фронте**: Vue-фронт всё ещё показывает «LeadRadar» (LoginView, заголовки). Фронт
«не трогаем» — но бренд теперь «Дейл». Менять ли строки бренда во фронте (точечно) или позже?
2. **Инвайты/оператор**: ТЗ требует invite-only + отдельный вход оператора; во фронте такого UI нет.
Оставляем dev-seed (admin/admin + дефолтный тенант) до этапа 7? Тогда auth остаётся «как прототип».
3. **Имена колонок БД**: EF генерирует PascalCase (`UpdatedAt`), ТЗ/доки местами в SQL-нотации
(snake_case). Оставляем PascalCase (конвенция кода) — подтвердить.
4. **Имя куки** `deal_session` (прототип: `leadradar_session`) — фронту всё равно; ок?
5. **Сервисы ml/ai/telegram**: полный ребилд — это самые крупные этапы. Подтвердить, что локально
между этапами допустимы порты с детерминированными заглушками (пока сервисы не написаны), чтобы
pipeline/kanban разрабатывать и показывать на синтетических входах.
6. **Приоритет и темп**: продолжать строго по roadmap (Settings → Kanban → Pipeline → Projects →
сервисы+Discovery → SaaS-контур) и в каком темпе/объёме на сессию.
**Статус на 2026-09-08 (этапы 0–7 завершены):** п.2 **закрыт** — инвайты/оператор реализованы в этапе 7
(API-only: `/api/operator/*` + `/api/join`), dev-seed `admin/admin` остаётся dev-only (Ruling 1);
UI операторской админки и страницы активации — задел этапа 8+. п.1 — бренд во фронте меняется точечно
(как решено); п.3–5 — зафиксированы решениями (PascalCase, `deal_session`, локальные реализации портов)
и не пересматривались; п.6 — roadmap исполнен: этапы 0–7 пройдены (STATUS.md — 100%, Manual-чек-лист
вынесен отдельно).
@@ -0,0 +1,825 @@
# Дейл (Deal) — Этап 0: Каркас решения Implementation Plan
> Исторический документ этапа 0. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Создать каркас нового продукта «Дейл»: структуру `src/`, решение `core` (модульный монолит) с пустыми модулями, стандарты кода (.editorconfig + анализаторы), dev-Postgres со схемой на тенанта и tenant-контекст.
**Architecture:** Модульный монолит в `src/core` (один процесс, одно sln: `Deal.Api` + `Deal.Modules.*` + `Deal.SharedKernel` + `Deal.Infrastructure` + `Deal.Contracts`). Postgres: одна БД, системные таблицы в `public`, данные тенантов в `tenant_<id>.*`. Сервисы ml/ai/telegram — отдельные процессы со своими sln (создаются в этом этапе как пустые каталоги, наполняются позже). Фронтенд Vue переезжает как есть в `src/frontend`.
**Tech Stack:** .NET 10 (C#), ASP.NET Core (Web API + minimal), EF Core, Npgsql, xUnit, docker compose.
**Spec:** `docs/architecture/2026-09-05-deal-architecture-design.md` (разделы 2, 3, 4, 10, 11)
**ТЗ:** `docs/spec/ТЗ-дейл-новая-архитектура.md` (разделы 3, 11)
## Global Constraints
- Проект **НЕ git-репозиторий** (рабочее дерево `C:\telbase`, деплой docker compose). Вместо коммитов фиксируем затронутые файлы и результат проверок в отчёте задачи. Рабочая папка плана: `.superpowers/sdd/deal-scaffold/`.
- Решение собирается на .NET 10 SDK (установлен: `10.0.400`).
- Код-стайл: `C:\telbase\Стиль_кода.docx` + адаптации: 1 тип = 1 файл; комментарии на русском; XML-doc только для public-контрактов; настройки через `IOptions<T>`; без snake_case-хелперов и регионов; public-члены — только свойства; явные модификаторы доступа.
- Все имена: namespace `Deal.*`, проекты `Deal.*`, имя решения `Deal.sln`.
- Каждый публичный тип — в отдельном файле, имя файла = имя типа.
- Анализаторы: `Microsoft.CodeAnalysis.NetAnalyzers` включён; нарушения стиля — ошибки сборки (через `.editorconfig` severity).
- Запрещено: секреты в коде/репозитории; конкатенация SQL; magic numbers.
- Старый LeadRadar-код (`backend/`, `frontend/` верхнего уровня) не трогаем, кроме переноса `frontend/``src/frontend/`.
---
### Task 1: Структура src/ и перенос фронтенда
**Files:**
- Create: `src/README.md`
- Create: `README.md` (корневой, краткий)
**Interfaces:**
- Consumes: — (старт)
- Produces: структура папок `src/{core, ml-service, ai-service, telegram-service, contracts, frontend}`; фронтенд перенесён в `src/frontend/`.
- [ ] **Step 1: Создать структуру каталогов**
Run:
```bash
mkdir -p src/core src/ml-service src/ai-service src/telegram-service src/contracts
```
- [ ] **Step 2: Перенести фронтенд**
Run:
```bash
mkdir -p src/frontend
cp -r frontend/* src/frontend/ && rm -rf frontend
```
Expected: `src/frontend/` содержит package.json, src/, index.html и т.д.; старая папка `frontend/` удалена.
- [ ] **Step 3: Создать `src/README.md`**
```markdown
# Дейл (Deal) — исходники
- `core/` — модульный монолит .NET (бизнес-логика, API)
- `ml-service/` — ML (.NET + ONNX), отдельный процесс
- `ai-service/` — LLM-фасад, отдельный процесс
- `telegram-service/` — ферма сессий Telegram, отдельный процесс
- `contracts/` — общие .proto (gRPC)
- `frontend/` — Vue (переехал из LeadRadar как есть)
Подробности: `docs/architecture/2026-09-05-deal-architecture-design.md`
```
- [ ] **Step 4: Создать корневой `README.md`**
```markdown
# Дейл (Deal)
SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов.
- Архитектура: `docs/architecture/2026-09-05-deal-architecture-design.md`
- ТЗ: `docs/spec/ТЗ-дейл-новая-архитектура.md`
- Техдок: `docs/technical/Техническая-документация-Дейл.md`
- Исходники: `src/`
```
- [ ] **Step 5: Проверить**
Run: `ls src/` — 6 папок; `ls src/frontend/` — файлы Vue-проекта; `test -f README.md && echo ok`.
Expected: все проверки успешны.
- [ ] **Step 6: Зафиксировать в отчёте** `task-1-report.md` (файлы, результат проверок).
---
### Task 2: Стандарты кода — .editorconfig, Directory.Build.props
**Files:**
- Create: `.editorconfig`
- Create: `src/core/Directory.Build.props`
**Interfaces:**
- Produces: единые правила для всех проектов `src/core`; нарушения — ошибки сборки.
- [ ] **Step 1: Создать корневой `.editorconfig`**
```editorconfig
root = true
[*]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{cs,vb}]
indent_size = 4
# Стиль фигурных скобок — Allman (на отдельной строке)
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
# using — в начале файла
dotnet_sort_system_directives_first = true
# Модификаторы доступа — всегда явные
dotnet_style_require_accessibility_modifiers = always:error
# this. — не требуется
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_property = false:silent
dotnet_style_qualification_for_method = false:silent
# Члены
csharp_style_var_for_built_in_types = false:silent
csharp_style_var_when_type_is_apparent = false:silent
csharp_style_var_elsewhere = false:silent
[*.cs]
# Отключить лишние правила IDE, которые конфликтуют с код-стайлом проекта
dotnet_diagnostic.IDE0290.severity = none
```
- [ ] **Step 2: Создать `src/core/Directory.Build.props`**
```xml
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
```
- [ ] **Step 3: Зафиксировать в отчёте** (проверка сборки — после Task 3).
---
### Task 3: Решение Deal.sln и пустые проекты core
**Files:**
- Create: `src/core/Deal.sln`
- Create: `src/core/Deal.Api/Deal.Api.csproj` + `Program.cs`
- Create: `src/core/Deal.Modules.Pipeline/`, `...Kanban/`, `...Projects/`, `...Discovery/`, `...Settings/`, `...Tenants/` (csproj + класс-маркер)
- Create: `src/core/Deal.SharedKernel/`, `Deal.Infrastructure/`, `Deal.Contracts/` (csproj + маркер)
**Interfaces:**
- Produces: собираемое решение; проекты-модули, готовые к наполнению в следующих этапах.
- [ ] **Step 1: Создать решение и проекты командой**
```bash
cd /c/telbase/src/core
dotnet new sln -n Deal
dotnet new web -n Deal.Api -o Deal.Api --no-https
dotnet new classlib -n Deal.Modules.Pipeline -o Deal.Modules.Pipeline
dotnet new classlib -n Deal.Modules.Kanban -o Deal.Modules.Kanban
dotnet new classlib -n Deal.Modules.Projects -o Deal.Modules.Projects
dotnet new classlib -n Deal.Modules.Discovery -o Deal.Modules.Discovery
dotnet new classlib -n Deal.Modules.Settings -o Deal.Modules.Settings
dotnet new classlib -n Deal.Modules.Tenants -o Deal.Modules.Tenants
dotnet new classlib -n Deal.SharedKernel -o Deal.SharedKernel
dotnet new classlib -n Deal.Infrastructure -o Deal.Infrastructure
dotnet new classlib -n Deal.Contracts -o Deal.Contracts
```
- [ ] **Step 2: Добавить проекты в решение**
```bash
dotnet sln Deal.sln add Deal.Api Deal.Modules.Pipeline Deal.Modules.Kanban Deal.Modules.Projects Deal.Modules.Discovery Deal.Modules.Settings Deal.Modules.Tenants Deal.SharedKernel Deal.Infrastructure Deal.Contracts
```
- [ ] **Step 3: Удалить Class1.cs и добавить маркеры модулей**
Каждый модуль получает публичный маркер-класс (1 тип = 1 файл), например `Deal.Modules.Pipeline/PipelineModuleMarker.cs`:
```csharp
namespace Deal.Modules.Pipeline;
/// <summary>Маркер модуля Pipeline: используется для DI-сканирования и тестов.</summary>
public sealed class PipelineModuleMarker
{
}
```
Аналогично для всех модулей и Infrastructure/SharedKernel/Contracts (маркеры: `InfrastructureMarker`, `SharedKernelMarker`, `ContractsMarker`).
- [ ] **Step 4: Ссылки между проектами (минимальные, по дизайн-доку)**
```bash
dotnet add Deal.Api reference Deal.SharedKernel Deal.Contracts Deal.Infrastructure
dotnet add Deal.Modules.Pipeline reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Kanban reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Projects reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Discovery reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Settings reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Tenants reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Infrastructure reference Deal.SharedKernel Deal.Contracts
```
- [ ] **Step 5: Минимальный Program.cs в Deal.Api (health)**
```csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
app.Run();
public partial class Program
{
}
```
- [ ] **Step 6: Собрать решение**
Run: `dotnet build Deal.sln`
Expected: Build succeeded, 0 warnings, 0 errors.
- [ ] **Step 7: Проверить health локально**
Run: `dotnet run --project Deal.Api --urls http://localhost:5080` (в фоне), затем `curl http://localhost:5080/api/health`
Expected: `{"ok":true,"service":"deal"}` (процесс остановить после проверки).
- [ ] **Step 8: Зафиксировать в отчёте** `task-3-report.md`.
---
### Task 4: Тесты — xUnit-каркас
**Files:**
- Create: `src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj`
- Test: `src/core/tests/Deal.Tests.Unit/MarkerTests.cs`
**Interfaces:**
- Consumes: маркеры модулей из Task 3.
- Produces: тестовый проект, подключённый к решению.
- [ ] **Step 1: Создать тестовый проект**
```bash
cd /c/telbase/src/core
dotnet new xunit -n Deal.Tests.Unit -o tests/Deal.Tests.Unit
dotnet sln Deal.sln add tests/Deal.Tests.Unit
dotnet add tests/Deal.Tests.Unit reference Deal.SharedKernel Deal.Modules.Pipeline Deal.Modules.Kanban Deal.Modules.Projects Deal.Modules.Discovery Deal.Modules.Settings Deal.Modules.Tenants
```
- [ ] **Step 2: Написать тест на маркеры модулей**
`tests/Deal.Tests.Unit/MarkerTests.cs`:
```csharp
using Deal.Modules.Pipeline;
namespace Deal.Tests.Unit;
public sealed class MarkerTests
{
[Fact]
public void PipelineModuleMarker_IsPublicAndSealed()
{
Assert.True(typeof(PipelineModuleMarker).IsPublic);
Assert.True(typeof(PipelineModuleMarker).IsSealed);
}
}
```
- [ ] **Step 3: Запустить тесты**
Run: `dotnet test tests/Deal.Tests.Unit`
Expected: 1 тест PASS.
- [ ] **Step 4: Зафиксировать в отчёте** `task-4-report.md`.
---
### Task 5: Dev-Postgres в docker compose (схема на тенанта)
**Files:**
- Create: `deploy/compose.dev.yml`
- Create: `deploy/.env.example`
- Modify: `README.md` (инструкция запуска dev-БД)
**Interfaces:**
- Produces: dev-контейнер Postgres 16; БД `deal`; схема `public` готова к миграциям.
- [ ] **Step 1: Создать `deploy/compose.dev.yml`**
```yaml
services:
postgres:
image: postgres:16-alpine
container_name: deal-postgres
environment:
POSTGRES_DB: deal
POSTGRES_USER: deal
POSTGRES_PASSWORD: deal_dev_password
ports:
- "5433:5432" # 5432 может быть занят LeadRadar-стеком
volumes:
- deal_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U deal -d deal"]
interval: 5s
timeout: 3s
retries: 10
volumes:
deal_pgdata:
```
- [ ] **Step 2: Создать `deploy/.env.example`**
```
DEAL_PG_HOST=localhost
DEAL_PG_PORT=5433
DEAL_PG_DB=deal
DEAL_PG_USER=deal
DEAL_PG_PASSWORD=deal_dev_password
```
- [ ] **Step 3: Поднять контейнер**
Run: `docker compose -f deploy/compose.dev.yml up -d`
Expected: `deal-postgres` running, healthy.
- [ ] **Step 4: Проверить подключение**
Run:
```bash
docker exec deal-postgres psql -U deal -d deal -c "SELECT current_database(), current_schema();"
```
Expected: `deal | public`
- [ ] **Step 5: Дополнить README.md разделом «Запуск dev-окружения»**
```markdown
## Запуск dev-окружения
Postgres (схема на тенанта): `docker compose -f deploy/compose.dev.yml up -d`
```
- [ ] **Step 6: Зафиксировать в отчёте** `task-5-report.md`.
---
### Task 6: Tenant-контекст и подключение к Postgres
**Files:**
- Create: `src/core/Deal.SharedKernel/Tenants/TenantId.cs`
- Create: `src/core/Deal.SharedKernel/Tenants/ITenantContext.cs`
- Create: `src/core/Deal.Infrastructure/Data/TenantContext.cs`
- Create: `src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs`
- Modify: `Deal.Api/Program.cs`
- Test: `tests/Deal.Tests.Unit/TenantIdTests.cs`
**Interfaces:**
- Produces:
- `TenantId` — readonly record struct, обёртка над строкой.
- `ITenantContext``TenantId? TenantId { get; }`, `bool HasTenant { get; }`, `string? SchemaName { get; }`.
- `TenantContext` — реализация на AsyncLocal.
- `ConnectionStringProvider` — строка подключения с `search_path`.
- [ ] **Step 1: `TenantId.cs` (1 тип = 1 файл)**
```csharp
namespace Deal.SharedKernel.Tenants;
/// <summary>Идентификатор тенанта. Инвариант: непустой.</summary>
public readonly record struct TenantId(string Value)
{
public string Value { get; } = string.IsNullOrWhiteSpace(Value)
? throw new ArgumentException("TenantId не может быть пустым", nameof(Value))
: Value;
/// <summary>Имя схемы Postgres для тенанта.</summary>
public string SchemaName => $"tenant_{Value}";
}
```
- [ ] **Step 2: Тест `TenantIdTests.cs`**
```csharp
using Deal.SharedKernel.Tenants;
namespace Deal.Tests.Unit;
public sealed class TenantIdTests
{
[Fact]
public void SchemaName_PrefixesTenant()
{
var id = new TenantId("abc123");
Assert.Equal("tenant_abc123", id.SchemaName);
}
[Fact]
public void TenantId_Empty_Throws()
{
Assert.Throws<ArgumentException>(() => new TenantId(""));
}
}
```
- [ ] **Step 3: Запустить тесты**
Run: `dotnet test tests/Deal.Tests.Unit`
Expected: 3 теста PASS.
- [ ] **Step 4: `ITenantContext.cs`**
```csharp
namespace Deal.SharedKernel.Tenants;
/// <summary>Контекст текущего тенанта запроса.</summary>
public interface ITenantContext
{
TenantId? TenantId { get; }
bool HasTenant { get; }
/// <summary>Имя схемы текущего тенанта или null для системного контекста (public).</summary>
string? SchemaName { get; }
}
```
- [ ] **Step 5: `TenantContext.cs` (реализация в Infrastructure)**
```csharp
using Deal.SharedKernel.Tenants;
namespace Deal.Infrastructure.Data;
/// <summary>Контекст тенанта на AsyncLocal: пробрасывается через весь запрос.</summary>
public sealed class TenantContext : ITenantContext
{
private static readonly AsyncLocal<TenantId?> Current = new();
public TenantId? TenantId => Current.Value;
public bool HasTenant => Current.Value is not null;
public string? SchemaName => Current.Value?.SchemaName;
public void SetTenant(TenantId tenantId) => Current.Value = tenantId;
}
```
- [ ] **Step 6: `ConnectionStringProvider.cs`**
```csharp
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Configuration;
namespace Deal.Infrastructure.Data;
/// <summary>Строит строку подключения к Postgres с учётом схемы тенанта.</summary>
public sealed class ConnectionStringProvider
{
private readonly string _baseConnectionString;
public ConnectionStringProvider(IConfiguration configuration)
{
_baseConnectionString = configuration.GetConnectionString("DealPostgres")
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
}
/// <summary>Строка подключения; при tenantId не null добавляет search_path к схеме тенанта.</summary>
public string ForTenant(TenantId? tenantId)
{
if (tenantId is null)
{
return _baseConnectionString;
}
return $"{_baseConnectionString};Search Path={tenantId.Value.SchemaName}";
}
}
```
- [ ] **Step 7: Подключить в `Program.cs` (DI)**
```csharp
using Deal.Infrastructure.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<ConnectionStringProvider>();
var app = builder.Build();
```
(недостающие `using Deal.SharedKernel.Tenants;` добавить по месту)
- [ ] **Step 8: Собрать и прогнать тесты**
Run: `dotnet build Deal.sln && dotnet test tests/Deal.Tests.Unit`
Expected: build 0 ошибок, тесты PASS.
- [ ] **Step 9: Зафиксировать в отчёте** `task-6-report.md`.
---
### Task 7: EF Core + миграции (public)
**Files:**
- Create: `src/core/Deal.Infrastructure/Persistence/DealDbContext.cs`
- Create: `src/core/Deal.Infrastructure/Persistence/Entities/TenantEntity.cs`
- Create: `src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs`
- Modify: `Deal.Api/Program.cs` (регистрация DbContext)
- Test: `tests/Deal.Tests.Unit/TenantEntityTests.cs`
**Interfaces:**
- Produces:
- `DealDbContext` — базовый DbContext; системная сущность Tenant в схеме `public`.
- Миграция `InitialPublic`, применённая к `public`.
- [ ] **Step 1: Добавить EF Core пакеты в Infrastructure**
```bash
cd /c/telbase/src/core
dotnet add Deal.Infrastructure package Microsoft.EntityFrameworkCore
dotnet add Deal.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add Deal.Infrastructure package Microsoft.EntityFrameworkCore.Design
```
- [ ] **Step 2: `TenantEntity.cs` (в `Deal.Infrastructure/Persistence/Entities/`)**
```csharp
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>Тенант в системной схеме public.</summary>
public sealed class TenantEntity
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Status { get; set; } = "active";
public DateTimeOffset CreatedAt { get; set; }
}
```
- [ ] **Step 3: `DealDbContext.cs`**
```csharp
using Deal.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence;
/// <summary>Базовый DbContext. Системные сущности — в схеме public.</summary>
public sealed class DealDbContext(DbContextOptions<DealDbContext> options) : DbContext(options)
{
public DbSet<TenantEntity> Tenants => Set<TenantEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TenantEntity>(entity =>
{
entity.ToTable("tenants", "public");
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).HasMaxLength(200).IsRequired();
});
}
}
```
- [ ] **Step 4: `DealDbDesignTimeFactory.cs`**
```csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Deal.Infrastructure.Persistence;
/// <summary>Фабрика для dotnet-ef (миграции). Читает строку подключения из env.</summary>
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
{
public DealDbContext CreateDbContext(string[] args)
{
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseNpgsql(connectionString)
.Options;
return new DealDbContext(options);
}
}
```
- [ ] **Step 5: Регистрация DbContext в Program.cs**
```csharp
using Deal.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));
```
`appsettings.Development.json` положить `ConnectionStrings:DealPostgres`; в проде — из env)
- [ ] **Step 6: Создать `appsettings.Development.json` в Deal.Api**
```json
{
"ConnectionStrings": {
"DealPostgres": "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"
}
}
```
- [ ] **Step 7: Установить dotnet-ef tool и создать миграцию**
```bash
dotnet tool install --global dotnet-ef
cd /c/telbase/src/core
dotnet ef migrations add InitialPublic --project Deal.Infrastructure --startup-project Deal.Api
```
- [ ] **Step 8: Применить миграцию к public**
```bash
dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api
```
- [ ] **Step 9: Проверить таблицу**
```bash
docker exec deal-postgres psql -U deal -d deal -c "\dt public.*"
```
Expected: таблицы `tenants`, `__EFMigrationsHistory`.
- [ ] **Step 10: Тест `TenantEntityTests.cs`**
```csharp
using Deal.Infrastructure.Persistence.Entities;
namespace Deal.Tests.Unit;
public sealed class TenantEntityTests
{
[Fact]
public void TenantEntity_Defaults_AreValid()
{
var entity = new TenantEntity();
Assert.Equal("active", entity.Status);
Assert.NotEqual(Guid.Empty, entity.Id == Guid.Empty ? Guid.Empty : entity.Id);
}
}
```
(тест проверяет дефолты; при необходимости скорректировать под реальную модель)
- [ ] **Step 11: Зафиксировать в отчёте** `task-7-report.md`.
---
### Task 8: Применение миграций ко всем схемам тенантов
**Files:**
- Create: `src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs`
- Test: `tests/Deal.Tests.Unit/TenantSchemaMigratorTests.cs`
**Interfaces:**
- Consumes: `TenantId`.
- Produces: `TenantSchemaMigrator` — чистые функции формирования SQL для схем тенантов.
- [ ] **Step 1: Написать тест**
`tests/Deal.Tests.Unit/TenantSchemaMigratorTests.cs`:
```csharp
using Deal.Infrastructure.Migrations;
namespace Deal.Tests.Unit;
public sealed class TenantSchemaMigratorTests
{
[Fact]
public void CreateSchemaSql_IsEscaped()
{
var sql = TenantSchemaMigrator.CreateSchemaSql("tenant_abc");
Assert.Contains("CREATE SCHEMA IF NOT EXISTS \"tenant_abc\"", sql);
Assert.DoesNotContain("; DROP", sql);
}
[Fact]
public void CreateSchemaSql_EscapesQuotes()
{
var sql = TenantSchemaMigrator.CreateSchemaSql("tenant_a\"b");
Assert.DoesNotContain("\"b\"", sql);
}
}
```
- [ ] **Step 2: `TenantSchemaMigrator.cs`**
```csharp
namespace Deal.Infrastructure.Migrations;
/// <summary>Миграции схем тенантов. Чистые функции формирования SQL.</summary>
public static class TenantSchemaMigrator
{
/// <summary>SQL создания схемы тенанта. Имя экранируется (не интерполируется из ввода).</summary>
public static string CreateSchemaSql(string schemaName)
{
var escaped = schemaName.Replace("\"", "\"\"");
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
}
/// <summary>Имена схем тенантов из БД.</summary>
public static string ListTenantSchemasSql() =>
"SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant\\_%' ESCAPE '\\'";
}
```
- [ ] **Step 3: Запустить тесты**
Run: `dotnet test tests/Deal.Tests.Unit`
Expected: PASS.
- [ ] **Step 4: Зафиксировать в отчёте** `task-8-report.md`.
---
### Task 9: CI-скрипты и финальная проверка этапа
**Files:**
- Create: `scripts/build.sh`
- Create: `scripts/test.sh`
**Interfaces:**
- Produces: воспроизводимая сборка и тесты одной командой.
- [ ] **Step 1: `scripts/build.sh`**
```bash
#!/usr/bin/env sh
set -e
cd "$(dirname "$0")/../src/core"
dotnet build Deal.sln
```
- [ ] **Step 2: `scripts/test.sh`**
```bash
#!/usr/bin/env sh
set -e
cd "$(dirname "$0")/../src/core"
dotnet test tests/Deal.Tests.Unit
```
- [ ] **Step 3: Прогнать оба скрипта**
Run: `sh scripts/build.sh && sh scripts/test.sh`
Expected: build succeeded, все тесты PASS.
- [ ] **Step 4: Итоговая проверка этапа**
Run:
- `dotnet build Deal.sln` — 0 ошибок, 0 предупреждений;
- `dotnet test tests/Deal.Tests.Unit` — все PASS;
- `docker ps``deal-postgres` healthy;
- `curl http://localhost:5080/api/health``{"ok":true,"service":"deal"}`.
- [ ] **Step 5: Зафиксировать в отчёте** `task-9-report.md` + обновить `progress.md`.
---
## Self-Review
**1. Spec coverage (дизайн-док):**
- §2 (стратегия/структура) → Task 1, 3.
- §3 (структура src/) → Task 1, 3.
- §4 (мультитенантность: схема на тенанта, search_path) → Task 5, 6, 7, 8.
- §10 (деплой compose) → Task 5.
- §11 (стандарты: editorconfig, анализаторы, 1 тип = 1 файл) → Task 2, все задачи.
- Frontend-перенос → Task 1.
- Сервисы ml/ai/telegram — пустые каталоги (Task 1); их sln создаются в следующих этапах (вне scope этапа 0).
- Auth/инвайты/лимиты — следующие этапы (вне scope «каркаса»).
**2. Placeholder scan:** код во всех шагах конкретный. Task 7 Step 10 — тест на дефолты TenantEntity упрощён, с пометкой скорректировать под реальную модель.
**3. Type consistency:** `TenantId`, `ITenantContext`, `TenantContext`, `ConnectionStringProvider`, `TenantSchemaMigrator`, `DealDbContext`, `TenantEntity` — имена и сигнатуры согласованы между задачами 6–8.
**Вне scope этапа 0:** auth/сессии, модули с бизнес-логикой, gRPC-сервисы, .proto, админка, observability, безопасность сервисов, лимиты — отдельные планы следующих этапов.
@@ -0,0 +1,136 @@
# Дейл (Deal) — Этап 1: Доступ, мультитенантность и фундамент данных Implementation Plan
> Исторический документ этапа 1. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** Сделать ядро «Дейл» способным принимать запросы реального фронта (Vue из LeadRadar):
аутентификация (login/logout/me/change-password) на пользователях в `public`, сессии (httpOnly-cookie),
tenant-контекст из сессии, физическая схема на тенанта с применением tenant-миграций, первая
tenant-таблица (настройки). Этап закладывает паттерн для всех последующих модулей.
**Architecture:** модульный монолит `src/core`. HTTP-эндпоинты живут в `Deal.Api` (папка `Endpoints/`),
вызывают сервисы модулей. Модули содержат домен и прикладные сервисы, НЕ содержат EF. Персистентность —
в `Deal.Infrastructure` (два DbContext: системный `public` и tenant-схемы) + сущности/конфигурации
модулей подключаются туда по одному соглашению (см. Ruling 1).
**Spec:** `docs/architecture/2026-09-05-deal-architecture-design.md` §4, 6.1, 8; `docs/spec/ТЗ-дейл-новая-архитектура.md` §3, 8 (частично); контракт: `docs/api/api-map.md` (auth); референс-семантика: `backend/app/auth.py`, `backend/app/routers/auth_routes.py`, `backend/app/main.py`, `backend/app/config.py`.
## Global Constraints
- Проект **НЕ git**; фиксация — отчёты задач и `progress.md` плана. Рабочая папка плана: `.superpowers/sdd/deal-stage1-tenancy/`.
- .NET 10 SDK, решение собирается с 0 warnings / 0 errors (TreatWarningsAsErrors).
- Код-стайл: 1 тип = 1 файл; XML-doc для public-контрактов; комментарии на русском; явные модификаторы; настройки через `IOptions<T>`; без регионов и snake_case-хелперов.
- namespace `Deal.*`. Секретов в коде нет (dev-пароль по умолчанию — только seed, из env `DEAL_BOOTSTRAP_*`).
- Сущности тенантов — в схеме `tenant_<id>`; системные — в `public`. `tenantId` только из сессии, никогда из тела запроса.
- LeadRadar-контейнеры и `backend/`, `mlservice/`, `src/frontend/` не трогаем. Dev-Postgres `deal-postgres` (:5433) — наша БД.
## Зафиксированные решения (Rulings этапа)
- **Ruling 1 (модель персистентности этапа):** сущности этапа 1 — в `Deal.Infrastructure/Persistence/Entities` (POCO, 1 тип = 1 файл), EF-конфигурации — в `Deal.Infrastructure/Persistence` (рядом с контекстами). Модули (`Deal.Modules.*`) НЕ содержат EF и НЕ ссылаются на Infrastructure: они объявляют интерфейсы своих хранилищ/сервисов и работают с record-DTO. Реализации интерфейсов — в Infrastructure (паттерн «port & adapter»). Это эталон для последующих модулей; когда у модуля появится богатая логика, его сущности переедут в модуль без изменения контрактов наружу.
- **Ruling 2 (два контекста):** `DealDbContext` остаётся системным (схема `public`, явный `ToTable(...,"public")`;
таблицы: tenants, users, sessions). Новый `TenantDbContext` — бессхемная модель (таблицы без указания схемы),
живут в схеме через `search_path`. У `TenantDbContext` `MigrationsHistoryTable` получает ИМЯ
`__TenantMigrationsHistory` и схему текущего тенанта на этапе применения (см. Ruling 3).
- **Ruling 3 (применение tenant-миграций):** `TenantProvisioningService` для каждого тенанта: (1) создать схему
`tenant_<id>` (SQL `TenantSchemaMigrator.CreateSchemaSql`), (2) открыть контекст на строке подключения с
`Search Path=tenant_<id>` и `MigrationsHistoryTable("__TenantMigrationsHistory", "tenant_<id>")`, (3) `Database.Migrate()`.
- **Ruling 4 (dev-сброс схемы):** в `public` уже применена `InitialPublic` (пустая таблица tenants — тестовые данные).
Пересоздаём миграции системного контекста начисто: удаляем старую миграцию `InitialPublic`, создаём
`InitialSystem` (tenants+users+sessions), дропаем и пересоздаём dev-БД (`deal-postgres`). Реальные данные отсутствуют.
- **Ruling 5 (hash пароля):** Argon2id через пакет `Isopoh.Cryptography.Argon2` (чистый managed, без нативных
зависимостей). Формат хранения — encoded-строка из `Argon2.Hash(password)`; проверка `Argon2.Verify`.
- **Ruling 6 (сессии):** токен = 32 случайных байта (Base64Url); в БД хранится SHA-256 токена. Кука
`deal_session`, httpOnly, SameSite=Lax, MaxAge=30 дней, `Secure` — из конфига (dev=false). Смена пароля
удаляет все сессии пользователя и выдаёт свежую (семантика прототипа `auth.py`).
- **Ruling 7 (эндпоинты):** минимальные API-эндпоинты живут в `Deal.Api/Endpoints/` (статик-классы `MapXxxEndpoints(this IEndpointRouteBuilder)`), делегируют в интерфейсы модулей. Конвенция для всех модулей.
- **Ruling 7a (DTO модулей):** модуль объявляет record-DTO (папка `Application/Models`), сериализация наружу — camelCase (ASP.NET default); эндпоинты не видят EF-сущности.
- **Ruling 8 (bootstrap/seed):** при старте, если нет тенантов: создаём дефолтного тенанта с ФИКСИРОВАННЫМ id `00000000-0000-0000-0000-000000000001` (схема `tenant_000...0001`, детерминирована) и пользователя `admin` (логин/пароль из env `DEAL_BOOTSTRAP_LOGIN/PASSWORD`, по умолчанию `admin`/`admin`) — повторяет `ensure_creds` прототипа. Seed идемпотентен. Провижининг схемы дефолтного тенанта — тем же `TenantProvisioningService`.
- **Ruling 9 (первая tenant-таблица):** `settings` (модуль Settings): `key text PK`, `value_json text NOT NULL`,
`updated_at timestamptz NOT NULL`. Без неё tenant-миграции нечего применять; таблица понадобится всем модулям.
- **Ruling 10 (DTO/сериализация):** ответы — camelCase JSON (ASP.NET default); ошибки — HTTP-код + `{"detail": "..."}`
(семантика FastAPI, см. `api.js`).
## Задачи
### Task 1: Карта API
Выполнена (артефакт `docs/api/api-map.md`). В этом этапе используется секция Auth.
### Task 2: Персистентность — системный и tenant-контексты, миграции
**Files:**
- Create: `src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs`
- Create: `src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs`, `SessionEntity.cs`, `TenantSettingEntity.cs`
- Create: `src/core/Deal.Infrastructure/Persistence/UserConfiguration.cs`, `SessionConfiguration.cs`, `TenantSettingConfiguration.cs`
- Modify: `Deal.Infrastructure/Persistence/DealDbContext.cs` (добавить DbSet Users/Sessions)
- Create: `src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs`
- Delete: старая миграция `InitialPublic*` в `Deal.Infrastructure/Migrations/``DealDbContextModelSnapshot.cs` — пересоздастся)
- Migrations: `Migrations/InitialSystem` (контекст DealDbContext), `Migrations/InitialTenant` (контекст TenantDbContext) — обе в общей папке `Migrations/` (без `--output-dir`): имена классов миграций и снапшотов (`DealDbContextModelSnapshot`/`TenantDbContextModelSnapshot`) не конфликтуют.
**Acceptance:**
1. `DealDbContext` (системный): `Tenants`, `Users`, `Sessions` в схеме `public` (явная схема в конфигурациях).
2. `TenantDbContext`: модель без схемы, таблица `settings` (см. Ruling 9), `MigrationsHistoryTable` = `__TenantMigrationsHistory` (без схемы в модели; схема задаётся при применении).
3. Сущности — в отдельных файлах (1 тип = 1 файл), конфигурации в отдельных файлах.
4. Сборка: `dotnet build Deal.sln` — 0 warnings/0 errors.
5. Dev-БД пересоздана: `public` содержит `tenants`, `users`, `sessions`, `__EFMigrationsHistory` (одна строка `InitialSystem`).
6. Tenant-миграция `InitialTenant` существует и при применении к схеме создаёт там `settings` и историю — проверка через psql (применение выполняет Task 5; здесь достаточно `dotnet ef migrations list` и того, что SQL миграции не содержит схемы).
7. Отчёт: `task-2-report.md`.
### Task 3: Модуль Tenants — домен и прикладные сервисы аутентификации
**Files:**
- Create: `src/core/Deal.Modules.Tenants/Application/IPasswordHasher.cs`, `DefaultPasswordHasher.cs` (Argon2id, Ruling 5)
- Create: `src/core/Deal.Modules.Tenants/Application/Models/*.cs` — record-DTO: `UserIdentityDto`, `SessionDto`, `LoginResult` и т.п. (минимум, что нужно сервисам)
- Create: `src/core/Deal.Modules.Tenants/Application/IAuthStore.cs` (поиск пользователя по логину, чтение/создание/удаление сессий, смена пароля — на DTO)
- Create: `src/core/Deal.Modules.Tenants/Application/AuthService.cs` (login/logout/changePassword/resolveSession)
- Create: `src/core/Deal.Modules.Tenants/Application/ITenantRepository.cs`, `TenantService.cs` (реестр тенантов; создание тенанта вызывает `ITenantProvisioner` — интерфейс из модуля)
- Modify: `Deal.Infrastructure` — EF-реализации (`Persistence/Repositories/AuthStore.cs`, `TenantRepository.cs`) + регистрация DI (`Deal.Infrastructure/ServiceCollectionExtensions.cs`)
- Test: `tests/Deal.Tests.Unit/PasswordHasherTests.cs`, `AuthServiceTests.cs` (с fake-хранилищем)
**Семантика (референс `backend/app/auth.py`):**
- login: неверные данные → 401 «Неверный логин или пароль»; ok → `{ok:true, login}`.
- changePassword: `oldPassword` неверен → false→400 «Текущий пароль неверен»; новая длина <4 → 400 «Пароль слишком короткий (минимум 4 символа)»; успех → удалить все сессии пользователя.
- resolveSession по токену (с учётом expires) → login.
- Сессия живёт 30 дней; «протухшие» сессии удаляются при resolve (очистка).
**Acceptance:** build 0/0; `dotnet test tests/Deal.Tests.Unit` — все PASS (было 6 + новые ≥6). Тесты: hash/verify, неверный пароль, смена пароля инвалидирует старые сессии, resolve протухшей сессии → null. Отчёт: `task-3-report.md`.
### Task 4: Эндпоинты auth, middleware сессии, DI, curl-приёмка
**Files:**
- Create: `src/core/Deal.Api/Endpoints/AuthEndpoints.cs`
- Create: `src/core/Deal.Api/Middleware/SessionMiddleware.cs` (чтение куки → resolve → `TenantContext` + `CurrentUser` в `HttpContext.Items`; слабые запросы без сессии — дальше, 401 выставляют сами эндпоинты)
- Create: `src/core/Deal.Api/Configuration/CookieOptions.cs` (IOptions; Name=deal_session, Days=30, Secure=false)
- Modify: `Deal.Api/Program.cs` (CORS dev как в прототипе, cookie-конфиг, DI модулей+инфраструктуры, map auth-группы; статика SPA не нужна)
- Test/скрипт приёмки: последовательность curl на :5080 (health → login admin/admin → cookie → me → change-password → старый logout/401)
**Контракт эндпоинтов (1:1 с прототипом):** `POST /api/auth/login` {login,password} → 200 {ok,login} | 401; `POST /api/auth/logout` → {ok:true}; `GET /api/auth/me` → 200 {login,ok} | 401 {detail:"Требуется авторизация"}; `POST /api/auth/change-password` {oldPassword,newPassword} → {ok:true} | 400.
**Acceptance:** build 0/0; curl-цепочка проходит (кука выставляется, me работает, после logout — 401). Отчёт: `task-4-report.md`.
### Task 5: Провижининг схем тенантов и bootstrap при старте
**Files:**
- Create: `src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs` (Ruling 3; реализует `ITenantProvisioner` из модуля)
- Create: `src/core/Deal.Api/Hosting/TenantBootstrapService.cs` (IHostedService: seed дефолтного тенанта+admin (Ruling 8), провижининг схем ВСЕХ тенантов при старте; идемпотентно)
- Modify: `Deal.Modules.Tenants/Application/IAuthStore.cs` — добавить `Task CreateUserAsync(StoredUserDto user, CancellationToken ct)` (seed через порт модуля, НЕ через DbContext в Api)
- Modify: `Deal.Infrastructure/Persistence/Repositories/AuthStore.cs` — реализовать CreateUserAsync
- Modify: `Deal.Modules.Tenants/Application/TenantService.cs``CreateTenantAsync(string name, CancellationToken)` оставить; при необходимости дать возможность передать явный Guid id (для дефолтного тенанта)
- Modify: `Deal.Infrastructure/ServiceCollectionExtensions.cs` — регистрация `ITenantProvisioner→TenantProvisioningService`
- Modify: `Deal.Api/Program.cs` — hosted-сервис вместо StartupSeed; удалить `PendingTenantProvisioner`
- Delete: `Deal.Api/Hosting/StartupSeed.cs`, временная DI-заглушка `PendingTenantProvisioner`
- Modify: `Deal.Api/Configuration/CookieOptions.cs``Days` по умолчанию = константа сессии модуля (единый источник «30»)
**Acceptance:** app стартует, seed создан (psql: tenants строка с фикс. id, users `admin`), схема `tenant_<32hex>` дефолтного тенанта создана с таблицей `settings` и `__TenantMigrationsHistory` (содержит InitialTenant); повторный старт идемпотентен; `dotnet build` 0/0; все тесты PASS; login admin/admin работает после старта. Отчёт: `task-5-report.md`.
### Task 6: Финал этапа
- `scripts/build.sh`, `scripts/test.sh` — успешны; `dotnet ef migrations list` — System: InitialSystem, Tenant: InitialTenant.
- Полная curl-приёмка (health, login, me, logout) + psql-проверка схем.
- Обновить `docs/technical/Техническая-документация-Дейл.md` (раздел «Быстрый старт dev» — актуальные шаги: поднять postgres, мигрировать public, запустить API, креды).
- Отчёт `task-6-report.md` + финальная строка в `progress.md`.
## Self-Review
1. Spec coverage: ТЗ §3 (роли/доступ) — Task 3–5; архитектура §4 (мультитенантность) — Task 2, 5; §6.1 (контракт /api, auth) — Task 4; §11 (стандарты) — все задачи.
2. Placeholder scan: код везде конкретный; референсы на `auth.py`/api-map точные.
3. Type consistency: `TenantId`, `ITenantContext`, `TenantContext`, `ConnectionStringProvider`, `TenantProvisioningService`, `DealDbContext`, `TenantDbContext`, сущности — согласованы между задачами 2–5.
4. Вне scope этапа 1: kanban/колонки/карточки, проекты, pipeline/очередь/отсев, discovery, сервисы ml/ai/telegram, операторская админка, инвайты, лимиты токенов, валюты — следующие этапы.
@@ -0,0 +1,430 @@
# Дейл (Deal) — Этап 2: Настройки тенанта (Settings) Implementation Plan
> Исторический документ этапа 2. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** Реализовать в модульном монолите `src/core` модуль Settings с 1:1-контрактом `/api`,
который потребляет экран «Настройки» Vue-фронта (`src/frontend/src/views/SettingsView.vue`,
`components/MLPanel.vue`, `PromptLibraryModal.vue`): чтение/сохранение дерева настроек тенанта
(таблица `settings` уже есть), шифрование секретов (ключи AI/Telegram), проверка подключения
AI-провайдера, курсы валют, ML-панель на детерминированной локальной заглушке, тестер фильтров
входящих. К концу этапа Settings-экран обслуживается бэкендом полностью (кроме зон, помеченных
зависимостями этапов 3–6); приёмка — curl/psql/unit-тесты (Vue-фронт полностью оживает только
с этапом 3: его `boot()` требует `/api/boards`, `/api/leads`, `/api/projects`, `/api/tg/status`
см. Ruling 11).
**Architecture:** новый модуль `Deal.Modules.Settings` (чистый, без EF): константы/дефолты,
типизированный каталог ключей, порты `ISettingsStore`/`ISecretCipher`/`IRatesSource`/
`IAiConnectionChecker`, сервисы `SettingsService` (public-снимок + частичный PATCH), `RatesService`,
`IncomingRules` (этап-1 правила тестера). Адаптеры — в `Deal.Infrastructure`: KV `SettingsStore`
(таблица `settings`, JSON в `value_json`), `AesGcmSecretCipher`, `CbrRateSource`, HTTP-проверка AI.
Интеграционный порт `IMlClient` + record-DTO — в `Deal.Contracts/Integrations`, заглушка
`LocalMlClient` — в `Deal.Infrastructure/Integrations`. HTTP-эндпоинты — в `Deal.Api/Endpoints/`
(`MapSettingsEndpoints`, `MapMlEndpoints`, `MapFilterTesterEndpoints`). Внешние сервисы
(реальные ml/ai/telegram) на этапе 6 заменят заглушки gRPC-адаптерами без правки эндпоинтов.
**Spec:** `docs/api/api-map.md` §3.4 (L142152), §3.7 (L187199), §4.6 (L315341), §4.7 (L343346),
§4.10 (L363365), правила L7–24, п.9 «экономия» (L399); `docs/spec/ТЗ-дейл-новая-архитектура.md`
§8 (L165179), §5 (L89121, фильтры), §7 (L150–161 — только пересечения), §9 (лимиты — НЕ в этап);
`docs/architecture/2026-09-05-deal-architecture-design.md` §5 (границы модулей), §8 (секреты L207);
референс-семантика: `backend/app/routers/settings_routes.py`, `backend/app/services/rates.py`,
`backend/app/services/ai.py` (L3677, L188198), `backend/app/routers/ml_routes.py`,
`backend/app/services/ml_client.py`, `backend/app/routers/dashboard_routes.py` (admin/check-message
L267284), `backend/app/services/pipeline.py` (stage1_plain L94124), `backend/app/constants.py`
(L3050, L54245), `backend/app/crypto.py`, `backend/app/config.py` (L4851);
фронт: `src/frontend/src/store.js` (boot L565628, applySettings L343397, applyMlStatus L487502,
schedulePersist L17371766, refreshRates L18441848), `src/frontend/src/data.js` (L6141 дефолты
промптов; `AI_PROVIDERS` L1780; `PROMPT_LIBRARY` L180–200 — библиотека по сферам живёт ТОЛЬКО
во фронте, бэкенд её не отдаёт), `views/SettingsView.vue` (вкладки L3949), `components/MLPanel.vue`,
`components/PromptLibraryModal.vue`.
## Global Constraints
- Проект **НЕ git**; фиксация — отчёты задач `task-N-report.md` и `progress.md`. Рабочая папка плана: `.superpowers/sdd/deal-stage2-settings/`.
- .NET 10 SDK, решение собирается с 0 warnings / 0 errors (`TreatWarningsAsErrors`).
- Код-стайл этапа 1: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском; явные модификаторы; настройки через `IOptions<T>`; без регионов.
- namespace `Deal.*`. Секретов в коде нет; ключи шифрования — env/файл (Ruling 2). `tenantId` — только из сессии.
- Таблица `settings` уже в `TenantDbContext` (миграция `InitialTenant`) — новые EF-таблицы в этапе 2 НЕ создаются.
- LeadRadar-контейнеры, `backend/`, `mlservice/`, `src/frontend/` не трогаем. Dev-Postgres `deal-postgres` (:5433).
- Ответы: camelCase JSON; ошибки — HTTP + `{"detail"}`; «мягкие» ошибки (ml/reset) — HTTP 200 с полем `error`.
- Дефолтные значения настроек/промптов — из констант прототипа `constants.py` и `data.js` (фронт — высший авторитет форм; тексты промптов копируются из `data.js` L94141).
## Зафиксированные решения (Rulings этапа)
- **Ruling 1 (модель настроек):** типизированные ключи в существующей таблице `settings`
(`key` text PK, `value_json` — JSON-сериализованное значение любого типа, `updated_at`).
Модуль хранит только переопределения; дефолты — в коде (`SettingsDefaults`), при чтении
снимок = дефолты, перекрытые сохранёнными значениями. Каталог публичных ключей —
статический словарь «ключ → категория» (Int/Bool/String/List/Dict/MyPrompts/AiConfigs/TgKeys).
Внутренние (непубличные) ключи — `ratesCache`, `mlDecisions`, `aiDecisions` — хранятся в той же
таблице через `ISettingsStore`, но в GET/PATCH `/settings` не участвуют. Неизвестные ключи в
PATCH игнорируются (семантика `settings_routes.py` L110185).
- **Ruling 2 (шифрование секретов):** AES-256-GCM (`System.Security.Cryptography.AesGcm`),
nonce 12 байт, tag 16 байт. Ключ — env `DEAL_ENCRYPTION_KEY` (32 байта, urlsafe-Base64);
при отсутствии в dev — файл `<ContentRoot>/data/encryption.key` (генерируется при первом
старте, лог-warning; путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`). Формат значения в
БД: `enc:` + Base64(nonce‖ct‖tag). Расшифровка повреждённого/чужого значения → пустая строка
+ warning (совместимость `crypto.decrypt_text`, `crypto.py` L5261). Порт `ISecretCipher`
в модуле Settings, адаптер `AesGcmSecretCipher` — в Infrastructure.
- **Ruling 3 (маски и публичная форма):** маска `mask(v)`: пусто → `""`, `len≤8` → как есть,
иначе `v[:4]+"…"+v[-4:]` (`settings_routes.py` L2832). `aiConfigs` наружу —
`{id: {baseUrl, model, keySet, keyMasked}}`; `tgKeys``{apiId: <маска>, apiHashSet: bool}`.
Список `providers` — статический из модуля (`id,name,base,local,models`; зеркало
`constants.AI_PROVIDERS` L170186; `api_style` — внутреннее поле, наружу не отдаётся).
- **Ruling 4 (границы интеграционных портов):** порты будущих внешних сервисов (ML/AI/telegram)
объявляются в `Deal.Contracts/Integrations` (интерфейс + record-DTO) — их потребляют несколько
модулей и Api. Заглушки этапа — детерминированные адаптеры в `Deal.Infrastructure/Integrations`;
на этапе 6 заменяются gRPC-клиентами с тем же контрактом. `IAiFacade` на этапе 2 не заводится:
классификация/фильтр ИИ — этап 6, проверка соединения — модульный порт `IAiConnectionChecker`.
- **Ruling 5 (ML-заглушка):** `IMlClient` (Contracts): `StatusAsync/PredictAsync/ResetAsync`
(+ `PushAsync` добавится этапом 3). `LocalMlClient` — детерминированная: `reachable=true`,
`ready=false`, `classes={}`, `learned=0`, `eval={count:0,correct:0,accuracy:0}` (обучение на
действиях появится с Kanban-этапом 3); `PredictAsync` неготовой модели →
`{take:false,label:null,scores:{},hits:0,ready:false,margin:null,terms:[],type:null}`;
`ResetAsync``{ok:true}`. Таблиц `ml_outbox`/`learning_log` в этапе 2 нет (владельцы —
этапы 3/4); счётчики `mlDecisions`/`aiDecisions` — KV-настройки.
- **Ruling 6 (курсы валют):** кэш — tenant-настройка `ratesCache` `{rates, source, updatedAtMs}`.
Источник по `rateSource` (`cbr`|`mock`); интервал обновления 6 часов (≤4 запроса/сутки,
`rates.py` L20); `USDT=USD` (`rates.py` L8691). Mock-курсы — константа `MockRates`
(`constants.py` L41–50). Обновление: лениво на GET при протухании/смене источника, синхронно
на `POST /rates/refresh`, фоново-запуск на PATCH `rateSource` (`settings_routes.py` L186192).
Массовый пересчёт карточек (`recompute_conversions`) — этап 3 (таблицы leads нет); в этапе 2 —
только чистый `ConvertAmount`.
- **Ruling 7 (проверка AI):** реальный HTTP, без LLM-вызовов, 1:1 `settings_routes.py` L195219:
нет ключа → `{ok:false, message:"Не задан API-ключ"}`; локальный провайдер → `{ok:true,
message:"Локальный сервер «<name>» (ping в проде)"}`; облачный → `GET {base}/models`
(Anthropic: `{base}/v1/models`, заголовок `x-api-key`); HTTP<400 → ok, 401/403 → «Ключ не
принят (HTTP n)…», иначе «HTTP n — проверьте Base URL и модель»; сетевой сбой → «Ошибка
соединения: …». Ответ — `{ok, message}` + статус провайдера (`provider,name,base,model,local,
keySet,keyMasked`, `ai.py` L3658).
- **Ruling 8 (эндпоинты этапа и границы):** файлы `Deal.Api/Endpoints/*`, группы
`MapSettingsEndpoints` (GET/PATCH `/settings`), `MapRatesEndpoints` (GET `/rates`,
POST `/rates/refresh`), `MapAiCheckEndpoint` (POST `/ai/check`), `MapMlEndpoints` (/ml/*),
`MapFilterTesterEndpoints` (POST `/admin/check-message`). «Только для Settings-экрана»:
GET/PATCH `/settings`, POST `/ai/check`, GET/POST `/rates*`, POST `/admin/check-message`,
ML-статус/сброс/проверка. «Переиспользуются этапами 3+»: `GET/PATCH /settings` — общий
источник настроек для pipeline/kanban/projects/discovery; `/api/ml/*` — счётчики и обучение
(этап 3), предсказания (этап 4), кандидаты/apply оживают с telegram-данными (этап 6);
правила `IncomingRules` — этап-1 пайплайна (этап 4). НЕ входят в этап 2 (зависимости):
`/api/tg/*` (этап 6), `admin/tick`, `admin/fts/rebuild` (кнопки «Хранение и очистка» — этапы
3/4), `/api/ai/suggest-keywords` и `suggest-columns` (этапы 3/6), `/api/columns/*`,
`/api/leads/*`, `/api/boards/*`, `/api/projects/*`, `/api/pipeline/*`, `/api/discovery/*`,
`/api/meta/constants` (фронт не вызывает — api-map п.9 L399), `ml/learn`, `ml/flush` (там же),
события SSE, лимиты ТЗ §9 (этап 7).
- **Ruling 9 (колонки/colState):** колонки и их правила — этап 3 (Kanban). В этапе 2 `colState`
— обычный dict-ключ (passthrough в PATCH, дефолт `{}`), отдельные `/api/columns/*` НЕ делаются.
- **Ruling 10 (звук/вид/напоминания):** `soundOn`/`volume` и тема — локальное состояние фронта
(`store.js` L106111, в PATCH не шлются) — бэкенд не нужен. Общие напоминания — ключ
`remindersEnabled` (passthrough); отложенные напоминания и `reminder_due` — этап 5 (Projects).
- **Ruling 11 (приёмка и фронт):** Vue `boot()` (`store.js` L571–581) требует отсутствующие до
этапа 3 группы (`/boards`, `/leads`, `/leads/counts`, `/projects`, `/tg/status`,
`/columns/state`) — полная работа фронта восстанавливается этапом 3; поэтому приёмка этапа 2 —
unit-тесты + curl + psql. Строки ошибок/сообщений — фиксированные из прототипа (см. задачи).
## Задачи
Сокращения путей: `S=` `src/core/Deal.Modules.Settings/`, `I=` `src/core/Deal.Infrastructure/`,
`A=` `src/core/Deal.Api/`, `C=` `src/core/Deal.Contracts/`, `T=` `src/core/tests/Deal.Tests.Unit/`.
### Task 1: Шифрование секретов (AES-GCM) — фундамент хранения ключей AI/Telegram
**Files:**
- Create: `S/Application/ISecretCipher.cs``Encrypt(string)→string` (префикс `enc:`),
`Decrypt(string)→string` (без префикса — вернуть как есть; сбой → `""`), `MaybeEncrypt`.
- Create: `I/Security/AesGcmSecretCipher.cs` — AES-256-GCM, nonce 12/tag 16, формат
`enc:` + Base64(nonce‖ct‖tag) (Ruling 2).
- Create: `I/Security/EncryptionKeyProvider.cs` — ключ из `IConfiguration` (`DEAL_ENCRYPTION_KEY`,
Base64 32 байта); fallback: файл `data/encryption.key` (env `DEAL_ENCRYPTION_KEY_FILE`),
генерация при первом старте + warning; невалидный env-ключ → исключение при старте
(семантика `crypto._get_fernet`, `crypto.py` L2242).
- Create: `A/Configuration/EncryptionOptions.cs` (IOptions: секция `Encryption`: `KeyFilePath`,
дефолт `data/encryption.key`).
- Modify: `I/ServiceCollectionExtensions.cs` — регистрация `ISecretCipher→AesGcmSecretCipher`
(singleton, ключ из provider).
- Test: `T/SecretCipherTests.cs` (roundtrip; префикс `enc:`; незашифрованная строка проходит
как есть; повреждённый токен → `""`; `MaybeEncrypt("")``""`).
**Источники:** `backend/app/crypto.py` L170; `backend/app/config.py` L4851.
**Acceptance:** build 0/0; `dotnet test` — SecretCipherTests PASS. Отчёт: `task-1-report.md`.
### Task 2: Модуль Settings — каталог ключей, дефолты, DTO, порт хранилища
**Files:**
- Create: `S/Application/SettingKind.cs` (enum: Int/Bool/String/List/Dict/MyPrompts/AiConfigs/TgKeys/Internal).
- Create: `S/Application/SettingsKeys.cs` — статический каталог публичных ключей
(категория каждого ключа, 1:1 список §4.6 и PATCH-список L340): Int — `archiveAfterDays`,
`archiveClearDays`, `trashClearDays`, `minLen`, `discJoinLimit`, `discJoinDelayMin/Max`,
`discEvalSample`, `discEvalThreshold`; Bool — `autoArchive`, `aiEnabled`, `aiFilterEnabled`,
`conversionOn`, `remindersEnabled`, `mlEnabled`, `blockResumes`, `budgetRequiredHire/Order`,
`autoMonitorNew`, `discPaused`; String — `targetCurrency`, `rateSource`, `aiProvider`,
`aiPrompt`, `aiFilterPrompt`, `cardPrompt`, `domainDescription`, `wantedType`, `hireLabel`,
`orderLabel`; List — `stopPhrases`, `domainKeywords`, `hireMarkers`, `levelTerms`,
`resumeMarkers`; Dict — `colState`; + special: `myPrompts`, `aiConfigs`, `tgKeys`; Internal:
`ratesCache`, `mlDecisions`, `aiDecisions` (в PATCH/GET не участвуют, Ruling 1).
- Create: `S/Application/SettingsDefaults.cs` — значения по умолчанию из `constants.py` L189245
(включая дефолтные стоп-фразы L55, `minLen=24`, hire/level/resume-маркеры L144167,
`aiConfigs` для каждого провайдера с первым `model`, `tgKeys={apiId:"",apiHash:""}`).
- Create: `S/Application/DefaultPrompts.cs` — константы `DefaultAiPrompt`, `DefaultCardPrompt`,
`DefaultAiFilterPrompt` — тексты КОПИРУЮТСЯ из `src/frontend/src/data.js` L94141 (фронт —
источник; в `constants.py` L63–141 те же тексты для сверки).
- Create: `S/Application/AiProviderDefinition.cs` (record: Id, Name, Base, Local, Models,
ApiStyle? `null`=OpenAI-совместимый, `"anthropic"`), `S/Application/AiProviders.cs`
(статический список 7 провайдеров: deepseek/openai/openrouter/anthropic/ollama/lmstudio/custom —
`constants.py` L170186).
- Create: `S/Application/MockRates.cs` (константа, `constants.py` L4150) + `RatesFetchInterval = 6h`.
- Create: `S/Application/ISettingsStore.cs` — порт: `Task<object?> GetAsync(string key, ct)`,
`Task<Dictionary<string,object?>> GetAllAsync(ct)`, `Task SetAsync(string key, object? value, ct)`
(значения JSON-сериализуемые; список/словарь/строка/число/булево).
- Test: `T/SettingsCatalogTests.cs` (все ключи §4.6 присутствуют с корректной категорией;
внутренние ключи не в каталоге публичных; провайдеры: 7 шт., id/base соответствуют списку;
MockRates содержит RUB/USD/EUR/USDT).
**Источники:** api-map §4.6 L315341; `constants.py`; `data.js` L6141.
**Acceptance:** build 0/0; SettingsCatalogTests PASS. Отчёт: `task-2-report.md`.
### Task 3: SettingsService — public-снимок и частичное обновление (PATCH-семантика 1:1)
**Files:**
- Create: `S/Application/Models/PublicSettingsDto.cs` — record со всеми полями §4.6
(вложенные: `MyPromptDto{Id,Name,Description,Prompt}`, `AiConfigPublicDto{BaseUrl,Model,KeySet,
KeyMasked}`, `TgKeysPublicDto{ApiId,ApiHashSet}`, `ProviderPublicDto{Id,Name,Base,Local,Models}`).
- Create: `S/Application/SettingsService.cs``GetPublicAsync(ct)` (дефолты+сохранённые,
маскирование, Ruling 3; для `apiHashSet``SecretCipher.Decrypt(apiHash) != ""`, для каждого
провайдера — расшифровка ключа + `keySet/keyMasked`); `ApplyPatchAsync(
Dictionary<string,JsonElement> body, ct)` с клампами и валидацией (см. ниже), ответ — полный
public-снимок (фронт затирает локальный state ответом — api-map L147, L341).
- Create: `T/…/FakeSettingsStore.cs` (in-memory Dictionary), `T/SettingsServiceTests.cs`.
**Семантика PATCH (референс `settings_routes.py` L75192):**
- Int: нечисловое → пропуск ключа; клампы: `archiveAfterDays` 1..30, `minLen` 10..500,
`discJoinLimit` 1..200, `discJoinDelayMin/Max` 5..600, `discEvalSample` 3..30,
`discEvalThreshold` 1..100; интервалы задержек: при паре — клампы+swap при min>max; при одном
конце — кламп относительно сохранённого другого конца (L80–109).
- Bool: JSON-булево (строки не «питон-булеватся»). String: `targetCurrency` → Upper;
`aiProvider` вне списка провайдеров → пропуск; остальные — строка как есть.
- List: только список → строки, срез 200. Dict: `colState` — как есть (Ruling 9).
- `myPrompts`: ≤100; name≤80, prompt≤8000, description≤300 (trim); пустые name/prompt — дроп;
id ≤40 или генерация `pp_` + 8 hex (Ruling дефолта, референс L143–160).
- `aiConfigs`: только существующие провайдеры; `baseUrl`/`model` — строки; `apiKey` непустой,
≥8 симв., без префикса `enc:` → шифруется (L161–175).
- `tgKeys`: `apiId` — только цифры, длина 6..9 (5<len<10); `apiHash` ≥16 симв. → шифруется
(L176185).
- Побочные эффекты PATCH: при `rateSource` — запуск `RatesService.RefreshAsync` (fire-and-forget);
при `targetCurrency`/`conversionOn` — в этапе 2 ничего (нет leads; этап 3) (L186192).
**Источники:** api-map §4.6 L147, L340341; `settings_routes.py` целиком; `crypto.py`.
**Acceptance:** `dotnet test` — SettingsServiceTests PASS: снимок дефолтов; маскирование ключа;
каждый кламп; swap интервалов; `myPrompts` clean+id; шифрование aiConfigs/tgKeys (в БД `enc:`);
неизвестный ключ игнорируется. Отчёт: `task-3-report.md`.
### Task 4: KV-адаптер SettingsStore (EF) и DI
**Files:**
- Create: `I/Persistence/Repositories/SettingsStore.cs` — реализует `ISettingsStore` на
`TenantDbContext.Settings` (сущность `TenantSettingEntity` уже есть): чтение всех строк,
сериализация/десериализация значений в JSON, `updated_at` — UTC-now.
- Modify: `I/ServiceCollectionExtensions.cs``AddScoped<ISettingsStore, SettingsStore>()`;
регистрация `ISecretCipher` из Task 1, `RatesService`-зависимостей из Tasks 6–8.
- Modify: `S/SettingsModuleRegistrar.cs` (Create) — `AddSettingsModule()`: `SettingsService`,
`RatesService`, `IncomingRules` (scoped); вызывается в `A/Program.cs` (Task 5).
- Modify: `A/Deal.Api.csproj` — ProjectReference на `Deal.Modules.Settings`.
**Источники:** эталон: `I/Persistence/Repositories/AuthStore.cs`, `TenantModuleRegistrar.cs`,
`ServiceCollectionExtensions.cs` (этап 1).
**Acceptance:** build 0/0; psql-проверка: GET через сервис на пустой схеме тенанта возвращает
дефолты, `SetAsync` создаёт строку с `value_json`. Отчёт: `task-4-report.md`.
### Task 5: Эндпоинты GET/PATCH /api/settings + DI + curl-приёмка
**Files:**
- Create: `A/Endpoints/SettingsEndpoints.cs` (`MapSettingsEndpoints`): `GET /api/settings`
PublicSettingsDto; `PATCH /api/settings` — тело произвольный JSON-объект →
полный снимок после применения. Авторизация — через `SessionMiddleware`/`CurrentUser`
(эталон `AuthEndpoints.cs`), 401 `{"detail":"Требуется авторизация"}`.
- Modify: `A/Program.cs``AddSettingsModule()`, map групп эндпоинтов.
- Модификации предыдущих задач собираются здесь же (порядок исполнения: T1→T4 затем T5).
**Контракт (api-map §3.4 L146147, §4.6):** GET — все ключи §4.6 (camelCase, дефолты, маски,
`providers` список); PATCH — те же поля-группы, что шлёт фронт (L340), ответ — полный снимок.
Ошибок-исключений нет (мягкая семантика: невалидное поле просто не применяется).
**Acceptance (curl, cookie-сессия admin/admin):**
1. `GET /api/settings` → дефолты: `aiEnabled:true, mlEnabled:true, minLen:24,
archiveAfterDays:14, stopPhrases:[4 дефолтные], wantedType:"both", rateSource:"cbr",
aiProvider:"deepseek", tgKeys:{apiId:"", apiHashSet:false}, colState:{}`, `providers` — 7.
2. `PATCH {"archiveAfterDays":99,"minLen":3,"discJoinDelayMin":700,"discJoinDelayMax":5}` →
в ответе `archiveAfterDays:30, minLen:10, discJoinDelayMin:5, discJoinDelayMax:700` (swap).
3. `PATCH {"myPrompts":[{name:"x",prompt:"y"},{name:"",prompt:""}]}` → 1 элемент, `id` начинается `pp_`.
4. `PATCH {"aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}}}` → ответ `keySet:true,
keyMasked:"sk-1…90ab"`; psql: `value_json` содержит `enc:` (см. Task 7-контракт psql).
5. `PATCH {"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"}}` → `apiHashSet:true`.
6. Неизвестный ключ `{"foo":1}` — без ошибки, снимок без `foo`.
Отчёт: `task-5-report.md`.
### Task 6: ИИ-провайдеры и POST /api/ai/check (проверка подключения)
**Files:**
- Create: `S/Application/IAiConnectionChecker.cs` — `Task<AiCheckResultDto> CheckAsync(
AiCheckRequest request, ct)`, `S/Application/Models/AiCheckResultDto.cs` (Ok, Message, Provider,
Name, Base, Model, Local, KeySet, KeyMasked), `AiCheckRequest` (ProviderId, BaseUrl, Model,
ApiKey, IsLocal, ApiStyle).
- Create: `I/Integrations/AiConnectionChecker.cs` — HTTP-реализация (Ruling 7) через
`IHttpClientFactory` (таймаут 12 с), переиспользует формат сообщений прототипа.
- Create: `A/Endpoints/AiCheckEndpoint.cs` (`MapAiCheckEndpoint`) — читает активную конфигурацию
провайдера из `ISettingsStore` (расшифровка ключа через `ISecretCipher`), вызывает checker,
отдаёт `{ok,message,provider,name,base,model,local,keySet,keyMasked}` (api-map §4.10 L365).
- Test: `T/AiConnectionCheckerTests.cs` (fake `HttpMessageHandler`): без ключа; local; 200;
401; 403; HTTP 500; сетевая ошибка.
**Источники:** `settings_routes.py` L195219; `ai.py` L3658 (provider_status + mask_key).
**Acceptance:** build 0/0; тесты PASS. curl: без ключа → `{"ok":false,"message":"Не задан
API-ключ",...}`; провайдер `ollama` → ok:true «Локальный сервер…»; `deepseek` с неверным ключом
и недоступным хостом → `"Ошибка соединения: …"` (сеть недоступна — допустимо). Отчёт:
`task-6-report.md`.
### Task 7: Промпты и «Мои промпты» — интеграционная проверка границы с фронтом
Бэкенд-логика уже в Tasks 2–3 (`DefaultPrompts`, валидация `myPrompts`). Задача — контроль
1:1 границы и приёмочные проверки (библиотека по сферам — фронтовая, `data.js` PROMPT_LIBRARY
L180200; `PromptLibraryModal.vue` не ходит в API; наружу идут только промпты-строки и
`myPrompts`).
**Files:**
- Test: `T/PromptDefaultsTests.cs` — дефолтные тексты начинаются/содержат маркеры из
`data.js` (например `aiPrompt` содержит «Ты — классификатор входящих сообщений» и
плейсхолдеры `{domain}`/`{keywords}`; `cardPrompt` — «О заявке»; `aiFilterPrompt` — «страж
входящих»); `fill_prompt`-подстановка (аналог `ai.fill_prompt` L6377): пустой domain →
фраза-фолбэк, keywords склейка, ≤60 ключей.
- Create: `S/Application/PromptFiller.cs` — подстановка `{domain}`/`{keywords}` (чистая функция,
используется этапом 6 для ИИ-вызовов).
**Acceptance (curl):** 1) PATCH `aiPrompt` с плейсхолдерами → GET возвращает тот же текст;
2) PATCH `myPrompts` 3 записи → GET отдаёт их (camelCase `id/name/description/prompt`);
3) «Применить из библиотеки» фронта = локальная операция — API не вызывается. `dotnet test`
PromptDefaultsTests PASS. Отчёт: `task-7-report.md`.
### Task 8: Курсы валют — сервис, кэш, эндпоинты /api/rates*
**Files:**
- Create: `S/Application/IRatesSource.cs` — порт: `Task<Dictionary<string,double>?> FetchAsync(ct)`
(курсы к RUB). `S/Application/Models/RatesDto.cs` — record `{Base, Rates, Source, UpdatedAtMs?}`.
- Create: `S/Application/RatesService.cs` — `GetAsync(ct)` (кэш `ratesCache`; нет кэша → дефолт
MockRates/source "mock"/updatedAt null); `RefreshAsync(ct)` (source из настройки: mock →
сохранить MockRates; cbr → `IRatesSource`; неуспех → `false`, кэш не трогаем); `ShouldFetch(ct)`
(нет кэша / смена источника / ≥6 ч, `rates.py` L7783); `ConvertAmount(amount, fromCur, toCur)`
— USDT→USD (L86103). Ленивое обновление на GET при `ShouldFetch` — фоновый запуск
`RefreshAsync`, ответ — текущий кэш.
- Create: `I/Integrations/CbrRateSource.cs` — HTTP GET `https://www.cbr-xml-daily.ru/daily_json.js`
(JSON), `Valute[code].Value/Nominal`, `RUB:1`; сбой → null (лог) (`rates.py` L4359).
- Create: `A/Endpoints/RatesEndpoints.cs` (`MapRatesEndpoints`): `GET /api/rates` → RatesDto;
`POST /api/rates/refresh` → `{ok, rates: RatesDto}` (ok=false при сбое cbr; при mock — true).
- Modify: `A/Program.cs` — map; DI: `IRatesSource→CbrRateSource` (scoped), `AddHttpClient`.
- Test: `T/RatesServiceTests.cs` (fake store+source): mock-режим; cbr успех/сбой; ShouldFetch
(интервал 6 ч, смена источника); ConvertAmount USDT=USD, отсутствующая валюта → null.
**Источники:** `services/rates.py` целиком; api-map §3.4 L149150; `settings_routes.py` L224232.
**Acceptance:** build 0/0; тесты PASS. curl: `PATCH {"rateSource":"mock"}` затем
`POST /api/rates/refresh` → `{ok:true, rates:{base:"RUB", rates:{RUB:1,USD:92.5,…},
source:"mock", updatedAt:<ms>}}`; `GET /api/rates` — тот же кэш. Отчёт: `task-8-report.md`.
### Task 9: ML-панель — порт IMlClient, детерминированная заглушка, эндпоинты /api/ml
**Files:**
- Create: `C/Integrations/IMlClient.cs` + `C/Integrations/Models/*.cs` — record-DTO:
`MlServiceStatusDto {Ready, Classes(Dictionary<string,double>), Learned, Eval{MlEvalDto}}`,
`MlEvalDto {Count, Correct, Accuracy}`, `MlPredictResultDto {Take, Label?, Scores, Hits,
Ready, Margin?, Terms[], Type?}`, `MlStatusResponseDto {Enabled, Service, Reachable, Stats{
MlStatsDto}}`, `MlStatsDto {Ml, Ai, Learning, Ready, Classes, Learned, Reachable, Outbox}`
(поля/типы 1:1 `ml_routes.py` L7075 + `ml_client.snapshot()` L138150).
- Create: `I/Integrations/LocalMlClient.cs` — заглушка Ruling 5 (детерминированная; обучение
недоступно до этапа 3 — модель всегда «не готова»; счётчики `mlDecisions/aiDecisions` —
из KV settings, Ruling 1).
- Create: `A/Endpoints/MlEndpoints.cs` (`MapMlEndpoints`):
- `GET /api/ml/status` → `MlStatusResponseDto` (`enabled` = `mlEnabled !== false`);
- `POST /api/ml/reset` → `{ok:true}` (мягкая ошибка `{ok:false,error}` — зарезервирована);
- `POST /api/ml/predict` `{text}`: trim <2 симв. → 400 «Введите текст»; ответ
`{text:<первые 200>, take, label, scores, hits, ready, margin, terms, type}`;
- `POST /api/ml/candidates` `{dialogId, limit=10 (clamp 1..60)}` → `{items: []}` (данных
telegram нет — этап 6; контракт §3.7 L196);
- `POST /api/ml/apply` `{dialogId, msgId, action}` → 404 «Исходное сообщение не найдено»
(нет сообщений до этапов 3/6; ветка `skip` — этап 6; контракт §3.7 L197).
- НЕ реализуем: `ml/learn`, `ml/flush` (фронт не вызывает, api-map п.9).
- Modify: `A/Program.cs` — DI `IMlClient→LocalMlClient` (scoped), map.
- Test: `T/LocalMlClientTests.cs` (status-форма; predict неготовой модели — все поля; reset → ok).
**Источники:** api-map §3.7, §4.10 L363; `ml_routes.py` L6691, L112171; `ml_client.py` L127150;
`mlservice/model.py` (predict L184293, status L325345 — эталон полей для этапа 6).
**Acceptance:** build 0/0; тесты PASS. curl: login → `GET /api/ml/status` (все поля, `reachable:
true`, `ready:false`, `stats.outbox:0`); `POST /api/ml/predict {"text":"x"}` → 400;
`POST /api/ml/predict {"text":"Python backend на fastapi, бот в телеграм"}` → `take:false,
label:null, scores:{}, ready:false`; `POST /api/ml/reset` → `{ok:true}`; `POST /api/ml/candidates`
→ `{"items":[]}`. Отчёт: `task-9-report.md`.
### Task 10: Тестер фильтров — этап-1 правила и POST /api/admin/check-message
**Files:**
- Create: `S/Application/IncomingRules.cs` — чистая реализация `stage1_plain` (`pipeline.py`
L94124) поверх `ISettingsStore`: минимальная длина (`minLen`), стоп-фразы (casefold, ответ —
конкретная фраза), блокировка резюме (`blockResumes` + `resumeMarkers` с контекстным guard
«вакансия… присылайте резюме» — не режем, `pipeline._resume_reason` L644654), тип заявки
(`wantedType` + маркеры найма `hireMarkers`); результат
`{pass, reason, stage:1, kind:length|stop|resume|type, kw}`.
- Create: `A/Endpoints/FilterTesterEndpoints.cs` (`MapFilterTesterEndpoints`):
`POST /api/admin/check-message` `{text}` → `{stage1:{pass,reason}, stage2, passed}`
(1:1 `dashboard_routes.py` L267284): если этап-1 не прошёл → `stage2:{pass:false,reason:null,
skipped:true}, passed:false`; иначе `stage2:{pass:true,reason:null,skipped:true}` — ИИ-фильтр
на этапе 2 всегда skipped (Ruling 4/8; реальный ИИ-фильтр — этап 6).
- Test: `T/IncomingRulesTests.cs`: короткий текст; стоп-фраза из настроек; резюме (маркер);
guard «…вакансия… присылайте резюме» → pass; `wantedType:"freelance"` с вакансионным маркером;
`wantedType:"vacancy"` с разовым заказом.
**Источники:** api-map §3.2 L109, §4.10 L364; `dashboard_routes.py` L267284; `pipeline.py`
L94124; фронт: `SettingsView.vue` L142155 (тестер), `store.js` L17231725.
**Acceptance:** build 0/0; тесты PASS. curl: с дефолтами текст «Заработок на крипте…» (длина
≥24, без стоп-фраз) → `stage1.pass:true, stage2.skipped:true, passed:true`; текст «Ищу работу
python» → `stage1.pass:false, kind:"resume"` (если ≥minLen); «взаимный пиар» внутри → `kind:
"stop"`. Отчёт: `task-10-report.md`.
### Task 11: Финал этапа — интеграция и сквозная приёмка
- `scripts/build.sh`/`scripts/test.sh` — успешны; `dotnet build Deal.sln` 0/0; все unit-тесты PASS.
- Сквозной curl-сценарий Settings-экрана: login admin/admin → GET /settings →
PATCH-группы из Tasks 5/7 (обработка, ИИ-промпты, myPrompts, aiConfigs, tgKeys, валюта,
хранение/уведомления: `autoArchive/archiveAfterDays/remindersEnabled`, colState) →
POST /ai/check → GET /rates + POST /rates/refresh (mock) → GET /api/ml/status + predict +
reset → POST /api/admin/check-message (pass и отсев).
- psql-проверка схемы дефолтного тенанта (`SET search_path TO tenant_00000000000000000000000000000001;`):
строки settings созданы, `value_json` для aiConfigs/tgKeys содержит `enc:` и не содержит
открытого ключа; внутренние ключи (`ratesCache`, `mlDecisions`) не появляются в GET /settings.
- Известные ограничения этапа (зафиксировать в отчёте): Telegram-вкладка, кнопки «Проверить
правила сейчас»/«Пересобрать индекс» (admin/tick, admin/fts), «Предложить ключи»
(ai/suggest-keywords) и весь канбан-фронт не работают до этапов 3–6 (Ruling 8/11).
- Обновить `docs/technical/Техническая-документация-Дейл.md` (раздел настроек: env
`DEAL_ENCRYPTION_KEY`, поведение GET/PATCH /settings, креды).
- Отчёт `task-11-report.md` + финальная строка в `progress.md`.
## Self-Review
1. **Spec coverage:** ТЗ §8 (настройки тенанта) — Tasks 1–10; §5 (этап-1 фильтры/тип/резюме —
только настройки+тестер) — Task 10, (ML/ИИ-слои пайплайна — этапы 4/6, вне); §7 (обработка) —
вне (этап 4); §9 (лимиты) — вне; api-map §3.4 — Tasks 5/7/8; §3.7 — Task 9; admin/check-message
— Task 10; §4.6/4.7 — Tasks 2/3/5/7; tgKeys-часть §4.6 — Task 3/5; шифрование §8 архитектуры —
Task 1.
2. **Placeholder scan:** конкретные адаптеры и контракты; «заглушки» только там, где разрешено
решением владельца (п.5): `LocalMlClient` (Task 9), ИИ-фильтр в тестере = skipped (Task 10);
референсы на строки файлов точные. FIXME/TODO нет.
3. **Type consistency:** один модуль Settings владеет каталогом ключей/дефолтами — Kanban/Pipeline
(этапы 3/4) читают те же ключи через `ISettingsStore`; `IMlClient`-контракт (Contracts)
един для панели (этап 2), счётчиков (этап 3) и предсказаний (этап 4); сущность
`TenantSettingEntity` не меняется; схемы/миграции не добавляются.
4. **Вне scope этапа 2:** канбан-колонки/карточки/архив-корзина и их эндпоинты (этап 3),
pipeline/очередь/отсев/дедуп (этап 4), projects/напоминания-отложенные/файлы (этап 5),
реальные ml/ai/telegram-сервисы и /api/tg/* (этап 6), discovery, оператор/инвайты/лимиты/
аудит (этап 7); colState-эндпоинты; библиотека промптов (фронтовая); звук/вид (фронт).
@@ -0,0 +1,535 @@
# Дейл (Deal) — Этап 3: Kanban (дашборд): колонки, карточки, архив/корзина Implementation Plan
> Исторический документ этапа 3. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** Оживить в модульном монолите `src/core` дашборд Vue-фронта 1:1-контрактом `/api` канбана:
колонки-доски и их правила (детерминированная раскладка + «почему карточка в колонке»), карточки
(поля ТЗ §5, комментарии, быстрые действия), архив/корзина с правилами хранения (автоархив 1–30 дн.,
очистка архива 90 дн. и корзины 7 дн., ручная очистка, возврат), переносы drag&drop с журналом
обучения и сигналами ML, полнотекстовый-LIKE поиск по карточкам, SSE-реалтайм (new_lead/toast),
ИИ-предложения колонок/ключей на детерминированной эвристике, пересчёт конверсий бюджетов при смене
курсов/целевой валюты. К концу этапа канбан-экран фронта (колонки, карточки, архив/корзина, поиск,
предложения) полностью обслуживается бэкендом; приёмка — unit/curl/psql + сквозной сценарий на демо-
карточках (реальный ввод сообщений — этап 4 Pipeline).
**Architecture:** новый модуль `Deal.Modules.Kanban` (чистый, без EF): DTO (Board/Card/…), порт
`IKanjStore`, сервисы `BoardsService`/`CardsService`/`StorageTickService`/`ConversionRecomputer`,
чистые правила колонок `ColumnRules` (перенос `backend/app/services/rules.py`) и ядро эвристик
предложений `SuggestHeuristics`. Адаптеры — в `Deal.Infrastructure`: `KanbanStore` (таблицы
Boards/Cards/LeadComments/CardMoves/MlOutbox), доработка `LocalMlClient` (PushAsync + счётчики
learning/outbox из таблиц), `LocalColumnSuggester` (порт `IColumnSuggester` из `Deal.Contracts`).
HTTP-эндпоинты — `Deal.Api/Endpoints/*` (`MapBoardsEndpoints`, `MapLeadsEndpoints`,
`MapStorageEndpoints`, `MapDemoEndpoints`, `MapAiSuggestEndpoints`, `MapEventsEndpoint`,
`MapBootStubEndpoints`); SSE-брокер per-tenant — в `Deal.Api`. Карточки создаёт пока только демо-путь
(simulate-lead, как devtests прототипа) — pipeline-воркер приходит этапом 4; внешний ИИ/ML —
этапы 6/4. Один новый EF-контекст не заводится: таблицы добавляются в существующий `TenantDbContext`
(миграция `TenantKanban`, применяется провижинером ко всем схемам тенантов, этап 1).
**Spec:** `docs/api/api-map.md` §3.2 (L60121), §2 SSE (L2744), правила (L7–24, п.9 «экономия» L399,
кривые места L390–400); §4.1 карточка (L228257), §4.2 доска (L259278), §4.6 colState (L333);
`docs/spec/ТЗ-дейл-новая-архитектура.md` §5 «Карточка» (L112–121), §6 «Дашборд (канбан)» (L121135);
roadmap (этап 3, L46–53); референс-семантика: `backend/app/routers/dashboard_routes.py` целиком,
`backend/app/services/leads.py`, `rules.py`, `suggest.py`, `rates.py` (L6274, L106130),
`backend/app/services/ml_client.py`, `backend/app/services/pipeline.py` (L433514, L540586),
`backend/app/sse.py`, `backend/app/main.py` (L43–53 фоновые циклы), `backend/app/constants.py`
(PALETTE L1216, DAY_MS L249254); фронт: `src/frontend/src/store.js` (boot L565–628 — какие группы
обязаны отвечать 200; SSE L650–688; действия лидов L833–975; доски L9771183; поиск L11851206;
tickAuto L18551863, rebuildFts L18841889; colMeta/orderedCols L188223), `src/frontend/src/api.js`
(openEvents L62104 — слушает только new_lead/toast/reminder_due/system_status), `views/DashboardView.vue`,
`components/Column.vue`, `LeadCard.vue`, `LeadDrawer.vue`, `MoveMenu.vue`, `BoardRulesDialog.vue`,
`SearchPalette.vue`, `ConfirmDialog.vue`, `data.js`.
## Global Constraints
- Проект **НЕ git**; фиксация — отчёты `task-N-report.md` и `progress.md` в
`.superpowers/sdd/deal-stage3-kanban/`.
- .NET 10 SDK, `scripts/build.sh`/`scripts/test.sh`; решение собирается 0 warnings / 0 errors
(`TreatWarningsAsErrors`). Dev-Postgres `deal-postgres` (:5433), curl-приёмка :5080.
- Код-стайл этапов 1–2: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском;
явные модификаторы; настройки через `ISettingsStore`/`IOptions<T>`; без регионов; без магических
чисел; PascalCase-колонки БД; JSON camelCase; ошибки `{"detail"}`.
- Модуль Kanban — чистый: без EF и HTTP; зависимости — `Deal.Modules.Settings` (порт `ISettingsStore`)
и `Deal.Contracts` (`IMlClient`). Реверс-зависимостей (Settings → Kanban) нет.
- LeadRadar-контейнеры, `backend/`, `mlservice/`, `src/frontend/` НЕ трогаем.
- Строки ошибок/тостов — фиксированные из прототипа (см. задачи); новые строки только для
согласованных заглушек (Ruling 7, Ruling 11).
- Vue-фронт не переписывается: формы JSON и эндпоинты 1:1 с api-map; «кривые места» (голый массив
`/boards`, `messages: []`, недостижимые SSE-события) сохраняем как в прототипе.
## Зафиксированные решения (Rulings этапа)
- **Ruling 1 (а) — миграция TenantKanban и таблицы.** Новая миграция `TenantKanban` контекста
`TenantDbContext` (папка `I/Migrations/TenantDb`, применяется провижинером к схемам всех тенантов).
Таблицы (PascalCase, соответствие прототипу): `Boards` (= boards; колонки-доски), `Cards`
(= leads; карточки дашборда), `LeadComments` (= comments-массив строки leads, нормализуем),
`CardMoves` (= learning_log; журнал действий/обучения, id `lm_`), `MlOutbox` (= ml_outbox; очередь
обучающих сигналов, id `mle_`). JSON-поля храним как text с сериализованным JSON (как `value_json`
настроек). Времена — `timestamptz` (`DateTimeOffset`); наружу epoch-ms конвертирует маппинг.
`Cards.Col` — текст без FK (значения `inbox|archive|trash|taken|<b_…>`, как прототип); приложение
валидирует существование досок. `LeadComments.CardId` — FK → `Cards.Id` (cascade delete);
`CardMoves`/`MlOutbox` — без FK (журнал живёт дольше карточки, прототип `_hard_delete` его не чистит).
Индексы: `Cards (Col, ReceivedAt DESC)`, `Cards (Col, IsNew)`, `Boards (Suggested, Position)`
(ORDER BY suggested, pos), `LeadComments (CardId)`, `MlOutbox (CreatedAt)`. Колонки Boards:
Id/Name/Description/Color/Width/Position/KeywordsJson/Prompt/VisibleFieldsJson/Collapsed/Suggested/
RulesJson/Note/CreatedAt; Cards: Id/Col/IsNew/IsVacancy/IsVacancyKnown/Title/Summary/StackJson/
BudgetFrom/BudgetTo/BudgetCur/ConvFrom/ConvTo/ConvCur/Contact/ContactsJson/ChannelName/ChannelHandle/
ChannelHue/ReceivedAt/SourceMsg/SourceDialogId/SourceMsgId/PrevCol/ArchivedAt/MatchHitsJson/CreatedAt
(сущности/конфигурации — 1 тип = 1 файл, эталон TenantSettingEntity+Configuration).
- **Ruling 2 (б) — «почему карточка в колонке» (matchHits).** Совпавшие критерии вычисляет модуль
Kanban в момент размещения карточки в доску: перенос `move` (`leads.py L163174`), возврат
`restore` (L204–222), назначение при создании (этап 4/демо). Вычисление — чистые функции
`ColumnRules` (перенос `rules.py`: `match_text` L176209, `score_text` L212227, `excluded_terms`/
`is_excluded` L230248, `board_accepts` L251268, `hits` L271296, `hits_for_board` L311319,
`has_active_rules` L322338, `describe` L341368, `extract_amounts` L93144 с grade-алиасами L2027
и `content_text` L5154). Для `inbox/archive/trash` и досок без активных правил — `[]`. Значение
хранится в `Cards.MatchHitsJson`, отдаётся как `matchHits` (§4.1 L251). Страховка «ИИ/ML не кладут
в отфильтрованную колонку» (`board_accepts`) понадобится этапам 4/6 — правила готовы сейчас.
- **Ruling 3 (в) — порт ИИ-предложений колонок.** В `C/Integrations` объявляется
`IColumnSuggester` + record-DTO (`SuggestColumnsResultDto {Ok, Created, Reason, Cooldown}`,
`SuggestKeywordsResultDto {Ok, Keywords, Reason}`) — этап 6 заменит реализацию gRPC-клиентом
ai-service (тот же контракт). Этап 3 — детерминированная эвристика: чистый `SuggestHeuristics`
в модуле Kanban (частотные слова-темы по source_msg карточек «Неразобранного»; MIN_INBOX=6,
группа ≥2 карточек, ≤4 колонок, `{mode:"any", keywords:[…]}`) + тонкий адаптер
`Infrastructure/Integrations/LocalColumnSuggester` (читает карточки через `IKanjStore`, создаёт
колонки-предложения `suggested=true` c `note`, раскладывает карточки). 1:1 ответы `{ok,created}` /
`{ok:false, reason}`; детерминированные причины — строками прототипа («мало карточек в
«Неразобранном» (нужно от 6)», «похожие колонки уже есть или нечего сгруппировать»). Фоновый
автоцикл suggest (180 с, `main.py L114123`) НЕ заводим — фронт запускает предложение только
кнопкой, а `boards_changed` не слушает (Ruling 5).
- **Ruling 4 (г) — ML-обучение drag&drop.** Обе таблицы этапа создаём (Ruling 1). Семантика 1:1
с `leads.py`/`ml_client.py`: каждое действие (move/trash/restore/comment) пишет строку `CardMoves`
(журнал, `_log_learning` L40–44) — из него счётчик `learning`. Обучающие сигналы для модели —
`IMlClient.PushAsync(text, label, delta)` (добавляется в контракт, Ruling 5 этапа 2 L7682):
перенос на доску (не inbox) → push(text, `<b_…>`, 1.0); корзина из канбана → push(text, `"spam"`,
1.0); возврат из корзины → push(text, `"spam"`, 1.0) (`move_lead` L177191, `trash_lead` L194201,
`restore_lead` L204222). `LocalMlClient.PushAsync` пишет строку `MlOutbox` (text≤6000, label, delta,
created_at); `StatusAsync` читает `learning = count(CardMoves)`, `outbox = count(MlOutbox)`;
`ResetAsync` очищает `MlOutbox` (как `reset_model` L122; CardMoves и KV-счётчики не трогает).
KV `mlDecisions`/`aiDecisions` не инкрементируются — это счётчики РЕШЕНИЙ пайплайна (этап 4),
на этапе 3 всегда 0. Обоснование: без таблиц нельзя 1:1 держать `learning/outbox` и семантику reset.
- **Ruling 5 (д) — SSE.** `GET /api/events` (`text/event-stream`, `Cache-Control: no-cache`,
`X-Accel-Buffering: no`; ping каждые 15 с; без сессии — 401). Брокер — singleton `SseBroker` в
`Deal.Api`: per-tenant канал по `TenantId` (тенант сессии при подписке; публикация вне tenant-запроса
не падает), очередь подписчика ≤200 с вытеснением старых (прототип `sse.py`). События этапа 3 —
только те, что фронт реально слушает (`api.js L62104`) и которые в этапе возникают: `new_lead`
(полный объект карточки §4.1; шлёт demo simulate-lead) и `toast` `{text, icon}` (автоархив/очистки,
demo, ИИ-предложения). `boards_changed`/`pipeline_stats`/`leads_reclassified` (недостижимы у фронта,
api-map L43) и `reminder_due`/`system_status` (этапы 5/6) НЕ публикуем. Публикации делают ТОЛЬКО
эндпоинты Api после вызова сервисов модуля — модуль Kanban остаётся чистым.
- **Ruling 6 (е) — поиск/FTS.** `GET /api/search?q=` в этапе 3 ищет по карточкам LIKE-дополнением
(`leads.py search` L509551: title/summary/contact/source_msg, `col != 'taken'`, ORDER BY received_at
DESC, limit 12; q<2 символов → `{leads:[], messages:[]}`) без FTS-снимка; `messages: []` (api-map
п.3 L393 разрешает). `POST /admin/fts/rebuild` — контракт-заглушка `{ok:true, ready:true}` (реального
tsvector-индекса нет; кнопка Settings «Пересобрать индекс» получает ожидаемый ok). Полноценный FTS
(карточки+отсев) — этап 4.
- **Ruling 7 (ж) — пересчёт конверсий.** Владелец — модуль Kanban (`ConversionRecomputer`): читает
`conversionOn`/`targetCurrency` через `ISettingsStore`, курсы — из ключа `ratesCache`
(`SettingsKeys.RatesCache`), USDT=USD (L8691), обновляет `ConvFrom/ConvTo/ConvCur` у карточек с
`budgetCur != ''` и `col NOT IN ('archive','trash','taken')` (L106–130). Триггеры — через порт модуля
Settings `IRatesChangedListener` (объявляется в Settings, реализует `ConversionRecomputer`,
регистрация в `AddKanbanModule`): (1) `RatesService.RefreshAsync` — после успешной записи кэша
(покрывает и фоновый RatesRefreshScheduler, как `rates.py refresh_rates` L6274); (2) PATCH
`/settings` — если в теле присутствовали `targetCurrency`/`conversionOn` (синхронно,
`settings_routes.py` L186192).
Первичный пересчёт «при поступлении» (бюджет → целевая валюта, `ai.py budget_to_target` L342352) —
чистый `BudgetNormalizer` (используется демо-путём и этапом 4).
- **Ruling 8 (з) — архив/корзина: тик и фоновый цикл.** Чистый `StorageTickService` (модуль)
повторяет `tick_storage` (`leads.py L454493`): автоархив (`autoArchive`, `archiveAfterDays` 1..30,
карточки досок+inbox по `ReceivedAt` старше срока → col=archive, isNew=false, ArchivedAt=now);
очистка архива (`ArchivedAt` старше `archiveClearDays`, дефолт 90); очистка корзины (`ReceivedAt`
старше `trashClearDays`, дефолт 7); возврат `{archived, purgedArchive, purgedTrash, purgedRejected:0}`.
`POST /api/admin/tick` = тик текущего тенанта + `{storage, reminders: [], pipeline: {}, queue: 0}`
(reminders/pipeline — этапы 5/4; фронт в `tickAuto` L1855–1863 читает только `storage`) + SSE-toast
статистики (`notify_tick_stats` L496504; тексты 1:1 «Автоархив: N карточек»/«Архив очищен: N
(90 дн.)»/«Корзина очищена: N (7 дн.)», иконки clock/trash). Фоновый цикл — `StorageTickScheduler`
(Api, IHostedService): каждые 30 с обходит все тенанты системного репозитория, на каждый —
собственный scope с `ITenantContext` (паттерн TenantBootstrapService + guard RatesRefreshScheduler);
аналог `_storage_loop` `main.py L4353`.
- **Ruling 9 — служебные точки фронта (boot).** `boot()` фронта (`store.js L571581`) требует 200 от
девяти групп сразу; до этапов 5/6 недостающие `GET /api/projects` и `GET /api/tg/status` даём
заглушками: `/projects``{items: []}` (проектные карточки — этап 5), `/tg/status` → форма §4.9
`{phase:"idle", connected:false, listener:false, account:"", monitored:0, keysSet:false, error:null,
qrUrl:null}` (telegram — этап 6). Без них фронт на 404 разлогинивается (catch boot).
- **Ruling 10 — форматы/маршрутизация/colState.** Времена наружу — epoch-ms; человеческая метка
`time` («только что»/«N мин»/«N ч»/«N дн», `human_age` L528–537) вычисляется на лету от ReceivedAt
(колонку `time_label` не храним; расхождение — только для demo age-lead). Статические сегменты
регистрируются до `/leads/{lead_id}` (api-map L19). colState — KV `colState`
(`SettingsKeys.ColState`): `GET /columns/state` → весь объект; `PATCH /columns/{id}/state` → merge +
ответ одной колонки (L141–149); свёрнутость/ширина ДОСКИ — поля Boards (`PATCH /boards/{id}`
принимает `collapsed`/`width`, ответ `{id}` — quirk L400/п.10). Создание доски: pos = MAX+1,
цвет `PALETTE[pos % 8]`, width='md', visibleFields `["budget","stack","contacts"]` (L74104).
Сортировка — ReceivedAt DESC. Удаление карточки навсегда = Cards + LeadComments (cascade),
CardMoves/MlOutbox не трогаем (`_hard_delete` L225234).
- **Ruling 11 — границы и согласованные заглушки.** В этап 3 входят эндпоинты: доски (5), состояние
колонок (2), карточки 12 из 13 (без `/leads/{id}/seen` — фронт не вызывает, api-map п.9 L399),
`/search`, `/admin/tick`, `/admin/fts/rebuild`, `/ai/suggest-columns`, `/ai/suggest-keywords`,
`/demo/simulate-lead`, `/demo/age-lead` (флаг `DEAL_DEMO=1`, иначе 404 «Демо-режим отключён»),
`/events`, boot-заглушки (Ruling 9). `POST /leads/reclassify` — заглушка всегда
`{started:false, busy:false, attempted:0, reason:"ИИ недоступен — переклассификация требует сервиса
ИИ"}` (форма ветки `leads.py L424`; реальная классификация — этапы 4/6). НЕ реализуем: admin/wipe,
admin/clear-cards, admin/pump-gate, ml/learn, ml/flush, meta/constants, leads/{id}/seen (api-map п.9).
За пределами этапа: pipeline/очередь/отсев (этап 4), projects/напоминания/файлы и `reminder_due`
(этап 5), реальные ai/telegram/ml и discovery (этап 6), оператор/инвайты/лимиты (этап 7);
`ml/candidates` и `ml/apply` остаются как в этапе 2.
- **Ruling 12 — DI и зависимости.** `AddKanbanModule()` (модуль) регистрирует сервисы/`IRatesChangedListener`;
`AddDealPersistence()` дополнительно — `IKanjStore → KanbanStore`; `AddDealIntegrations()`
`IColumnSuggester → LocalColumnSuggester`; `IMlClient` уже scoped. Порядок вызовов в Program.cs —
как в этапе 2, с добавлением map-групп этапа. Новые HTTP-клиенты не нужны. Id-генерация: короткие
префиксные id (`l_`/`b_`/`cm_`/`lm_`/`mle_` + случайный hex, прототип `store.uid`) — утилита в
модуле Kanban (не GUID: прототип и фронт требуют коротких ключей в JSON).
## Задачи
Сокращения путей: `K=` `src/core/Deal.Modules.Kanban/`, `I=` `src/core/Deal.Infrastructure/`,
`A=` `src/core/Deal.Api/`, `C=` `src/core/Deal.Contracts/`, `S=` `src/core/Deal.Modules.Settings/`,
`T=` `src/core/tests/Deal.Tests.Unit/`. Отчёты — `task-N-report.md` в `.superpowers/sdd/deal-stage3-kanban/`.
### Task 1: Миграция TenantKanban — таблицы Boards/Cards/LeadComments/CardMoves/MlOutbox
**Files:**
- Create: `I/Persistence/Entities/{BoardEntity,CardEntity,LeadCommentEntity,CardMoveEntity,
MlOutboxItemEntity}.cs` (поля Ruling 1; `DateTimeOffset` для времён; text для JSON-полей и SourceMsg).
- Create: `I/Persistence/{BoardConfiguration,CardConfiguration,LeadCommentConfiguration,
CardMoveConfiguration,MlOutboxItemConfiguration}.cs` (имена таблиц/индексы Ruling 1; `Col` max 200;
FK LeadComments→Cards cascade).
- Modify: `I/Persistence/TenantDbContext.cs` — DbSet'ы и `ApplyConfiguration`.
- EF: миграция `TenantKanban` для `TenantDbContext` (как `InitialTenant`: `dotnet ef migrations add
TenantKanban --context TenantDbContext --output-dir Migrations/TenantDb --project
src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`); старт Api применяет её к схеме
дефолтного тенанта (TenantBootstrapService/провижинер).
**Источники:** эталон: `I/Persistence/Entities/TenantSettingEntity.cs` +
`I/Persistence/TenantSettingConfiguration.cs` + миграции `I/Migrations/TenantDb/`; Ruling 1.
**Acceptance:** build 0/0; `dotnet test` MarkerTests PASS; psql (`SET search_path TO
tenant_00000000000000000000000000000001;`): таблицы Boards/Cards/LeadComments/CardMoves/MlOutbox
созданы, PK, индексы `IX_Cards_Col_ReceivedAt`, `IX_Cards_Col_IsNew`, `IX_Boards_Suggested_Position`,
`IX_LeadComments_CardId`; `__TenantMigrationsHistory` содержит TenantKanban. Отчёт: `task-1-report.md`.
### Task 2: Модуль Kanban — DTO, порт IKanjStore, реестр
**Files:**
- Create: `K/Application/Models/BoardDto.cs` (§4.2 L261277), `BoardRulesDto.cs` (+`BudgetRangeDto.cs`),
`BoardPatchDto.cs`, `CardDto.cs` (§4.1 L230254; `ReceivedAtMs` наружу int64),
`CardBudgetDto.cs`, `CardContactDto.cs`, `CardChannelDto.cs`, `CardCommentDto.cs` (id/by/text/time),
`MatchHitDto.cs` (label/term/word?), `CardCountsDto.cs`, `CardsQuery.cs` (col-фильтр),
`CardSnapshot.cs` (сырая запись для создания карточки — демо/этап 4), `StorageTickStatsDto.cs`.
- Create: `K/Application/IKanjStore.cs` — порт: Boards (List/Get/Create/Update/Delete→moved/Reorder);
Cards (List(col?), Get, Add(CardSnapshot), UpdateColumn, UpdateSeen(id|col|all), DeleteForever,
ClearCol(col)→count, CountsByCol); Comments (List/Add); CardMoves (Add/Count); StorageTick
(ListArchiveCandidates/ListTrashCandidates/Purge); Conversion (ListForConversion); Suggest
(ListInboxWithSource).
- Create: `K/Application/KanbanModuleRegistrar.cs` — `AddKanbanModule()`: scoped `BoardsService`,
`CardsService`, `StorageTickService`, `ConversionRecomputer` + `AddScoped<IRatesChangedListener,
ConversionRecomputer>()` (Ruling 7). Modify: `K/Deal.Modules.Kanban.csproj` — ProjectReference на
`Deal.Modules.Settings` и `Deal.Contracts`.
**Источники:** Rulings 12, 7; api-map §4.1/§4.2; `leads.py` (структуры); `pipeline.py lead_to_dict`
L540586.
**Acceptance:** build 0/0 (модуль собирается, DTO — record'ы c camelCase при сериализации, проверка
Markers: маркер Kanban в MarkerTests). Отчёт: `task-2-report.md`.
### Task 3: Чистые правила колонок — ColumnRules + BudgetParser + unit-тесты
**Files:**
- Create: `K/Application/ColumnRules/ContentNormalizer.cs` (ссылки/markdown, L4754), `AmountParser.cs`
(extract_amounts L93144: «к/К», символы/слова валют, «от…до»/«до…»/«A–B», «$1 200»),
`GradeAliases.cs` (L2027), `ColumnMatcher.cs` (match/score/has_active_rules L176227, L322338),
`ColumnExclusions.cs` (excluded/is_excluded L230248), `MatchHitBuilder.cs` (hits L271296, метки
«Направление»/«Слова»/«Стек»/«Грейд/уровень»/«Бюджет», `word` для грейдов), `RulesDescriber.cs`
(describe L341368 — для note), `BudgetInRange.cs` (конвертация валюты при сравнении — чистый
интерфейс курсов).
- Create: `K/Application/BudgetNormalizer.cs` — clean_budget (`ai.py L316326`: одна сумма → from=to,
«до X» → from null; from=0 → null) + conv-поля «при поступлении» (budget_to_target L342352:
conversionOn/targetCurrency, курсы через интерфейс курсов).
- Test: `T/ColumnRulesTests.cs`, `T/AmountParserTests.cs`, `T/BudgetNormalizerTests.cs` (кейсы из
правил прототипа: alias «mid»→middle, исключение veto, budget-диапазон с конвертацией USDT=USD,
«2к», «от 0 до 100» и т.п.).
**Источники:** `rules.py` целиком (L15368), `ai.py L316352`; BoardRulesDialog.vue (поля правил).
**Acceptance:** `dotnet test` новых тестов PASS; build 0/0. Отчёт: `task-3-report.md`.
### Task 4: EF-адаптер KanbanStore + DI
**Files:**
- Create: `I/Persistence/Repositories/KanbanStore.cs` — реализация `IKanjStore` на `TenantDbContext`
(AsNoTracking для чтения; JSON-поля сериализует/читает модуль — порт оперирует DTO, маппинг вручную,
эталон `SettingsStore.cs`). Хранимые id: PrefixGenerator в модуле (Ruling 12) передаёт готовые id.
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IKanjStore, KanbanStore>()`.
- Modify: `A/Program.cs` — `AddKanbanModule()`.
**Источники:** `SettingsStore.cs` (эталон), Ruling 1/12.
**Acceptance:** build 0/0; psql+curl-проверка пустых чтений (GET /boards → [], GET /leads →
`{items:[]}`, counts → 0) после Task 8-map (порядок: T4 затем T8). Отчёт: `task-4-report.md`.
### Task 5: IMlClient.PushAsync + LocalMlClient (outbox/learning/status/reset)
**Files:**
- Modify: `C/Integrations/IMlClient.cs` — добавить `PushAsync(string text, string label, double delta,
CancellationToken)` (ml_client.push L4049). DTO-метки: label = id доски | `"spam"` | `"t:hire"` |
`"t:order"` (полные — этап 4/6).
- Modify: `I/Integrations/LocalMlClient.cs` — ctor + `TenantDbContext` (таблицы CardMoves/MlOutbox):
PushAsync → INSERT MlOutbox (id `mle_`, text[:6000], label, delta, CreatedAt=UtcNow);
StatusAsync: `learning = count(CardMoves)`, `outbox = count(MlOutbox)`, ml/ai — KV
(как сейчас); модель не готова (ready=false) до этапа 4; ResetAsync — удалить строки MlOutbox
(прототип reset_model L122); predict — не меняется.
- Test: `T/LocalMlClientTests.cs` — дополнить PushAsync (пишет outbox, счётчики learning/outbox в
status, reset чистит только outbox). Чтобы тест оставался unit — подсчёты вынести за чистый порт
`IMlLearningCounters` (модуль Kanban); финальное решение за исполнителем, но LocalMlClient и тесты
должны остаться unit-чистыми.
**Источники:** `ml_client.py` (L4049, L110124, L138150), Ruling 4, этап 2 Task 9.
**Acceptance:** build 0/0; тесты PASS; curl: login → `GET /api/ml/status` → `stats.learning:0,
stats.outbox:0`; после переноса карточки (Task 7/8) — `learning:1`, `outbox:1` (если колонка не inbox);
`POST /api/ml/reset` → outbox:0, learning не меняется. Отчёт: `task-5-report.md`.
### Task 6: BoardsService — колонки-доски и colState + unit-тесты
**Files:**
- Create: `K/Application/BoardsService.cs` — list_boards L4967 (ORDER BY suggested, pos; дефолты
collapsed из поля), create_board L74104 (цвет/позиция/ширина/visibleFields; name
`strip() or «Новая колонка»`), patch_board L107121 (404-семантика через результат; allowed:
name/description/color/width/collapsed/prompt/keywords/visibleFields/suggested/rules/note),
delete_board L124130 (карточки → inbox isNew, prevCol=inbox; вернуть moved), reorder_boards L133135,
get/set_col_state L138146 (KV colState через ISettingsStore; словарь JSON).
- Test: `T/BoardsServiceTests.cs` (fake IKanjStore): создание (pos/цвет/width/visibleFields), патч
(JSON-поля), удаление (moved→inbox), colState merge/значения.
**Источники:** `leads.py` L49146; api-map §3.2 доски L6670, §4.2; Rulings 1/10; `constants.py`
PALETTE L1216.
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-6-report.md`.
### Task 7: CardsService — карточки: чтение, переносы, архив/корзина, комментарии, counts
**Files:**
- Create: `K/Application/CardsService.cs`:
- list_leads/get_lead (L151160): маппинг CardDto (receivedAt ms, time от ReceivedAt, budget,
converted, contacts fallback `qualify_contact`-проверка, ch, comments из LeadComments, matchHits);
- move_lead (L177191): валидация `to ∈ inbox доски` (иначе 400 «Переносить можно только на доски
или в «Неразобранное»»), `_move` L163174 (matchHits пересчёт через ColumnRules для досок),
журнал CardMoves(action=move) + PushAsync (текст = sourceMsg или title) при to≠inbox;
- trash_lead (L194201): журнал(action=trash) + Push spam 1.0 (кроме карточек уже в archive/trash);
- restore_lead (L204222): назад в prevCol (валидный), isNew=true, archivedAt=null, matchHits,
журнал(action=restore); возврат из корзины — Push spam 1.0;
- delete_forever (L225234), clear_col (L237247: только trash|archive, 400 «Очищать можно только
корзину или архив», вернуть cleared);
- mark_seen (L250256: id|col|all); add_comment (L259265: 400 «Пустой комментарий», LeadComments
вставка, журнал(action=comment));
- counts (L268279): по Cards (col + isNew) + learning/ml/ai из IMlClient.StatusAsync;
- search (L509551, LIKE-вариант) — вызывается эндпоинтом напрямую или через сервис (см. Task 8).
- Create: `K/Application/CardMapper.cs` (CardEntity/сырые строки → CardDto; чистая функция;
`human_age` L528537), `K/Application/PrefixId.cs` (Ruling 12).
- Test: `T/CardsServiceTests.cs` (fake IKanjStore + fake IMlClient): move с правилами (matchHits),
move на неизвестную доску → ошибка 400-текста, trash/restore (журнал+push), clear_col 400 на доске,
mark_seen, комментарий пустой/валидный, counts-форма, search лимит/мин-длина.
**Источники:** `leads.py` L151279, L509551; `pipeline.py lead_to_dict` L540586; `rules.py`
hits_for_board; api-map §3.2 лиды L8395, §4.1; Rulings 2/4/10.
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-7-report.md`.
### Task 8: Эндпоинты досок/колонок/карточек/поиска + DI + curl-приёмка
**Files:**
- Create: `A/Endpoints/BoardsEndpoints.cs` (`MapBoardsEndpoints`): GET `/api/boards` (голый массив!),
POST `/api/boards`, PATCH `/api/boards/{boardId}` (404 «Доска не найдена»), DELETE `/api/boards/{id}`,
POST `/api/boards/reorder`, GET `/api/columns/state`, PATCH `/api/columns/{colId}/state`.
- Create: `A/Endpoints/LeadsEndpoints.cs` (`MapLeadsEndpoints`): GET `/api/leads?col=` (400 «Неизвестная
колонка»), GET `/api/leads/counts`, GET `/api/leads/{leadId}` (404 «Карточка не найдена»),
POST `/api/leads/mark-all-seen`, POST `/api/leads/mark-col-seen` {col}, POST `/api/leads/{id}/move`
{to} (400 текст move_lead) → обновлённый CardDto, POST `/api/leads/{id}/trash`, POST
`/api/leads/{id}/restore` → `{ok, col}`, DELETE `/api/leads/{id}`, POST `/api/leads/clear-col`
{col: trash|archive} → `{ok, cleared}`, POST `/api/leads/{id}/comments` {text} → `{comments}`,
POST `/api/leads/reclassify` (заглушка Ruling 11), GET `/api/search?q=` (Ruling 6).
⚠ Статические сегменты регистрируются до `{leadId}` (Ruling 10). Сессия — `HasUser`/`GetCurrentUser`,
401 `AuthHelpers.UnauthorizedDetail` (эталон MlEndpoints).
- Create: `A/Endpoints/RequestModels/*` — `BoardCreateRequest`, `BoardPatchRequest`, `OrderBody`,
`ColStateBody`, `MoveBody`, `CommentBody`, `MarkColBody`, `ClearColBody`, `ReclassifyBody` (1 тип =
1 файл).
- Modify: `A/Program.cs` — `MapBoardsEndpoints()`, `MapLeadsEndpoints()`.
- Modify: `A/Deal.Api.csproj` — ProjectReference `Deal.Modules.Kanban`.
**Контракт:** api-map §3.2 L66101; ответы/детали — Task 6/7/Rulings. GET /boards — без `{items}`.
**Acceptance (curl, admin/admin):** пустые boards/leads/counts; создание доски POST {name:"Middle
Python", keywords:["python"], rules:{mode:"all", stack:["python"]}} → {id:"b_…"}; PATCH width/collapsed;
reorder; GET /columns/state {} и PATCH collapsed → `{"collapsed":true}`; затем Task 13 демо-карточки и
полный цикл карточек (move/trash/restore/clear-col/комментарий/404-тексты). Отчёт: `task-8-report.md`.
### Task 9: SSE-брокер + GET /api/events + boot-заглушки /projects и /tg/status
**Files:**
- Create: `A/Events/SseBroker.cs` (singleton; Ruling 5), `A/Events/SseEvent.cs` (record: тип+JSON),
`A/Endpoints/EventsEndpoint.cs` (`MapEventsEndpoint`): GET `/api/events` — авторизация (401), заголовки
no-cache/X-Accel-Buffering, ping каждые 15 с, подписка на канал тенанта (ITenantContext), отписка при
завершении.
- Create: `A/Endpoints/BootStubEndpoints.cs` (`MapBootStubEndpoints`): GET `/api/projects` →
`{items: []}`; GET `/api/tg/status` → idle-форма Ruling 9 (комментарий: этапы 5/6).
- Modify: `A/Program.cs` — singleton SseBroker, map-группы.
**Источники:** `sse.py` целиком; `api.js openEvents` L62104; api-map §2, L43; `store.js boot`
L571581; §4.9 L359.
**Acceptance:** build 0/0; curl: `curl -N` на /api/events без куки → 401; с кукой — поток открыт, ping
`:` ~15 с; `GET /api/projects` → `{"items":[]}`, `GET /api/tg/status` — все поля §4.9. (Публикация
событий проверяется в Tasks 10/13/14.) Отчёт: `task-9-report.md`.
### Task 10: StorageTickService + POST /api/admin/tick + /admin/fts/rebuild + SSE-toast
**Files:**
- Create: `K/Application/StorageTickService.cs` — Ruling 8 (архив/очистки через IKanjStore; кандидаты
— по ReceivedAt/ArchivedAt с настройками из ISettingsStore; удаление = DeleteForever).
- Create: `A/Endpoints/StorageEndpoints.cs` (`MapStorageEndpoints`): POST `/api/admin/tick` →
StorageTickService.TickAsync + `{storage, reminders:[], pipeline:{}, queue:0}` + публикация SSE-toast
по статистике (тексты/иконки 1:1, Ruling 8) через SseBroker; POST `/api/admin/fts/rebuild` →
`{ok:true, ready:true}` (Ruling 6).
- Modify: `A/Program.cs` — map.
**Источники:** `leads.py` tick_storage L454493 + notify_tick_stats L496504; `dashboard_routes.py`
L327337 (admin_tick), L261264 (fts_rebuild); api-map L103112; `store.js tickAuto` L18551863,
rebuildFts L18841889; Rulings 5/6/8.
**Acceptance:** `dotnet test` (если юнит для StorageTickService — на fake store); curl: с демо-карточкой
на доске PATCH settings archiveAfterDays=1 → POST /api/admin/tick (после demo/age-lead из Task 13) →
storage.archived=1, SSE-toast «Автоархив…»; clear-col/trash → purged-тосты; fts/rebuild → ok:true.
Отчёт: `task-10-report.md`.
### Task 11: StorageTickScheduler — фоновый цикл правил хранения по тенантам
**Files:**
- Create: `A/StorageTickScheduler.cs` — IHostedService: Timer 30 с; каждое срабатывание в собственном
scope: список тенантов (`ITenantRepository`/системный контекст), на каждый тенант — новый scope,
`ITenantContext` set (эталон TenantBootstrapService), `StorageTickService.TickAsync` + SSE-toast через
SseBroker (публикация в канал тенанта; без подписчиков — no-op). In-flight guard (Interlocked) и
try/catch — как RatesRefreshScheduler.
- Modify: `A/Program.cs` — `AddHostedService<StorageTickScheduler>()`.
**Источники:** `main.py _storage_loop` L4353; `A/Hosting/TenantBootstrapService.cs`,
`A/RatesRefreshScheduler.cs` (эталоны); Ruling 8.
**Acceptance:** build 0/0; запуск Api — в логе нет ошибок цикла; с демо-возрастом карточки архив
срабатывает и без ручного tick (в пределах ~40 с). Отчёт: `task-11-report.md`.
### Task 12: Пересчёт конверсий — ConversionRecomputer + IRatesChangedListener
**Files:**
- Create: `S/Application/IRatesChangedListener.cs` — порт модуля Settings:
`Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct)`.
- Modify: `S/Application/RatesService.cs` — после успешной записи кэша (mock или cbr) вызвать всех
`IRatesChangedListener` (список в ctor, пустой — no-op). Modify: `S/Application/SettingsService.cs`
— в PATCH, если в теле присутствовали `targetCurrency` или `conversionOn`, вызвать listener'ов (Ruling 7).
- Create: `K/Application/ConversionRecomputer.cs` (scoped; `IRatesChangedListener`): полный пересчёт —
карточки из `IKanjStore.ListCardsForConversion`; курс из ratesCache (JSON `{rates,…}`, USDT=USD);
conversionOn=false → 0; обновление ConvFrom/ConvTo/ConvCur через KanbanStore.
- Create: `K/Application/RateTable.cs` — чистый парсинг ratesCache (`{rates,…}`, USDT=USD) +
конвертер; RatesService-часть Settings не трогаем.
- Modify: `K/Application/KanbanModuleRegistrar.cs` — регистрация (Ruling 12).
- Test: `T/ConversionRecomputerTests.cs` (fake settings-store + fake kanban-store: mock-курсы,
USDT=USD, conversionOn=false, col archive исключён, targetCurrency смена).
**Источники:** `rates.py` recompute_conversions L106130, refresh L6274, _resolve_rate L8691;
`settings_routes.py` L186192; Ruling 7.
**Acceptance:** тесты PASS; curl-сценарий: demo-карточка с бюджетом USD (Task 13) → conv в RUB;
PATCH settings {targetCurrency:"USD"} → conv пересчитан; PATCH {rateSource:"mock"} + POST /rates/refresh
→ conv обновлён; карточка в архиве — conv не меняется (psql-проверка). Отчёт: `task-12-report.md`.
### Task 13: Демо-карточки — POST /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO)
**Files:**
- Create: `K/Application/DemoLeadFactory.cs` — демо-пул 1:1 с `dashboard_routes.py L7789` + создание
карточки: нормализация бюджета (BudgetNormalizer), контакты (build_contacts/primary_contact —
достаточно примитивной версии для заданных полей), matchHits=[] для inbox, prevCol=inbox,
sourceMsg/dialogId (`demo_channel`)/ch-поля, isNew=true. Добавление через IKanjStore.Add.
- Create: `A/Endpoints/DemoEndpoints.cs` (`MapDemoEndpoints`): POST `/api/demo/simulate-lead` —
флаг (appsettings/`DEAL_DEMO`), иначе 404 «Демо-режим отключён»; создание карточки → CardDto;
SseBroker: new_lead (полная карточка) + toast «Демо: новый лид» (sparkles); POST `/api/demo/age-lead`
— состарить самую старую карточку досок (receivedAt = now (archiveAfterDays+1) дней; 400 «Нет
карточек на досках для демо»), затем тик StorageTickService и toast при архивировании.
- Modify: `A/appsettings*.json` — секция `Demo: { Enabled: false }` (Development — true).
- Modify: `A/Program.cs` — map + DI.
**Источники:** `dashboard_routes.py` L287324; `pipeline.py _store_lead` L433514; devtests
`backend/devtests/{boot_test,e2e_test}.py` (эталон сценариев приёмки); api-map L114.
**Acceptance:** curl с DEAL_DEMO=1: simulate-lead → полный объект §4.1 (id l_…, col inbox, title,
summary, stack, budget, contacts, ch, receivedAt); повторные вызовы наполняют inbox; age-lead → 200;
`GET /api/leads?col=inbox` сортировка DESC. Без флага — 404. Отчёт: `task-13-report.md`.
### Task 14: ИИ-предложения — порт IColumnSuggester + эвристика + /api/ai/suggest-columns|keywords
**Files:**
- Create: `C/Integrations/IColumnSuggester.cs`, `C/Integrations/Models/ColumnSuggestionDto.cs`
(Ok/Created/Reason/Cooldown/Keywords) — Ruling 3.
- Create: `K/Application/SuggestHeuristics.cs` — чистое ядро: частотные слова-темы по текстам (≥3 букв,
lowercase, минус стоп-слова), темы ≥2 карточек (MAX_TEXT=12, MIN_INBOX=6, ≤4 колонок), похожесть с
существующими досками (L55–61), правила `{mode:"any", keywords:[…]}` и note-обоснования («Эвристика
(этап 3): …N карточек; реальные предложения ИИ — этап 6»); для suggest-keywords — частотные маркеры
(≤60, ≤40 симв.).
- Create: `I/Integrations/LocalColumnSuggester.cs` — реализует IColumnSuggester: читает inbox через
`IKanjStore`, вызывает SuggestHeuristics, создаёт доски `suggested=true` (note/description) и
раскладывает карточки (isNew=true), возвращает created; причины — детерминированные строки Ruling 3.
suggest-keywords: <3 карточек → «мало карточек — сначала накопите заявки (нужно хотя бы 3)».
- Create: `A/Endpoints/AiSuggestEndpoints.cs` (`MapAiSuggestEndpoints`): POST `/api/ai/suggest-columns`
→ результат; при ok:true — SSE-toast «ИИ предложил колонок: N — откройте и решите» (sparkles) 1:1
(boards_changed не шлём — Ruling 5); POST `/api/ai/suggest-keywords` → `{ok, keywords}` | `{ok:false,
reason}`.
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IColumnSuggester, LocalColumnSuggester>()`;
`A/Program.cs` — map.
**Источники:** `suggest.py` целиком (константы L4852, suggest L76163, keywords L166193,
_make_note/_store_suggested/_assign_ids/_rollback L196248); api-map L120121; `store.js
suggestColumns` L10971113; Rulings 3/5.
**Acceptance:** тесты на SuggestHeuristics (детерминированность: одинаковый вход → одинаковый выход);
curl: 6+ демо-карточек с общей темой (например, повторяющиеся simulate с «Python») →
POST /api/ai/suggest-columns → `{ok:true, created≥1}`; GET /api/boards — доска suggested=true с
карточками; PATCH suggested:false → принята; «мало карточек» на пустом inbox → `{ok:false, reason}`.
Отчёт: `task-14-report.md`.
### Task 15: Финал этапа — интеграция и сквозная приёмка
- `scripts/build.sh`/`scripts/test.sh` — успешны; `dotnet build Deal.sln` 0/0; все unit-тесты PASS
(175 этапа 2 + новые).
- Сквозной curl-сценарий канбана: login → boot-группы (boards/leads/counts/columns/state/projects/
tg/status/settings/rates/ml) → demo simulate-lead ×N → создание доски с правилами → move карточки
(matchHits в ответе) → learning/ml-счётчики (status) → mark-col-seen/mark-all-seen → комментарий →
trash → restore → clear-col → suggest-columns (эвристика, ok/created) → age-lead + admin/tick
(автоархив, SSE-toast) → PATCH targetCurrency + rates/refresh (пересчёт conv, psql) → search?q= →
admin/fts/rebuild → 401-проверки без куки.
- psql-проверка схемы дефолтного тенанта: строки Boards/Cards/LeadComments/CardMoves/MlOutbox,
PascalCase-колонки; matchHits/конвертации корректны; colState в settings.
- Обновить `docs/technical/Техническая-документация-Дейл.md`: раздел «Дашборд/канбан» (эндпоинты,
таблицы этапа, SSE-события, StorageTickScheduler, демо-режим DEAL_DEMO, пересчёт конверсий).
- Отчёт `task-15-report.md` + финальная строка в `progress.md`; roadmap-флаг «этап 3 выполнен».
## Self-Review
1. **Spec coverage:** ТЗ §5 «Карточка» (L112121) — Task 7/13 (поля, «О заявке»-summary — приходит
структурой из pipeline/demo; блоки Компания→Условия — формат summary, композиция — этап 4);
ТЗ §6 (L121135) — Tasks 114 (колонки/фильтры/отрицательные — Task 3/6; «почему в колонке» —
Task 7; свежие сверху/виджеты/ширина/colState — Task 6/8; drag&drop+ML — Task 7; ИИ-предложения —
Task 14; архив/корзина — Tasks 10/11); api-map §3.2 (L60121) — Tasks 8/10/13/14; §2 SSE —
Task 9; §4.1/4.2 — Tasks 2/6/7; роадмап-этап 3 — все задачи; рекомендации этапа 2 (Ruling 5 —
PushAsync, Ruling 6 — recompute_conversions) — Tasks 5/12; boot-требование фронта — Ruling 9/Task 9.
2. **Placeholder scan:** заглушки — только согласованные: `LocalMlClient` (модель не готова до этапа 4,
outbox/learning живые), `LocalColumnSuggester` (эвристика до ИИ-этапа 6), reclassify (форма-ветка,
Ruling 11), boot-стабы /projects и /tg/status (этапы 5/6), fts/rebuild no-op (этап 4), демо-пул
(как прототип). Референсы на строки файлов прототипа — точные; FIXME/TODO нет.
3. **Type consistency:** один модуль Kanban владеет карточками/колонками; настройки (архив/colState/
счётчики/курсы) — через `ISettingsStore` модуля Settings (общий каталог ключей не дублируется);
`IMlClient`-контракт един (панель этапа 2 + обучение этапа 3 + предсказания этапа 4);
`IColumnSuggester` в Contracts — подмена реализации на ИИ этапа 6 без правки эндпоинтов;
новые сущности/конфиги/миграция следуют конвенции `TenantSettingEntity`; сущности Settings не
меняются; время жизни — scoped/singleton как в этапах 1–2.
4. **Вне scope этапа 3:** Projects (этап 5; отдаём boot-заглушку), Pipeline/очередь/отсев/FTS-индекс/
дедуп и pipeline_stats (этап 4; reclassify — заглушка), Discovery (этап 6), реальные ai/telegram/ml
сервисы и /api/tg/* (этап 6; tg/status — boot-заглушка), «Отклонено» (проектный канбан, этап 5),
reminder_due (этап 5), оператор/инвайты/лимиты/аудит (этап 7), события boards_changed/
leads_reclassified (недостижимы у фронта — не публикуем), админ-эндпоинты wipe/clear-cards/pump-gate
(фронт не вызывает), ml/learn|flush, /leads/{id}/seen.
@@ -0,0 +1,569 @@
# Дейл (Deal) — Этап 4: Pipeline и «Обработка»: очередь, стоп-лист, дедуп, отсев, ML/ИИ-порты, FTS Implementation Plan
> Исторический документ этапа 4. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** Оживить в модульном монолите `src/core` вкладку «Обработка» Vue-фронта 1:1-контрактом `/api`
пайплайна входящих: приём сообщений (порт + демо-ингвест до telegram-этапа 6), очередь сырых сообщений,
разбор фоновым воркером по пути ТЗ §5 **источник → очередь → стоп-лист → дедуп → ML → ИИ → карточка**,
отсев с причиной/источником решения (правила/ML/ИИ/система + конкретное слово/фраза), возврат из отсева
(ignore-причин + обучение), полнотекстовый поиск по отсеву и карточкам (настоящий FTS в Postgres),
автоочистка отсева раз в 3 суток + ручная, счётчики вкладки. К концу этапа ProcessingView полностью
обслуживается бэкендом на реальном сквозном пути «демо-сообщение → очередь → фильтры → карточка/отсев»
(telegram-источник — этап 6); приёмка — unit/curl/psql. ML-модель не готова (LocalMlClient ready:false) —
ML-ветка реализована, но «спит» до этапа 6; ИИ — порт `IAiClassifier` + детерминированный локальный
классификатор (реальный ai-service — этап 6).
**Architecture:** новый модуль `Deal.Modules.Pipeline` (чистый, без EF/HTTP) — владелец таблиц
`QueueItems`/`RejectedItems`/`DedupEntries` (миграция `TenantPipeline` в `TenantDbContext`) и логики
воркера: DTO очереди/отсева (§4.5), порт `IPipelineStore`, сервисы `PipelineIngestService` (приём,
используется демо-ингвестом и, на этапе 6, gRPC-адаптером telegram-service), `PipelineProcessingService`
(чтение/поиск очереди и отсева, возврат, очистки, запись отсева), чистое ядро разбора `MessageParseCore`
(clean_short/clean_block, normalize_list/stack, qualify/build/primary контакты, dedup-хэш, compose_summary
«О заявке», локальные поля `_local_fields`), `PipelineWorkerService` (pump: stale → stage1 → дедуп → ML →
ИИ/локальный разбор → карточка/отсев). Настройки — порт `ISettingsStore` + `IncomingRules` модуля Settings
(этап-1 готов); доски/правила/карточки — через публичный интерфейс модуля Kanban: порт `IKanjStore`
(GetBoardAsync/AddCardAsync), статические чистые `ColumnRules`/`BudgetNormalizer`/`AmountParser`;
ML — существующий порт `IMlClient` (Contracts); ИИ — новый порт `IAiClassifier` (Contracts/Integrations) с
детерминированным `LocalAiClassifier` в Infrastructure (замена gRPC-клиентом ai-service на этапе 6).
Адаптеры EF — в `Deal.Infrastructure`: `PipelineStore`, доработка `KanbanStore` (жёсткое удаление карточки
чистит строки `DedupEntries` по LeadId), доработка `LocalMlClient` НЕ требуется (счётчики решений
ml/ai инкрементирует сам модуль Pipeline в KV). HTTP — `Deal.Api/Endpoints` (`MapPipelineEndpoints`,
`/api/demo/ingest` в `MapDemoEndpoints`); фоновые циклы — `PipelineWorkerScheduler` (2 с) и доработка
`StorageTickScheduler` (чистка отсева). Публикации SSE — только из Api-слоя (Ruling 5 этапа 3): `new_lead`
при создании карточки воркером, toast при автоочистке отсева; `pipeline_stats` НЕ публикуем (фронт его не
слушает — Ruling 5/9).
**Spec:** `docs/api/api-map.md` §3.6 (L176186), §2 SSE (L3343), правила (L7–24; п.9 «экономия» L399,
кривые места L390400, п.1 SSE L43); §4.5 очередь и отсев (L306–314), §4.1 карточка (L228257), §4.6
(L319–341 — настройки обработки: stopPhrases/minLen/blockResumes/wantedType/budgetRequired*/autoArchive/
archiveAfterDays/aiEnabled/aiFilterEnabled/mlEnabled/domainKeywords/hireMarkers/resumeMarkers/levelTerms);
`docs/spec/ТЗ-дейл-новая-архитектура.md` §5 «Обработка входящих» (L84–121), §7 «Вкладка „Обработка"»
(L150161); roadmap (этап 4, L57–62); референс-семантика прототипа: `backend/app/services/pipeline.py`
(целиком: enqueue L5385, stage1_plain L94124, clean_short/block L148193, _skip_no_budget L196218,
compose_summary L225284, нормализация L294–341, контакты L344430, _store_lead L433514, локальные
поля L661798, воркер L8031183), `backend/app/services/processing.py` (целиком: record L66101,
purge_expired L104117, clear_all/return_to_queue L120193, list_queue/list_rejected/stats L201320),
`backend/app/routers/processing_routes.py` (целиком), `backend/app/services/fts.py` (целиком),
`backend/app/services/leads.py` (L225247 _hard_delete/clear_col, L454504 tick_storage + notify,
L509551 search), `backend/app/services/ai.py` (L261267 normalize_dedup, L316352 clean_budget/
budget_to_target), `backend/app/services/ml_client.py` (L2628 веса, L160162 is_enabled),
`backend/app/routers/dashboard_routes.py` (L261284, L327337), `backend/app/constants.py`,
`backend/app/db.py` (L8892 dedup, L226268 pipeline_msg/rejected_msgs);
фронт: `src/frontend/src/views/ProcessingView.vue` (вся вкладка: счётчики L221–243, очередь L297400,
отсев L402556, canReturn/return), `src/frontend/src/store.js` (pipeline-секция L12101343: loadPipelineQueue
L12131223 limit=120, loadRejected L12261246 limit=80 offset, refreshPipelineStats L12491258,
deleteRejectedItem L12841295, returnRejected L13001318, clearRejectedAll L13201333, SSE L676679 —
обработчик pipeline_stats недостижим, api.js L62–104 слушает только 4 события), `src/frontend/src/api.js`,
`utils.js` (tgSourceUrl); конвенции планов этапов 1–3 (файлы `docs/superpowers/plans/2026-09-05-deal-stage{1,2,3}-*.md`).
## Global Constraints
- Проект **НЕ git**; фиксация — отчёты задач `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage4-pipeline/`.
- .NET 10 SDK, решение собирается 0 warnings / 0 errors (`TreatWarningsAsErrors`); dev-Postgres `deal-postgres`
(:5433); curl-приёмка :5080 (`scripts/build.sh`/`scripts/test.sh`).
- Код-стайл этапов 1–3: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском; явные
модификаторы; без регионов; без магических чисел (именованные константы); PascalCase-колонки БД;
времена — `DateTimeOffset` (UTC) в БД, наружу epoch-ms; JSON camelCase; ошибки `{"detail"}`.
- Модуль Pipeline — чистый: без EF и HTTP; зависимости — `Deal.Modules.Settings` (порт `ISettingsStore`,
сервис `IncomingRules`), `Deal.Modules.Kanban` (порт `IKanjStore`, статические ColumnRules/BudgetNormalizer/
AmountParser, модели CardSnapshot/CardDto), `Deal.Contracts` (IMlClient, IAiClassifier). Реверс-зависимостей
нет (Kanban/Settings о Pipeline не знают). Kanban НЕ получает ссылок на Pipeline — слияние статистик тика
и жёсткое удаление dedup — в адаптерах/Api (Rulings 3/9).
- LeadRadar-контейнеры, `backend/`, `mlservice/`, `src/frontend/` НЕ трогаем. Vue-фронт не переписывается:
формы JSON 1:1 с api-map; «кривые места» этапа 4: `pipeline_stats` у фронта недостижим (Ruling 9),
`GET /api/search``messages: []`.
- Строки ошибок/тостов/причин — фиксированные из прототипа (см. задачи); новые строки — только для
согласованных добавок (демо-ingest, Ruling 11).
## Зафиксированные решения (Rulings этапа)
- **Ruling 1 (а) — миграция TenantPipeline и таблицы.** Новая миграция `TenantPipeline` контекста
`TenantDbContext` (папка `I/Migrations/TenantDb`, применяется провижинером ко всем схемам). Таблицы
(PascalCase, владелец — модуль Pipeline; соответствие db.py L8892/L226268): `QueueItems` (= pipeline_msg),
`RejectedItems` (= rejected_msgs), `DedupEntries` (= dedup). Колонки QueueItems: Id (`p_`, текст),
DialogId, ChannelName/ChannelHandle/ChannelHue (дефолт `#666`), Text (≤6000), MsgId (long?, nullable),
MsgAt, Status (`new`|`filtered`), Force (bool), CreatedAt, UpdatedAt; индекс `(Status, CreatedAt)`.
RejectedItems: Id (текст; детерминированный `r_<dialog>_<msgId>` при наличии dialog+msgId, иначе `r_`+hex —
как processing.record L77; upsert `ON CONFLICT (id) DO UPDATE`), DialogId, MsgId (long?), ChannelName/
ChannelHandle/ChannelHue, Text (≤6000), Stage, Reason (≤500), Kw (≤200), Source, MsgAt, RejectedAt,
Returned (bool), ReturnedAt (nullable), ReturnReason (≤500), SearchTsv (см. Ruling 6); индекс `(RejectedAt)`
+ GIN `(SearchTsv)`. DedupEntries: Hash (текст, PK), LeadId (nullable, БЕЗ FK — «мягкая» ссылка на Cards,
как прототип; чистка при жёстком удалении карточки — Ruling 3), CreatedAt. JSON-полей нет (все поля —
плоские колонки); связи с Cards нет FK (журнал/отсев живут дольше карточки, конвенция Ruling 1 этапа 3).
В той же миграции — FTS: `Cards.SearchTsv` и `RejectedItems.SearchTsv` (Ruling 6). Индексы/конфиги — 1
файл на сущность, эталон CardEntity+CardConfiguration.
- **Ruling 2 (б) — порт приёма сообщений и демо-ингвест.** Приём — публичный scoped-сервис модуля
`PipelineIngestService.EnqueueAsync(QueuedMessage message, CancellationToken)` (1:1 prototype enqueue L5385:
trim текста, пустой текст/нет dialog → no-op; text[:6000]; msg_id-дубль-гвард на уровне адаптера
`SELECT 1 FROM QueueItems WHERE DialogId=? AND MsgId=?` — защита от двойного события Telethon; id `p_`).
На этапе 4 его вызывает ТОЛЬКО демо-эндпоинт `POST /api/demo/ingest` (Ruling 11; флаг DEAL_DEMO, иначе
404 «Демо-режим отключён»); этап 6 — gRPC-ингресс telegram-service вызовет тот же сервис (контракт
стабилен, интерфейс не плодим — YAGNI). Разбор очереди — воркер (Ruling 8) + `POST /api/admin/tick`
(Ruling 10), как прототип (pump L890–918 вызывается из `_pipeline_loop` и admin_tick L336).
- **Ruling 3 (в-1) — кто пишет карточку и доступ к Kanban.** Карточку создаёт модуль Pipeline, но ТОЛЬКО
через публичный интерфейс модуля-владельца Kanban (архитектура §5 L130–131): `IKanjStore.AddCardAsync
(CardSnapshot)` + `GetBoardAsync`; проверка назначения колонки и «почему в колонке» — статические чистые
`ColumnRules.BoardAccepts/HasActiveRules/ComputeHits` и `BudgetNormalizer`/`AmountParser` модуля Kanban
(доступ к чистым помощникам владельца — не дублируем). Добавление ссылки `Pipeline → Kanban` цикла не
создаёт (Kanban про Pipeline не знает). Подготовка полного `CardSnapshot``CardComposer` в модуле Pipeline
(перенос `_store_lead` L433514, Ruling 4). Жёсткое удаление карточки (Kanban DELETE /leads/{id}, clear-col,
очистки тика) по контракту api-map §3.2 L92 — «leads+dedup+messages»: дорабатываем EF-адаптер `KanbanStore`
(DeleteForeverAsync/PurgeAsync/ClearColAsync дополнительно удаляют `DedupEntries WHERE LeadId=?` — «сирота»
не должна блокировать повторное создание, leads.py _hard_delete L229). Порт Kanban и его XML-doc обновляются
(семантика «полное удаление»).
- **Ruling 4 (г) — карточка из сообщения (CardComposer).** Перенос `_store_lead` (L433514) в чистый
`CardComposer` модуля Pipeline: title = clean_short(raw.title, 140) или clean_short(text, 140); summary =
compose_summary (блоки «О заявке» Компания→Формат→О задаче→Требования→Будет плюсом→Условия, 1:1 с
cardPrompt и compose_summary L225284; локальный путь без структуры — «О задаче: …», _local_summary
L294–314; футер-хинты L288291) ≤2000 через clean_block; stack = normalize_stack ≤12 (L332341);
бюджет: нормализованный из разбора (`BudgetNormalizer.Normalize`), иначе fallback из первой суммы
`AmountParser.Parse` по исходнику/суммари (L459–468), конверсия один раз при поступлении —
`BudgetNormalizer.ToTarget` (conversionOn/targetCurrency/ratesCache, USDT=USD, мок-фолбэк, как
ConversionRecomputer/CardsService.LoadRatesAsync); контакты: `ContactsQualifier.Build` из разбора или
текста (L389421, ≤6, типы tg/phone/email/linkedin/whatsapp/site, отбрасывание ботов/сервисных t.me/
«постовых» сайтов L344386), primary_contact (tg→phone→whatsapp→email→linkedin→site, L424430, ≤200);
ch-поля канала; sourceMsg = text[:4000]; sourceDialogId/sourceMsgId; prevCol=inbox; matchHits =
ComputeHits доски, если назначена и прошла BoardAccepts (иначе колонка сбрасывается в inbox — страховка
L449450); isVacancyKnown = признак ИИ. Создание: `IKanjStore.AddCardAsync` затем
`IPipelineStore.LinkDedupAsync(hash, cardId)` (порядок как L512–513).
- **Ruling 5 (в-2) — ML/ИИ-ветки этапа 4.** ML-слой вызывает существующий порт `IMlClient.PredictAsync`
когда `mlEnabled` (не false) и не force (L966). Локальная модель не готова (LocalMlClient ready:false →
predict `{take:false,...}`) — все сообщения уходят к ИИ-ветке; ветки «решил сам» реализуются ПОЛНОСТЬЮ
1:1 с L9691061 (spam → отсев `{source:ml, stage:spam_ml, reason:«ML уверен, что это спам/не заявка
(score …)»}`; доска → разрешена только не-suggested без активных правил, карточка в доску с
локальными полями + типом ML + докладом terms в стек; typeDrop по wantedType; тип известен + aiEnabled
false → карточка inbox) и покрываются юнит-тестами на fake-клиенте с ready:true (FakeMlClient в тестах
расширяется). ИИ-слой: новый порт `IAiClassifier` (Contracts/Integrations; этап 6 заменит реализацию
gRPC-клиентом ai-service) с record-DTO `AiFilterResult {Pass, Reason, Skipped}` и `AiParsedLead`
(title/company/format/task/requirements/plus/conditions/stack/budget/contacts/is_vacancy/is_vacancy_known/
is_spam/board — структура классификации ТЗ §5 L104106 и ai.py classify). Этап 4 — детерминированный
`LocalAiClassifier` (Infrastructure/Integrations): фильтр — всегда `{pass:true, skipped:true}` (реального
ИИ-фильтра нет; при aiFilterEnabled=true это ветка «ИИ недоступен» прототипа L1103–1106; отсевы
spam_ai/filter_ai недостижимы — их причины готовы для этапа 6); классификатор — локальный разбор ядра
`MessageParseCore` (Ruling 4/Ruling 7: budget из AmountParser, контакты qualify, is_vacancy по hire-маркерам,
is_vacancy_known=false, board=null — «смысловые колонки до ИИ не назначаем», L954958). aiEnabled=false →
тот же локальный разбор напрямую (прототип L1081–1096), без вызова порта. Возврат (force): ИИ-фильтр
пропускается (L1097–1100), вердикт «спам» ИИ отменяется (L1117–1121). Счётчики решений: KV
`mlDecisions`/`aiDecisions` инкрементирует модуль Pipeline после pump (`ml=mlStored+mlDrop,
ai=aiStored+aiDrop`, ml_client.track_decisions L153157) через ISettingsStore read-modify-write —
LocalMlClient.StatusAsync их уже читает (этап 3), контракт IMlClient не меняется.
- **Ruling 6 (е) — FTS.** Механизм — встроенный полнотекстовый поиск Postgres БЕЗ внешних расширений
(pg_trgm и DuckDB-FTS НЕ нужны: LIKE-дополнение на объёмах этапа выполняется сканом, а русская морфология
есть в конфигурации `russian`): в миграции TenantPipeline добавляются генерируемые колонки
`Cards.SearchTsv` и `RejectedItems.SearchTsv` = `to_tsvector('russian', coalesce(<текст.поля>,''))`
(Cards: Title+Summary+SourceMsg+Contact — поля поиска leads L527529; Rejected: Text — fts.py
`_FTS_TARGETS` L2327) `STORED` + GIN-индексы. Колонки авто-актуальны (аналог DuckDB «rebuild каждые
сутки» не нужен). Поиск карточки `/api/search?q=` (q≥2) — один SQL: `col != 'taken' AND (SearchTsv @@
plainto_tsquery('russian', q) OR lower(title/summary/source_msg/contact) LIKE '%q%')`, порядок
`ts_rank DESC, ReceivedAt DESC`, limit 12 — кандидаты FTS LIKE как в leads.search L509551 (`messages:[]`
— api-map п.3). Поиск отсева `GET /pipeline/rejected?q=` — FTS-кандидаты (`SearchTsv @@ plainto_tsquery`)
∪ LIKE-дополнение по `lower(text)/reason/kw/ch_name` (processing.list_rejected L246277, лимиты
limit*2 на каждую выборку, total = размер объединения, страницы по offset/limit ≤500). `POST
/admin/fts/rebuild` — реальная идемпотентная обслуживающая операция `FtsMaintenance.RebuildAsync`:
`CREATE INDEX IF NOT EXISTS` + `ANALYZE` обеих таблиц (самовосстановление индекса, если отсутствует),
ответ `{ok:true, ready:true}`.
- **Ruling 7 (в-3) — чистое ядро разбора в модуле Pipeline.** Перенос функций pipeline.py в чистые классы
модуля `MessageParseCore` (1 тип = 1 файл): `MessageTextCleaner` (clean_short L148156 / clean_block
L158193 — markdown-ссылки, **__`~~, ||, голые URL, эмодзи-диапазоны, «C#»-защита, схлопывание, обрезка
по границе), `MessageListNormalizer` (normalize_list L317329, normalize_stack L332341),
`ContactsQualifier` (L344430: qualify_contact/build_contacts/primary_contact + регэкспы/наборы L345–347,
L597604), `DedupHasher` (normalize_dedup ai.py L261267: `[^\wа-яё]+` → SHA1 hex), `SummaryComposer`
(compose_summary + _local_summary + футер-хинты L288291), `LocalFieldsParser` (_local_fields L718798:
метки `Стек/Грейд/Контакты/Бюджет` L591596 через `_field_of`-эквивалент, fallback-извлечения, заголовок,
суть, is_vacancy по hireMarkers, is_vacancy_known=false, board=null) + словарь стоп-слов стека
(`_STOP_STACK` L604–610), маркеры найма/грейда/резюме читаются из настроек (S) как в IncomingRules.
Эти же классы использует `LocalAiClassifier` (Infrastructure). Unit-тесты — на эталонных текстах
(кейсы из devtests/e2e прототипа + примеры вакансий/заказов с контактами и бюджетами).
- **Ruling 8 (ж/з) — воркер, очистки, счётчики, SSE.** `PipelineWorkerService.PumpOnceAsync` (модуль)
— перенос `_pump_unlocked` L920–1183 (порядок строго 1:1): для status='new' (лимит 12): force? →
stale-проверка (только не force; msgAt старше archiveAfterDays*суток при autoArchive=true → отсев
`{source:stale, stage:stale, reason:«сообщение старше N дн. (срок до автоархива) — не заводим в
систему»}`, строка удаляется, карточка НЕ создаётся — правка владельца «устаревшие не попадают в
систему») → `IncomingRules.CheckAsync` (не прошёл → отсев `{source:stop, stage=kind(length|stop|resume|
type), reason, kw}`, строка+dedup-claim удаляются) → дедуп (`DedupHasher`; хэш уже в DedupEntries →
отсев `{source:dup, stage:dup, reason:«сообщение уже в системе: карточка создана ранее или этот текст
уже обрабатывается»}`, удаление строки и её dedup-claim; иначе INSERT claim LeadId=null) → ML-слот
(Ruling 5) → не решено → status='filtered'. Для status='filtered' (лимит 4): force? → stale (только не
force) → aiEnabled=false: локальный разбор + no-budget(не force) → карточка inbox (счётчик aiStored —
имя прототипа) ; aiEnabled=true: force → фильтр-пропуск; иначе `IAiClassifier.FilterAsync` (локально
pass/skipped); `ClassifyAsync`; сбой/пустой разбор → локальный разбор (aiFail); is_spam → отсев
`{source:ai, stage:filter_ai|spam_ai, reason:«ИИ-фильтр: …»|«ИИ: не заявка — спам, реклама, скам или
служебное сообщение»}` + `IMlClient.PushAsync(text, "spam", AI_WEIGHT 0.4)`; no-budget (не force) → отсев
`{source:stop, stage:budget, reason:«включён фильтр „не создавать карточку без суммы" — в тексте не
указан бюджет»}`; карточка (CardComposer) + new-лид-сигнал; обучение ML: ИИ назначил доску (не inbox и
не-suggested, без активных правил) → `PushAsync(text, boardId, 0.4)`; тип известен → `PushAsync(text,
"t:hire"|"t:order", 0.4)` (L11551180). Результат pump — `PipelinePumpResult`: счётчики {staged,
rulesStored, mlStored, mlDrop, typeDrop, aiStored, aiDrop, aiFail, noBudget} (1:1 имена wire-ключами
admin/tick pipeline-словаря) + `IReadOnlyList<CardDto> CreatedCards` (для SSE, Ruling 9) + счётчики
решений для KV. Воркер-гейт «не параллелить pump одного тенанта» — `PipelinePumpGate` (Api, singleton,
Interlocked/ConcurrentDictionary; аналог asyncio.Lock L40). Очистка отсева: `PipelineProcessingService.PurgeExpiredAsync`
(RejectedAt старше 3 суток, processing.purge_expired L104117) вызывается из тика (Ruling 10); полная
ручная очистка — отдельный эндпоинт /rejected/clear. Счётчики вкладки — `GET /pipeline/stats`
(queue.counts из QueueItems по status + rejected count). SSE этапа 4: `pipeline_stats` НЕ публикуем —
api-map L43 фиксирует, что фронтовый `openEvents()` слушает только new_lead/toast/reminder_due/
system_status, а «Обработка» живёт на поллинге (ProcessingView reloadAll 2,6 с + store.js 60 с);
публикуем: `new_lead` (полный CardDto; из Api после PumpOnce — admin/tick и PipelineWorkerScheduler,
Ruling 5 этапа 3) и toast «Отсев очищен: N записей (3 дн.)» (trash) при ненулевой автоочистке
(доработка StorageToastPublisher, notify_tick_stats L503504).
- **Ruling 9 (и) — интеграция с тиком/настройками без циклов.** `StorageTickService` (Kanban) НЕ трогаем
(purgedRejected=0 у него остаётся). Автоочистку отсева выполняет модуль Pipeline
(`PipelineProcessingService.PurgeExpiredAsync`) в рамках тика: оркестрацию делает Api — `POST /api/admin/tick`
вызывает Kanban-тик + purge-отсева + pump (Ruling 10), фоновый `StorageTickScheduler` — Kanban-тик +
purge-отсева на каждый тенант; ответ тика объединяет статистику (`storage = {…, purgedRejected}` 1:1 с
leads.tick_storage L488493). Публикация тостов — StorageToastPublisher. Настройки этапа-1 переиспользуют
`IncomingRules` (Settings, scoped) и новые порции настроек читаются через ISettingsStore/SettingsKeys +
дефолты SettingsDefaults (без дублирования каталога ключей). Спам-квоты/«системный отсев сверх
stale|dup» в прототипе нет — НЕ реализуем (за этапом; roadmap §L57–62 трактуем как stale/dup source
= «система», уже покрыто).
- **Ruling 10 — эндпоинты этапа и DI.** Входят: 6 эндпоинтов `/api/pipeline/*` (api-map §3.6) — GET
/stats, GET /queue (limit ≤500, дефолт 100; ответ `{items, counts:{new,ai,total}, rejected}`),
GET /rejected (q/offset/limit ≤500; `{items,total,offset,limit}`), POST /rejected/clear →
`{ok:true, cleared}`, DELETE /rejected/{rejId} → `{ok:true}`, POST /rejected/{rejId}/return
`{reason=""}` → `{id, returned:true, returnedAt}` (404 «Запись не найдена»; 400 «Сообщение уже возвращено
в обработку»/«Повтор: карточка с таким текстом уже есть в системе — возвращать нечего»/«В записи нет
текста сообщения»; при stage ∈ {spam_ml, spam_ai, filter_ai} — `PushAsync(text,"spam",-1.0)`; строки
очереди с force=true; запись отсева помечается returned/returnedAt/returnReason, НЕ удаляется) +
демо-ingest `POST /api/demo/ingest` `{text, dialogId?, channelName?, channelHandle?, channelHue?, msgId?,
msgAt?}` → `{ok:true, id, queue:{new,ai,total}}` (400 «Текст сообщения пуст»; DEAL_DEMO guard).
Возврат из отсева «мимо ML к ИИ» (ТЗ §5 L100) обеспечивает force. Модифицируются: `POST /api/admin/tick`
(ответ 1:1 `{storage, reminders:[], pipeline:<dict pump>, queue:int}` + тосты + new_lead по созданным
карточкам), `POST /api/admin/fts/rebuild` (Ruling 6). НЕ реализуем (фронт не вызывает, api-map п.9):
admin/wipe|clear-cards|pump-gate, ml/learn|flush, /leads/{id}/seen; `reclassify` остаётся заглушкой
Ruling 11 этапа 3. DI: `AddPipelineModule()` (модуль: Ingest/Processing/Worker/Rejects/ядра), адаптеры
в `AddDealPersistence` (IPipelineStore → PipelineStore), `AddDealIntegrations` (+IAiClassifier →
LocalAiClassifier); Program.cs — AddPipelineModule + MapPipelineEndpoints + hosted services (Ruling 8/10).
Id-префиксы Pipeline — `p_` (очередь), `r_` (отсев; детерминированный вариант), хэш-ключ без префикса.
- **Ruling 11 (к) — демонстрация сквозного пути без telegram.** Пресеты демо НЕ заводим: `POST
/api/demo/ingest` принимает произвольный текст (детерминированная приёмка curl-текстами из Task 13:
вакансия с бюджетом/контактами → карточка; короткое сообщение/стоп-фраза/резюме/чужой тип → отсев;
одинаковый текст дважды → «повтор»; msgAt старше срока → «устарело»; без суммы при
budgetRequiredHire=true → «нет суммы»). `simulate-lead`/`age-lead` этапа 3 не меняются. После ingest
очередь разбирается фоном (2 с) или `POST /api/admin/tick` (детерминированно в curl). Вне этапа 4:
реальные ai/telegram/ml-сервисы и gRPC (этап 6), Projects/reminder_due (этап 5), discovery,
оператор/лимиты (этап 7), события pipeline_stats/boards_changed/leads_reclassified (недостижимы у фронта).
## Задачи
Сокращения путей: `P=` `src/core/Deal.Modules.Pipeline/`, `K=` `src/core/Deal.Modules.Kanban/`,
`S=` `src/core/Deal.Modules.Settings/`, `C=` `src/core/Deal.Contracts/`, `I=` `src/core/Deal.Infrastructure/`,
`A=` `src/core/Deal.Api/`, `T=` `src/core/tests/Deal.Tests.Unit/`. Отчёты —
`task-N-report.md` в `.superpowers/sdd/deal-stage4-pipeline/`.
### Task 1: Миграция TenantPipeline — QueueItems/RejectedItems/DedupEntries + FTS-колонки
**Files:**
- Create: `I/Persistence/Entities/{QueueItemEntity,RejectedItemEntity,DedupEntryEntity}.cs` и
`I/Persistence/{QueueItemConfiguration,RejectedItemConfiguration,DedupEntryConfiguration}.cs`
(поля/индексы Ruling 1; Text/Reason/Kw — text; времена — `DateTimeOffset`; SearchTsv — computed).
- Modify: `I/Persistence/Entities/CardEntity.cs` + `I/Persistence/CardConfiguration.cs` — свойство
`SearchTsv` (`HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", stored:true)` + GIN-индекс) — Ruling 6.
- Modify: `I/Persistence/TenantDbContext.cs` — DbSet'ы + `ApplyConfiguration`.
- EF: миграция `TenantPipeline` для `TenantDbContext` (как TenantKanban: `dotnet ef migrations add
TenantPipeline --context TenantDbContext --output-dir Migrations/TenantDb --project
src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`); старт Api применяет её к схеме
дефолтного тенанта.
**Источники:** db.py L8892, L226268; Rulings 1/6; эталон: TenantKanban-миграция, CardEntity/Configuration.
**Acceptance:** build 0/0; `dotnet test` MarkerTests PASS; psql (search_path дефолтного тенанта): таблицы
QueueItems/RejectedItems/DedupEntries + PK; `Cards` получила `SearchTsv` (generated, stored) и
`RejectedItems.SearchTsv`; индексы `IX_QueueItems_Status_CreatedAt`, `IX_RejectedItems_RejectedAt`,
GIN на SearchTsv (обоих таблиц); `__TenantMigrationsHistory` содержит TenantPipeline. Отчёт: `task-1-report.md`.
### Task 2: Модуль Pipeline — DTO, словари отсева, порт IPipelineStore, реестр
**Files:**
- Create: `P/Application/Models/QueueItemDto.cs` (§4.5 очередь L308: id/dialogId/msgId/text/status/ch{name,
handle,hue}/msgAt/queuedAt), `RejectedItemDto.cs` (§4.5 отсев L310313: +stage/stageLabel/reason/kw/
source/sourceLabel/rejectedAt/returned/returnedAt/returnReason; наружу epoch-ms), `QueueCountsDto.cs`
({new,ai,total}), `RejectRecord.cs` (команда записи отсева: source/stage/reason/kw + канальные поля),
`QueuedMessage.cs` (команда приёма: dialog/ch/msgId/text/msgAt/force), `PipelinePumpResult.cs` (счётчики
Ruling 8 + CreatedCards), `PipelineRejectConstants.cs` (словари stage→stageLabel, source→sourceLabel,
«система», Ruling 1/Ruling 9; processing.py L2646).
- Create: `P/Application/IPipelineStore.cs` — порт (реализация — EF-адаптер Task 3): Queue
(ExistsDuplicateAsync(dialogId,msgId), AddAsync, ListAsync(limit), CountByStatusAsync, SetStatusAsync,
RemoveAsync); Rejects (UpsertAsync(RejectRecord) с детерминированным id, ListPageAsync(offset,limit),
SearchIdsAsync(q, limitFts, limitLike) → упорядоченный список id, CountAsync, RemoveAsync, ClearAsync,
PurgeExpiredAsync(olderThan), GetAsync(id), MarkReturnedAsync(id, reason, at)); Dedup (ExistsAsync(hash),
ClaimAsync(hash), DeleteClaimAsync(hash) (только LeadId=null), LinkAsync(hash, cardId),
DeleteByLeadAsync(cardId)).
- Create: `P/Application/PipelineIdPrefixes.cs` (`p_`, `r_`) + переиспользование `PrefixId` (модуль Kanban)
— при необходимости вынести общий генератор в SharedKernel (на усмотрение исполнителя, без дублирования).
- Create: `P/Application/PipelineModuleRegistrar.cs` — `AddPipelineModule()` (регистрация сервисов задач
4/5/7/9 по мере появления). Modify: `P/Deal.Modules.Pipeline.csproj` — ProjectReference на
`Deal.Modules.Settings` и `Deal.Modules.Kanban`.
**Источники:** api-map §4.5 L306314; processing.py L2646, L218320; Rulings 1/2/8/10.
**Acceptance:** build 0/0; DTO — record'ы (camelCase при сериализации); словари 1:1 (length→«короткое
сообщение», …, dup→«повтор»; stop→«правила», ml→«ML», ai→«ИИ», stale|dup→«система»); MarkerTests PASS.
Отчёт: `task-2-report.md`.
### Task 3: EF-адаптер PipelineStore + DI + жёсткое удаление карточек (DedupEntries)
**Files:**
- Create: `I/Persistence/Repositories/PipelineStore.cs` — реализация `IPipelineStore` на `TenantDbContext`
(эталон KanbanStore.cs; AsNoTracking для чтений; маппинг вручную; времена ↔ epoch-ms наружу).
Детали: `ExistsDuplicateAsync` — `SELECT 1 FROM QueueItems WHERE DialogId=? AND MsgId=?` (Ruling 2);
добавление строки очереди — id `p_` генерирует модуль; `UpsertAsync` для RejectedItems — raw SQL
`INSERT … ON CONFLICT (id) DO UPDATE SET …` (processing.record L79101: детерминированный id
`r_<dialog>_<msgId>` либо `r_`+hex; пустой текст — no-op); `SearchIdsAsync` — FTS-кандидаты
`plainto_tsquery('russian', q)` по `SearchTsv` (rank DESC) + LIKE-дополнение по
lower(text)/reason/kw/ch_name (limit*2 каждое), объединение без дублей (processing L252270);
`PurgeExpiredAsync`/`ClearAsync`/`RemoveAsync` — по RejectedAt/безвозвратно; `ClaimAsync` —
`INSERT … ON CONFLICT DO NOTHING`; `DeleteClaimAsync` удаляет только строки с `LeadId IS NULL`;
`LinkAsync` — `UPDATE DedupEntries SET LeadId=? WHERE Hash=?`.
- Modify: `I/Persistence/Repositories/KanbanStore.cs` — жёсткое удаление карточки (DeleteForeverAsync,
PurgeAsync, ClearColAsync) дополнительно `DELETE FROM DedupEntries WHERE LeadId=?` (Ruling 3).
- Modify: `K/Application/IKanjStore.cs` — XML-doc метода DeleteForeverAsync/PurgeAsync (семантика
«Cards + комментарии + DedupEntries», Ruling 3).
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IPipelineStore, PipelineStore>()`.
**Источники:** processing.py L66117, L246312; leads.py _hard_delete L225247; Rulings 1/3; эталон
KanbanStore.cs/SettingsStore.cs.
**Acceptance:** build 0/0; unit (LocalMlClient-стиль не нужен — PipelineStore на EF покрывается curl/psql):
upsert отсева дважды с тем же dialog+msgId → одна строка с обновлёнными полями; psql+curl — после Task 9.
Отчёт: `task-3-report.md`.
### Task 4: Чистое ядро разбора сообщения — cleaners, нормализация, контакты, dedup, «О заявке»
**Files:**
- Create в `P/Application/Parse/`: `MessageTextCleaner.cs` (clean_short/clean_block L148193 + регэкспы/
наборы эмодзи/футер-хинты L131145, L288291), `MessageListNormalizer.cs` (normalize_list/normalize_stack
L317–341 + стоп-слова стека L604–610), `ContactsQualifier.cs` (L344430 + _contacts_from L666679,
_norm_phone L661663), `DedupHasher.cs` (ai.py L261267), `SummaryComposer.cs` (compose_summary L225284 +
_local_summary L294314), `LocalFieldsParser.cs` (_local_fields L718798 + _field_of L686698, метки
L591–596, маркеры/токены L597612; hireMarkers/levelTerms/resumeMarkers — через ISettingsStore +
SettingsDefaults, нормализация как в IncomingRules), `AmountRangeBudgetFallback.cs` (fallback бюджета из
`AmountParser.Parse`, L459468).
- Test: `T/MessageParseCoreTests.cs` — кейсы: markdown/URL/эмодзи-чистка, «C#» не режется, обрезка по
границе; normalize_list «Java, Kotlin»/«;»-список; qualify: @user, @…bot → нет, t.me-ссылка, email,
телефон +7, linkedin, site-спам (teletype.in → нет); build_contacts из текста (≤6, дедуп); dedup-хэш
детерминирован (регистр/пунктуация не влияют, «Тест!» ≡ «тест»); compose_summary: блоки
Компания→…→Условия в порядке; локальный путь «О задаче: …»; _local_fields на объявлении с метками
«Стек:/Бюджет:/Контакты:» и без меток (fallback по тексту; is_vacancy по hire-маркерам, known=false,
board=null).
**Источники:** pipeline.py L131341, L591798; ai.py L261267; Rulings 4/7.
**Acceptance:** `dotnet test` новых тестов PASS; build 0/0. Отчёт: `task-4-report.md`.
### Task 5: PipelineService — приём (ingest), очередь, отсев, возврат, очистки, счётчики
**Files:**
- Create: `P/Application/PipelineIngestService.cs` — `EnqueueAsync(QueuedMessage)` (Ruling 2: trim, no-op
пустого текста/нет dialogId, text[:6000], msg_id-дубль-гвард, id `p_`, status=new, CreatedAt/UpdatedAt).
- Create: `P/Application/PipelineProcessingService.cs` — запись отсева (Ruling 1/8: детерминированный
upsert), чтение очереди (list_queue L218241: limit clamp 1..500), queue_counts (L207215),
rejected_count, list_rejected (L246312: q-путь FTS+LIKE/страницы/лимиты, no-q путь по RejectedAt DESC),
`ReturnAsync` (processing.return_to_queue L128193: 404 «Запись не найдена»; 400-строки Ruling 10;
stage∈{spam_ml,spam_ai,filter_ai} → `IMlClient.PushAsync(text,"spam",-1.0)`; пометка записи returned +
return_reason; enqueue force=true с msg_at из записи), `ClearAsync`, `DeleteAsync`, `PurgeExpiredAsync`
(3 суток от RejectedAt), stats (форма `/pipeline/stats`).
- Test: `T/PipelineProcessingServiceTests.cs` + `T/FakePipelineStore.cs` (+ использование существующих
FakeSettingsStore/FakeMlClient): ingest (trim/no-op/дубль-dialog+msgId), возврат: dup → 400-текст;
повторный → 400; не найдена → 404-результат; спам-этап → PushAsync(spam, 1.0) вызван; очистки/счётчики.
**Источники:** pipeline.py L5385; processing.py L66193, L201320; processing_routes.py L1774; Rulings 2/8/10.
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-5-report.md`.
### Task 6: Порт IAiClassifier + детерминированный LocalAiClassifier
**Files:**
- Create: `C/Integrations/IAiClassifier.cs` + `C/Integrations/Models/{AiFilterResultDto,AiParsedLeadDto,
AiBudgetDto}.cs` — порт Ruling 5: `FilterAsync(string text, ct)` и `ClassifyAsync(string text, ct)`.
- Create: `I/Integrations/LocalAiClassifier.cs` — реализация: фильтр всегда `{pass:true, skipped:true}`
(aiFilterEnabled НЕ читает — выключатель обрабатывает воркер, как прототип filter_incoming L190192:
выключен → skipped, включён при недоступном ИИ → pass+skipped, L11031106); классификатор —
`LocalFieldsParser` (модуль Pipeline) → `AiParsedLeadDto` (title/summary/stack/budget из AmountParser/
BudgetNormalizer.Normalize/contacts через ContactsQualifier/is_vacancy/is_vacancy_known=false/board=null).
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IAiClassifier, LocalAiClassifier>()` (секция
AddDealIntegrations).
- Test: `T/LocalAiClassifierTests.cs` — фильтр-пропуск; классификатор детерминирован (одинаковый текст →
одинаковый DTO); бюджет «до 2к$» → {from:null, to:2000, cur:USD}; контакты квалифицированы.
**Источники:** ai.py L188198, L316352; Rulings 5/7; эталон LocalColumnSuggester.cs (адаптер, зовущий
модульное ядро).
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-6-report.md`.
### Task 7: CardComposer — карточка из разобранного сообщения через публичный интерфейс Kanban
**Files:**
- Create: `P/Application/CardComposer.cs` — сборка `CardSnapshot` из `AiParsedLeadDto`/локального разбора
+ метаданных сообщения (Ruling 4): title (clean 140), summary (compose_summary + clean_block 2000,
fallback clean_short(text,2000)), stack ≤12, бюджет Normalize + fallback AmountParser по
text/summary (первая сумма), ToTarget (conversionOn/targetCurrency/rates из ratesCache с мок-фолбэком,
USDT=USD), contacts/primary contact, ch/source-поля, text[:4000], prevCol=inbox, isVacancy/Known.
`BuildAsync` читает доску, если назначена (board): `ColumnRules.BoardAccepts` — иначе col=inbox;
matchHits = `ColumnRules.ComputeHits` для прошедшей доски (иначе пусто).
- Create: `P/Application/PipelineCardWriter.cs` — тонкая обёртка создания: `PrefixId.New("l_")` →
`IKanjStore.AddCardAsync(snapshot)` → `IPipelineStore.LinkDedupAsync(hash, cardId)` →
`store.GetCardAsync(cardId)` (CardDto для SSE). (id `l_` генерирует KanbanIdPrefixes — переиспользуем.)
- Test: `T/CardComposerTests.cs` (FakeKanjStore/FakeSettingsStore): сборка полной карточки (блоки «О
заявке», бюджет+conv, контакты, sourceMsg ≤4000); назначенная доска без правил → колонка доски +
matchHits; доска с несовпадающими правилами → inbox (BoardAccepts-страховка); fallback-бюджет из текста;
conv выключен (conversionOn=false) → conv-поля пусты.
**Источники:** pipeline.py L433514, L540586; rules.py board_accepts/hits (Kanban ColumnRules); Rulings 3/4.
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-7-report.md`.
### Task 8: PipelineWorkerService — воркер pump (stale/правила/дедуп/ML/ИИ/карточка/обучение)
**Files:**
- Create: `P/Application/PipelineWorkerService.cs` — `PumpOnceAsync(newLimit=12, aiLimit=4)` (Ruling 8,
порядок 1:1 `_pump_unlocked` L9201183): проход new → проход filtered; создание карточек через
`PipelineCardWriter`; отсевы через `PipelineProcessingService`; счётчики KV ml/ai инкремент после pump;
возврат `PipelinePumpResult` (+CreatedCards). Зависимости: IPipelineStore, ISettingsStore, IncomingRules
(Settings), IKanjStore, IMlClient, IAiClassifier, PipelineProcessingService, CardComposer, DedupHasher.
Константы: `PushWeightAi = 0.4`, сроки из SettingsDefaults. Решения ML-ветки (Ruling 5) — на порту
IMlClient: не готов/не уверен → filtered; spam/доска/тип — полные ветки.
- Test: `T/PipelineWorkerServiceTests.cs` (+ доработка `T/FakeMlClient.cs` — настраиваемый ready/take/
label/type/terms; `T/FakeAiClassifier.cs`): (1) короткое → отсев length, строка удалена; (2) стоп-фраза →
отсев stop с kw; (3) резюме → отсев resume; (4) dup: дважды один текст — второй отсев dup; (5) stale
(msgAt старше срока, autoArchive=true) → отсев stale БЕЗ карточки; (6) вакансия с бюджетом → карточка
inbox (aiStored=1, счётчики KV aiDecisions+1, CreatedCards=1); (7) no-budget при budgetRequiredHire →
отсев budget, dedup-claim удалён; (8) ML ready+spam → отсев spam_ml + счётчик mlDecisions; (9) ML
ready+доска (без правил, не suggested) → карточка в доску БЕЗ обучающего push (ML-путь не учит, L10171021);
(10) ML ready+тип+aiEnabled=false → карточка inbox is_vacancy/known; (11) force: минует правила/
stale/no-budget и создаёт карточку; (12) ИИ-слот с fake-классификатором: доска назначена → BoardAccepts-
страховка; is_spam → отсев spam_ai + Push(spam, 0.4); пустой разбор → локальный (aiFail).
**Источники:** pipeline.py L8031183; ml_client.py L2628; Rulings 5/8.
**Acceptance:** `dotnet test` новых тестов PASS; build 0/0. Отчёт: `task-8-report.md`.
### Task 9: Эндпоинты /api/pipeline/* + /api/demo/ingest + DI + curl-приёмка
**Files:**
- Create: `A/Endpoints/PipelineEndpoints.cs` (`MapPipelineEndpoints`): GET `/api/pipeline/stats`, GET
`/api/pipeline/queue?limit=` (фронт шлёт 120; clamp 1..500; ответ `{items, counts, rejected}`), GET
`/api/pipeline/rejected?q=&offset=&limit=` (clamp offset≥0/limit 1..500; `{items,total,offset,limit}`),
POST `/api/pipeline/rejected/clear` → `{ok, cleared}`, DELETE `/api/pipeline/rejected/{rejId}` →
`{ok:true}` (прототип delete_one L196198 всегда ok, 404 не шлём), POST `/api/pipeline/rejected/{rejId}/return`
`{reason}` → 200 `{id, returned:true, returnedAt}` | 400 | 404 (детали Ruling 10). Статические
сегменты до `{rejId}`; сессия 401 (эталон MlEndpoints/StorageEndpoints).
- Create: `A/Endpoints/RequestModels/ReturnReasonRequest.cs`, `PipelineIngestRequest.cs`.
- Modify: `A/Endpoints/DemoEndpoints.cs` — `POST /api/demo/ingest` (флаг DEAL_DEMO; тело Ruling 11;
400 «Текст сообщения пуст»; вызов `PipelineIngestService.EnqueueAsync`; ответ
`{ok:true, id, queue:{new,ai,total}}`).
- Modify: `A/Program.cs` — `AddPipelineModule()`, `MapPipelineEndpoints()`; `A/Deal.Api.csproj` — ссылка
на `Deal.Modules.Pipeline`.
- Test: `T/PipelineEndpointsContractsTests.cs` — не нужен (endpoint-слои покрываются curl); достаточно
существующих MarkerTests.
**Контракт:** api-map §3.6 L178186; §4.5; processing_routes.py.
**Acceptance (curl admin/admin, DEAL_DEMO=1):** stats/queue/rejected пустые формы; demo/ingest → очередь 1;
ingest того же (dialogId+msgId) снова → очередь не растёт (гвард); queue?limit=120 — items/counts/rejected;
rejected пуст; 401 без куки. Отчёт: `task-9-report.md`.
### Task 10: POST /admin/tick и /admin/fts/rebuild реальные + SSE-тост отсева
**Files:**
- Create: `I/Services/FtsMaintenance.cs` (или `I/Persistence/Repositories/`): `RebuildAsync(context)` —
`CREATE INDEX IF NOT EXISTS` для `Cards(SearchTsv)`/`RejectedItems(SearchTsv)` (raw SQL; имена —
внутренние константы) + `ANALYZE Cards/RejectedItems` (Ruling 6).
- Modify: `A/Endpoints/StorageEndpoints.cs` — `AdminTickAsync`: `StorageTickService.TickAsync` +
`PipelineProcessingService.PurgeExpiredAsync` (merge в `storage.purgedRejected`) + `PipelineWorkerService.PumpOnceAsync`
(один раз) + ответ `{storage, reminders:[], pipeline:<PipelinePumpResult wire-dict>, queue:<count>}`
(dashboard_routes.py L327337); публикации: тосты StorageToastPublisher, `new_lead` на каждую карточку
CreatedCards (Ruling 8/9). `FtsRebuildAsync` → FtsMaintenance + `{ok:true, ready:true}`.
- Modify: `A/Events/StorageToastPublisher.cs` — ветка `PurgedRejected > 0` → toast «Отсев очищен: N
записей (3 дн.)» (trash) (notify_tick_stats L503504; тест `T/StorageToastPublisherTests.cs` дополняется).
**Источники:** dashboard_routes.py L261264, L327337; leads.py L486504; fts.py L4867; Rulings 6/8/10.
**Acceptance:** curl: demo/ingest вакансии → POST /api/admin/tick → pipeline содержит aiStored/созданную
карточку (GET /leads), queue:0; после отсева (стоп-фраза) tick → pipeline-счётчики, /pipeline/rejected
содержит запись; fts/rebuild → ok/ready. Отчёт: `task-10-report.md`.
### Task 11: Фоновые циклы — PipelineWorkerScheduler (2 с) + purge-отсева в StorageTickScheduler
**Files:**
- Create: `A/PipelineWorkerScheduler.cs` — IHostedService (эталон StorageTickScheduler/RatesRefreshScheduler):
Timer 2 с; на каждое срабатывание — обход тенантов (системный репозиторий), на тенант — свой scope с
`ITenantContext`; воркер-гейт `A/PipelinePumpGate.cs` (Interlocked per-tenant: admin/tick и цикл не
разбирают очередь тенанта одновременно — аналог asyncio.Lock pipeline.py L40); после PumpOnce — публикация
`new_lead` для CreatedCards (Ruling 8/9); try/catch + без подписчиков no-op.
- Modify: `A/Hosting/StorageTickScheduler.cs` — после Kanban-тика каждого тенанта вызывать
`PipelineProcessingService.PurgeExpiredAsync` и учесть в тостах (Ruling 8/9).
- Modify: `A/Program.cs` — `AddHostedService<PipelineWorkerScheduler>()`.
**Источники:** main.py `_pipeline_loop`/`_storage_loop` (L4353); pipeline.py L40; StorageTickScheduler.cs;
Rulings 8/9/10.
**Acceptance:** build 0/0; запуск Api — лог без ошибок цикла; demo/ingest → в пределах ~5 с очередь
разобрана (карточка в /leads или запись в /rejected) без ручного tick; psql: отсев со старым
RejectedAt удаляется фоном (в пределах тика) + toast при подписанном SSE. Отчёт: `task-11-report.md`.
### Task 12: Полнотекстовый поиск карточек — /api/search (FTS + LIKE)
**Files:**
- Modify: `K/Application/IKanjStore.cs` + `K/Application/Models/CardsQuery.cs` (или новый метод):
`SearchCardsAsync(string q, int limit, CancellationToken)` — упорядоченный список CardDto по Ruling 6.
- Modify: `I/Persistence/Repositories/KanbanStore.cs` — реализация: raw SQL по `Cards.SearchTsv`
(`plainto_tsquery('russian')` + `ts_rank DESC, ReceivedAt DESC` + LIKE по title/summary/source_msg/contact,
`col != 'taken'`, limit=12), затем полные CardDto (существующий маппинг/комментарии/time).
- Modify: `K/Application/CardsService.cs` — `SearchCardsAsync` делегирует порту (старый перебор удаляется;
поведение для q<2 — как сейчас, пусто).
- Test: `T/CardsServiceTests.cs` — дополнить: вызов порта с q≥2/лимитом; q<2 → пусто (порт не зовётся).
**Источники:** leads.py search L509551; fts.py; api-map §3.2 L101; Rulings 6; этап 3 Task 7 (текущий LIKE-путь).
**Acceptance:** `dotnet test` PASS; build 0/0; curl (после Task 10-приёмки, карточки созданы): /api/search?q=
<слово из title/source> → карточка; морфология «разработчик»/«разработчику» (по summary) → карточка
(tsvector); messages: []. Отчёт: `task-12-report.md`.
### Task 13: Финал этапа — интеграция и сквозная приёмка
- `scripts/build.sh`/`scripts/test.sh` — успешны; `dotnet build Deal.sln` 0/0; все unit-тесты PASS
(410 этапа 3 + новые).
- Сквозной curl-сценарий (DEAL_DEMO=1, admin/admin): boot-группы → demo/ingest вакансии
(«Middle Python…, бюджет 16002200$, @crm_head, tg…», dialog demo_channel) → admin/tick → GET /leads:
карточка l_… inbox (title/summary-«О заявке»/stack/budget/converted/contacts/ch/sourceMsg) → ingest
короткого текста → tick → GET /pipeline/rejected: stageLabel «короткое сообщение», source «правила»;
ingest текста со стоп-фразой (PATCH settings stopPhrases) → отсев stop c kw; повторный ingest того же
текста вакансии → отсев dup (карточка уже есть); ingest без суммы при budgetRequiredHire=true → отсев
«нет суммы»; ingest с msgAt старше archiveAfterDays → отсев «устарело» (карточки нет);
GET /pipeline/queue?limit=120 — статусы new/filtered по ходу; GET /pipeline/stats — счётчики;
GET /pipeline/rejected?q=<слово> (FTS) и ?q=<имя канала> (LIKE) → записи; DELETE /rejected/{id} →
ok; POST /rejected/{id}/return {reason} (запись не dup/не returned) → возвращена в очередь (queue=1,
запись returned=true), tick → карточка создана; повторный return той же записи → 400; POST
/rejected/clear → {ok, cleared}; GET /api/search?q= по созданным карточкам; POST /admin/fts/rebuild →
{ok,ready}; 401-проверки.
- psql дефолтного тенанта: строки QueueItems/RejectedItems/DedupEntries; карточка ↔ dedup-связь
(LeadId=карточка); удаление карточки (DELETE /leads/{id}) чистит DedupEntries; SearchTsv заполнены.
- Обновить `docs/technical/Техническая-документация-Дейл.md`: раздел «Обработка/Pipeline» (таблицы
этапа, эндпоинты /pipeline, демо-ingest, воркер-цикл 2 с, FTS, автоочистка отсева 3 дня, SSE-политика).
- Отчёт `task-13-report.md` + финальная строка `progress.md`; roadmap-флаг «этап 4 выполнен».
## Self-Review
1. **Spec coverage:** ТЗ §5 (L84121) — путь сообщения Tasks 5/8/10/11 (очередь→стоп-лист→дедуп→ML→ИИ→
карточка); этап-1 (длина/стоп-фразы/резюме/тип) — Task 8 через IncomingRules; «устаревшее» — Task 8;
ML-слой (уверен — сам, иначе ИИ, возврат мимо ML) — Ruling 5/Task 8; ИИ-слой (фильтр/классификация/
колонка с проверкой правил) — Rulings 5/4, Tasks 6/7/8 (локальный детерминированный классификатор,
реальный ИИ — этап 6); глобальные фильтры «без суммы» — Task 8; карточка (структура «О заявке»,
поля §5/§4.1, контакты-квалификация, конверсия) — Task 7; ТЗ §7 (L150161) — очередь/отсев/причины/
поиск/возврат/автоочистка/счётчики — Tasks 5/9 + Rulings 1/8; api-map §3.6/§4.5 — Tasks 2/3/5/9;
§3.2 admin-tick/fts — Task 10; §2 SSE — Ruling 8/9; roadmap этап 4 — все задачи.
2. **Placeholder scan:** заглушки — только согласованные: `LocalMlClient` (ready:false — ML-ветка «спит»,
ветки покрыты тестами на фейках), `LocalAiClassifier` (детерминированный до ai-service этапа 6;
фильтр — pass+skipped, ветки отсева spam_ai/filter_ai готовы к этапу 6), demo-ingest (до telegram-этапа
6; контракт приёма — публичный сервис модуля), `messages:[]` в /api/search (api-map п.3), reclassify —
заглушка этапа 3. Референсы на строки прототипа — точные; FIXME/TODO нет.
3. **Type consistency:** Pipeline → Settings (порты/IncomingRules) и Pipeline → Kanban (IKanjStore + чистые
помощники) — без циклов; Kanban не знает Pipeline; оркестрация тика и SSE — в Api (Ruling 5 этапа 3);
FTS-колонки — в миграции TenantPipeline, владельцы таблиц не меняются (Kanban: Cards; Pipeline:
QueueItems/RejectedItems/DedupEntries); контракт IMlClient не меняется (счётчики решений — KV через
ISettingsStore); IAiClassifier в Contracts — подмена на gRPC этапа 6 без правки эндпоинтов; словари
отсева/причины/тексты — 1:1 с прототипом; сущности/конфиги — конвенция TenantSettingEntity/CardEntity.
4. **Вне scope этапа 4:** реальные ai/telegram/ml-сервисы и их gRPC-ингресс (этап 6; приём только demo-
ingest), Projects/reminder_due (этап 5), discovery (этап 6), события pipeline_stats/boards_changed/
leads_reclassified (фронт не слушает — не публикуем), «спам-квоты»/новые глобальные exclude-настройки
(в api-map/прототипе нет), admin/wipe|clear-cards|pump-gate, ml/learn|flush, /leads/{id}/seen,
reclassify-реализация (этап 6), оператор/лимиты/аудит (этап 7).
@@ -0,0 +1,528 @@
# Дейл (Deal) — Этап 5: Projects («Выбранные»): стадии, напоминания, файлы/ссылки, история, ручное создание Implementation Plan
> Исторический документ этапа 5. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** Оживить в модульном монолите `src/core` вкладку «Выбранные» Vue-фронта 1:1-контрактом `/api`
проектного канбана: карточки, взятые «в работу» из дашборда (лид уходит безвозвратно, `col='taken'`) и
созданные вручную («локальные»), путь по 9 предзаданным стадиям (planned → … → ready/hold, терминальные
finished/rejected), редактирование суммы/стека/контактов/ТЗ, комментарии, ссылки, файлы (тип по MIME/расширению;
хранение через порт `IFileStorage`: локальный диск по умолчанию и MinIO при конфигурации), история движения
под спойлером, напоминания стадии «Отложено» (окно настройки — фронт, бэкенд хранит `at`; фоновая проверка
в 30-с цикле; SSE `reminder_due` + баннер), очистка «Отклонено». К концу этапа ProjectsView полностью
обслуживается бэкендом (boot-заглушка `GET /api/projects {items:[]}` заменяется реальным списком), приёмка —
unit/curl/psql; «взятые» в архив/корзину дашборда не попадают, автоархив тика их не касается.
**Architecture:** новый модуль `Deal.Modules.Projects` (чистый, без EF/HTTP) — владелец таблицы
`ProjectCards` (миграция `TenantProjects` в `TenantDbContext`) и логики «Выбранных»: стадии-константы
`ProjectStages` (1:1 constants.py PIPELINE_STAGES), DTO карточки (§4.3), порт `IProjectStore`, сервисы
`ProjectsService` (чтение, ручное создание, «взять в работу», правка полей, move+история, clear-rejected,
комментарии, ссылки), `ProjectFilesService` (добавить/удалить файл: детект типа → `IFileStorage.Put`
метаданные в карточку), `ProjectReminderService` (set/clear/snooze и фоновая проверка due). Чужие владения
модуль не трогает: чтение лида и пометку `col='taken'` выполняет через публичный порт Kanban
(`IKanjStore.GetCardAsync` + новый `MarkTakenAsync`, Ruling 5); настройки — порт Settings `ISettingsStore`
(`remindersEnabled` уже в каталоге ключей, дефолт true). Файлы — внешний порт `IFileStorage`
(Contracts/Integrations) с двумя адаптерами в Infrastructure: `LocalFileStorage` (корень
`data/attachments`, dev-режим по умолчанию) и `MinioFileStorage` (MinIO S3-клиент, включается секцией
`Storage:Minio`/`DEAL_MINIO_*`; бакет `deal-files` создаётся лениво; сервис minio добавляется в
`deploy/compose.dev.yml`). HTTP — `Deal.Api/Endpoints/ProjectsEndpoints.cs` (`MapProjectsEndpoints`); фоновая
проверка напоминаний — внутри существующего `StorageTickScheduler` (30 с, паттерн Kanban-тика по тенантам) и
ручного `POST /api/admin/tick` (`AdminTickOrchestrator`); SSE `reminder_due` публикуется только из Api-слоя
(Ruling 5 этапа 3); boot-заглушка GET /api/projects удаляется (остаётся /tg/status).
**Spec:** `docs/api/api-map.md` §3.5 (L153174), §2 SSE (L3343: `reminder_due` = `{id, title, stage}`), правила
(L7–24: контент-типы multipart/octet-stream, 410/404, «кривые места» L390400 — п.5 reminder_due, п.6
DELETE-400, п.9 экономия: `/projects/reminders` НЕ реализуем), §4.3 проектная карточка (L280–300), §4.4
стадии (L302304), §3.2 admin/tick reminders (L103112), §4.6 remindersEnabled (L328, L340);
`docs/spec`/ТЗ.md §4.8 «Выбранные» (L119132); roadmap (этап 5, L69–73); референс-семантика прототипа:
`backend/app/services/projects.py` (целиком: _insert/_row_to_card L31100, create_local_card L103124,
take_lead_to_projects L127156, patch_card L159199, add_comment L194199, move_stage L202216,
clear_stage L223231, напоминания L236–282), `backend/app/routers/projects_routes.py` (целиком),
`backend/app/services/files.py` (целиком: KIND_BY_EXT/KIND_LABELS L1328, detect L3145, add_file L5775,
get_file_entry L7883, remove_file L8694), `backend/app/services/object_store.py` (целиком: configured,
put/get/remove, локальный fallback L5479), `backend/app/services/leads.py` (L151156, L526545 — взятые
исключены из списков/поиска), `backend/app/db.py` (L103125 — таблица projects), `backend/app/constants.py`
(L1627 — PIPELINE_STAGES), `backend/app/sse.py`, `backend/app/main.py` (L4753 — 30-с цикл с
check_reminders), `backend/app/routers/dashboard_routes.py` (admin_tick L327337);
фронт: `src/frontend/src/views/ProjectsView.vue` (колонки по PIPELINE_STAGES data.js), `components/
{ProjectColumn,ProjectCard,ProjectDrawer,HoldReminderDialog,ReminderNotice}.vue`, `store.js` (boot L571593;
startProject L19541966; moveProject L19681980; patchProject/addProjectComment/addProjectLink/removeProjectLink
L19852027; addProjectFiles/removeProjectFile L20312053; setHoldReminder/clearHoldReminder/snooze/
clearDueReminder L20602146; startRealtime L670674 — reminder_due), `api.js` (openEvents L6283 — слушает
reminder_due); конвенции/образцы планов этапов 1–4 (файлы `docs/superpowers/plans/2026-09-05-deal-stage{1,2,3,4}-*.md`).
## Global Constraints
- Проект **НЕ git**; фиксация — отчёты задач `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage5-projects/`.
- .NET 10 SDK, решение собирается 0 warnings / 0 errors (`TreatWarningsAsErrors`); dev-Postgres `deal-postgres`
(:5433); curl-приёмка :5080 (`scripts/build.sh`/`scripts/test.sh`); NuGet `Minio` — только в этапе файлов (Task 6).
- Код-стайл этапов 1–4: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском; без регионов;
без магических чисел (именованные константы); PascalCase-колонки БД; времена — `DateTimeOffset` (UTC) в БД,
наружу epoch-ms; JSON camelCase; ошибки `{"detail"}`.
- Модуль Projects — чистый: без EF и HTTP; зависимости — `Deal.Contracts` (IFileStorage), `Deal.Modules.Settings`
(порт ISettingsStore), `Deal.Modules.Kanban` (порт IKanjStore и его read-DTO CardDto/CardBudgetDto/CardCommentDto).
Реверс-зависимостей нет: Kanban/Settings/Contracts о Projects не знают; публикации SSE — только из Api (Ruling 5
этапа 3); оркестрация тика — `AdminTickOrchestrator`/`StorageTickScheduler`.
- LeadRadar-контейнеры, `backend/`, `mlservice/`, `src/frontend/` НЕ трогаем; Vue-фронт не переписывается: формы
JSON 1:1 с api-map. Проектные карточки живут до терминальной стадии: автоархив/корзина тика (StorageTickService,
таблица Cards) их не касается; единственный hard-delete — ручная очистка стадии «Отклонено».
- Строки ошибок/тостов/комментариев — фиксированные из прототипа (см. задачи): «Карточка не найдена», «Лид не
найден», «Пустой комментарий», «Пустая ссылка», «Неизвестная стадия», «Напоминания об отложенных выключены в
настройках», «Удаление проектных карточек отключено» (не используется — DELETE не реализуем), «Файл не найден
в MinIO», «Файл не сохранён в объектном хранилище», «Взял в работу из лида.».
## Зафиксированные решения (Rulings этапа)
- **Ruling 1 (а) — таблицы tenant-схемы (миграция TenantProjects) и стадии.** Новая миграция `TenantProjects`
контекста `TenantDbContext` (папка `I/Migrations/TenantDb`, применяется провижинером ко всем схемам). Таблиц
ОДНА — `ProjectCards` (1:1 с таблицей `projects` db.py L103–125; владелец — модуль Projects). Колонки
(PascalCase, JSON-массивы — text-колонками как `Cards.StackJson`): Id (`pr_`, PK), Stage (строка),
Local (bool), LeadId (nullable, БЕЗ FK — «мягкая» ссылка на `Cards.Id`, конвенция DedupEntries Ruling 1 этапа 4),
Title, Summary (text), StackJson (text), BudgetFrom/BudgetTo (double?), BudgetCur (пусто — бюджета нет),
Contact, CommentsJson/LinksJson/FilesJson/HistoryJson/TzText (text), ReminderAt (nullable), ReminderFired (bool),
CreatedAt, UpdatedAt (`DateTimeOffset`). JSON-массивы хранят wire-формы элементов (комментарий {id,by,text,time};
ссылка {id,name,url}; файл {id,name,size,kind,label,objectKey}; история {id,at,type|stage}) — как python хранит
готовые dict-ы (projects.py _insert L71100). Индексы: `(Stage)` (idx_projects_stage L125), `(UpdatedAt)` DESC
(порядок списка), частичный UNIQUE `(LeadId)` `WHERE LeadId IS NOT NULL` — «один лид → одна проектная карточка»
(страховка гонки take). Комментарии/история НЕ выносятся в отдельные таблицы (внешних читателей нет — YAGNI).
**Стадии канбана «Выбранных» ПРЕДЗАДАНЫ и не являются сущностями** (прототип: константа, не таблица) — проверено
по прототипу/фронту: 9 фиксированных стадий §4.4 = planned Запланировано `#818cf8`, reply Отклик `#38bdf8`,
agree Согласование `#a78bfa`, work В работе `#fbbf24`, review Проверка `#f97316`, ready Готово `#4ade80`,
hold Отложено `#94a3b8` (не terminal), finished Выполнено `#2bd576` (terminal), rejected Отклонено `#ff6b6b`
(terminal) (constants.py L1727). Пользовательских стадий/досок проектного канбана в прототипе НЕТ.
- **Ruling 2 (б) — миграция/владелец/границы.** Владелец схемы — модуль `Deal.Modules.Projects` (Ruling 1);
EF-адаптер `ProjectStore` — в `Deal.Infrastructure`; регистрация `AddProjectsModule()` + `AddScoped<IProjectStore, ProjectStore>`
(в AddDealPersistence). Порт `IProjectStore` объявлен в модуле (эталон IKanjStore/IPipelineStore); DTO-модели —
в `P/Application/Models/`. Публичный контракт наружу (эндпоинты) — сервисы модуля: `ProjectsService`,
`ProjectFilesService`, `ProjectReminderService`. csproj модуля: ProjectReference на `Deal.Modules.Settings`,
`Deal.Modules.Kanban`, `Deal.Contracts`. HTTP — `A/Endpoints/ProjectsEndpoints.cs`, `Program.cs`
`AddProjectsModule()` + `MapProjectsEndpoints()` + `AddDealFileStorage(...)` (Task 6/8); Api.csproj — ссылка на модуль.
- **Ruling 3 (в) — напоминания «Отложено»: механика и границы «бэкенд/фронт».** Окно при переносе в «Отложено»
(`HoldReminderDialog`) — ФРОНТ: после успешного `move` на hold store.js L19761980 сам открывает окно, если
`state.remindersEnabled`, и никакого напоминания при move не шлёт; бэкенд получает напоминание отдельным
`POST /{card}/reminder {at}` (setHoldReminder L20602089: «через N дней (1–30)» или «дата+время» — расчёт `at`
полностью на клиенте, epoch-ms). Семантика 1:1 с projects.py L236282: (1) `set_reminder`: если
`remindersEnabled` == false → 400 «Напоминания об отложенных выключены в настройках»; иначе запись
reminder_at + reminder_fired=false; стадия карточки НЕ проверяется (фронт шлёт только для hold); (2)
`clear_reminder` и `snooze` (at = now + 24 ч) выключатель НЕ проверяют (1:1); (3) ЛЮБОЙ move сбрасывает
напоминание (reminder_at=NULL, reminder_fired=false — move_stage L210213); (4) фоновая проверка
`ProjectReminderService.CheckDueAsync`: выключено → ТОЛЬКО очистка протухших (reminder_at ≤ now; чтобы при
включении старые не «выстрелили»), возврат []; включено → строки `stage='hold' AND reminder_fired=false AND
reminder_at ≤ now` помечаются fired и возвращаются списком `[{id,title,stage}]`; (5) SSE `reminder_due` по каждой
записи публикует Api-слой (Ruling 5 этапа 3) — в ручном тике и фоновом цикле; ответ `POST /admin/tick`
`reminders: [те же записи — «уже выстрелившие», после SSE]` (api-map §3.2 L103112); (6) цикл проверки — 30 с в
существующем `StorageTickScheduler` (main.py L4753: тик → тосты → check_reminders), отдельный hosted-сервис НЕ
заводим; ручной путь — `POST /api/admin/tick` (dashboard_routes.py L327337). Настройка — уже готовый публичный
ключ SettingsKeys.RemindersEnabled (дефолт true, SettingsDefaults L117; PATCH /api/settings работает с этапа 2).
- **Ruling 4 (г) — файлы: порт IFileStorage, адаптеры, ключи, тип.** Новый внешний порт
`C/Integrations/IFileStorage.cs`: `PutAsync(objectKey, Stream, contentType, ct)` (возвращает objectKey),
`GetAsync(objectKey, ct) → Stream?` (null — объекта нет), `DeleteAsync(objectKey, ct)` — как object_store.py
L61–107. Адаптеры в `Deal.Infrastructure/Integrations/` (секция AddDealIntegrations/отдельный
`AddDealFileStorage(IConfiguration, contentRoot)`): `LocalFileStorage` — root `data/attachments` под ContentRoot
(fallback прототипа object_store.py L5479: `_local_path` строит путь из objectKey и не даёт выйти за root),
`MinioFileStorage` — MinIO S3-клиент (NuGet `Minio`; ленивая проверка/создание бакета при первом put —
object_store.py L2651; креды `Storage:Minio` {Endpoint, AccessKey, SecretKey, Bucket="deal-files", Secure} из
appsettings/env `Storage__Minio__*`). Выбор на старте: Minio-адаптер регистрируется, только если Endpoint и
AccessKey/SecretKey заполнены; иначе LocalFileStorage — dev/curl/unit по умолчанию идут БЕЗ MinIO (требование
«заглушка-адаптер, если MinIO недоступен» из roadmap). В `deploy/compose.dev.yml` добавляется сервис `minio`
(порты 9000/9001, volume deal_minio_data, root-пользователь) — опциональная ручная проверка MinIO-режима.
objectKey = `projects/{cardId}/{unixMs}_{safeName}` — 1:1 с object_store.put L65 (safeName: имя файла
санитизируется — path-разделители/кавычки заменяются; единственный бакет и отсутствие tenant-префикса — как в
прототипе: бакет один, доступ к объекту только через метаданные карточки в БД тенанта; мульти-аренда
объектного хранилища — этап 7 SaaS). Тип файла — чистый `FileKindDetector` модуля Projects: MIME-префиксы
image|video|audio → kind, иначе расширение по наборам files.py L1328 (KIND_BY_EXT, метки KIND_LABELS:
Изображение/Видео/Аудио/Архив/Документ/Файл). Метаданные — в `ProjectCards.FilesJson` (запись
{id `pf_`, name, size, kind, label, objectKey}); значки-счётчики на карточке — длина массивов links/files в
ProjectCardDto. Download: stream, `application/octet-stream`, `Content-Disposition: attachment; filename="…"`
(кавычки имени убираются, projects_routes.py L174179); отсутствие objectKey у записи → 410 «Файл не сохранён
в объектном хранилище»; GetAsync == null → 404 «Файл не найден в MinIO» (фиксированная строка прототипа);
запись/карточка не найдены → 404 «Карточка не найдена» (прототип на этом пути отдаёт 500 — для .NET выбираем
корректный 404, фронт таких запросов не шлёт). Upload — multipart/form-data, поле `files` (несколько файлов),
ответ `{items: [файл]}`; фронт после upload/delete перечитывает карточку (store.js L20312053).
- **Ruling 5 (д) — «взять в работу».** Эндпоинт `POST /api/projects/take {leadId}` принадлежит модулю Projects
(api-map §3.5 L161 — не leads). Поток 1:1 с take_lead_to_projects (projects.py L127156): (1) лид читается
через публичный порт Kanban `IKanjStore.GetCardAsync` — null → 404 «Лид не найден»; (2) по LeadId ищется
существующая проектная карточка (`IProjectStore.GetByLeadAsync`) — есть → возврат её (идемпотентность);
(3) создаётся ProjectCard: stage=planned, local=false, title/summary/stack/budget/contact копируются из CardDto
лида, comments=[{id `cm_`, by «Вы», text «Взял в работу из лида.», time «только что»}], history=[{id `h_`, at,
type:"created"}], tzText=""; (4) лид помечается `IKanjStore.MarkTakenAsync(leadId)` — новый метод порта Kanban
(UPDATE Cards SET Col='taken', IsNew=false WHERE Id=?; возвращает bool «строка обновлена»), реализация — в
KanbanStore; метод НЕ пишет CardMoves, не трогает matchHits/prevCol/архивные поля (1:1 с проектом L155 — только
col и is_new). Гонка двух take: частичный UNIQUE `ProjectCards.LeadId` (Ruling 1) — вторая вставка падает,
сервис перечитывает и возвращает существующую карточку. Никаких журналов/ML-сигналов/SSE при take. matchHits и
dedup-связь лида НЕ удаляются (текст остаётся в системе — повтор не заведётся); лид остаётся строкой Cards
(col=taken) и уже исключён из списков/поиска/счётчиков (leads.py L151156, L526545; этапы 3–4). Обратного пути
«Выбранные → дашборд» НЕТ (ТЗ L124–125). «Отклонено»/«Выполнено» — терминальные стадии проектного канбана;
проектные карточки в архив/корзину дашборда не попадают (отдельная таблица, автоархив StorageTickService
оперирует только Cards) — StorageTickService/Kanban НЕ меняем.
- **Ruling 6 (е) — ручное создание.** `POST /api/projects` с телом {title, summary, stack?, budget?, contact,
tzText?, stage?} (projects_routes.py L2028): local=true, history=[{type:"createdLocal"}], title — Trim(),
stage = переданный, если в каталоге ProjectStages, иначе "planned" (create_local_card L103124). Фронт шлёт
`{title:''}` (store.js L19091915) — пустой заголовок допустим (1:1).
- **Ruling 7 (ж) — история движения.** Пишется ТОЛЬКО на создание (запись {id `h_`, at, type:"created"|"createdLocal"})
и на каждую смену стадии (запись {id, at, stage:<новая>}) — move_stage L207215; правка полей, комментарии,
ссылки, файлы, напоминания в историю НЕ пишутся (1:1 прототип). Хранится JSON-массивом в карточке; фронт
показывает под спойлером «История движения» (ProjectDrawer). Ответы мутаций несут полную `history`.
- **Ruling 8 (з) — SSE `reminder_due`.** Событие `reminder_due` несёт `{id, title, stage}` (api-map §2 L3342; id —
проектной карточки, stage всегда "hold"); фронт слушает событие (api.js L62–83) и для баннера берёт карточку из
локального `projectCards` по id (api-map п.5 L395) — публикуем только после того, как карточки ушли в
`GET /api/projects`. Публикации — только из Api (ручной тик AdminTickOrchestrator и StorageTickScheduler);
дополнительный toast НЕ шлём (у фронта — модалка ReminderNotice с действиями Открыть/Позже/Снять).
- **Ruling 9 (и) — эндпоинты этапа.** Реализуем 16 из 18 эндпоинтов §3.5 (столько вызывает фронт). НЕ реализуем:
`GET /api/projects/reminders` (api-map п.9 L399 — фронт не вызывает: активные напоминания фронт берёт из
projectCards; список в настройках-UI отсутствует) и `DELETE /api/projects/{card_id}` (п.6 L396 — всегда 400
«отключено», фронт кнопки не имеет; по истории правок пользователя «удаление проектной карточки не делаем»).
Удаление карточек — только `POST /api/projects/clear-rejected` (hard-delete строк стадии rejected, 1:1
clear_stage L223231; при пустой стадии {ok:true, cleared:0}). Порядок маршрутов: статические сегменты
(`/clear-rejected`, `/take`) регистрируются до `/{cardId}`; вложенные (`/move`, `/comments`, `/links`,
`/files`, `/reminder`) — за `/{cardId}` (методы разные, конфликтов GET/POST нет, но соблюдаем конвенцию api-map
L19–24). Смежные доработки: `POST /api/admin/tick` возвращает reminders (Ruling 3), boot-заглушка GET /api/projects
удаляется из BootStubEndpoints (остаётся /tg/status — этап 6).
- **Ruling 10 (к) — «жизненный цикл» проектной карточки.** Карточка живёт от создания (take/local) до
терминальной стадии; hard-delete только через clear-rejected. Никаких автоочисток «Выполнено» (готово живёт в
списке). Напоминание не мешает move на другие стадии; переход на терминальную стадию не архивирует и не
удаляет карточку (фронт считает её в «всего»). Локальный флаг `local` (wire) — пометка «создано локально» на
карточке (ProjectCard.vue L6170: local, «из лида» = leadId && !local).
- **Ruling 11 (л) — сервисы модуля, id и wire.** Проектные id (короткие, генератор PrefixId этапа 3): карточка
`pr_`, комментарий `cm_` (общий префикс Kanban), ссылка `pl_`, файл `pf_`, история `h_` (python store.uid).
ProjectCardDto — формы §4.3 (camelCase; createdAt/updatedAt/at — epoch-ms); stack — массив строк; budget —
объект {from,to,cur}|null (DTO Kanban CardBudgetDto переиспользуется; Cur пустой строкой означает «нет
бюджета» → null наружу); комментарий — форма {id,by,text,time} (DTO Kanban CardCommentDto). Сортировка списка —
UpdatedAt DESC, опциональный фильтр `?stage=` (list_cards L5863). Настройки модуль читает портом
ISettingsStore.GetBoolAsync(SettingsKeys.RemindersEnabled) (дефолт — через SettingsDefaults).
- **Ruling 12 (м) — детерминированная приёмка без внешних сервисов.** Unit — fake-зависимости
(FakeProjectStore/FakeIFileStorage/FakeKanjStore/FakeSettingsStore); файловая приёмка — локальный режим
LocalFileStorage (data/attachments); MinIO-режим проверяется вручную при поднятом compose-сервисе (не входит в
обязательную приёмку); напоминания приёмки — ручной POST /admin/tick (фоновый 30-с цикл не ждём).
## Задачи
Сокращения путей: `P=` `src/core/Deal.Modules.Projects/`, `K=` `src/core/Deal.Modules.Kanban/`,
`S=` `src/core/Deal.Modules.Settings/`, `C=` `src/core/Deal.Contracts/`, `I=` `src/core/Deal.Infrastructure/`,
`A=` `src/core/Deal.Api/`, `T=` `src/core/tests/Deal.Tests.Unit/`. Отчёты —
`task-N-report.md` в `.superpowers/sdd/deal-stage5-projects/`.
### Task 1: Миграция TenantProjects — таблица ProjectCards
**Files:**
- Create: `I/Persistence/Entities/ProjectCardEntity.cs` и `I/Persistence/ProjectCardConfiguration.cs`
(поля/типы Ruling 1; JSON-колонки `.HasColumnType("text")`; индексы `(Stage)`, `(UpdatedAt)` (DESC),
частичный UNIQUE `(LeadId)``HasFilter("\"LeadId\" IS NOT NULL")`; ReminderAt — nullable).
- Modify: `I/Persistence/TenantDbContext.cs` — DbSet `ProjectCards` + `ApplyConfiguration`.
- EF: миграция `TenantProjects` для `TenantDbContext` (как TenantKanban: `dotnet ef migrations add
TenantProjects --context TenantDbContext --output-dir Migrations/TenantDb --project
src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`); старт Api применяет её к схеме
дефолтного тенанта (провижинер).
**Источники:** db.py L103125 (таблица projects); projects.py L31100 (_insert/_row_to_card); Ruling 1;
эталон: TenantPipeline-миграция, CardEntity/CardConfiguration.
**Acceptance:** build 0/0; `dotnet test` MarkerTests PASS; psql (search_path дефолтного тенанта): таблица
ProjectCards с PK/колонками; индексы `IX_ProjectCards_Stage`, `IX_ProjectCards_UpdatedAt` (DESC), UNIQUE
`IX_ProjectCards_LeadId` (partial: два NULL-а допустимы, два одинаковых LeadId — нет); `__TenantMigrationsHistory`
содержит TenantProjects. Отчёт: `task-1-report.md`.
### Task 2: Модуль Projects — стадии, DTO карточки, порт IProjectStore, реестр
**Files:**
- Create: `P/Application/ProjectStage.cs` (record Id/Name/Color/Terminal) и `P/Application/ProjectStages.cs`
(каталог 9 стадий Ruling 1 в порядке planned→rejected + `Contains(stage)`; 1:1 constants.py L1727/§4.4).
- Create: `P/Application/ProjectIdPrefixes.cs` (`pr_`/`pf_`/`pl_`/`h_`; комментарий — KanbanIdPrefixes.Comment).
- Create: `P/Application/Models/`: `ProjectFileDto.cs` (id/name/size/kind/label/objectKey), `ProjectLinkDto.cs`
(id/name/url), `ProjectHistoryEntryDto.cs` (id/at; **или** type="created"|"createdLocal" — запись {Id, At, Type},
либо stage — отдельный record с nullable-полями и фабриками `Created(now, local)`/`Moved(now, stage)`),
`ProjectReminderDto.cs` ({At} объект|null на карточке), `ProjectCardDto.cs` (§4.3: id/stage/local/leadId/title/
summary/stack/budget(CardBudgetDto?)/contact/comments(CardCommentDto[])/links/files/tzText/history/reminder/
createdAt/updatedAt — наружу epoch-ms), `ProjectCardRow.cs` (полная запись для InsertAsync),
`ProjectCardPatch.cs` (partial-поля правки: title/summary/contact/tzText/stack/budget/comments/links/files).
- Create: `P/Application/IProjectStore.cs` — порт: ListAsync(stage?), GetAsync, GetByLeadAsync, CreateAsync(row),
PatchAsync(cardId, patch) → bool, MoveStageAsync(cardId, stage, historyEntry, at) → bool (стадия+история+
сброс reminder+bump UpdatedAt), SetReminderAsync(cardId, at) (bump), ClearReminderAsync(cardId),
ClearStageAsync(stage) → int, ListDueAsync(now) → мини-DTO {Id,Title,Stage}, MarkFiredAsync(ids),
ClearExpiredAsync(now) → int, RemoveAsync(cardId) (откат take, Ruling 5).
- Create: `P/Application/ProjectsModuleRegistrar.cs` — `AddProjectsModule()` (сервисы задач 4/5/7 — по мере
появления). Modify: `P/Deal.Modules.Projects.csproj` — ProjectReference на `Deal.Modules.Settings`,
`Deal.Modules.Kanban`, `Deal.Contracts`.
**Источники:** api-map §4.3 L280300, §4.4 L302304; projects.py L31100; constants.py L1627; db.py L103125;
Rulings 1/2/11.
**Acceptance:** build 0/0; стадии 1:1 (имена/цвета/terminal, порядок); DTO — record'ы (camelCase); MarkerTests
PASS. Отчёт: `task-2-report.md`.
### Task 3: EF-адаптер ProjectStore + DI
**Files:**
- Create: `I/Persistence/Repositories/ProjectStore.cs` — реализация `IProjectStore` на `TenantDbContext`
(эталон PipelineStore.cs/KanbanStore.cs): чтения AsNoTracking; JSON-опции camelCase (эталон KanbanStore
JsonOptions L3842); маппинг строки ↔ ProjectCardDto вручную (JSON-разбор stack/comments/links/files/history,
бюджет → CardBudgetDto|null, reminder → ProjectReminderDto|null, времена ↔ epoch-ms); `CreateAsync` —
INSERT; `PatchAsync` — точечные UPDATE по присутствующим полям патча (текстовые — как есть; stack —
сериализация; budget — from/to/cur; comments/links/files — полная замена массива) + bump UpdatedAt;
`MoveStageAsync` — один UPDATE (stage, reminder_at=NULL, reminder_fired=false, updated_at) + перезапись
history-массива с добавленной записью; `ClearStageAsync` — DELETE WHERE Stage=; `ListDueAsync` —
SELECT hold-карточек (ReminderAt ≤ now, ReminderFired=false, ORDER BY ReminderAt); `MarkFiredAsync` —
UPDATE ... SET ReminderFired=true; `ClearExpiredAsync` — UPDATE ReminderAt=NULL WHERE ReminderAt ≤ now (1:1
check_reminders L266268: fired не важен — чистим все протухшие).
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IProjectStore, ProjectStore>()`.
**Источники:** projects.py L31100, L159199, L202231, L264282; Ruling 1/2; эталон KanbanStore.cs/PipelineStore.cs.
**Acceptance:** build 0/0; EF-путь покрывается psql/curl последующих задач (юнит на EF-адаптерах не пишем —
конвенция этапа 4); базовые проверки psql (вставка/патч/move/список). Отчёт: `task-3-report.md`.
### Task 4: «Взять в работу» — порт Kanban MarkTakenAsync + ProjectsService (чтение/создание/take/патч/move/очистка)
**Files:**
- Modify: `K/Application/IKanjStore.cs` — новый метод `MarkTakenAsync(string cardId, CancellationToken ct) →
Task<bool>` (XML-doc: UPDATE Cards SET Col='taken', IsNew=false WHERE Id=? — «взять в работу» projects
take_lead_to_projects L155; журнал CardMoves/архивные поля/matchHits не трогает, Ruling 5).
- Modify: `I/Persistence/Repositories/KanbanStore.cs` — реализация `MarkTakenAsync` (affected == 1).
- Create: `P/Application/ProjectsService.cs` — публичный сервис (Rulings 5/6/7/10): `ListAsync(stage?, ct)`,
`GetAsync(cardId, ct)`; `CreateLocalAsync(ProjectCardPatch-начальные поля, ct)` (local=true, history createdLocal,
stage-валидация); `TakeLeadAsync(leadId, ct)` (Ruling 5: GetCardAsync → 404-результат; GetByLeadAsync → возврат
существующей; CreateAsync с комментарием «Взял в работу из лида.» + history created; MarkTakenAsync — false →
RemoveAsync-откат и 404; конфликт UNIQUE LeadId (DbUpdateException) → перечитать GetByLeadAsync);
`PatchAsync(cardId, patch, ct)` (404-результат); `MoveAsync(cardId, stage, ct)` (валидация ProjectStages →
400-результат; запись истории + сброс reminder); `ClearRejectedAsync(ct)`; методы-результаты — тонкие
record-результаты/исключения модуля (эталон CardsService/LeadsEndpoints-паттернов: сервис кидает доменные
ошибки, эндпоинт мапит в 400/404 с точными строками).
- Test: `T/FakeProjectStore.cs`, `T/ProjectsServiceTests.cs` (+ расширение `T/FakeKanjStore.cs` — GetCardAsync/
MarkTakenAsync): take создаёт карточку (поля из лида, local=false, planned, комментарий-«Взял в работу из
лида.», history created) и вызывает MarkTakenAsync; повторный take того же лида возвращает ту же карточку
(GetByLeadAsync) без новой вставки; лид не найден → 404; create local (local=true, createdLocal, stage из тела/
planned); move (история + запись stage + сброс reminder); move на неизвестную стадию → 400; patch полей (в т.ч.
budget {from,to,cur}/null, stack) и bump UpdatedAt; clear-rejected удаляет только rejected и возвращает счётчик.
**Источники:** projects.py L103124, L127156, L159231; projects_routes.py L78121; leads.py L151156;
Rulings 5/6/7/10; эталон CardsService + IKanjStore-порт.
**Acceptance:** `dotnet test` новых тестов PASS; build 0/0. Отчёт: `task-4-report.md`.
### Task 5: Комментарии и ссылки (ProjectsService) + тесты
**Files:**
- Modify: `P/Application/ProjectsService.cs` — `AddCommentAsync(cardId, text, ct)`: пустой после Trim → 400
«Пустой комментарий»; новый {id `cm_`, by «Вы», text, time «только что»}; ответ — список comments
(routes L124128, projects.py add_comment L194199). `AddLinkAsync(cardId, name, url, ct)`: url Trim, пустой →
400 «Пустая ссылка»; без схемы → префикс `https://`; запись {id `pl_`, name: name.Trim() или url, url};
через PatchAsync(files-нет → links-замена). `RemoveLinkAsync(cardId, linkId, ct)` (удаление из массива).
- Test: `T/ProjectsServiceTests.cs` — комментарий (id/форма, пустой → 400, 404 карточки), ссылка (префикс
https://, name=url по умолчанию, удаление по id, 400 пустой url).
**Источники:** projects_routes.py L124150; projects.py add_comment L194199, patch_card L159187; Ruling 11.
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-5-report.md`.
### Task 6: Файлы — порт IFileStorage, Local/MinIO-адаптеры, FileKindDetector, compose-minio, DI
**Files:**
- Create: `C/Integrations/IFileStorage.cs` (Ruling 4; XML-doc: objectKey — opaque, `projects/<card>/<ms>_<name>`).
- Create: `P/Application/FileKindDetector.cs` — чистый детектор: `Detect(name, mime) → ProjectFileKind {Kind,
Label}`; MIME-префиксы image/video/audio; иначе расширение по наборам (1:1 files.py L1328: image/video/audio/
archive/document + «other» → «Файл»).
- Create: `I/Integrations/Storage/StorageOptions.cs` (секция Storage: Local {Root} + Minio {Endpoint, AccessKey,
SecretKey, Bucket, Secure}), `LocalFileStorage.cs` (root `data/attachments` под ContentRoot; Put — mkdir + write,
Get — FileStream|null, Delete — unlink; безопасный путь из objectKey: Path.GetFileName сегментов, object_store.py
L5479), `MinioFileStorage.cs` (Minio SDK: ленивый клиент + bucket_exists/make_bucket бакета `deal-files`,
PutObject/GetObject/RemoveObject; NuGet `Minio` в `I/Deal.Infrastructure.csproj`).
- Create: `I/Integrations/Storage/FileStorageRegistrar.cs` (или в ServiceCollectionExtensions) — метод
`AddDealFileStorage(IConfiguration, string contentRoot)`: секция Storage:Minio заполнена → MinioFileStorage,
иначе LocalFileStorage (root из Storage:Local:Root или дефолт).
- Modify: `deploy/compose.dev.yml` — сервис `minio` (image minio/minio, container_name deal-minio, порты
9000:9000/9001:9001, env MINIO_ROOT_USER/PASSWORD=deal_minio/deal_minio_secret, volume deal_minio_data,
command server /data --console-address ":9001") + volume.
- Test: `T/FileKindDetectorTests.cs` (png/jpg/webp → image; mp4 → video; mp3 → audio; pdf/docx/txt → document;
zip/7z → archive; mime-image поверх неизвестного расширения; неизвестное → other/«Файл»);
`T/LocalFileStorageTests.cs` (put/get round-trip; get отсутствующего → null; delete; objectKey с `..` не выходит
за root).
**Источники:** files.py L1345; object_store.py L26107; ТЗ §4.8 L130; Ruling 4.
**Acceptance:** `dotnet test` PASS; build 0/0; запуск Api — LocalFileStorage (лог/путь data/attachments);
compose config валиден (`docker compose -f deploy/compose.dev.yml config`). Отчёт: `task-6-report.md`.
### Task 7: ProjectFilesService — добавить/удалить файл (мета + объект)
**Files:**
- Create: `P/Application/ProjectFilesService.cs` (Ruling 4): `AddAsync(cardId, fileName, contentType, dataStream/
bytes, ct)` → ProjectFileDto: карточка существует (GetAsync → иначе 404-результат); `FileKindDetector.Detect`;
objectKey = `projects/{cardId}/{unixMs}_{safeName}` (safeName: имя без path-символов/кавычек); `IFileStorage.Put`;
запись {id `pf_`, name (как прислано), size (length), kind, label, objectKey} → PatchAsync(files-замена);
`RemoveAsync(cardId, fileId, ct)` — entry из FilesJson → `IFileStorage.Delete(objectKey)` + PatchAsync(files без
записи); `GetEntryAsync(cardId, fileId, ct)` → (entry|null) для download-эндпоинта. Зависимости: IProjectStore,
IFileStorage. (Файл-контент читает эндпоинт из multipart; в сервис приходит Stream + длина.)
- Test: `T/FakeFileStorage.cs`, `T/ProjectFilesServiceTests.cs`: add (детект kind по mime/имени, objectKey-форма,
мета в карточке, порядок файлов сохраняется); remove (объект удалён, мета обновлена); 404 карточки; add на
несуществующей карточке не пишет объект.
**Источники:** files.py L5794; object_store.py L61107; projects_routes.py L153186; Ruling 4/11.
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-7-report.md`.
### Task 8: Эндпоинты /api/projects — карточки, стадии, комментарии, ссылки; замена boot-заглушки; curl-приёмка
**Files:**
- Create: `A/Endpoints/ProjectsEndpoints.cs` (`MapProjectsEndpoints`, Ruling 9) — 10 эндпоинтов карточек/
комментариев/ссылок (файл- и reminder-эндпоинты — задачи 9/10): GET
`/api/projects?stage=` → `{items:[…]}`; GET `/api/projects/{cardId}` → карточка | 404 «Карточка не найдена»;
POST `/api/projects` (CreateLocalRequest: title/summary/stack?/budget?{from,to,cur}/contact/tzText/stage?) →
карточка; POST `/api/projects/take` {leadId} → карточка | 404 «Лид не найден»; POST `/api/projects/clear-rejected`
→ `{ok:true, cleared}`; PATCH `/api/projects/{cardId}` (PartialUpdateRequest — все поля optional, budget может
быть null) → карточка | 404; POST `/api/projects/{cardId}/move` {stage} → карточка | 400 «Неизвестная стадия» |
404; POST `/api/projects/{cardId}/comments` {text} → `{comments:[…]}` | 400 «Пустой комментарий» | 404;
POST `/api/projects/{cardId}/links` {name?,url} → карточка | 400 «Пустая ссылка» | 404; DELETE
`/api/projects/{cardId}/links/{linkId}` → карточка; статические `/take`+`/clear-rejected` до `/{cardId}`.
Сессия 401 (эталон LeadsEndpoints/StorageEndpoints: проверка HasUser + RequestServices-резолв ПОСЛЕ).
- Create: `A/Endpoints/RequestModels/{CreateLocalProjectRequest,TakeLeadRequest,MoveStageRequest,
ProjectCommentRequest,ProjectLinkRequest,ProjectPatchRequest}.cs`.
- Modify: `A/Endpoints/BootStubEndpoints.cs` — удалить GET /api/projects-заглушку и константу ProjectsPath
(остаётся /api/tg/status; класс-комментарий обновить). Modify: `A/Program.cs` — `AddProjectsModule()`,
`MapProjectsEndpoints()`; `A/Deal.Api.csproj` — ProjectReference на `Deal.Modules.Projects`.
- Test: `T/ProjectsEndpointsContractsTests.cs` НЕ нужен (endpoint-слои покрываются curl); MarkerTests остаются.
**Контракт:** api-map §3.5 L155168; §4.3; projects_routes.py L15150.
**Acceptance (curl admin/admin):** GET /api/projects → {items:[]}; POST /api/projects {title:''} → карточка
(local=true, stage=planned, createdLocal-история); PATCH (title/stack/budget) → карточка с изменениями и
возросшим updatedAt; POST /move {stage:'work'} → история пополнена {id,at,stage:work}, reminder null;
move невалидной стадии → 400; POST /comments (пустой → 400 «Пустой комментарий»; текст → {comments:[…]});
POST /links без схемы → https://…; DELETE /links/{id} → карточка без ссылки; POST /take {leadId=несуществующий}
→ 404 «Лид не найден»; boot-группа (GET /api/projects) 200 — заглушка снята; 401 без куки. Отчёт: `task-8-report.md`.
### Task 9: Файл-эндпоинты /api/projects/{cardId}/files* — upload/download/delete + curl-приёмка
**Files:**
- Modify: `A/Endpoints/ProjectsEndpoints.cs` — POST `/api/projects/{cardId}/files` (multipart/form-data, поле
`files`; `request.ReadFormAsync`; каждый файл: имя/ContentType/Stream → `ProjectFilesService.AddAsync`);
ответ `{items:[§4.3 файл]}` (404 «Карточка не найдена» при отсутствии карточки); GET
`/api/projects/{cardId}/files/{fileId}/download` — entry через GetEntryAsync: нет записи → 404 «Карточка не
найдена»/404 файла нет в метаданных; objectKey пуст → 410 «Файл не сохранён в объектном хранилище»;
`IFileStorage.GetAsync` → null → 404 «Файл не найден в MinIO»; иначе `Results.Stream(stream,
"application/octet-stream", fileDownloadName: имя без кавычек)` (Content-Disposition attachment, 1:1
projects_routes.py L164179); DELETE `/api/projects/{cardId}/files/{fileId}` → `{ok:true}` (404 карточки).
Скачивание: один файл — в ответ Stream (Results.Stream сам диспозит).
- Modify: DI-проверка — AddDealFileStorage вызван в Program.cs (Task 6; если Task 6 не успел — здесь).
**Контракт:** api-map L710 (multipart/octet-stream), L169171; projects_routes.py L155186; store.js L20312053.
**Acceptance (curl, local-режим):** загрузить 2 файла (`-F files=@tz.pdf -F files=@photo.png`) → {items:[2]};
GET /api/projects/{id} — files с kind/label (document/«Документ», image/«Изображение»), size;
download → 200 attachment + байты совпадают; DELETE файла → {ok:true}, карточка без файла, объект удалён из
data/attachments; download удалённого → 404. Отчёт: `task-9-report.md`.
### Task 10: Напоминания — ProjectReminderService + эндпоинты reminder/reminder/snooze
**Files:**
- Create: `P/Application/ProjectReminderService.cs` (Ruling 3): `SetAsync(cardId, atMs, ct)` — GetBoolAsync
(ISettingsStore, RemindersEnabled) false → 400-результат «Напоминания об отложенных выключены в настройках»;
карточки нет → 404; SetReminderAsync + возврат полной карточки; `ClearAsync(cardId, ct)` (404-результат);
`SnoozeAsync(cardId, ct)` (now + 24 ч, не проверяет выключатель); `CheckDueAsync(ct)` → `IReadOnlyList<
ProjectReminderDueDto{Id,Title,Stage}>` (Ruling 3: disabled → ClearExpiredAsync + []; enabled → ListDueAsync +
MarkFiredAsync + due-список). Константа `ReminderSnoozeMs = 24 ч` (имя, не магия).
- Modify: `A/Endpoints/ProjectsEndpoints.cs` — POST `/api/projects/{cardId}/reminder` {at: epoch-ms} → карточка |
400 (напоминания выключены) | 404; DELETE `/api/projects/{cardId}/reminder` → `{ok:true}` | 404; POST
`/api/projects/{cardId}/reminder/snooze` → `{ok:true}` | 404. `POST /{cardId}/reminder` и `DELETE
/{cardId}/reminder` — до `/{cardId}/reminder/snooze` (snooze — статический сегмент за параметром).
- Test: `T/FakeSettingsStore.cs` — уже умеет задавать значения; `T/ProjectReminderServiceTests.cs`: set при
remindersEnabled=false → 400-текст; set ok → карточка с reminder.at; clear; snooze (+24 ч); CheckDueAsync:
disabled → ClearExpired вызван, due пуст; enabled + due-строки → fired проставлены (MarkFired), возвращены
{id,title,stage}; не-hold/будущие не «выстреливают».
**Источники:** projects.py L236282; projects_routes.py L189211; api-map L172174, §4.6 L328/L340;
Rulings 3/11.
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-10-report.md`.
### Task 11: POST /api/admin/tick — reminders + SSE reminder_due
**Files:**
- Modify: `A/AdminTickOrchestrator.cs` — зависимость `ProjectReminderService`; порядок 1:1 с admin_tick
(L327337): (1) Kanban-тик → (2) purge отсева → (3) тосты → (4) **check-reminders** → SSE `reminder_due`
({id,title,stage}, broker) по каждому due → (5) pump → (6) new_lead → (7) queue; ответ — reminders списком due
(после SSE, api-map §3.2 L103112). Ошибки проверки напоминаний не роняют тик (лог + reminders:[]).
- Modify: `A/AdminTickResultDto.cs` — `Reminders: IReadOnlyList<object>` → типизированный
`IReadOnlyList<ProjectReminderDueDto>` (XML-doc: этап 5 — реальный список).
- Modify: `A/Program.cs` — регистрация ProjectReminderService уже через AddProjectsModule (Task 8).
**Источники:** dashboard_routes.py L327337; projects.py check_reminders L264274; main.py L4753; api-map §3.2;
Rulings 3/8.
**Acceptance:** build 0/0; unit — AdminTickOrchestratorTests (существуют): тик вызывает CheckDueAsync, публикует
reminder_due по каждому due, reminders ответа = due; сбой reminder-проверки → reminders:[] без падения тика
(обновить тесты под новую зависимость — fake ProjectReminderService). curl: reminder на hold-карточку в прошлом
(at=now1 мин) → POST /admin/tick → в SSE-подписке приходит reminder_due, ответ tick содержит reminders:[{id,
title, stage:'hold'}]. Отчёт: `task-11-report.md`.
### Task 12: Фоновая проверка напоминаний — StorageTickScheduler (30 с)
**Files:**
- Modify: `A/Hosting/StorageTickScheduler.cs` — в `TickTenantAsync` после Kanban-тика/purge/тостов:
`ProjectReminderService.CheckDueAsync` из tenant-scope (резолв после SetTenant) → SSE `reminder_due` в канал
тенанта (`SseBroker` — новая singleton-зависимость конструктора, эталон StorageToastPublisher L3644); ошибки
ветки логируются (тик тенанта продолжается, паттерн существующего catch). Порядок 1:1 с _storage_loop main.py
L47–53 (тик → тосты → напоминания). Класс-комментарий обновить.
- Modify: `A/Program.cs` — (регистрация уже есть) AddHostedService<StorageTickScheduler> остаётся; DI singleton
SseBroker уже зарегистрирован.
- Test: `T/StorageTickSchedulerTests.cs` — дополнить: тик тенанта вызывает CheckDueAsync и публикует reminder_due
по due-записям (fake ProjectReminderService + реальный SseBroker с подпиской, как в существующих тестах
тостов); disabled → событий нет.
**Источники:** main.py L4753; projects.py check_reminders L264274; StorageTickScheduler.cs L152192;
Rulings 3/8.
**Acceptance:** `dotnet test` PASS; build 0/0; запуск Api: hold-карточка с прошедшим reminder_at → в пределах
30-с тика в SSE-подписке приходит reminder_due (psql: reminder_fired=true). Отчёт: `task-12-report.md`.
### Task 13: Финал этапа — интеграция и сквозная приёмка
- `scripts/build.sh`/`scripts/test.sh` — успешны; `dotnet build Deal.sln` 0/0; все unit-тесты PASS (535 этапа 4
+ новые).
- Сквозной curl-сценарий (DEAL_DEMO=1, admin/admin, local-файлы): демо-ingest вакансии → admin/tick →
карточка в /leads; POST /api/projects/take {leadId} → проектная карточка (local=false, planned, leadId, история
created, комментарий «Взял в работу из лида.»); повторный take → та же карточка; лид исчез из GET /leads и
/api/search (taken); GET /api/projects — список (UpdatedAt DESC); PATCH карточки (title/stack/budget/contact/
tzText) → поля обновлены; POST /move по стадиям planned→reply→work→hold (история: 4 записи stage) → reminder на
past-время → admin/tick → SSE reminder_due + reminders ответа; move hold→ready (напоминание снято — reminder
null); POST /comments, POST/DELETE /links; upload 2 файлов (kind по MIME/расширению) → счётчики в карточке →
download (attachment, байты) → DELETE файла; локальная карточка POST /api/projects {title} (local=true,
createdLocal); перенос локальной в rejected → POST /clear-rejected {ok, cleared:1}; 401-проверки без куки;
GET /api/projects/reminders и DELETE /api/projects/{id} — 404 маршрута нет (сознательно не реализованы, Ruling 9).
- psql дефолтного тенанта: ProjectCards — строки всех сценариев; reminder_at/fired; UNIQUE-индекс (вставка
второго проекта с тем же LeadId → ошибка unique); лид в Cards col='taken' + is_new=false; файлы в
data/attachments соответствуют objectKey.
- Обновить `docs/technical/Техническая-документация-Дейл.md`: раздел «Projects/„Выбранные“» (таблица
ProjectCards, стадии, эндпоинты, напоминания/SSE reminder_due, файлы/IFileStorage/MinIO-compose, take-семантика,
исключённые эндпоинты) и зафиксировать roadmap-флаг «этап 5 выполнен» (roadmap L69–73 → «Выполнено»).
- Отчёт `task-13-report.md` + финальная строка `progress.md`.
## Self-Review
1. **Spec coverage:** ТЗ §4.8 (L119132): стадии-канбан и терминальные статусы — Rulings 1/10, Task 4;
«взять в работу» с уходом лида безвозвратно — Ruling 5, Task 4 (+ исключение из списков/поиска — уже в этапах
3/4); ручное создание «локальных» — Ruling 6, Task 4; редактирование суммы/стека/контактов/ТЗ и комментарии —
Tasks 4/5; ссылки и значки-счётчики — Task 5 + DTO; файлы с определением типа (MIME+расширение) и хранением
MinIO/локальный fallback — Rulings 4, Tasks 6/7/9; история движения под спойлером (создание/каждая стадия,
статус-дата-время) — Rulings 7, Tasks 2/4; напоминания «Отложено» (окно 1–30 дней/календарь — фронт;
выключено → окно не показывается и не срабатывают; автоснятие при уходе с hold) — Ruling 3, Tasks 10/11/12;
очистка «Отклонено» и «не попадают в архив/корзину» — Rulings 5/10, Task 4. api-map: §3.5 — Tasks 8/9/10;
§4.3/§4.4 — Task 2; §2 SSE reminder_due — Rulings 3/8, Tasks 11/12; admin/tick reminders — Task 11; boot-фронт
(`GET /api/projects` в boot L571593) — Task 8. Roadmap этапа 5 (L6973) — все задачи.
2. **Placeholder scan:** Заглушек нет: единственная «заглушка» — dev-файловое хранилище LocalFileStorage по
умолчанию (1:1 с прототипом без MinIO, объектный ключ в БД тот же) при полной реализации MinIO-адаптера
(включается конфигурацией); GET /api/projects/reminders и DELETE /{card_id} сознательно НЕ реализуются
(api-map п.9/п.6, Ruling 9) — это не TODO, а решения. Референсы строк прототипа точные; FIXME/TODO нет.
3. **Type consistency:** ProjectCardDto собирается из JSON-полей ProjectCards (тексты wire-форм 1:1 с python,
хранятся/читаются с camelCase-опциями адаптера); IProjectStore (Task 2) реализуется ProjectStore (Task 3) без
расхождений имён (ListAsync/GetAsync/GetByLeadAsync/CreateAsync/PatchAsync/MoveStageAsync/SetReminderAsync/
ClearReminderAsync/ClearStageAsync/ListDueAsync/MarkFiredAsync/ClearExpiredAsync/RemoveAsync); IKanjStore
расширяется одним методом MarkTakenAsync (Kanban не узнаёт о Projects); IFileStorage в Contracts не знает о
таблицах (objectKey opaque), метаданные — владение Projects; ModuleProjects csproj → Settings/Kanban/Contracts —
циклов нет; SSE-публикации только в Api (AdminTickOrchestrator/StorageTickScheduler), модули чистые; карточки
Kanban (`Cards`) и Projects (`ProjectCards`) — разные таблицы, Kanban-тик не пересекается; хранилище
LocalFileStorage/MinioFileStorage закрывают один порт по конфигурации (на старте) — юнит-тесты на fake.
4. **Вне scope этапа 5:** реальные ai/telegram/ml-сервисы и их gRPC-ингресс (этап 6; demo-ingest остаётся
источником), discovery (этап 6), telegram-вкладка и /tg/status-реализация (этап 6; boot-заглушка остаётся),
события pipeline_stats/boards_changed/leads_reclassified (фронт не слушает), «список активных напоминаний в
настройках» (ТЗ L131; у фронта UI нет — GET /reminders не реализуем), DELETE проектной карточки (отключено по
решению, п.6), мульти-аренда бакетов/тенант-префиксы объектов MinIO и SaaS-контур (этап 7), загрузка файлов
по прямой ссылке в MinIO с подписанными URL (не в прототипе).
@@ -0,0 +1,542 @@
# Дейл (Deal) — Этап 6: Сервисы telegram/ai/ml (отдельные процессы) + Discovery + gRPC-ингресс Implementation Plan
> Исторический документ этапа 6. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** Подключить к модульному монолиту `src/core` реальные автономные сервисы telegram/ai/ml как отдельные
процессы (свои sln/контейнеры), общаясь по gRPC (`.proto` в `src/contracts/`), и оживить вкладки Vue-фронта
«Каналы» (ChannelsView) и Discovery 1:1-контрактом `/api`: telegram-вкладка заменяет boot-заглушку
`GET /api/tg/status` реальным статусом/QR-входом/списком диалогов/мониторингом/«Перечитать»; Discovery —
полноценный модуль ядра (задачи поиска, кандидаты с оценкой по каскаду фильтров, чёрный список, авто-вступление
с квотами, лог). Пайплайн и канбан начинают получать настоящие сообщения (входящий gRPC → `EnqueueAsync`),
настоящие ИИ-классификацию/фильтр и ML-предсказания/обучение — за конфиг-флагами, с Local-заглушками как
фолбэком, когда сервис недоступен/выключен.
**Architecture:** сервисы — самодостаточные процессы (namespace `Deal.Telegram`/`Deal.Ml`/`Deal.Ai`): telegram
исполняет только команды ядра (сессии по тенантам 1:1, анти-бан, mark-as-read; ни БД-бизнеса, ни настроек), ml
держит пул инкрементальных моделей per-tenant с сохраняемыми весами (онлайн-обучение без дата-сайентиста — 1:1
с проверенным python `mlservice/model.py`, не ONNX), ai — фасад LLM-провайдеров без БД: core передаёт заполненные
промпты и конфиг провайдера в теле каждого запроса, сервис возвращает JSON-ответ модели + оценку токенов.
В ядре: новый модуль `Deal.Modules.Telegram` (владелец tenant-таблиц Dialogs/TgMessages, каталог каналов и
статус) с портом-гейтом `ITelegramGateway`, gRPC-сервер ингресса в `Deal.Api` (PushMessage → IngestService,
SyncDialogs, StatusReport → SSE); новые модульные части Discovery (таблицы/сервисы/воркер 5 с/оценка/анти-бан);
gRPC-адаптеры в `Deal.Infrastructure` заменяют Local-заглушки за флагом `Services:{Ml,Ai,Telegram}:UseLocal`.
**Tech Stack:** .NET 10 (Grpc.Tools/Google.Protobuf/Grpc.AspNetCore), WTelegramClient (NuGet), Net.Codecrete.QrCodeGenerator
(SVG QR), Microsoft.Data.Sqlite (веса моделей), HttpClient (OpenAI-совместимые + Anthropic), существующие порты
Contracts. Docker: сервисы добавляются в `deploy/compose.dev.yml`.
**Spec:** `docs/architecture/2026-09-05-deal-architecture-design.md` §6.27 (L145187: gRPC-контракты, сервисы, пул
моделей, учёт токенов, mTLS+service-token); `docs/api/api-map.md` §3.3/3.7/3.8 (L123142, L187217), §2 SSE (L2743),
§4.6/4.8/4.9/4.10 (настройки, каналы/discovery, статус), «кривые места» п.4/п.8/п.9 (L390400); roadmap этапа 6
(L8291); ТЗ §4.2/4.3/4.9, §5, §8; референс-семантика прототипа: `backend/app/services/telegram.py` (целиком),
`services/{discovery,discovery_worker,discovery_eval,ai,suggest,ml_client,ban_guard}.py`, `routers/{tg_routes,
discovery_routes,ml_routes}.py`, `mlservice/model.py`, `backend/app/{db.py,constants.py,config.py,main.py}`; фронт
`ChannelsView.vue`/`DiscoveryView.vue`/`store.js`/`api.js`; образцы планов этапов 1–5.
## Global Constraints
- Проект **НЕ git**; фиксация — отчёты задач `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage6-services/`.
- .NET 10 SDK; каждая sln собирается 0 warnings/0 errors (`TreatWarningsAsErrors`); dev-Postgres `deal-postgres`
(:5433); curl-приёмка core :5080 (`scripts/build.sh`/`scripts/test.sh` — собирают/тестируют только `src/core`).
- Код-стайл этапов 1–5: 1 тип = 1 файл; XML-doc на public; комментарии на русском; без регионов; без магических
чисел (именованные константы); времена `DateTimeOffset` (UTC), наружу epoch-ms; JSON camelCase; `{detail}`-ошибки.
- Сервисы — отдельные sln (`src/{telegram-service,ml-service,ai-service}`), ничего общего с core, кроме `.proto`
и NuGet; ни один сервис не ходит в БД тенантов и не знает домен. Core — единственное место с БД и бизнес-логикой.
- Vue-фронт, `backend/`, `mlservice/` (python), корневой `docker-compose.yml` НЕ трогаем.
- Сервисы подключаются флагами: по умолчанию dev = Local-заглушки (этапы 2–5), реальные сервисы — `UseLocal=false`.
- Строки ошибок/тостов/причин 1:1 с прототипом (см. задачи): «Telegram не подключён», «Сначала сохраните Telegram
api_id и api_hash в настройках», «QR не активен — начните вход по QR», «Неверный код», «Код истёк — запросите
новый», «Неверный облачный пароль», «Telegram подключён, сессия сохранена», «Telegram отключён», «Уже вступили в
этот источник», «Уже вступили — удалите источник из каналов», «Задача не найдена», «Кандидат не найден» и т.д.
- НЕ выполнять автоматических сетевых подключений к Telegram/LLM в тестах: приёмка сервисов — unit + in-proc gRPC
с фейками; живые проверки Telegram помечены «ручная проверка» (нужны api_id/api_hash/QR).
- Новые NuGet в сервисах: `Grpc.AspNetCore`, `Grpc.Tools`, `Google.Protobuf`, `WTelegramClient`,
`Net.Codecrete.QrCodeGenerator`, `Microsoft.Data.Sqlite`; в core: `Grpc.AspNetCore`, `Grpc.Tools`,
`Google.Protobuf`, `Microsoft.Extensions.Http` (есть).
## Зафиксированные решения (Rulings этапа)
Сокращения путей: `TG=` `src/telegram-service/`, `ML=` `src/ml-service/`, `AI=` `src/ai-service/`, `PR=` `src/contracts/`,
`C=` `src/core/Deal.Contracts/`, `I=` `src/core/Deal.Infrastructure/`, `A=` `src/core/Deal.Api/`, `PL=` `src/core/Deal.Modules.Pipeline/`,
`KB=` `src/core/Deal.Modules.Kanban/`, `ST=` `src/core/Deal.Modules.Settings/`, `TM=` `src/core/Deal.Modules.Telegram/`,
`DC=` `src/core/Deal.Modules.Discovery/`, `T=` `src/core/tests/Deal.Tests.Unit/`, `DEP=` `deploy/compose.dev.yml`.
- **Ruling 1 (а) — контракты `.proto`, кодогенерация, metadata.** Три файла: `PR/telegram.proto`,
`PR/ai.proto`, `PR/ml.proto` (пакеты `deal.telegram.v1`/`deal.ai.v1`/`deal.ml.v1`, `option csharp_namespace`
`Deal.Grpc.Telegram`/`Deal.Grpc.Ai`/`Deal.Grpc.Ml`). Каждый RPC несёт обязательные gRPC-metadata:
`tenant-id` (строка) и `service-token`; серверный interceptor (общий шаблон в каждом процессе) проверяет
`service-token` против env `DEAL_SERVICE_TOKEN` (общий в compose; отказ — `UNAUTHENTICATED`). Каждый сервис
проверяет принадлежность по своей модели (сессия/модель тенанта есть — иначе `NOT_FOUND`/`FAILED_PRECONDITION`),
полю не доверяет. Ошибки домена — `INVALID_ARGUMENT`/`NOT_FOUND`/`UNAVAILABLE` с `detail` = текст причины 1:1;
FloodWait → `RESOURCE_EXHAUSTED` с кодом `flood`. Кодогенерация — Grpc.Tools: каждый процесс компилирует только свои `.proto` через `<Protobuf
Include="..\..\contracts\X.proto" GrpcServices="Both" Link="Protos/X.proto"/>` (генерация client+server в одном
проходе; неиспользуемая сторона игнорируется): telegram-service — telegram.proto, ml/ai-сервисы — свои; core
(Deal.Api и Deal.Infrastructure по месту использования) — все три (telegram: сервер Ingress + клиент-гейт; ai/ml:
клиенты). Контракты — единственный «язык» между процессами (дизайн-док L145–152).
- **Ruling 2 (безопасность dev/prod).** Dev (этап 6): gRPC **без mTLS** — plaintext в локальной сети/хосте
(`localhost`/compose-сеть) + **обязательный service-token** вторым фактором. mTLS-сертификаты, их генерация и
prod-compose — этап 7 (roadmap L9597: «compose-prod … безопасность (mTLS…)»); код интерцепторов один и тот же,
включение TLS в этап 7 не меняет контракты. Обоснование: 4 процесса + генерация/ротация сертификатов в dev —
высокая трудоёмкость без защиты реальных данных; service-token закрывает сценарий «случайный процесс в сети».
- **Ruling 3 (б) — telegram-service: библиотека и сессии.** Библиотека — **WTelegramClient** (де-факто стандарт
.NET, активная поддержка, API-уровень MTProto; TeleSharp/TLSharp заброшены). Один клиент на тенанта
(`tenantId → WTelegram.Client`, 1:1; команды исполняются только на сессии своего тенанта; нет сессии → отказ).
Хранение сессий — **файлы** `data/sessions/<tenantId>.session` (session_pathname WTelegramClient; volume в
compose). Шифрование at-rest: файл сессии оборачивается AES-GCM (существующий AesGcmSecretCipher-паттерн этапа 2;
ключ — env `DEAL_TELEGRAM_SESSION_KEY`, 32 байта base64): сервис держит расшифрованный файл только в памяти
процесса (temp-файл под личным каталогом процесса) и перешифровывает при сохранении/остановке. api_id/api_hash —
НЕ env, а настройка `tgKeys` тенанта (Settings, шифруется AES-GCM с этапа 2; api-map §4.6 L337); core
расшифровывает и передаёт в теле запросов подключения. Внутренний анти-бан сервиса (паузы между сетевыми
операциями одной сессии): backfill 1.5–3 с/сообщение и 3–6 с/диалог, поиск 2–4 с (константы telegram.py L3536,
ban_guard.search_pause L7880); mark-as-read сразу после приёма/чтения. Внешний анти-бан (суточная квота
авто-вступлений, паузы 50–70 с, flood-день, стоп-кран) — владение core (воркер Discovery), счётчики в tenant-БД.
- **Ruling 4 (в) — ml-service: алгоритм и сохраняемость.** НЕ ONNX и НЕ ML.NET: переносим **инкрементальную
наивно-байесовскую модель по терминам** 1:1 с `mlservice/model.py` (tokenize L7887, upsert L105131,
predict L184293, adaptive margin L4255, самооценка eval L296322, status/reset L325354). Обоснование:
(1) python-прототип уже даёт работающее онлайн-обучение на русском тексте без дата-сайентиста, порт-контракт
Deal (`MlPredictResultDto`/status) спроектирован 1:1 под его ответы; (2) ONNX Runtime не умеет онлайн-обучение
(нужен экспорт/переобучение вне процесса), ML.NET — не для инкрементального обучения; (3) сохраняемость весов =
три таблицы. Хранилище — **SQLite-файл на тенанта** `data/ml/<tenantId>.sqlite` (Microsoft.Data.Sqlite), таблицы
`classes(label,n,updated_at)/terms(label,term,count)/eval_log(created_at,expected,predicted,correct)` 1:1 db-схемы
model.py L64–75; запись — транзакциями, batch-вставка терминов (executemany-эквивалент). Пул:
`ConcurrentDictionary<tenantId, TenantModel>`, модель лениво грузится по первому обращению, у каждой — свой lock
(predict/learn сериализованы на тенанта). Перенос «мозгов» между инстансами (экспорт/импорт, дизайн-док L176) —
по решению владельца НЕ делаем; сохранение между рестартами обязательно (файлы). Пороги: MIN_TOTAL 20,
MIN_WINNER 6, MIN_WINNER_SPAM 4, MIN_HITS 2, MARGIN 0.9; адаптивный отрыв 0.35/0.5/0.7 после 400/150/60 примеров;
классы типа `t:hire`/`t:order` (MIN_TYPE_WINNER 4); веса сигналов 1.0 (пользователь), 0.4 (ИИ), 0.6 (правила) —
константы ml_client.py L2628.
- **Ruling 5 (г) — ai-service: устройство и контракт с core.** ai-service **без БД**: core передаёт в теле
каждого запроса (1) заполненные промпты (`fill_prompt` L63–77: подстановка `{domain}`/`{keywords}` из настроек
тенанта делает core), (2) конфиг активного провайдера (id/base/model/apiKey/api_style — расшифрованный core из
`aiConfigs`), (3) текст. Методы: `Filter` (промпт aiFilterPrompt, текст) → `{pass,reason}`; `Classify`
(system_prompt = aiPrompt+cardPrompt, user-контекст «Доски + примеры разметки + Сообщение» — собирает core)
`{ok,json}`**json-строка** извлечённого ответа модели (типовая схема ответа задаётся промптом, python
держит его сырым dict; строгий маппинг json→`AiParsedLeadDto` делает core, 1:1 normalize_stack/clean_budget/
build_contacts/python `_store_lead`); `GenerateKeywords` (фикс. промпт L36–47 routes + описание) → `{keywords}`
(очистка `_clean_keywords` в core); `EvaluateFit` (текст + description + keywords задачи, промпт discovery_eval
L5054) → `{fit,reason}`. Вызовы LLM: OpenAI-совместимые `POST {base}/chat/completions` (Bearer), Anthropic
`POST {base}/v1/messages` (x-api-key+anthropic-version); temperature 0.2; таймауты 90 с (openai) / 60 с
(anthropic); retry `max_retries=2` с паузами 0.8/2 с; извлечение JSON из markdown-обёрток (extract_json L175183);
ошибки провайдера наружу как `UNAVAILABLE` с текстом «ИИ (имя) не ответил корректно — повторите попытку через
несколько секунд». Учёт токенов: ответ несёт `usage{prompt/completion/total}` — берётся из usage API-ответа
провайдера, при отсутствии оценивается по символам (≈chars/4); core копит в tenant-KV `aiTokenUsage` (этап 7 —
лимиты/бюджеты). Выключатели aiEnabled/aiFilterEnabled читает core (как в воркере этапа 4) — сервис их не знает.
- **Ruling 6 (д) — core-интеграция ML/AI: флаги, адаптеры, судьба MlOutbox.** Секция конфигурации
`Services:Ml|Ai``{UseLocal: bool (default true), Endpoint: string}` (env `SERVICES__ML__USELOCAL=false`,
`SERVICES__ML__ENDPOINT=http://localhost:5103`). В `AddDealIntegrations` регистрируются gRPC-адаптеры
(`GrpcMlClient: IMlClient`, `GrpcAiClassifier: IAiClassifier`, `GrpcAiTools: IAiTools` — новый порт, Ruling 9),
когда `UseLocal=false`, иначе текущие Local-* (фолбэк). Никакой логики переключения в рантайме — выбор на старте.
**Судьба MlOutbox:** PushAsync ВСЕГДА пишет в MlOutbox (этап 3), новый фоновый `MlOutboxFlushScheduler` (10 с,
per-tenant цикл, эталон PipelineWorkerScheduler) выгружает по 10 строк (`ORDER BY created_at`), батч ≤100/цикл, в
`ml.proto TrainBatch`; удаляет строки только после успеха; при недоступности сервиса строки остаются (python
L5682). `ResetAsync`: сервис Reset + `ClearOutboxAsync` (1:1 reset_model L110124). Кэш статуса сервиса 15 с
(python L3031, refresh_status) → `reachable` в `/api/ml/status`; недоступен — Predict → «не уверен», Status →
кэш. Счётчики/выключатели/SSE воркера не меняются (Ruling 5 этапа 4; исключения порта воркер уже ловит).
Входящий gRPC telegram: сервер в Deal.Api (отдельный порт) — см. Ruling 7.
- **Ruling 7 (д/ж) — Telegram-ингресс и каталог каналов.** Новый чистый модуль `TM` `Deal.Modules.Telegram`
владелец tenant-таблиц (миграция `TenantTelegram` контекста TenantDbContext): `Dialogs` (Id string PK,
Name/Handle/Kind/Hue, Monitor bool, LastText/LastAt, Backfilled bool, UpdatedAt; 1:1 db.py L7686) и `TgMessages`
(Id `m_<dialog>_<msg>` PK, DialogId, Text, MsgAt, LeadId nullable; L6774). Порт `ITelegramStore` + DTO
(диалог §4.8 L349, сообщение превью L351) + `DialogsService`: `List`, `SetMonitor` (первое включение → фон
Backfill), `SetMonitorAll` (1:1 L548567), `SyncFromTelegram(entries)` — авто-мониторинг новых по `autoMonitorNew`,
обновление имени/типа, удаление отсутствующих (1:1 `_persist_dialogs` L468503), `MarkBackfilled`, `SavePreview`.
Порт-гейт `C/Integrations/ITelegramGateway.cs` (команды наружу): `StatusAsync`, `StartQrAsync`, `StartPhoneAsync`,
`SendCodeAsync`, `SendPasswordAsync`, `LogoutAsync`, `RefreshDialogsAsync` (→entries), `SetMonitorAsync` (id,
enabled), `SetMonitorAllAsync`, `BackfillAsync(id, force)`, `ReadRecentAsync(id, limit)` (превью), `SearchAsync`,
`InfoAsync`, `ReadForEvalAsync(id, limit)`, `JoinAsync(username)`, `LeaveAsync(id)`; недоступность сервиса →
исключение → ветки эндпоинтов как «не подключён». **Входящий gRPC в core** (сервер `A/Telegram/TelegramIngressService.cs`,
RPC `PushMessage`/`SyncDialogs`/`ReportStatus`): kestrel-порт :5082 (env `GRPC_INGRESS_PORT`), Http2; интерцептор
service-token; tenantId из metadata → собственный scope с `ITenantContext.SetTenant` (доверенный источник, не
сессия); `PushMessage` (dialogId/msgId/text/канальные поля/hue/msgAt — hue считает сервис по DIALOG_HUES-палитре)
`PipelineIngestService.EnqueueAsync` (тот же контракт, что demo-ingest, L759) + пишет превью в TgMessages;
`SyncDialogs``DialogsService.SyncFromTelegram`, ответ = актуальный список monitored id (сервис держит зеркало
мониторинга в памяти); `ReportStatus{phase,connected,listener,account,error,qrUrl}` → KV `tgAccount`/`tgStatus`
(внутренние ключи SettingsKeys) + из Api-слоя SSE `system_status` и тосты «Telegram подключён, сессия
сохранена»/«Telegram отключён» при переходах фаз (python L178–207). Сервис сам фильтрует события по своему
зеркалу monitored (обновляется ответом SyncDialogs и командой SetMonitor) — как python `_monitored`.
- **Ruling 8 (ж) — /api/tg и статус.** Снимается boot-заглушка `BootStubEndpoints` (остаётся в коде до Task 14).
Эндпоинты 1:1 api-map §3.3 (13 шт., фронт): статус/start-phone/start-qr/send-code/send-password/logout/qr-image/
dialogs/refresh/monitor-all/backfill-all/{id}/monitor/{id}/backfill(сервер-only)/preview. `GET /api/tg/status`
(§4.9): live-поля (phase/connected/listener/error/qrUrl) из gateway (сервис недоступен → idle-форма), account из
KV tgAccount, monitored = count(Dialogs WHERE Monitor), keysSet из настроек. `GET /qr-image` — SVG через
**Net.Codecrete.QrCodeGenerator** (SVG-first, без внешних зависимостей; 404 «QR не активен — начните вход по QR»).
Публикации SSE system_status/toast из Api-слоя (Ruling 5 этапа 3); фронт-флоу 1:1 (store.js L14191508).
- **Ruling 9 (д/е) — Discovery: модуль, таблицы, порт ИИ-инструментов.** Новый модуль `DC` (чистый) — владелец
таблиц (миграция `TenantDiscovery`): `DiscTasks`/`DiscCandidates`/`DiscBlacklist`/`DiscLog` 1:1 db.py L136196
(+idx L177/196; json-колонки marks/topics/keywords text). DTO §4.8 L353355; `IDiscoveryStore`; сервисы
`DiscoveryTasksService`/`DiscoveryCandidatesService`/`DiscoveryBlacklistService`/`DiscoveryLogService` +
`DiscoveryPlanGuard` — 1:1 discovery.py: create (имя; plan 1..discJoinLimit; бюджет активных задач, L234–282),
patch (рост plan с бюджетом), delete (с кандидатами и логом), start (пустые ключи → 400 «Нет ключевых слов для
поиска — добавьте их в задачу»; reset прогресса для done/failed), pause, advance_search, кандидаты (add с
исключениями «уже мониторится»/«чёрный список»/«кандидат есть», L409453; set_candidate; mark_joined/rejected
L497567; blacklist), лог. Новый порт `C/Integrations/IAiTools.cs`: `GenerateKeywordsAsync(description)`
`{ok, keywords, error}`, `EvaluateFitAsync(text, description, keywords)``{fit, reason}` — локальные реализации
на этапе 6 не нужны (Disco-воркер сам падает в эвристику при сбое/aiEnabled=false, python L187194); порт
реализуется gRPC-адаптером `GrpcAiTools` за тем же флагом `Services:Ai:UseLocal=false`.
- **Ruling 10 (е) — Discovery: воркер, оценка, анти-бан.** `DiscoveryWorkerScheduler` (5 с, per-tenant, эталон
PipelineWorkerScheduler) + `DiscoveryWorkerService.TickAsync` — одно действие за тик, порядок шагов 1:1
discovery_worker.tick L444484: (1) план достигнут → done+лог; (2) поиск — следующий ключ задачи через
gateway.Search (личные чаты/боты пропускаются, kind→channel/group/forum); (3) оценка первого `new` кандидата:
info (kind/forum/участники; minSubscribers → skip), чтение выборки (ReadForEval; история недоступна →
метки «канал: история недоступна»/«закрытая группа (история скрыта) — вступите сами»; язык ru → skip при
«не русский», иначе метка; <3 сообщений → метка «мало сообщений»), фит: ML-спам (Predict через IMlClient, только
mlEnabled) → не подходит; ИИ (IAiTools.EvaluateFit, если aiEnabled) → иначе эвристика по ключам; форумы — по
темам (group_by_topic; passed — есть проходная тема); вердикт — `total>=3 && ratio*100>=threshold`
(passed L229237). (4) авто-вступление первого `review` при autoJoin: повторная проверка «не состоим» →
пауза discJoinDelayMin..Max (core) → join; FloodWait/ошибка → лог flood/error, join_failures (3 → delete);
успех → mark_joined(auto), +в Dialogs (монитор on), +фоновый Backfill, −чёрный список. Лимит: авто-вступления за
сутки по DiscLog event='join_auto' (UTC) < discJoinLimit; discFloodDay (внутренний KV, ключ SettingsKeys
DiscFloodDay — новый) и discPaused стопят сетевые шаги. Метки/поля кандидата и fitRatio 1:1 (L154173, marks
L8589).
- **Ruling 11 (е) — эндпоинты Discovery.** 1:1 api-map §3.8 (13 шт.): tasks CRUD+start/pause+generate-keywords,
candidates(статус-фильтр), join/reject (ручные, вне квот; ошибки 400 «Уже вступили…»), blacklist, log.
generate-keywords: aiEnabled/ключ-недоступность → `{keywords:[], error}` HTTP 200 (мягкие ошибки, api-map L209,
«кривое место» п.7), успех — `_clean_keywords`-фильтр в core (≤30, ≤60 симв., дедуп). Счётчики/статусы задач и
кандидатов — как discovery.py.
- **Ruling 12 (з) — compose и окружение dev.** В `DEP` добавляются сервисы `telegram-service`/`ai-service`/
`ml-service`: build из `src/<svc>/Deal.*.sln` (Dockerfile в корне сервиса), порты 5101/5102/5103 на host, volumes
`deal_tg_sessions` (`/data/sessions`), `deal_ml_data` (`/data/ml`), общий env `DEAL_SERVICE_TOKEN`; healthcheck —
gRPC health (встроенный Grpc.HealthCheck, порт health на том же endpoint). core dev запускается из хоста и ходит
на `localhost:5101..5103` (`SERVICES__*__ENDPOINT`), сервисы ходят в core-ингресс через
`SERVICES__CORE__INGRESS=http://host.docker.internal:5082` (env). Порядок подъёма не критичен: Local-фолбэки
переживают отсутствие сервисов; сквозная приёмка — при поднятых процессах.
- **Ruling 13 (и/к) — события/безопасность.** Новых типов SSE нет: используются system_status/toast (telegram),
существующие new_lead (после карточки — уже в PipelineWorkerScheduler). Аудит команд сервиса
`(tenantId, действие, диалог, результат)` — структурированные логи Serilog на каждом RPC (этап 7 — аудит-поток);
rate-лимиты gRPC-ингресса — этап 7. Ключи/секреты не логируются; `DEAL_ENCRYPTION_KEY`/`DEAL_SERVICE_TOKEN`/
`DEAL_TELEGRAM_SESSION_KEY` — только env.
## Задачи
Отчёты — `task-N-report.md` в `.superpowers/sdd/deal-stage6-services/`. Пути сокращены по Rulings.
### Task 1: `.proto`-контракты telegram/ai/ml + спецификация
**Files:** Create: `PR/telegram.proto`, `PR/ai.proto`, `PR/ml.proto`, `PR/README.md` (сервисы/RPC/messages/поля,
metadata `tenant-id`+`service-token`, коды ошибок, deadline-рекомендации). telegram.proto: `TelegramService`
GetStatus/StartQr/StartPhone/SendCode/SendPassword/Logout/RefreshDialogs(→entries[])/SetMonitor/SetMonitorAll/
Backfill/ReadRecent/Search/GetInfo/ReadForEval/Join/Leave + `IngressService` PushMessage/SyncDialogs/ReportStatus
(контракты Rulings 7). ai.proto: `AiService` Filter/Classify/GenerateKeywords/EvaluateFit (Ruling 5; usage в каждом
reply). ml.proto: `MlService` Predict/Status/Reset/TrainBatch (поля 1:1 с `MlPredictResultDto`/status: classes map,
eval{count,correct,accuracy}, take/label/scores/hits/ready/margin/terms/type).
**Источники:** Rulings 1/3/5/7; IMlClient/IAiClassifier + Models/*.cs (core Contracts, формы DTO);
mlservice/model.py predict/status; ai.py filter_incoming/classify; telegram.py методы (имена L134–873).
**Acceptance:** файлы + README со схемой каждого RPC (поля/messages/коды) согласованы; контракты валидируются
компиляцией в Task 2–4 (кодогенерация — первый прогон здесь невозможен без csproj). Отчёт: `task-1-report.md`.
### Task 2: Каркас telegram-service (sln, host gRPC, health, service-token)
**Files:** Create: `TG/Deal.Telegram.sln`, `TG/Deal.Telegram/Deal.Telegram.csproj` (link telegram.proto, Server),
`TG/Deal.Telegram/Program.cs` (Kestrel :5101 Http2; AddGrpc+HealthChecks; env `PORT`/`GRPC_PORT`),
`TG/Deal.Telegram/ServiceTokenInterceptor.cs`, `TG/Deal.Telegram/TelegramServiceImpl.cs` (заглушки: методы →
`UNIMPLEMENTED`), `TG/Deal.Telegram/Dockerfile`, `TG/Deal.Telegram.Tests/` (хост поднимается, health OK, запрос без
токена → UNAUTHENTICATED), `DEP` — запись `telegram-service`.
**Источники:** Rulings 1/2/12; эталон gRPC-сервера — настройка AddGrpc/HealthChecks (документация Grpc.AspNetCore).
**Acceptance:** `dotnet build Deal.Telegram.sln` 0/0 (доказывает кодогенерацию telegram.proto); юнит-тесты: health
ready; интерцептор отклоняет пустой/неверный токен. Отчёт: `task-2-report.md`.
### Task 3: Каркас ml-service (sln, host gRPC, health)
**Files:** Create: `ML/Deal.Ml.sln`, `ML/Deal.Ml/Deal.Ml.csproj` (link ml.proto Server), `ML/Deal.Ml/Program.cs`
(Kestrel :5103, env `GRPC_PORT`), `ML/Deal.Ml/ServiceTokenInterceptor.cs`, `ML/Deal.Ml/MlServiceImpl.cs` (заглушки),
`ML/Deal.Ml/Dockerfile`, `ML/Deal.Ml.Tests/` (health; token), запись `ml-service` в `DEP`.
**Источники:** Rulings 1/2/12; Task 2 (эталон).
**Acceptance:** build 0/0 (кодогенерация ml.proto); тесты health/token PASS. Отчёт: `task-3-report.md`.
### Task 4: Каркас ai-service (sln, host gRPC, health)
**Files:** Create: `AI/Deal.Ai.sln`, `AI/Deal.Ai/Deal.Ai.csproj` (link ai.proto Server), `AI/Deal.Ai/Program.cs`
(Kestrel :5102, env `GRPC_PORT`), `AI/Deal.Ai/ServiceTokenInterceptor.cs`, `AI/Deal.Ai/AiServiceImpl.cs` (заглушки),
`AI/Deal.Ai/Dockerfile`, `AI/Deal.Ai.Tests/` (health; token), запись `ai-service` в `DEP`.
**Источники:** Rulings 1/2/12; Task 2.
**Acceptance:** build 0/0 (кодогенерация ai.proto); тесты PASS. Отчёт: `task-4-report.md`.
### Task 5: ml-service — движок инкрементальной модели (per-tenant, SQLite)
**Files:** Create: `ML/Deal.Ml/Model/ModelConstants.cs` (пороги Ruling 4), `ML/Deal.Ml/Model/MlTokenizer.cs`
(снятие ссылок regex + токены [a-zа-яё0-9@+.#]+, len≥3 и «~prefix» len≥6 — 1:1 L7887),
`ML/Deal.Ml/Model/OnlineNaiveBayes.cs` (upsert/learn/batch/predict/status/reset/_maybe_eval, математика L184323:
score термина w<1→1.0 иначе 1+(w1)/(w+1); prior n/total; best=score+3·prior; adaptive margin; type-решение),
`ML/Deal.Ml/Storage/MlDb.cs` (Microsoft.Data.Sqlite; EnsureSchema/Tables), `ML/Deal.Ml/Model/TenantModel.cs` +
`ModelPool.cs` (lazy-load по тенанту, lock на модель), `ML/Deal.Ml/Model/ModelState.cs` (состояние: классы/термины/
eval-окно, JSON). Тесты `ML/Deal.Ml.Tests/`: tokenize; learn→predict спам/колонка; ready-пороги (20/6/4/2);
адаптивный margin; delta<0 «разучивание»; eval-окно (50/200); перезапуск пула сохраняет веса (2-й инстанс на тот
же файл).
**Источники:** `mlservice/model.py` целиком; Ruling 4; референс predict-математики L184293.
**Acceptance:** build 0/0; тесты PASS (обучение/предсказание на русских примерах: «нужен middle python…» →
колонка/тип; «резюме…» → spam после обучения). Отчёт: `task-5-report.md`.
### Task 6: ml-service — gRPC-сервис поверх пула
**Files:** Modify: `ML/Deal.Ml/MlServiceImpl.cs` — Predict/Status/Reset/TrainBatch; tenantId metadata → `ModelPool`
(модели нет — она создаётся лениво: для Predict отсутствие опыта даёт «не готов» — не ошибка; Ruling 4);
TrainBatch = learn_batch (1 транзакция) → число примеров; Reset — reset модели + пересоздание файла (очистка);
Status — ready/classes/learned/eval 1:1. Тесты: in-proc gRPC (GrpcChannel к тестовому хосту): train → predict;
train-батч из 3; reset обнуляет; неверный service-token → UNAUTHENTICATED.
**Источники:** mlservice/server.py (эталон форм ответов), model.py status/reset; Rulings 1/4/6.
**Acceptance:** build 0/0; in-proc gRPC-тесты PASS. Отчёт: `task-6-report.md`.
### Task 7: ai-service — LLM-фасад (OpenAI-совместимые + Anthropic)
**Files:** Create: `AI/Deal.Ai/Llm/LlmConfig.cs` (provider: id/name/base/model/key/apiStyle/local), `AI/Deal.Ai/Llm/
LlmHttpClient.cs` (HttpClientFactory; OpenAI `POST {base}/chat/completions` Bearer temperature 0.2 max_tokens 8000;
Anthropic `POST {base}/v1/messages` x-api-key+version; таймауты 90/60 с), `AI/Deal.Ai/Llm/LlmRetryPolicy.cs` (2
ретрая: 0.8 с/2 с — ai.py L96117), `AI/Deal.Ai/Llm/JsonExtractor.cs` (extract_json L175183),
`AI/Deal.Ai/Llm/TokenEstimator.cs` (usage провайдера или chars/4), `AI/Deal.Ai/Llm/ProviderCaller.cs` (ошибки →
AiException с кодом). Тесты: фейковый HttpMessageHandler: OpenAI-ответ; Anthropic-ответ; markdown-обёртка;
usage из ответа и оценка; 3 неудачи → исключение с текстом L115–117; таймаут.
**Источники:** ai.py `_call_openai`/`_call_anthropic`/`chat_json`/`extract_json` (L80183); Ruling 5.
**Acceptance:** build 0/0; unit-тесты PASS (без сети). Отчёт: `task-7-report.md`.
### Task 8: ai-service — gRPC AiService
**Files:** Modify: `AI/Deal.Ai/AiServiceImpl.cs` — Filter (chat_json по фильтр-промпту → pass/reason; при `ok=false`
из модели — pass:true,skipped? нет: воркер шлёт только при aiFilterEnabled; ошибка → UNAVAILABLE), Classify (json →
reply{ok,json}), GenerateKeywords (промпт Ruling 5 → keywords), EvaluateFit (промпт discovery_eval → fit/reason);
каждый reply + usage. Тесты in-proc: все 4 метода с фейковым провайдером; недоступный провайдер → UNAVAILABLE.
**Источники:** ai.py L188258; discovery_routes L3647/189211; discovery_eval L5054/153194; Rulings 1/5.
**Acceptance:** build 0/0; in-proc тесты PASS. Отчёт: `task-8-report.md`.
### Task 9: telegram-service — сессии, подключение, QR, статус
**Files:** Create: `TG/Deal.Telegram/Sessions/TgOptions.cs` (session dir, DEAL_TELEGRAM_SESSION_KEY), `TG/Deal.Telegram/
Sessions/SessionFileCipher.cs` (AES-GCM обёртка файла), `TG/Deal.Telegram/Sessions/TenantSession.cs` (id тенанта,
клиент WTelegramClient, состояние), `TG/Deal.Telegram/Sessions/SessionFarm.cs` (пул 1 акк/тенант, auto_resume на
старте — авторизованная сессия → ready, L209222), `TG/Deal.Telegram/Telegram/ClientFactory.cs` (конфиг:
api_id/api_hash из запроса; session_pathname; внутренние паузы). Реализация методов: StartQr/StartPhone/SendCode/
SendPassword/Logout/GetStatus (фазы idle|phone|code|password|qr|ready, account, qrUrl; heartbeat/авто-возобновление
фоновым циклом 30 с). Тесты: cipher roundtrip; farm: tenant-изоляция (нет сессии → отказ); фазовые переходы на
fake-клиенте (абстракция `ISessionClient`); ручная проверка QR — отдельно.
**Источники:** telegram.py L82222, L286329; config.py L31 (SESSIONS_DIR); Rulings 1/3.
**Acceptance:** build 0/0; unit PASS. ⚠ **Ручная проверка:** реальный QR-вход/код/2FA с кредов (api_id/api_hash),
auto_resume после рестарта контейнера. Отчёт: `task-9-report.md`.
### Task 10: telegram-service — диалоги, мониторинг, backfill, поток в core
**Files:** Create: `TG/Deal.Telegram/Dialogs/DialogCatalog.cs` (зеркало monitored-набора тенанта: SetMonitor/
SetMonitorAll/актуализация ответом SyncDialogs), `TG/Deal.Telegram/Dialogs/RealtimeListener.cs` (NewMessage →
фильтр по зеркалу → PushMessage в core; mark-as-read sendReadAcknowledge; сохранение last_text? нет — только пуш),
`TG/Deal.Telegram/Dialogs/BackfillService.cs` (последние 10 с паузами 1.5–3 с/сообщение и 3–6 с/диалог; read-ack;
реверс-порядок от старых к новым; force; L331390), `TG/Deal.Telegram/Dialogs/RealtimeSweep.cs` (30 с: догон
непрочитанных по unread_count, паузы, read-ack, L392456), `TG/Deal.Telegram/Core/CoreIngressClient.cs` (gRPC-клиент
к `SERVICES__CORE__INGRESS`; PushMessage/SyncDialogs; сбой — лог, упущенное догоняет sweep). Реализация RPC
RefreshDialogs/SetMonitor/SetMonitorAll/Backfill/ReadRecent (превью, свежие из TG). Тесты: фильтр мониторинга;
backfill-паузы (fake clock); PushMessage-клиент к in-proc fake-серверу ингресса.
**Источники:** telegram.py L244283, L331456, L505620; Rulings 3/7.
**Acceptance:** build 0/0; unit/in-proc PASS (без реальной сети). ⚠ **Ручная проверка:** refresh/подписка/backfill
живого аккаунта. Отчёт: `task-10-report.md`.
### Task 11: telegram-service — discovery-операции (search/info/read/join)
**Files:** Create: `TG/Deal.Telegram/Discovery/DiscoveryOps.cs` — Search (contacts.SearchRequest, пауза 24 с,
кэш entities, выходные id подписанные, kind «канал»/«группа»/«чат», L624664), GetInfo (participants via
GetFullChannel/GetFullChat, is_forum, L666716), ReadForEval (обычная лента; форумы — темы GetForumTopics + на
тему get_messages(reply_to), per-topic 3..10, cap 5 тем; ошибки → ok:false no_history; L718816), Join (по
username, FloodWait → RpcException RESOURCE_EXHAUSTED + код flood, L818839), Leave (L841848). Тесты: нормализация
kind/username; формат ответов (fake-слой TL не трогаем — тесты на чистых мапперах ответов).
**Источники:** telegram.py L622873; ban_guard.search_pause; Rulings 3/7.
**Acceptance:** build 0/0; unit PASS (мапперы/валидация). ⚠ **Ручная проверка:** поиск/чтение/join живого аккаунта.
Отчёт: `task-11-report.md`.
### Task 12: core — gRPC-ингресс telegram (PushMessage/SyncDialogs/ReportStatus)
**Files:** Modify: `A/Program.cs` (второй Kestrel-listen :5082, Http2, `GRPC_INGRESS_PORT`; AddGrpc; AddAuthentication
не нужен — интерцептор), `A/Infrastructure/` не трогаем. Create: `A/Telegram/IngressServiceTokenInterceptor.cs`,
`A/Telegram/TelegramIngressService.cs` (Grpc `Deal.Grpc.Telegram.IngressServiceBase`): PushMessage → scope с
`SetTenant(metadata tenant-id)``PipelineIngestService.EnqueueAsync` (+ `ITelegramStore.SavePreview`) → reply
{accepted/duplicate}; SyncDialogs → `DialogsService.SyncFromTelegram` → reply{monitoredIds}; ReportStatus → KV
`tgStatus`/`tgAccount` + публикация (через DI Api-слоя) SSE system_status/тостов на переходах фаз. Create:
`A/Telegram/TelegramIngressAuth.md`? нет. Тесты (in-proc WebApplicationFactory+gRPC-канал): PushMessage кладёт
строку очереди тенанта (эмуляция входящего сообщения — сквозная проверка без Telegram); неверный токен → отказ;
PushMessage для несуществующего тенанта не падает (нет схемы → ошибка ловится, reply not-accepted).
**Источники:** Rulings 7/13; PipelineIngestService L759; паттерн scope/SetTenant — PipelineWorkerScheduler L169213.
**Acceptance:** build 0/0; тесты PASS (см. выше). Отчёт: `task-12-report.md`.
### Task 13: core — модуль Telegram (таблицы, DTO, порт, DialogsService)
**Files:** Create: `TM/.../TelegramModuleMarker.cs`, `I/Persistence/Entities/{DialogEntity,TgMessageEntity}.cs` +
конфигурации (JSON не нужен; индексы DialogId/MsgAt), `TM/Application/Models/{TelegramDialogDto,TelegramMessageDto,TgStatusDto}.cs`,
`TM/Application/ITelegramStore.cs`, `TM/Application/DialogsService.cs` (List/SetMonitor/SetMonitorAll/SyncFromTelegram/
MarkBackfilled/SavePreview — Ruling 7), `TM/Application/TelegramModuleRegistrar.cs`, `I/Persistence/Repositories/
TelegramStore.cs`, `C/Integrations/ITelegramGateway.cs` (Ruling 7). Modify: `I/Persistence/TenantDbContext.cs`
DbSet `Dialogs`/`TgMessages` + ApplyConfiguration. EF: миграция `TenantTelegram` для TenantDbContext
(`dotnet ef migrations add TenantTelegram --context TenantDbContext --output-dir Migrations/TenantDb --project
src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`; старт Api применяет к дефолтной схеме).
csproj-ссылки: TM → ST (настройки) + Contracts; реестр `AddTelegramModule()` в Api. Тесты: SyncFromTelegram (новый+autoMonitorNew/обновление/удаление отсутствующих),
SetMonitor-семантика; порт-контракт гейта.
**Источники:** db.py L6786; telegram.py `_persist_dialogs`/list_dialogs/set_monitor* L468581; api-map §4.8 L349351;
Rulings 7/8.
**Acceptance:** build 0/0; миграция применяется к дефолтной схеме; тесты PASS. Отчёт: `task-13-report.md`.
### Task 14: core — эндпоинты /api/tg (каналы, статус, QR) + SSE; замена boot-заглушки
**Files:** Modify: `A/Program.cs` (+`MapTelegramEndpoints`), `A/Endpoints/BootStubEndpoints.cs` (удаляется вызов
`MapBootStubEndpoints`, файл — delete). Create: `A/Endpoints/TelegramEndpoints.cs` (13 шт. api-map §3.3; тела
запросов — record'ы; ошибки гейта → `{detail}` 400; refresh → upsert через DialogsService и ответ {ok,count} или
{ok:false, reason:"not-connected", count:0}; monitor/backfill по Ruling 8 с фоновым backfill-спуском при первом
включении), `A/Endpoints/QrImageEndpoint.cs` (SVG Net.Codecrete; 404 «QR не активен — начните вход по QR»),
`A/Telegram/TgStatusService.cs` (сборка §4.9: гейт+KV+monitored count+keysSet), события SSE/toast при ReportStatus
(из Task 12). Tests: юнит-тесты TgStatusService (сервис недоступен → idle-форма); curl-приёмка эндпоинтов со
стаб-гейтом (фейк-реализация ITelegramGateway в тестах, не Local).
**Источники:** api-map §3.3/§4.9; tg_routes.py целиком (тексты и статусы); store.js L13521508 (фронт-флоу);
Rulings 7/8.
**Acceptance:** build 0/0; юнит+curl: GET /api/tg/status (idle без гейта), dialogs, monitor, backfill-all, preview,
start-qr (фейк) → phase/qrUrl; 401 без куки. Отчёт: `task-14-report.md`.
### Task 15: core — ai-интеграция: контекст запроса, GrpcAiClassifier/GrpcAiTools, маппер, usage
**Files:** Modify: `C/Integrations/IAiClassifier.cs` — контракт остаётся, НО Classify/Filter переходят на
запросные record'ы: `ClassifyAsync(AiClassifyRequest, ct)`, `FilterAsync(AiFilterRequest, ct)` (в C/Integrations/
Models/: AiClassifyRequest{Text, SystemPrompt, UserContext}, AiFilterRequest{Text, SystemPrompt}); сигнатуры
LocalAiClassifier адаптируются (строит запрос сам: Filter — skipped; Classify — локальный разбор, Ruling 5 этапа 4).
Modify: `PL/Application/PipelineWorkerService.cs` — call-site'ы фильтра/классификации переходят на новые сигнатуры
через `AiClassifyContextBuilder` (Логика веток/выключателей/обучения ML не меняется — Ruling 5 этапа 4/6).
Create: `PL/Application/AiClassifyContextBuilder.cs` (fill_prompt 1:1 ai.py L6377; доски non-suggested с правилами/
ключами — python L226–243; примеры разметки по CardMoves/learning-истории, ≤8, L201215), `PL/Application/
AiRawLeadMapper.cs` (json-ответ модели → AiParsedLeadDto 1:1 python: title ≤140, стек normalize, бюджет
BudgetNormalizer=clean_budget L316339, контакты ContactsQualifier=build_contacts L389421, типы/spam/board;
доску решает CardComposer BoardAccepts — как сейчас), `I/Integrations/GrpcAiClassifier.cs`,
`I/Integrations/GrpcAiTools.cs` (IAiTools: GenerateKeywords/EvaluateFit; usage→KV `aiTokenUsage` — SettingsKeys
новый внутренний ключ), `I/Integrations/LocalAiTools.cs` (для UseLocal: методы не поддерживаются → исключение/
пустой результат — воркер Discovery сам выбирает эвристику), регистрация в `AddDealIntegrations` по флагу
`Services:Ai` (Ruling 6). Tests: контекст-билдер (промпты/доски/примеры); маппер json→DTO (бюджет «2к»/валюты/
контакты); адаптеры (in-proc gRPC ai-service); Local-фолбэк.
**Источники:** ai.py L61258; pipeline.py `_store_lead` L433514; CardComposer; Rulings 5/6.
**Acceptance:** build 0/0; тесты PASS; воркер с GrpcAiClassifier (UseLocal=false) проходит фильтр/классификацию
против in-proc ai-service. Отчёт: `task-15-report.md`.
### Task 16: core — ml-интеграция: GrpcMlClient + MlOutboxFlushScheduler
**Files:** Create: `I/Integrations/GrpcMlClient.cs` (IMlClient: Predict/Status/Reset/PushAsync — Push остаётся
записью в MlOutbox через IMlLearningStore как LocalMlClient; Predict → gRPC, сбой → NotReadyPrediction;
Status → service-статус + кэш 15 с (reachable), статистика из KV/таблиц; Reset → gRPC Reset + ClearOutbox),
`A/Hosting/MlOutboxFlushScheduler.cs` (10 с per-tenant; по 10 строк, ≤100 за цикл, TrainBatch; delete после успеха;
эталон PipelineWorkerScheduler). Регистрация по флагу `Services:Ml` (Ruling 6). Modify: `I/Integrations/
LocalMlClient.cs` — не трогаем (фолбэк); `ST/Application/SettingsKeys.cs` — внутренние ключи `AiTokenUsage`/
`DiscFloodDay`/`TgStatus`/`TgAccount`. Tests: flush (фейк-gRPC): 25 строк → 3 батча, строки удалены, сбой → строки
остались; reset; reachable false при недоступности; predict-fallback.
**Источники:** ml_client.py L3031/56135; Rulings 4/6; LocalMlClient (эталон Push/Status).
**Acceptance:** build 0/0; тесты PASS. Отчёт: `task-16-report.md`.
### Task 17: core — Discovery: таблицы, порт, сервисы задач/кандидатов/чёрного списка/лога
**Files:** Create: `DC/Application/Models/*.cs` (§4.8 DTO: задача L353, кандидат L355, чёрный список, лог),
`DC/Application/DiscoveryIdPrefixes.cs` (`dt_`/`dl_`), `DC/Application/IDiscoveryStore.cs`, `DC/Application/
DiscoveryTasksService.cs` (create/patch/delete/start/pause/advance/bump, план-бюджет 1:1 L234381),
`DC/Application/DiscoveryCandidatesService.cs` (add с исключениями, set_candidate, mark_joined/mark_rejected,
delete; метки/топики JSON), `DC/Application/DiscoveryBlacklistService.cs`, `DC/Application/DiscoveryLogService.cs`,
`DC/Application/DiscoveryModuleRegistrar.cs`; миграция `TenantDiscovery` (TenantDbContext — DbSet `DiscTasks`/`DiscCandidates`/`DiscBlacklist`/`DiscLog` +
ApplyConfiguration; команда как в Task 13); `I/Persistence/Repositories/DiscoveryStore.cs`; csproj DC → ST + Contracts. Tests: валидации (имя/бюджет/план), start без ключей, исключения
add_candidate, mark_joined→joined/autoJoined/счётчики, blacklist-перезапись rejected.
**Источники:** discovery.py (создание/кандидаты/чёрный список/лог L234608), db.py L136196, api-map §3.8/§4.8;
Rulings 9/10.
**Acceptance:** build 0/0; миграция применяется; тесты PASS. Отчёт: `task-17-report.md`.
### Task 18: core — Discovery-воркер (5 с): поиск/оценка/авто-join, бан-гард
**Files:** Create: `DC/Application/DiscoveryBanGuard.cs` (лимит дня по DiscLog join_auto за UTC-сутки; discFloodDay;
discPaused; wait-пауза из настроек 1:1 ban_guard.py), `DC/Application/DiscoveryLangDetector.cs` (detect_lang_ru
L6283), `DC/Application/DiscoveryEvaluator.cs` (фит: короткие → нет; ML-спам при mlEnabled (IMlClient.Predict);
ИИ EvaluateFit при aiEnabled (IAiTools), сбой → эвристика; форумы по темам — group_by_topic L96117 + passed
L229237), `DC/Application/DiscoveryWorkerService.cs` (шаги 14 tick L444484 через ITelegramGateway; маркеры и
логи 1:1; join_failures=3→delete), `A/Hosting/DiscoveryWorkerScheduler.cs` (5 с, per-tenant, эталон
PipelineWorkerScheduler). Тесты: бан-гард (лимит/флуд/пауза), оценка (язык/фит/порог/форумы/метки), воркер-шаги с
фейковым гейтом (search→candidate; eval→review; join с паузами; план выполнен → done).
**Источники:** discovery_worker.py целиком; discovery_eval.py целиком; ban_guard.py целиком; Rulings 9/10.
**Acceptance:** build 0/0; тесты PASS. Отчёт: `task-18-report.md`.
### Task 19: core — эндпоинты /api/discovery + generate-keywords; curl-приёмка
**Files:** Create: `A/Endpoints/DiscoveryEndpoints.cs` — 13 эндпоинтов api-map §3.8: tasks (list/create/patch/delete/
start/pause), generate-keywords (мягкая ошибка HTTP 200 `{keywords:[], error}`; `_clean_keywords` в core),
candidates(фильтр), join (ручной: валидация статуса, Join→add в Dialogs monitor→фон backfill→mark_joined(auto:false)
→remove_blacklist, ошибки 400 с текстом), reject (→blacklist reason «отклонено вручную»), blacklist list/delete,
log. Curl-приёмка discovery: создание задачи → start (после добавления ключей) → симуляция работы воркера
(фейк-гейт в тестовом host) → кандидаты new/review → reject → blacklist → лог; 404/400 ветки.
**Источники:** discovery_routes.py целиком; store.js L21712370; Rulings 9/11.
**Acceptance:** build 0/0; curl PASS (или тестовая приёмка) по сценарию выше. Отчёт: `task-19-report.md`.
### Task 20: compose-dev, сквозная интеграция и финал этапа
- `DEP`: сервисы из Task 2–4 доводятся (healthcheck gRPC, volumes, env `DEAL_SERVICE_TOKEN`, ingress env);
`docker compose -f deploy/compose.dev.yml config` валиден; локальный подъём всех процессов (ручной шаг — docker).
- Сквозная эмуляция (без реального Telegram/LLM): подняты core+3 сервиса (`SERVICES__*__USELOCAL=false`); gRPC-вызов
PushMessage в core (клиент-эмулятор, скрипт `scripts/grpc-emit.ps1`/`.sh` на grpcurl или тест-проект) → очередь →
admin/tick → карточка (new_lead); ml: TrainBatch → `/api/ml/status` показывает ready; ai: фильтр/классификация
через фейковый OpenAI-сервер? НЕТ — ai-сервис без ключа отдаёт UNAVAILABLE, воркер падает в локальный разбор
(проверяем); затем `SERVICES__AI__USELOCAL=true` — фолбэк жив.
- Обновить `docs/technical/Техническая-документация-Дейл.md` (сервисы/порты/gRPC-контракты, каналы-вкладка,
Discovery, флаги, ml-модель и веса) и roadmap (этап 6 → «Выполнено», ограничения этапа 7).
- Полный прогон: `scripts/build.sh` + `scripts/test.sh` (620 + новые PASS), build каждой sln 0/0.
- Отчёт `task-20-report.md` + финальная строка `progress.md`.
**Источники:** Rulings 2/12/13; compose.dev.yml (эталон minio-записи); паттерны отчётов этапов 1–5.
**Acceptance:** см. пункты выше; любые живые проверки Telegram/LLM — ⚠ ручные, по возможности, с кредами.
## Self-Review
1. **Spec coverage:** прото-контракты (а) — Task 1 + Rulings 1/3/5/7; каркасы сервисов — Task 2–4; ml-алгоритм/
сохраняемость — Task 5/6 (Ruling 4); ai-фасад/промпты/таймауты/токены — Task 7/8/15 (Ruling 5); core gRPC-клиенты
за флагом и судьба MlOutbox — Task 15/16 (Ruling 6); входящий telegram-gRPC→EnqueueAsync — Task 12 (Ruling 7);
замена Local-заглушек с фолбэком — Task 15/16/20; Discovery (таблицы/воркер/оценка/чёрный список/квоты/история/
генерация ключей/эндпоинты) — Task 17/18/19 (Rulings 911); каналы-эндпоинты и QR/статус/марк-as-рид/мониторинг/
«Перечитать»/ключи/авто-мониторинг — Task 10/13/14 (Rulings 3/7/8); SSE system_status/toast/new_lead — Task 12/14
(Ruling 13); compose-dev — Task 24/20; безопасность dev (service-token, mTLS-решение) — Rulings 1/2, Task 24/12.
Roadmap-скоуп (L8291) покрыт; ТЗ §4/§5/§8 — через api-map/референсы выше.
2. **Placeholder scan:** TODO/«добавьте обработку» нет; «ручная проверка» — явно помеченные живые проверки с
кредами (задачи 9/10/11/20), авто-приёмка — эмуляция ингресса и фейки. Onnx/TeleSharp альтернативы не
оставлены «на потом» — зафиксированы решения (Rulings 3/4). IColumnSuggester (LocalColumnSuggester) сознательно
НЕ заменяется gRPC (эвристика читает карточки тенанта в ядре; ai-service участвует только через IAiTools
GenerateKeywords — Kanban-suggest остаётся локальным, api-map L120–121 без изменений) — это решение, не TODO.
3. **Type consistency:** имена контрактов и методы: IAiClassifier переходит на запросные record'ы (Task 15) —
воркер Pipeline (Ruling 5 этапа 4) вызывает ClassifyAsync/FilterAsync; адаптеры Local/Grpc реализуют один порт;
IMlClient не меняет сигнатур (Predict/Status/Reset/Push) — GrpcMlClient/LocalMlClient взаимозаменяемы; новые
внутренние SettingsKeys (AiTokenUsage/DiscFloodDay/TgStatus/TgAccount) добавляются в ST-каталог как внутренние;
ITelegramGateway (Task 13) реализуется клиентом Task 12–14 и потребляется эндпоинтами/воркером Discovery (Task
18) — единый список методов Ruling 7; `QueuedMessage` (контракт ингресса) тот же, что у demo-ingest;
`MlPredictResultDto`/status-поля 1:1 с ml.proto (Task 1/6). Циклов ссылок нет: TM→ST+Contracts; DC→ST+Contracts;
TM/DC не знают друг о друге; Api оркестрирует.
4. **Вне scope этапа 6:** mTLS-сертификаты и prod-compose (этап 7); лимиты/бюджеты токенов (учёт уже есть);
оператор/админка/аудит-поток; экспорт/импорт ML-моделей (решение владельца); мультиаккаунтность на тенанта;
события pipeline_stats/boards_changed/leads_reclassified (фронт не слушает); reclassify ИИ-переклассификации на
реальном ИИ (контракт-заглушка остаётся; реальный вызов — вместе с операторским контуром этапа 7);
шифрование сессий и их бэкап-интеграция (сессии шифруются файлово, но ротация ключей/бэкап-политика — этап 7).
@@ -0,0 +1,586 @@
# Дейл (Deal) — Этап 7: SaaS-контур (оператор, инвайты, лимиты, аудит, безопасность, prod-деплой, финальные доки) Implementation Plan
> Исторический документ этапа 7. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** Замкнуть SaaS-контур «Дейла» поверх готового мультитенантного ядра этапов 0–6: отдельный
изолированный контур **оператора** (вход, тенанты, инвайты, лимиты/бюджеты, health, impersonation,
чтение аудита) в `public`-схеме и на новых REST-ручках `/api/operator/*` + `/api/join` (активация
инвайта); **бюджет токенов** на тенанта с автоматическим fallback на ML/локальный разбор и
уведомлением (приём не блокируется); **аудит-поток** (входы, инвайты, impersonation, действия
оператора — append-only); **безопасность**: лимит попыток входа, rate limiting (приложение + gRPC-
ингресс), Origin-проверка мутаций, security-заголовки, mTLS за флагом для внутренних сервисов;
**prod-деплой**: `deploy/compose.prod.yml` (postgres, minio, core, 3 сервиса, Caddy, grafana/loki/
promtail) + ежедневные бэкапы; **observability**: Serilog (JSON-логи в core и сервисах) → Promtail →
Loki → Grafana; **финальные доки** (техдок §11/§13, roadmap, STATUS, user-guide, api-map-дополнение)
и сквозная SaaS-приёмка. Фронт Vue не переписывается: операторская админка — API-only (UI — вне).
**Architecture:** все SaaS-сущности живут в **`public`** (системная схема), владелец — существующий
модуль `Deal.Modules.Tenants` (дизайн-док §5 L128: «тенанты, пользователи, инвайты, лимиты, аудит,
операторская админка»), EF-адаптеры — в `Deal.Infrastructure`, HTTP — в `Deal.Api/Endpoints`. Оператор —
НЕ тенант: отдельные таблицы `Operators`/`OperatorSessions`, отдельная кука `deal_operator_session`,
отдельный bootstrap из env. Тенант-сессия остаётся как есть (`deal_session`, SessionMiddleware).
Активация инвайта создаёт пользователя + тенанта (при необходимости) и провижинит схему существующим
`TenantService`/`ITenantProvisioner`. Учёт токенов ИИ, который этап 6 копил в tenant-KV
(`SettingsKeys.AiTokenUsage`, `AiUsageLedger`), на этапе 7 пишется в `public.tenant_limits` (период +
`UsedTokens`, ленивый reset) — это источник истины для бюджетного гейта; KV-ключ остаётся как
«lifetime»-счётчик. Гейт ставится НЕ внутрь ai-service, а в core на границе вызова ИИ (декораторы
`IAiClassifier`/`IAiTools` с fallback на Local-реализации — ровно семантика «aiEnabled=false/aiFail»
этапов 4–6), поэтому контракты/сервисы этапа 6 не меняются. Rate limiting — встроенный
`AddRateLimiter` ASP.NET Core + прикладной `LoginAttemptGuard`; mTLS — за флагом (dev остаётся
plaintext + service-token). Observability: Serilog JSON во всех процессах, сбор логов контейнеров
Promtail → Loki → Grafana (compose-prod); OTel-метрики задекларированы follow-up (минимум-объём).
**Tech Stack:** .NET 10, существующие порты/паттерны этапов 1–6; новые пакеты в core: `Serilog`,
`Serilog.Sinks.Console`, `Serilog.Sinks.File`, `Grpc.HealthCheck` (клиент health для операторского
health-эндпоинта). Rate limiting — shared-framework (`System.Threading.RateLimiting`/`AddRateLimiter`,
новый NuGet не нужен). Инфраструктурные файлы (не код): `deploy/compose.prod.yml`, `deploy/caddy/
Caddyfile`, `deploy/observability/{promtail.yml,loki.yml,grafana-provisioning/*}`, `deploy/.env.prod.
example`, `scripts/mtls-certs.sh`, `scripts/backup.sh`. Docker-движок в ходе этапа может быть выключен:
все acceptance-задачи — без docker там, где можно; «живые» шаги явно помечены ⚠ Manual.
**Spec:** `docs/architecture/2026-09-05-deal-architecture-design.md` §8 (безопасность, L187–216),
§9 (observability/админка/бэкапы, L216–233), §10 (деплой, L233243), §12.5; `docs/spec/
ТЗ-дейл-новая-архитектура.md` §3 (роли), §9 (лимиты), §10 (админка), §11 (НФТ); решения владельца в
`docs/superpowers/plans/2026-09-05-deal-roadmap.md` (L107–121 + «Выполнено» этапов 1–6 + «Оставшиеся
этапы» L101–105); ограничения этапа 6 (roadmap L8284, STATUS.md); текущий код: `Deal.Modules.Tenants`
(AuthService/TenantService/порты), `Deal.Infrastructure` (миграции/конфигурации/репозитории),
`Deal.Api` (Program.cs, SessionMiddleware, AuthEndpoints, хостинг-циклы, SseBroker), `AiUsageLedger`
+ `GrpcAiClassifier`/`GrpcAiTools`, `PipelineWorkerService` (ветки aiEnabled/fallback), `deploy/
compose.dev.yml`, техдок §8–§11/§13, api-map §3.9/§5.
## Global Constraints
- Проект **НЕ git**; фиксация — отчёты `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage7-saas/`.
- .NET 10; все sln собираются 0 warnings/0 errors (`TreatWarningsAsErrors`); dev-Postgres `deal-postgres`
(:5433); системные миграции применяются командой `dotnet ef database update --context DealDbContext`
(из `src/core`), tenant-миграции — провижинером на старте (не меняется).
- Код-стайл этапов 1–6: 1 тип = 1 файл; XML-doc на public; комментарии на русском; без регионов; без
магических чисел (именованные константы); времена `DateTimeOffset` (UTC); JSON camelCase; ошибки
API — `{detail}`; кука httpOnly/SameSite=Lax.
- Vue-фронт, `backend/`, `mlservice/` (python), корневой `docker-compose.yml`**не трогаем**.
Новые SaaS-ручки — дополнение к `/api` (фронт их не вызывает); контракт api-map для фронта не ломается.
- Секреты — только env/файлы (`DEAL_*`), никогда в коде/БД в открытом виде; в аудит и логи секреты не пишутся.
- Все SaaS-таблицы — `public`; `TenantDbContext`/схемы тенантов не меняются (кроме случаев, когда
требуется новое tenant-поле, — в этапе 7 таких нет).
- Креды оператора/инвайт-коды в тестах и примерах — фиксированные dev-значения; живые проверки
(Docker-стек, mTLS-рукопожатие, бэкап-прогон) — ⚠ Manual, по возможности.
## Зафиксированные решения (Rulings этапа)
Сокращения путей: `TM=` `src/core/Deal.Modules.Tenants/`, `I=` `src/core/Deal.Infrastructure/`,
`A=` `src/core/Deal.Api/`, `C=` `src/core/Deal.Contracts/`, `ST=` `src/core/Deal.Modules.Settings/`,
`PL=` `src/core/Deal.Modules.Pipeline/`, `T=` `src/core/tests/Deal.Tests.Unit/`, `DEP=` `deploy/compose.dev.yml`,
`PROD=` `deploy/compose.prod.yml`, `TG=` `src/telegram-service/`, `AI=` `src/ai-service/`, `ML=` `src/ml-service/`.
- **Ruling 1 (а) — модель оператора/сессий: public-таблицы, изоляция, bootstrap.** Новые таблицы
`public` (системная миграция `SystemSaaS`, команда EF как в техдок §13.2): `Operators` (Id Guid PK,
Login unique (нижний регистр), PasswordHash Argon2id, Status, CreatedAt), `OperatorSessions`
(TokenHash PK, OperatorId FK→Operators, Login, ExpiresAt, CreatedAt; срок жизни **12 часов**),
`Invites`, `TenantLimits`, `AuditLog` (Rulings 4/5/8). Сущности/конфигурации — по образцу
TenantEntity/UserEntity/SessionConfiguration (ToTable в `public`, нижний регистр имён). Кука
оператора — **`deal_operator_session`** (отдельная от тенантной `deal_session`; httpOnly,
SameSite=Lax, Secure из конфига, секция `OperatorCookies`). Операторская сессия разрешается
**отдельным** `OperatorSessionMiddleware` (после SessionMiddleware) в `HttpContext.Items["CurrentOperator"]`;
эндпоинты `/api/operator/*` требуют именно операторскую сессию (403/401), тенантные `/api`-ручки её
не видят (другое имя куки — взаимной подмены нет). **Bootstrap оператора**: env
`DEAL_OPERATOR_LOGIN`/`DEAL_OPERATOR_PASSWORD`; в `Development` при их отсутствии — дефолт
`operator`/`operator` (зеркало dev-seed admin/admin). В `Production` при отсутствии кред — стартовый
warning и пропуск (оператор заводится позже через env + рестарт; кода регистрации оператора нет).
**Dev-seed дефолтного тенанта/admin/admin становится dev-only**: `TenantBootstrapService` создаёт
дефолтного тенанта только в `Development` или при `DEAL_BOOTSTRAP_DEFAULT_TENANT=1`; провижининг схем
всех зарегистрированных тенантов выполняется всегда. Прод-тенантов заводит оператор.
- **Ruling 2 (б) — инвайты и активация.** `Invites` (public): Code PK (случайный url-safe, 16 симв.,
префикса нет), TenantId Guid **nullable** (null = «новый тенант»), Email (нормализованный, unique по
активным), Status (`pending`/`activated`/`revoked`/`expired`), ExpiresAt (**72 ч**, константа),
CreatedById (оператор), ActivatedAt null, CreatedAt. Создание/отзыв — только оператор. Активация —
публичная ручка **`POST /api/join`** `{code, email, name?, password}`: email обязан совпасть с
инвайтом; проверка статуса/expiry (expired → 410-семантика текстом «Срок действия приглашения
истёк»); пароль ≥4 (как в AuthService); создание пользователя (login=email, Argon2id) и, если
TenantId пуст, тенанта (`TenantService.CreateTenantAsync(name, newId)` — провижинит схему сам);
отметка `activated` + аудит. Глобальная уникальность email обеспечена unique-индексом `users.login`
(конфликт → 400 «Этот email уже зарегистрирован»). Инвайт на существующего тенанта (TenantId задан)
создаёт пользователя в нём. Отдельной страницы-активации во фронте нет — ручка API-only
(curl/будущий UI); в user-guide фиксируется описание.
- **Ruling 3 (в) — лимиты: модель, период, списание, гейт, fallback, уведомление.** Таблица
`TenantLimits` (public): TenantId PK (FK→tenants, Restrict), BudgetTokens bigint, Period
(`month`|`day`, default `month`), PeriodStart, UsedTokens bigint (с начала периода), Warned80 bool,
NotifiedExhausted bool, UpdatedAt. **Списание**: там, где этап 6 звал `AiUsageLedger.AddAsync`
(GrpcAiClassifier/GrpcAiTools, успешные RPC ai-service), новый `TokenUsageRecorder.AddAsync` пишет
(1) инкремент `UsedTokens` в `tenant_limits` (тот же scoped DealDbContext) и (2) по-прежнему
lifetime-сумму в KV `aiTokenUsage` (существующий ключ — счётчик «всего», оператор/будущий UI).
**Reset** — ленивый: при чтении/записи, если сейчас ≥ конца периода (PeriodStart+месяц/сутки),
`UsedTokens`/флаги обнуляются и PeriodStart=now; отдельного фонового цикла нет. **Гейт** — порт
`ITokenBudgetGate.CheckAsync(tenantId)``{Allowed, Exceeded, Status}`; статус тенанта
(`suspended`) трактуется как Not Allowed (приостановка замораживает ИИ). Гейт спрашивают
**декораторы** `BudgetedAiClassifier`/`BudgetedAiTools` (регистрируются в `AddDealIntegrations`,
только когда `Services:Ai:UseLocal=false`, поверх gRPC-адаптеров): исчерпано → фильтр/классификация
через Local-реализации (семантика aiEnabled=false / aiFail), IAiTools.EvaluateFit → исключение
`AiUnavailableException` (Discovery-воркер сам уходит в эвристику — код не меняется),
GenerateKeywords → мягкая ошибка `{keywords:[], error}`. **Уведомление**: пороги 80% и 100% от
бюджета; обнаружение перехода и публикация SSE-тоста («ИИ-бюджет израсходован на 80%» /
«ИИ-бюджет исчерпан — обработка в локальном режиме», иконка `bell`) — Api-хостинг
`BudgetAlertScheduler` (60 с, эталон StorageTickScheduler), флаги Warned80/NotifiedExhausted
гарантируют один тост на период на порог; смена бюджета оператором сбрасывает флаги. Приём и
базовая обработка сообщений не блокируются (fallback по замыслу ТЗ §9). Дефолт-бюджет нового
тенанта — константа модуля `TokenBudgetDefaults` (10 000 000 токенов/месяц), оператор задаёт
бюджет при создании или меняет позже.
- **Ruling 4 (г) — аудит: append-only поток.** Таблица `AuditLog` (public): Id bigint identity PK,
At, ActorType (`operator`|`tenant`|`system`), ActorId Guid null, TenantId Guid null, EventType
(строковая константа), Ip string null, DetailJson (JSON, без секретов). События (каталог
`AuditEvents`): `tenant_login_ok`, `tenant_login_failed`, `operator_login_ok`, `operator_login_failed`,
`invite_created`, `invite_revoked`, `invite_activated`, `tenant_created`, `tenant_status_changed`,
`tenant_limit_changed`, `impersonation_started`. Пишет **только** `AuditService` (модуль Tenants,
порт `IAuditLogStore` → адаптер `AuditLogStore`), вызывается из эндпоинтов/сервисов; UPDATE/DELETE в
приложении отсутствуют (append-only на уровне кода и конвенции; DB-триггеры не добавляем).
Читает — только оператор: `GET /api/operator/audit?eventType=&actorType=&tenantId=&from=&to=&limit=`
(сортировка At DESC, limit ≤500). TTL/авто-очистка — **не делаем** (retention 180 дней и выгрузка —
на усмотрение оператора, документируется в техдок §9); purge-скрипт — вне этапа.
- **Ruling 5 (д) — rate limiting и защита входа.** Реализация — встроенный `AddRateLimiter`
ASP.NET Core (политики-именованные, без нового NuGet) + прикладной guard. Порядок middleware:
SessionMiddleware → OperatorSessionMiddleware → **UseRateLimiter** → OriginGuard → эндпоинты
(политика «api» берёт ключ из `CurrentUser.TenantId` либо IP анонима — SessionMiddleware уже
отработал). Политики и флаги — секция `RateLimit` (класс `RateLimitOptions`): `Enabled` (**false**
в dev/тестах по умолчанию — curl-приёмки не режутся; true в PROD-окружении), `AuthPerMinute`
(10/мин на IP для `/api/auth/login` и `/api/operator/auth/login`), `ApiPerMinute` (600/мин на
тенанта/IP), `GrpcIngressPerMinute` (600/мин на тенанта gRPC-ингресса :5082, интерцептор
`IngressRateLimitInterceptor` — фиксированное окно по metadata `tenant-id`; health освобождён).
Ответ 429 — `{"detail":"Слишком много запросов. Повторите позже"}`. **Лимит попыток входа**
прикладной `LoginAttemptGuard` (singleton, in-memory фиксированное окно по ключу
`ip|normalizedLogin`, как в прототипе лимитов нет — новый): ≥5 неудач за 15 мин → 429 «Слишком
много попыток входа. Попробуйте через 15 минут»; успешный вход сбрасывает счётчик ключа. Один
инстанс core (compose) — in-memory достаточно; multi-instance — задел (зафиксировать в техдок §11).
- **Ruling 6 (е) — mTLS за флагом.** Dev остаётся как есть: plaintext + обязательный service-token
(Ruling 2 этапа 6). Новое: секция/`DEAL_MTLS_*` (`Enabled=false` default, `ServerCertPfx`,
`ServerCertPassword`, `ClientCertPfx`, `ClientCertPassword`, `CaPem`): при `Enabled=true`
(1) Kestrel внутренних gRPC-эндпоинтов (сервисы :5101–5103, ингресс core :5082) включает HTTPS
с серверным сертификатом и **требует** клиентский сертификат (chain → CA из `CaPem`);
(2) исходящие gRPC-клиенты core (Grpc*Client + health-пробы) и сервисы→ингресс подписывают запрос
клиентским сертификатом и проверяют CA сервера. Основной HTTP :5080 core остаётся http — TLS
терминирует Caddy (Ruling 9). Сертификаты генерируются **скриптом `scripts/mtls-certs.sh`**
(openssl: dev-CA + серверные сертификаты на `core`, `telegram-service`, `ai-service`,
`ml-service`, `localhost` + общий клиентский сертификат `deal-client`) в `deploy/certs/`
(в репозиторий не попадают — вне git, но и проект не git: каталог в `.dockerignore`/README-пометка).
Код интерцепторов/контрактов не меняется — меняется только транспорт (решение-рамка этапа 6).
Живое mTLS-рукопожатие — ⚠ Manual.
- **Ruling 7 (ж) — observability: минимально рабочий набор.** Serilog добавляется во **все четыре
процесса** (core + TG/AI/ML): консоль в формате JSON (prod-стиль; dev можно текст) + rolling-файл
`data/logs/deal-*.json` (core — под volume). Секреты/пароли/ключи не логируются (правило уже есть).
OTel-метрики/трейсы и Prometheus **в этапе 7 не добавляем** — объём ограничен, стек фиксируется
как «Serilog-логи → Promtail → Loki → Grafana», метрики ASP.NET Core задекларированы в техдок §7
TODO (решение-рамка архитектуры §9 соблюдена наполовину: структурированные логи + дашборды по
логам/health). Дашборды Grafana — минимальные (health-контейнеры и поиск по логам), provisioning-
файлами (datasource Loki + dashboard JSON), без коммерческих плагинов.
- **Ruling 8 (з) — бэкапы.** `scripts/backup.sh`: (1) Postgres — `docker compose exec -T postgres
pg_dump -Fc` всех схем (public+tenant_*) → `data/backups/pg/`; (2) MinIO — `mc mirror` бакета
`deal-files` в архив (или `docker run`-контейнер minio/mc); (3) файловые volume'ы telegram-сессий и
ML-моделей (`deal_tg_sessions`, `deal_ml_data`) — `docker run --rm -v`-тар (busybox), сессии уже
зашифрованы AES-GCM — архив без доп. шифрования, доступ только root; (4) core `data` (ключ
шифрования DEAL_ENCRYPTION_KEY/файл + attachments, если Local) — тар. Retention: **14 копий**
(find -mtime +14 -delete), имя файла `backup-YYYYMMDD-HHMMSS.*`. Планировщик — вне контейнера:
systemd timer/cron пример в шапке скрипта и техдок §9 (документировано, НЕ ставится скриптом).
Восстановление — раздел в техдок §9 (шаги: поднять compose → pg_restore → распаковать volume →
перезапуск сервисов). Реальный прогон бэкапа и restore-тест — ⚠ Manual (нужен docker).
- **Ruling 9 (и) — compose-prod и границы.** `PROD`: сервисы `postgres` (без host-портов; volume),
`minio` (без host-портов), `core` (:5080 в compose-сети + :5082 ингресс), `telegram-service`,
`ai-service`, `ml-service` (mTLS env из Ruling 6), `caddy` (единственный наружу: 80/443; терминация
TLS `tls internal` — для реального домена заменить на Cloudflare-origin/сертификаты, комментарий в
Caddyfile; статика `src/frontend/dist` + `reverse_proxy /api → core:5080`; security-заголовки),
`loki`/`promtail` (docker-логи по label'ам)/`grafana` (датасорс Loki, dashboard-провижининг,
publish **127.0.0.1:3001:3000** — доступ оператору по SSH-туннелю). Секреты — только из `.env`
(шаблон `.env.prod.example`, **без дефолтных паролей** — fail-fast на отсутствующие);
healthcheck'и как в dev (grpc_health_probe/`pg_isready`); rate limiting включён, CORS — явный
allowlist (`Security:AllowedOrigins`), куки Secure=true. Все сервисы — в одной внутренней сети,
наружу — только caddy. **Вне этапа:** Cloudflare (конфигурация вне кода, документируется), k8s,
биллинг-провайдер, саморегистрация, UI админки, multi-instance rate-limit. Живой подъём PROD —
⚠ Manual; авто-приёмка — `docker compose -f deploy/compose.prod.yml config` (rc=0).
- **Ruling 10 (к) — безопасность-доработки в коде.** (1) Защита входа — Ruling 5. (2) **Origin-
проверка мутаций**: `OriginGuardMiddleware` — для не-GET/HEAD/OPTIONS запросов `/api`, у которых есть
заголовок `Origin`, значение обязано совпасть с Host запроса либо быть в allowlist
`Security:AllowedOrigins` (CORS-дев-режим уже разрешает любой origin — middleware работает только
с явным allowlist из конфига; при пустом списке правило = «Origin == Host»); несовпадение → 403.
SameSite=Lax кук остаётся первым рубежом CSRF (документируется). (3) **Security-заголовки**:
`SecurityHeadersMiddleware` на весь core (X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
Referrer-Policy: no-referrer); CSP/HSTS — на Caddy (фронт-статика; CSP для Vue требует аккуратной
настройки nonce — документируется в техдок §10, фронт не меняется). (4) Секреты/параметризация
SQL/Argon2id — уже есть, новых исключений не вводим. (5) Приостановка тенанта: вход заблокирован
(AuthService проверяет статус тенанта через `ITenantRepository.GetByIdAsync`), ИИ-расход заморожен
(гейт Ruling 3); активные тенант-сессии доживают до expiry (мгновенный разлогин — вне этапа,
документируется). (6) IDOR: tenantId новых сущностей всегда из сессии/реестра, никогда из тела;
перекрёстные проверки — unit-сценарии в задачах-владельцах + сквозной curl-сценарий финальной
задачи (оператор против тенант-ручек и наоборот, чужой инвайт/чужой тенант).
- **Ruling 11 (л) — где живут новые ручки и кто их зовёт.** Операторская админка — **API-only** под
`/api/operator/*` (фронт не трогаем, UI админки — будущий отдельный инкремент): auth (login/logout/
me), тенанты (list/create/status/impersonate), инвайты (list/create/revoke), лимиты (view/change
по тенанту + сводка usage), аудит (list), health (core/БД/сервисы). Публичная активация — `/api/join`.
Ни одна из этих ручек не конфликтует с замороженным контрактом `/api` (api-map §3): тенантные
`/api/admin/*` (`tick`/`fts`/`check-message`) остаются тенантными. Новых SSE-типов нет (используются
существующие `toast`); событий `pipeline_stats`/`boards_changed`/`leads_reclassified` это не касается.
## Задачи
Отчёты — `task-N-report.md` в `.superpowers/sdd/deal-stage7-saas/`. Пути сокращены по Rulings.
### Task 1: SystemSaaS — public-таблицы оператора/инвайтов/лимитов/аудита + миграция
**Files:** Create: `I/Persistence/Entities/{OperatorEntity,OperatorSessionEntity,InviteEntity,
TenantLimitEntity,AuditLogEntity}.cs` (поля по Rulings 1/3/4; PascalCase-свойства), `I/Persistence/
{OperatorConfiguration,OperatorSessionConfiguration,InviteConfiguration,TenantLimitConfiguration,
AuditLogConfiguration}.cs` (ToTable("operators"|"operator_sessions"|"invites"|"tenant_limits"|
"audit_log", "public"); unique: operators.Login, invites.Email **partial** (активные), FK: OperatorSessions
→Operators (Cascade), Invites.CreatedById→Operators (Restrict), TenantLimits→Tenants (Restrict),
AuditLog без FK; индексы AuditLog(At), AuditLog(TenantId, EventType)). Modify: `I/Persistence/
DealDbContext.cs` — DbSet'ы `Operators/OperatorSessions/Invites/TenantLimits/AuditLog` + ApplyConfiguration.
EF: миграция `SystemSaaS` (`dotnet ef migrations add SystemSaaS --context DealDbContext --output-dir
Migrations --project src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`).
**Источники:** Rulings 1/3/4; эталоны TenantEntity/TenantConfiguration и SessionConfiguration;
техдок §13.2 (команда system-миграции).
**Acceptance:** build 0/0; миграция применяется к dev-PG (нужен поднятый `deal-postgres` — если
контейнер не поднят, применение и psql-проверка ⚠ Manual); psql: 5 новых таблиц в `public`,
уникальные индексы на месте. Отчёт: `task-1-report.md`.
### Task 2: Оператор — модели/порт/сервис auth, bootstrap из env, dev-only дефолтный тенант
**Files:** Create: `TM/Application/Models/{StoredOperatorDto,OperatorIdentityDto,OperatorSessionDto,
OperatorLoginResultDto}.cs`; `TM/Application/IOperatorAuthStore.cs` (FindByLogin/Create/FindSession/
CreateSession/DeleteSession/DeleteExpired), `TM/Application/OperatorAuthService.cs` (Login/Logout/
ResolveSession; срок жизни 12 ч, нормализация login, Argon2id через IPasswordHasher — эталон
AuthService), `TM/Application/OperatorBootstrapService.cs` (IHostedService-подобный шаг **внутри**
существующего TenantBootstrapService или отдельным hosted после него — идемпотентно: env
`DEAL_OPERATOR_LOGIN/PASSWORD`, в Development дефолт operator/operator, в Production без env —
warning и пропуск). Modify: `A/Hosting/TenantBootstrapService.cs` — дефолтный тенант создаётся только
в Development/`DEAL_BOOTSTRAP_DEFAULT_TENANT=1` (Ruling 1); `A/Configuration/CookieOptions.cs` или
новый `OperatorCookieOptions` — секция `OperatorCookies` (Name=deal_operator_session, Secure из конфига).
Tests: `T/OperatorAuthServiceTests.cs` (login ok/неверный пароль/нормализация/12 ч expiry),
`T/FakeOperatorAuthStore.cs`; bootstrap (идемпотентность, dev-default, prod-без env → skip).
**Источники:** AuthService/SessionTokens/TenantBootstrapService (эталоны); Rulings 1.
**Acceptance:** build 0/0; unit PASS. Отчёт: `task-2-report.md`.
### Task 3: Оператор — HTTP-контур /api/operator/auth + операторская сессия
**Files:** Create: `A/Middleware/OperatorSessionMiddleware.cs` (кука deal_operator_session →
OperatorAuthService.ResolveSession → `HttpContext.Items["CurrentOperator"]`; pass-through как
SessionMiddleware; Reset не нужен — общий ITenantContext не трогается), `A/Http/AuthHelpers.cs` —
добавить `GetCurrentOperator()`/`RequireOperator` (403 «Требуется вход оператора» или 401 —
согласовать с текстами: для `/api/operator/*` без операторской сессии — **401** `{"detail":
"Требуется вход оператора"}`), `A/Endpoints/OperatorAuthEndpoints.cs` (POST login/logout, GET me —
тела/ответы как AuthEndpoints, текст ошибки «Неверный логин или пароль оператора»). Modify:
`A/Program.cs` — регистрация OperatorAuthService/IOperatorAuthStore (AddTenantsModule расширяется),
`UseMiddleware<OperatorSessionMiddleware>()`, `MapOperatorAuthEndpoints()`, секция OperatorCookies.
Login-попытки пишут аудит-события (Task 4) — на этом шаге заглушка-вызов отсутствует, добавится в Task 4.
**Источники:** AuthEndpoints/SessionMiddleware/CookieOptions (эталоны); api-map §3.1 (форма ответов);
Rulings 1/4.
**Acceptance:** build 0/0; curl-приёмка на :5080 (Postgres поднят): login operator/operator → кука
deal_operator_session + `{ok:true,login}`; GET /api/operator/auth/me → login; неверный пароль → 401;
logout → ok и 401 после; тенантная кука deal_session НЕ проходит на /api/operator/auth/me (401);
операторская кука НЕ проходит на /api/auth/me (401). Отчёт: `task-3-report.md`.
### Task 4: Аудит-поток — AuditService, события входов, чтение оператором
**Files:** Create: `TM/Application/AuditEvents.cs` (константы Ruling 4), `TM/Application/Models/
AuditRecordDto.cs`, `TM/Application/IAuditLogStore.cs` (AppendAsync/QueryAsync(filter)/— без Update/
Delete), `TM/Application/AuditService.cs` (Append через store; хелперы ActorFromUser/Operator),
`I/Persistence/Repositories/AuditLogStore.cs` (EF: Append — Add+Save; Query — фильтры At-range/
EventType/TenantId/ActorType, At DESC, limit ≤500), регистрация в `I/ServiceCollectionExtensions.cs`
(AddDealPersistence). Modify: `A/Endpoints/AuthEndpoints.cs` и `A/Endpoints/OperatorAuthEndpoints.cs` —
после успеха/неудачи login вызывают `AuditService.Append` (tenant_login_ok/failed с login и IP,
operator_login_*); tenant_login_failed пишется и при неверном пароле, и при заблокированном
(suspended) входе (Task 7). Create: `A/Endpoints/OperatorAuditEndpoints.cs` (GET /api/operator/audit
с фильтрами-query; ответ `{items:[…], total}`). Tests: `T/AuditServiceTests.cs`,
`T/FakeAuditLogStore.cs`; endpoint-хелперы фильтров.
**Источники:** Rulings 4; эталон DiscoveryLogService/DiscLog (паттерн лога); техдок §10 (аудит-лог).
**Acceptance:** build 0/0; unit PASS (append-only: у порта нет Update/Delete); curl: failed login →
запись audit (psql или GET /api/operator/audit), успешный login → запись ok. Отчёт: `task-4-report.md`.
### Task 5: Инвайты — сервис/адаптер/операторские ручки + аудит
**Files:** Create: `TM/Application/Models/InviteDto.cs`, `TM/Application/IInviteStore.cs`
(Create/GetByCode/List/UpdateStatus/FindActiveByEmail), `TM/Application/InviteCodeGenerator.cs`
(url-safe, 16 симв.), `TM/Application/InvitesService.cs` (CreateInvite(tenantId?, email) — валидация
email, одна активная на email → 400 «Для этого email уже есть активное приглашение», expiry = +72 ч;
Revoke; List; GetByCode с вычислением статуса expired при чтении), `I/Persistence/Repositories/
InviteStore.cs`. Modify: `A/Endpoints/` — создать `A/Endpoints/OperatorInvitesEndpoints.cs` (GET list,
POST create `{email, tenantId?}`, POST `{code}/revoke`; ответы: create → `{code, email, tenantId?,
expiresAt, status}`; revoke → `{ok:true}`), вызовы AuditService (invite_created/invite_revoked с email
и code в DetailJson). Tests: `T/InvitesServiceTests.cs`, `T/FakeInviteStore.cs` (создание/expiry при
чтении протухшего/revoke/дубль email на активном/revoked позволяет новый); curl-минимум на ручки
(create → list → revoke, 401 без оператора).
**Источники:** Rulings 2/4; эталон DiscoveryTasksService (валидации/статусы); ТЗ §3 (инвайты).
**Acceptance:** build 0/0; unit PASS; curl-сценарий ручек PASS. Отчёт: `task-5-report.md`.
### Task 6: Активация инвайта — POST /api/join (пользователь + тенант + провижининг)
**Files:** Create: `A/Endpoints/JoinEndpoint.cs` (POST /api/join `{code,email,name?,password}`; без
сессии): InvitesService.GetByCode (expired → 400 «Срок действия приглашения истёк»; статус ≠ pending
→ 400 «Приглашение уже использовано»/«отозвано»), сверка email (400 «Email не совпадает с
приглашением»), существующий users.login (400 «Этот email уже зарегистрирован»), создание тенанта
при TenantId=null через `TenantService.CreateTenantAsync(name ?? email, new Guid)` + создание
пользователя `authStore.CreateUserAsync` (Argon2id, login=email), `TenantLimits`-строка с
дефолт-бюджетом (Ruling 3 — вставка через порт `ITenantLimitStore` из Task 8; до Task 8 допускается
прямая вставка адаптером Task 1-таблицы в этой же задаче — см. Task 8), статус invite → activated,
аудит `invite_activated`. Ответ: `{ok:true, login}` (кука НЕ ставится — далее обычный /api/auth/login).
Валидация пароля ≥4 (текст как в AuthEndpoints). Modify: регистрация `MapJoinEndpoint()` в Program.cs.
Tests: `T/JoinFlowTests.cs` — модульный сценарий на Fake-сторах: код+email+пароль → пользователь +
тенант (создан через фейк-провижинер, вызван 1 раз) + invite activated + аудит; ошибки (код/email/
дубль/протух/revoked).
**Источники:** Rulings 2/3/11; TenantService.CreateTenantAsync + AuthService (эталоны создания);
ТЗ §3 (инвайты/регистрация).
**Acceptance:** build 0/0; unit PASS; curl-сценарий: оператор создаёт инвайт → /api/join (новый
email) → psql: тенант в tenants + схема tenant_* провижинена + пользователь в users + invite
activated; повторный /api/join тем же кодом → 400. Отчёт: `task-6-report.md`.
### Task 7: Оператор-тенанты — список/создание/статус/приостановка/impersonation
**Files:** Create: `A/Endpoints/OperatorTenantsEndpoints.cs`: GET /api/operator/tenants (реестр +
счётчики: пользователи, статус, бюджет/использовано — чтение лимитов из Task 8 по мере готовности;
на этом шаге — без лимит-полей или через Task 8-порт после него), POST /api/operator/tenants
`{name, email?, budget?}` — email-опция создаёт сразу пользователя-владельца тенанта (иначе — через
инвайт), PATCH /api/operator/tenants/{id} `{status: "active"|"suspended"}` (аудит tenant_status_changed),
POST /api/operator/tenants/{id}/impersonate `{login?}` — mint сессии целевого пользователя
(переиспользуя механизм AuthService.CreateSession), ответ `{sessionToken, expiresAt, tenantId}` +
аудит `impersonation_started` (DetailJson: targetLogin, tenantId); завершение — logout'ом
пользователя (документируется). Modify: `TM/Application/ITenantRepository.cs` +
`I/Persistence/Repositories/TenantRepository.cs` — `GetByIdAsync`/`UpdateStatusAsync`;
`TM/Application/AuthService.cs` — Login блокирует suspended-тенант (LoginResultDto получает
опциональный `Error = "tenant_suspended"`, endpoint-текст «Учётная запись приостановлена. Обратитесь
к оператору»). Tests: `T/TenantAdminServiceTests`-сценарии или прямо на сервисах (suspend → login
заблокирован; impersonation: оператор ≠ тенант — сессия выдаётся пользователю тенанта, а не
оператору; аудит-записи); IDOR-кейсы: оператор не читает settings тенанта, тенант не вызывает
/operator (403/401 — через curl финальной задачи).
**Источники:** Rulings 1/4/10; TenantService/AuthService/SessionTokens; ТЗ §10 (тенанты/impersonation).
**Acceptance:** build 0/0; unit PASS; curl-минимум: create → suspend → login тенанта 401-текст →
resume → login ok; impersonate → полученный токен работает как deal_session на /api/auth/me.
Отчёт: `task-7-report.md`.
### Task 8: Лимиты-ядро — хранилище/период/рекордер/дефолт-бюджет
**Files:** Create: `TM/Application/Models/{TenantLimitDto,BudgetStateDto}.cs` (BudgetState: TenantId,
BudgetTokens, Period, PeriodStart, UsedTokens, Status, Allowed, Warned80, NotifiedExhausted),
`TM/Application/ITenantLimitStore.cs` (GetOrCreateAsync(tenantId, defaults), GetStateAsync, AddUsageAsync
(инкремент + ленивый reset периода + пересчёт флагов в одной транзакции/сохранении), UpdateBudgetAsync
(сброс флагов), TryMarkWarned/Notified), `TM/Application/TokenBudgetDefaults.cs` (DefaultBudgetTokens
= 10_000_000, Period = month), `TM/Application/TokenBudgetService.cs` (период-математика: начало
периода, ленивый reset, пороги 80/100), `I/Persistence/Repositories/TenantLimitStore.cs` (EF на
DealDbContext; AddUsage — `UPDATE tenant_limits SET UsedTokens = UsedTokens + @n ...` через ExecuteSql
не используем — читаем строку и пишем в транзакции с rowversion-семантикой: одиночный инстанс core,
конкурентность на тенанта сериализована воркер-гейтами; фиксируем простое read-modify-write).
Modify: `I/Integrations/AiUsageLedger.cs` → переименовать/расширить до `TokenUsageRecorder` (добавляет
вызов ITenantLimitStore.AddUsageAsync поверх lifetime-KV `aiTokenUsage`); call-site'ы в
`I/Integrations/GrpcAiClassifier.cs` и `I/Integrations/GrpcAiTools.cs`. Тесты: `T/TokenBudgetServiceTests.cs`
(reset месяца/дня, пороги, дефолты), `T/FakeTenantLimitStore.cs`.
**Источники:** Rulings 3/4; AiUsageLedger/GrpcAiClassifier (эталон учёта); ТЗ §9; архитектура §19.
**Acceptance:** build 0/0; unit PASS (ленивый reset: запись с PeriodStart прошлого месяца обнуляет
UsedTokens и ставит новый PeriodStart; порог 80% выставляет Warned80). Отчёт: `task-8-report.md`.
### Task 9: Бюджетный гейт ИИ + fallback-декораторы + SSE-уведомления
**Files:** Create: `I/Integrations/BudgetedAiClassifier.cs`, `I/Integrations/BudgetedAiTools.cs`
(декораторы портов IAiClassifier/IAiTools: перед каждым вызовом `ITokenBudgetGate` (или
ITenantLimitStore.GetStateAsync + TokenBudgetService) — исчерпано/suspended → Local-реализации
(классификатор/фильтр) или `AiUnavailableException` (инструменты); gRPC-адаптеры не меняются),
`A/Hosting/BudgetAlertScheduler.cs` (60 с, per-tenant: GetState → переход 80/100% → SseBroker-тост +
TryMarkWarned/Notified; сброс флагов при смене бюджета уже в Task 8). Modify: `I/Integrations/
ServiceCollectionExtensions.cs`/`AddDealIntegrations` — регистрация декораторов только при
`Services:Ai:UseLocal=false` (порядок: Grpc → Budgeted → наружу), регистрация `TokenUsageRecorder`,
`TokenBudgetService`, `ITenantLimitStore` (scoped), BudgetAlertScheduler в `A/Program.cs`. Тесты:
`T/BudgetedAiClassifierTests.cs` (лимит 0 → Local-ветка; лимит большой → gRPC-фейк вызван;
suspended → Local), `T/BudgetedAiToolsTests.cs` (исчерпано → AiUnavailableException), тест
`BudgetAlertScheduler`-логики на фейках (тост один раз на порог).
**Источники:** Rulings 3/5/7/11; LocalAiClassifier/LocalAiTools/AiUnavailableException (эталон
fallback); StorageTickScheduler/SseBroker (эталон тостов); ТЗ §9.
**Acceptance:** build 0/0; unit PASS. Отчёт: `task-9-report.md`.
### Task 10: Оператор-лимиты/usage/health — эндпоинты
**Files:** Create: `A/Endpoints/OperatorLimitsEndpoints.cs` (GET /api/operator/limits — сводка по всем
тенантам `{items:[{tenantId, name, budget, period, used, percent, status}]}`; GET/PATCH
/api/operator/tenants/{id}/limit — просмотр/смена `{budget?, period?}`; PATCH сбрасывает
Warned80/NotifiedExhausted; аудит tenant_limit_changed), `A/Endpoints/OperatorHealthEndpoints.cs`
(GET /api/operator/health: core+БД (`SELECT 1` через DealDbContext) + gRPC-health ml/ai/telegram по
`Services:*:Endpoint` через `Grpc.HealthCheck`-клиента; при UseLocal=true — `{reachable:false,
mode:"local"}`), `I/Integrations/ServiceHealthProbe.cs` (gRPC health-проба с таймаутом 3 с, клиентские
сертификаты из Ruling 6-конфига). Tests: `T/ServiceHealthProbeTests.cs` (in-proc health-сервер фейк),
хелперы percent-расчёта.
**Источники:** Rulings 3/9/11; DiscoveryEndpoints (формат items), техдок §8 (health); ТЗ §10 (health,
лимиты).
**Acceptance:** build 0/0; unit PASS; curl: GET/PATCH лимита оператором (psql-проверка строки),
health-эндпоинт 200 (в dev Local-режиме сервисы помечены local). Отчёт: `task-10-report.md`.
### Task 11: Rate limiting (приложение + gRPC-ингресс) и защита входа
**Files:** Create: `A/Configuration/RateLimitOptions.cs` (Enabled, AuthPerMinute=10, ApiPerMinute=600,
GrpcIngressPerMinute=600, LoginAttemptsMax=5, LoginAttemptWindowMin=15), `A/Middleware/
RateLimitPolicies.cs` (AddRateLimiter: политики `auth` — fixed window по IP, `api` — по
`CurrentUser.TenantId`/IP анонима; OnRejected → 429 `{detail:"Слишком много запросов. Повторите
позже"}`), `A/Http/LoginAttemptGuard.cs` (in-memory окно `ip|login`, блок 15 мин после 5 неудач,
сброс при успехе), `A/Telegram/IngressRateLimitInterceptor.cs` (gRPC: фиксированное окно по
metadata tenant-id, health-метод освобождён). Modify: `A/Program.cs` — `AddRateLimiter` (если
Enabled), порядок middleware (Session → Operator → RateLimiter), RequireRateLimiting на группах
auth/operator/auth; `A/Endpoints/AuthEndpoints.cs`/`OperatorAuthEndpoints.cs` — вызов
LoginAttemptGuard до AuthService; `A/Program.cs` Kestrel-gRPC — AddGrpc interceptor при Enabled.
Tests: `T/LoginAttemptGuardTests.cs` (5 неудач → блок, успех сбрасывает), unit политики-ключей
(tenant vs IP), interceptor-окно.
**Источники:** Rulings 5/10; IngressServiceTokenInterceptor (эталон); техдок §8/§10 (rate limit).
**Acceptance:** build 0/0; unit PASS; dev-прогон не режет curl-приёмки (Enabled=false). Отчёт:
`task-11-report.md`.
### Task 12: Безопасность — Origin-проверка, security-заголовки, CORS-allowlist
**Files:** Create: `A/Configuration/SecurityOptions.cs` (AllowedOrigins string[]), `A/Middleware/
OriginGuardMiddleware.cs` (не-GET/HEAD/OPTIONS и есть Origin → Origin ∈ {Host} AllowedOrigins, иначе
403), `A/Middleware/SecurityHeadersMiddleware.cs` (X-Content-Type-Options/X-Frame-Options/
Referrer-Policy). Modify: `A/Program.cs` — порядок middleware и регистрация (после RateLimiter),
CORS-политика: при пустом AllowedOrigins — dev-режим «любой» (текущий), при непустом — строгий
allowlist+credentials (для PROD). Tests: `T/OriginGuardTests.cs` (совпадение Host ok, чужой Origin →
403, allowlist ok, GET без Origin ok), headers-присутствие (in-proc host или unit на делегате).
**Источники:** Rulings 10; архитектура §8 (CSRF/XSS/headers); техдок §10 (прокси-заголовки — теперь
и кодом).
**Acceptance:** build 0/0; unit PASS; curl: мутация с `Origin: http://evil` → 403, без Origin → ok.
Отчёт: `task-12-report.md`.
### Task 13: mTLS — флаг/сертификаты в 4 процессах + скрипт генерации
**Files:** Create: `scripts/mtls-certs.sh` (openssl: CA + серверные PFX для core/telegram/ai/ml +
клиентский сертификат deal-client; SAN: localhost + имена compose-сервисов; вывод в
`deploy/certs/`), в каждом процессе класс `MtlsOptions` (Enabled/ServerCertPfx/ServerCertPassword/
ClientCertPfx/ClientCertPassword/CaPem; env `DEAL_MTLS_*`) и его применение: Modify: `TG/Deal.Telegram/
Program.cs`, `AI/Deal.Ai/Program.cs`, `ML/Deal.Ml/Program.cs` (Kestrel gRPC-endpoint: `UseHttps(serverPfx,
opts => opts.ClientCertificateMode = RequireCertificate; opts.ClientCertificateValidation = цепочка на
CaPem)`; исходящий канал в core-ингресс — клиентский сертификат), `A/Program.cs` (ингресс :5082 —
аналогично) и `I/Integrations/*GrpcConnection.cs` (каналы: HttpClientHandler с клиентским
сертификатом + проверка CA, только при Enabled). Dev-дефолт неизменен (plaintext). Тесты: unit на
опции/загрузку сертификата из файла (тестовые PFX генерируются в тесте скриптом? нет — фиктивные
сертификаты через `CertificateRequest` в памяти). Живое mTLS-рукопожатие между контейнерами —
⚠ Manual.
**Источники:** Rulings 6; этап 6 Ruling 2 (рамка dev/prod); техдок §8/§10; архитектура §8.
**Acceptance:** build 0/0 (все sln); `sh -n scripts/mtls-certs.sh`; unit PASS; PROD-compose-файл
ссылается на env mTLS (Task 14). Отчёт: `task-13-report.md`.
### Task 14: Observability + compose.prod (Caddy/Loki/Promtail/Grafana)
**Files:** Create/Modify: Serilog — `A/Program.cs`, `TG|AI|ML/.../Program.cs` (Serilog JSON console +
rolling file `data/logs/`; конфиг из appsettings/env; секреты не логируются), csproj'ы + пакеты.
Create: `PROD` (postgres/minio/core/3 сервиса по compose.dev.yml-образцу, но: без host-портов у
хранилищ, mTLS-env из Ruling 6, rate-limit/CORS/куки-Secure-флаги, `depends_on`-healthcheck'и,
frontend-сборка — из `src/frontend/dist` volume, комментарий), `deploy/caddy/Caddyfile`
(80/443, `tls internal`, статика dist, `reverse_proxy /api/* core:5080`, security-заголовки,
CSP-комментарий), `deploy/observability/promtail.yml` (docker_sd, labels, loki-адрес),
`deploy/observability/loki.yml` (local-storage, retention 7d), `deploy/observability/grafana/
{datasources.yml, dashboards/Deal-Health.json}` (Loki-датасорс, минимальный health/лог-дашборд),
`deploy/.env.prod.example` (все секреты БЕЗ значений-дефолтов). Modify: техдок §7/§8 (актуализация
под реальные файлы) — в Task 16 (доки). Acceptance-без-docker: `docker compose -f PROD config` rc=0
(если docker CLI недоступен — ⚠ Manual). Живой подъём PROD-стека — ⚠ Manual.
**Источники:** Rulings 6/7/9; compose.dev.yml (эталон); техдок §7/§8/§10; архитектура §9/§10.
**Acceptance:** build 0/0 всех sln; старт Api (dev, без docker) показывает JSON-логи в консоли/файле;
`PROD config` валиден. Отчёт: `task-14-report.md`.
### Task 15: Бэкапы — scripts/backup.sh + документация восстановления
**Files:** Create: `scripts/backup.sh` (Ruling 8: pg_dump -Fc через compose exec; mc mirror MinIO или
minio/mc-контейнер; tar volume'ов сессий/ML/core-data через busybox-контейнер; retention 14;
имена `backup-<ts>.*`; trap-очистка; заголовок с примером systemd-timer/cron; exit non-zero при
сбое любого шага), `docs/technical/...` §9 — раздел «Восстановление» (шаги pg_restore/распаковка
volume/перезапуск; тест восстановления раз в месяц) — в Task 16. Acceptance: `sh -n scripts/backup.sh`;
прогон скрипта и restore-тест — ⚠ Manual (нужен docker-стек PROD/DEV).
**Источники:** Rulings 8; техдок §9 (текущий текст — основа); архитектура §18 (ежедневные бэкапы).
**Acceptance:** `sh -n` rc=0; скрипт покрывает 4 источника данных из Ruling 8; retention-логика
читаема. Отчёт: `task-15-report.md`.
### Task 16: Финал — доки, сквозная SaaS-приёмка, полный прогон
- **Доки:** техдок — §11 (TODO-сводка: закрыть пункты этапа 7, оставить только реальные заделы:
OTel-метрики, multi-instance rate-limit, мгновенный разлогин suspended, ML-экспорт, reclassify на
реальном ИИ, мультиаккаунтность, k8s/биллинг/саморегистрация/UI-админки), новый блок §13.8 (этап 7:
оператор/инвайты/лимиты/аудит/rate-limit/mTLS/бэкапы/compose-prod — быстрый старт оператора),
§7/§8/§9/§10 актуализируются по ходу (compose.prod, бэкапы-restore, Serilog/Loki, mTLS-флаги,
Origin/заголовки, ограничения in-memory guard); api-map — раздел «Этап 7 (API-only, фронт не
вызывает)»: /api/operator/* + /api/join (формы/ответы); roadmap — этап 7 «Выполнено» (ограничения
→ заделы), «Открытые точки» — закрыть п.2 (инвайты/оператор реализованы, dev-seed остаётся dev-only);
STATUS.md — строка этапа 7 ✅, проценты, «Итого»; `docs/user-guide/Инструкция-пользователя-Дейл.md` —
раздел «Регистрация по приглашению» (как оператор пришлёт, как активировать, что такое бюджет ИИ и
fallback-уведомление).
- **Сквозная SaaS-curl-приёмка** (dev-stack, Postgres; без docker-сервисов — AI в Local-режиме,
бюджет-сценарий проверяется через Local-счётчики/прямые вызовы; полный стек с сервисами —
⚠ Manual после поднятия Docker, `sh scripts/dev-smoke.sh` + бюджет-прогон): оператор login →
создать тенанта → инвайт → /api/join → вход тенанта → работа /api (me/settings) → оператор:
лимит-бюджет мал → симуляция ИИ-вызова (через recorder) → fallback-декоратор (Local-ветка) →
тост-флаг в tenant_limits → аудит-лента (входы/инвайты/impersonation) → suspend → login 401 →
resume → IDOR-негативы (тенант на /operator → 401, чужой tenantId в /operator-фильтрах не отдаёт
чужие данные, чужой инвайт-код/email → 400).
- **Полный прогон:** `scripts/build.sh` + `scripts/test.sh` (830 + новые unit PASS), build каждого
сервисного sln 0/0; итоговые числа в отчёт.
- Отчёт `task-16-report.md` + финальная строка `progress.md`.
**Источники:** Rulings 1–11; все предыдущие задачи; паттерны финальных задач этапов 1–6.
**Acceptance:** пункты выше; живые проверки (docker-стек, mTLS, бэкап, реальные LLM/Telegram) —
⚠ Manual и помечены в отчёте.
## Self-Review
1. **Spec coverage:** оператор/роли/изоляция — Rulings 1, Task 2/3/7; инвайты + invite-only +
email-unique — Rulings 2, Task 5/6 (ТЗ §3); лимиты-бюджеты/fallback/уведомление — Rulings 3,
Task 8/9 (ТЗ §9); админка (тенанты/статусы/лимиты/health/impersonation/аудит/подозрительная
активность) — Rulings 4/11, Task 4/5/7/10 (ТЗ §10; «подозрительная активность» = операторский
фильтр по audit eventType login_failed, документируется); rate limiting + попытки входа —
Ruling 5, Task 11; mTLS/service-token — Ruling 6, Task 13; observability (Serilog+Loki+Grafana,
минимально) — Ruling 7, Task 14; бэкапы — Ruling 8, Task 15; compose-prod — Ruling 9, Task 14;
безопасность-доработки (Origin/headers/IDOR/приостановка) — Ruling 10, Task 7/12 (+IDOR-кейсы в
Task 57, сквозные в Task 16); финальные доки/приёмка — Task 16. Решения владельца учтены: dev-seed
admin/admin остаётся dev-only (Ruling 1), «ELF — B» = Loki+Promtail+Grafana (Ruling 7), «Админка А»
= API-контур оператора без фронта (Rulings 1/11), бэкапы раз в сутки (Ruling 8), лимиты в токенах с
fallback (Ruling 3), «compose, k8s отложен» (Ruling 9).
2. **Placeholder scan:** TODO/«позже сделать» не закладывается внутрь задач; осознанно вынесено за
этап (см. п.4). AiUsageLedger не «висит» дублирующим механизмом — он становится TokenUsageRecorder
с той же точкой вызова (Ruling 3). Fallback-семантика переиспользует существующие Local-реализации,
новых «заглушек» не появляется. OpenAPI-карта операторских ручек фиксируется в api-map (Task 16),
отдельной спеки не создаём.
3. **Type consistency:** все новые порты — в модуле Tenants (`IOperatorAuthStore`/`IInviteStore`/
`ITenantLimitStore`/`IAuditLogStore`), адаптеры — `I/Persistence/Repositories/*` (регистрация в
AddDealPersistence), сервисы — `TM/Application/*` (реестр AddTenantsModule расширяется в Task 3);
декораторы бюджета реализуют **существующие** порты IAiClassifier/IAiTools и регистрируются
последними в AddDealIntegrations (внешний контракт для PL/Discovery не меняется); изменения
AuthService/LoginResultDto — обратносовместимы (опциональное поле); TenantBootstrapService меняет
только условие создания дефолтного тенанта (dev/prod), провижининг — всегда. Циклов ссылок нет:
TM не знает Api/Infrastructure, Infrastructure оркестрирует, Api вызывает сервисы модуля и шлёт SSE.
4. **Вне scope этапа 7 (заделы):** UI операторской админки и UI активации (API-only + curl);
OTel-метрики/Prometheus и дашборды метрик (задекларировано, Ruling 7); multi-instance rate-limit и
бэкенд для попыток входа (in-memory, один инстанс); мгновенный разлогин suspended-сессий;
экспорт/импорт ML-моделей; reclassify на реальном ИИ; мультиаккаунтность Telegram на тенанта;
биллинг-провайдер/планы; k8s/Cloudflare-конфигурация; purge/retention-автоматика audit_log;
auto-purge tenant_limits-истории. Все перечислены в техдок §11 (Task 16).
⚠ **Manual-пункты этапа (требуют docker/живых кред):** применение system-миграции и curl-приёмки без
поднятого `deal-postgres` невозможны (Postgres — контейнер dev-stack, поднимается по требованию);
живой подъём `compose.prod.yml` и `dev-smoke.sh`-прогон полного стека с сервисами (Task 14/16);
mTLS-рукопожатие между контейнерами (Task 13); реальный прогон `scripts/backup.sh` и restore-тест
(Task 15); реальные LLM/Telegram-проверки — с кредами (вне этапа, как и в этапе 6).
@@ -0,0 +1,71 @@
# Дейл (Deal) — Этап 9: единая карточка (unified card) Implementation Plan
> Исторический документ этапа 9. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** Устранить дуальность «карточка канбана / проектная карточка». Одна сущность **карточка**
(ядро id/title/source + опциональные модули) работает во всех дашбордах; «лид» как понятие и
`ProjectCards`-дублирование упраздняются; колонки/стадии/зоны — единый контейнер с политиками.
Бэк (C#) и фронт (Vue) переписываются на единую модель; данные тестовые, схема пересоздаётся.
**Spec:** `docs/architecture/2026-09-09-unified-card.md`; ТЗ: `docs/spec/ТЗ-дейл-новая-архитектура.md`
(термины §2, карточка §5.5, канбаны §6); код: модули Kanban/Projects/Pipeline, Deal.Infrastructure
(миграции/адаптеры), Deal.Api (LeadsEndpoints/ProjectsEndpoints/PipelineEndpoints), фронт
`store/{leads,projects}.js`, компоненты LeadCard/ProjectCard/LeadDrawer/ProjectDrawer/Column/ProjectColumn.
## Global Constraints
- Проект **НЕ git**; фиксация — отчёты `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage9-unified-card/`.
- .NET 10; sln собираются 0 warnings/0 errors; dev-Postgres `deal-postgres` (:5433); системные миграции —
`dotnet ef database update --context DealDbContext` из `src/core`; tenant-миграции — провижинер на старте.
- Код-стайл: 1 тип = 1 файл; XML-doc на public; русские комментарии; без регионов; без магических чисел;
времена `DateTimeOffset` (UTC); JSON camelCase; ошибки API — `{detail}`.
- Фронт: Vue 3 + чистый JS, без TS/роутера; Composition API; `npm run build` зелёный после каждого шага.
- Тесты: core `Deal.Tests.Unit` (1139), telegram 118, ai 52, ml 38 — прогон после каждой фазы.
- Секреты — только env (`DEAL_*`).
- Вне рамок: Kafka, k8s, саморегистрация, «третий» дашборд (архитектура готова, реализация — позже).
## Ключевые решения (Rulings этапа)
- **R1 — единый агрегат карточки.** Ядро `Card { Id, Title, Source }`; модули-роли (контент, бюджет,
контакты, атрибуты, комментарии, ссылки, файлы, ТЗ, история, напоминание, размещение) — опциональные
части агрегата (jsonb/колонки одной таблицы), а не классы-наследники. Вид = композиция модулей.
- **R2 — Source.** `ISource` + варианты: Local/Web/File/Telegram/Row/Api/Ai/Composite (Origin+Pipeline).
У карточки из пайплайна — `Composite(Origin: Telegram, Pipeline: [Ai/ML])`.
- **R3 — единый контейнер.** Одна таблица/реестр контейнеров (kind: inbox/board/stage/archive/trash/
terminal), политики — роли (`IContainerPolicy`), не enum-свойства. Стадии «Выбранных» — контейнеры
kind=stage (предзаданный каталог), доски — kind=board (создаёт пользователь/ИИ).
- **R4 — переход.** Один `ICardMover.MoveAsync(card, toContainerId, ctx)`; «взять в работу» = переход в
контейнер planned той же карточки (никакого `col=taken` + клона в ProjectCards); «Выбранные → архив/
корзина дашборда» запрещено политикой пространства; терминальные зоны — политика.
- **R5 — API.** `/api/cards` + `/api/containers` (единый контракт); `/api/leads`, `/api/projects`
упраздняются; фронт переписывается. SSE-события переходят на карточки.
- **R6 — пайплайн.** Создаёт карточку (не «лид»): `CardComposer``ICardStore.Add`; дедуп/отсев/ML/ИИ
не знают «лидов». Названия в коде/БД: lead→card, project card→card in stage-container.
## Задачи этапа
- **T1. Доменные контракты единой карточки (C#)** — модуль Cards: ICard/ICard<TSource>, ISource-иерархия,
модули-роли, IContainer/IContainerPolicy, ICardMover; реестры (контейнеры по умолчанию, стадии,
SourceKind). Без изменения поведения текущих модулей (новые типы + тесты чистых правил).
- **T2. EF-модель и миграция** — одна таблица `Cards` (общие поля + jsonb-модули + source + container_id),
таблица `Containers` (доски/стадии/зоны), удаление ProjectCards/LeadComments-дублей; системная и
tenant-миграции; провижининг контейнеров по умолчанию.
- **T3. Адаптер ICardStore** — единый EF-адаптер (слияние KanbanStore/ProjectStore), чтение/запись
карточки целиком (jsonb-модули), контейнеры, атомарные append (комментарии/ссылки/файлы), move с
историей/напоминаниями.
- **T4. Сервисы карточек/контейнеров** — CardsService (переходы, правила колонок, обучение ML),
ContainersService (CRUD колонок, принятие ИИ-предложений, reorder), перенос логики Projects
(файлы/ТЗ/напоминания/история) в модули карточки.
- **T5. Pipeline** — создание карточки через ICardStore; терминология; дедуп на карточку.
- **T6. API единый** — `/api/cards` и `/api/containers`; SSE; удаление старых ручек; интеграционные
тесты/curl-приёмка.
- **T7. ML-сервис/контракты** — обучение на действиях с карточками (колонки/стадии едино), без «lead».
- **T8. Фронт: store** — единый слайс карточек/контейнеров вместо leads.js+projects.js; API-клиент.
- **T9. Фронт: компоненты** — единые LeadCard-база→Card, Column/ProjectColumn→ContainerColumn,
LeadDrawer/ProjectDrawer→CardDrawer; экраны Дашборд/«Выбранные» — один канбан по пространству.
- **T10. Финал** — сквозная приёмка, доки (ТЗ/техдок/api-map), чистка, ledger.
## Порядок и зависимости
T1 → T2 → T3 → (T4, T5) → T6 → T7 → (T8, T9) → T10. Каждая задача завершается зелёной сборкой и
прогоном тестов; API-контракт меняется один раз на T6 (до этого новые типы живут рядом со старыми).
@@ -0,0 +1,60 @@
# Дейл (Deal) — Этап 10: оператор-консоль, аналитика и аудит действий
> Исторический документ этапа 10. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** закрыть SaaS-контур снаружи: UI операторской админки и страница активации инвайта; сквозной
аудит (входы/выходы/действия пользователей); аналитика расхода токенов; дашборды по логам (ELK/Loki).
**Контекст:** этапы 0–9 завершены. Операторский API уже есть (`/api/operator/*`: auth, tenants, invites,
limits, audit, health; `/api/join`), но **UI отсутствует**. Аудит (`public.audit_log`, append-only) покрывает
SaaS-события (входы, инвайты, тенанты, лимиты, impersonation), но **не покрывает выходы и действия
тенант-пользователей**. Расход токенов хранится агрегатом (`public.tenant_limits.UsedTokens`), **истории нет**.
## Решения этапа
- **A1. Роутинг фронта.** Проект без vue-router. Ввести минимальный hash-роутер: `#/` — основное
приложение (как сейчас), `#/operator` — консоль, `#/join?code=…` — активация инвайта. Без новых зависимостей.
- **A2. Аудит — единая точка.** Только `AuditService` пишет в `public.audit_log` (append-only).
Действия тенант-пользователей пишутся оттуда же (actor=tenant). Секреты не логируются.
- **A3. Расход токенов — событийная история.** Новая таблица `public.token_usage_events`
(time-series: тенант, время, провайдер, модель, вид (ai|ml), токены). Агрегат `tenant_limits`
остаётся для гейта; история — для аналитики.
- **A4. Аналитика — операторские read-only эндпоинты** под `/api/operator/analytics/*`; никаких
изменений существующих контрактов (только расширение `/api/operator/audit` пагинацией/фильтром actorId).
- **A5. ELK.** Логи структурированы Serilog JSON. Аналитика по логам — Grafana/Loki: provisioning
datasource + дашборды (входы/выходы/неудачные входы, ошибки, RPS, действия).
## Задачи
- **T1. Аудит действий (бэк).** Дополнить `AuditEvents`: `tenant_logout`, `operator_logout`,
`invite_joined` (активация/join), действия карточек (`card_created`, `card_moved`, `card_trashed`,
`card_restored`, `card_deleted`, `card_comment_added`), контейнеры (`container_created`,
`container_updated`, `container_deleted`), настройки (`settings_updated`), каналы
(`channel_enabled`/`channel_created`), Telegram (`telegram_linked`). Записать в соответствующих
сервисах/эндпоинтах (без секретов). Войти обязаны: logout тенанта и оператора.
- **T2. История расхода токенов (бэк).** Таблица `public.token_usage_events` + EF-конфигурация +
системная миграция. Запись события в точке списания токенов (AI- и ML-путь). Порт для чтения
агрегатов/серий.
- **T3. Аналитика (бэк).** `/api/operator/analytics/overview`, `/tokens`, `/activity`; расширить
`/api/operator/audit` (offset/пагинация, actorId, total). Контракт:
`docs/architecture/2026-09-10-operator-analytics-contract.md`.
- **T4. Оператор-консоль (фронт).** Hash-роутер; экраны: вход оператора, тенанты (список/создать/
suspend/resume/impersonate), инвайты (создать/отозвать/ссылка), лимиты (список/правка), аудит-лента
(фильтры/пагинация), аналитика (обзор/токены/действия).
- **T5. Страница активации (фронт).** `#/join?code=…` → форма (email/имя/пароль) → `POST /api/join`.
- **T6. Наблюдаемость (ELK/Loki).** Grafana provisioning (datasource Loki + дашборды), promtail-лейблы;
дашборды: входы/выходы/неудачные входы, ошибки 5xx, RPS, действия пользователей.
- **T7. Приёмка/доки.** Сквозная проверка (operator → tenant → invite → join → действия → аудит/аналитика),
обновить `docs/api`, `docs/technical`, `docs/user-guide`, `docs/superpowers/STATUS.md`.
## Границы
- Kafka/k8s/биллинг/саморегистрация — вне рамок.
- Реальные Telegram/LLM-креды — не требуются (аналитика токенов наполняется на любых AI/ML-вызовах).
- Данные тестовые; схема system (`public`) расширяется одной миграцией.
## Порядок
T1+T2+T3 (бэк, контракт) → T4+T5 (фронт по контракту) → T6 (наблюдаемость, параллельно) → T7 (приёмка).
Каждая задача: `dotnet build Deal.sln` 0/0, core-тесты зелёные, `npm run build` зелёный.
@@ -0,0 +1,71 @@
# Дейл (Deal) — Этап 11: Локализация интерфейса (i18n)
> Исторический документ этапа 11. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> Статус: план (не начат). Требование владельца от 2026-09-10.
> Связанные документы: `docs/superpowers/plans/2026-09-05-deal-roadmap.md` (Этап 11),
> `docs/spec/ТЗ-дейл-новая-архитектура.md` (§11, локализация), `docs/superpowers/STATUS.md` (Заделы).
## Цель
Весь интерфейс — на русском; **все** пользовательские тексты вынесены в ресурсы (словари), чтобы
можно было добавлять новые языки и менять язык **на лету**. Русский — язык по умолчанию.
## Требования
- **Русский по умолчанию.** Все видимые строки UI: экраны, кнопки, подписи, заголовки, пустые состояния,
подсказки, тултипы, тексты подтверждений, уведомления/тосты, страницы оператора и активации инвайта.
- **Без хардкода.** Ни одна пользовательская строка не хранится в компонентах/шаблонах напрямую —
только ключ в словаре. Технические строки (id/ключи/логи) не локализуются.
- **Ошибки API.** Ответы бэка остаются `{detail}` + HTTP-код; фронт показывает локализованный текст по
коду/ключу ошибки (расширяемый словарь ошибок). При необходимости бэк отдаёт код ошибки, а не только текст.
- **Переключение на лету.** Смена языка без перезагрузки страницы; выбранный язык сохраняется
(localStorage/настройки пользователя) и восстанавливается при входе.
- **Расширяемость.** Новый язык = новый файл словаря (+ регистрация), без правок компонентов.
- **Форматирование.** Даты/время/числа/валюты — через i18n-форматтеры; плюрализация — по правилам языка.
Бэкенд-форматирование human-меток («только что», «N мин») — перевести на клиентские форматтеры или ключи.
- **Ключи.** Стабильные, сгруппированные по областям (`nav/`, `cards/`, `settings/`, `operator/`, `errors/`…).
Отсутствующий ключ в языке → фолбэк на русский (и, при необходимости, лог о пропуске).
## Область
- Основное приложение: дашборд, «Выбранные», настройки (все вкладки), каналы, обработка/состояние, вход.
- Оператор-консоль (этап 10): все разделы + страница активации инвайта.
## Объём (по факту кода на 2026-09-10)
- 69 `.vue` + 20 `.js`; ~708 строковых литералов на кириллице в ~67 файлах
(components ≈478, views ≈300, store ≈82) + текст прямо в шаблонах.
- Области: навигация/шапка, карточки и колонки, драйвер карточки, настройки (все вкладки), каналы,
обработка/состояние, вход, оператор-консоль (все разделы), страница активации, тосты/подтверждения.
## Решение владельца (2026-09-10)
- На этом этапе — **только русский**. Переключатель языка и второй язык — **в бэклоге**: делаем, когда
возникнет потребность (см. «Отложено» ниже).
- Задача этапа — **вынести все строки в ресурсы**, чтобы язык можно было добавить позже без правок компонентов.
- Визуал и тексты — **1:1 с текущими** (вынос не меняет отображаемый текст).
## Задачи
- **T1. i18n-ядро (без тяжёлых зависимостей).** Composable/модуль: `t(key, params)`, реактивный `locale`
(значение по умолчанию `ru`), `setLocale()` (архитектурно готов, UI-переключателя нет), загрузка
словарей, фолбэк на ru при отсутствии ключа. `src/i18n/` + `locales/ru.js`.
- **T2. Инвентаризация и словарь ru.** Вынести все строки в `locales/ru.js`, ключи сгруппированы по
областям (`common/`, `nav/`, `cards/`, `drawer/`, `settings/`, `channels/`, `processing/`, `auth/`,
`operator/`, `join/`, `errors/`). Значения — 1:1 с текущими.
- **T3. Миграция основного приложения** на `t()` (компоненты + вьюхи + store-слайсы).
- **T4. Миграция оператор-консоли и страницы активации** (`operator/`, `join/`).
- **T5. Локализация ошибок/статусов.** Маппинг известных `{detail}`/HTTP-кодов и статусов на ключи
(`errors/*`); неизвестное — как есть.
- **T6. Проверки.** Скрипт-«линтер»: нет кириллицы в шаблонах/логике вне словарей; `npm run build` зелёный.
- **T7. Доки и STATUS.** Инструкция/техдок: устройство i18n и как добавить язык позже.
### Отложено (в бэклоге — делаем при появлении потребности)
- Переключатель языка в UI и второй язык (en) — при потребности (ядро/`registerLocale` готовы).
- Форматтеры Intl/плюрализация — вместе с языком.
## Границы
- Машинный автоперевод не делаем — словари добавляются вручную.
- Локализация писем/внешних уведомлений — если появятся, отдельной задачей.
@@ -0,0 +1,46 @@
# Дейл (Deal) — Этап 12: Наблюдаемость, устойчивость и производительность
> Исторический документ этапа 12. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
**Goal:** закрыть автономные заделы (без кредов и продуктовых решений): метрики Prometheus, распределённый
rate-limit и инвалидация сессий, авто-очистки, перф фронта/бэка.
**Пакеты (порядок исполнения A → B → C → D).**
## Пакет A — Метрики (Prometheus + Grafana)
- Экспорт метрик по всем 4 процессам: HTTP/gRPC RPS, latency (p50/p95), ошибки 5xx, активные сессии,
глубины очередей (pipeline, ML-outbox), счётчики токенов/аудита.
- Общая обвязка для 3 сервисов — в `Deal.Grpc.Hosting`; core — в `Deal.Api`.
- Эндпоинт `/metrics` (Prometheus-формат); сервис `prometheus` в профиле observability (`deploy/compose*.yml`),
scrape-конфиг, Grafana-дашборды метрик + провайжининг datasource Prometheus.
- Документация: как поднять профиль, где графики.
## Пакет B — Безопасность/устойчивость
- Распределённый rate-limit (хранилище на Postgres — без новой инфры) вместо in-memory; бэкенд учёта
попыток входа (`LoginAttemptGuard`) на Postgres.
- Мгновенный разлогин suspended-сессий: при suspend тенанта активные сессии перестают действовать (проверка
статуса/инвалидация).
- Авто-purge `audit_log` (retention, настройка/константа) и auto-purge истории `tenant_limits`.
- Юнит-тесты + curl-приёмка в Docker.
## Пакет C — Производительность
- Фронт: вынести словарь i18n в ленивый чанк (устранить предупреждение >500 kB); пагинация/виртуализация
длинных колонок.
- telegram-service: LRU-кэши WTelegram (снижение памяти).
- Механизм миграций на 1000 схем (производительность провижининга).
- Линтер i18n включить в общий прогон `scripts/test.sh`.
## Пакет D — ИИ/ML без кредов
- `reclassify` на реальном ИИ: проводка + graceful-fallback/заглушка без кредов; тесты на Local-stub.
- Расширение учёта токенов ML-пути (метрики/события).
## Границы
- Не входит (нужны креды/решения владельца): реальный Telegram-вход, живые LLM-вызовы, биллинг/планы,
саморегистрация, Kafka, k8s/Cloudflare, ML export/import, переключатель языка/второй язык (в бэклоге — по потребности).
- Каждый пакет: build 0/0, core-тесты, `npm run build`; при поднятии Docker — приёмка и **полная остановка**
в конце (правило «без хвостов»).
@@ -0,0 +1,35 @@
# План: закрытие остатков код-стайла (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` не прогонялись.
@@ -0,0 +1,228 @@
# Ревью качества кода «Дейл» (2026-09-08)
> Исторический документ этапа 8 (ревью, 2026-09-08). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
Многоосевое ревью (корректность/читаемость/архитектура/безопасность/производительность) бэкенда и
фронтенда. Проводилось 5 ревьюерами по непересекающимся зонам (чтение; правок не вносилось), ключевые
находки перепроверены по коду. Проект НЕ git. Метки: **[Critical]/[Required]/[Nit]/[Optional]**
(Required = исправить до прода; Nit = желательно; Optional = задел).
## Сводка
| Зона | Объём | Critical | Required | Nit | Optional |
|---|---|---|---|---|---|
| Frontend (Vue3, JS) | 25 файлов / 11.3k LOC | 0 | 6 | 6 | 1 |
| Core-каркас (Api/Infrastructure/Contracts) | ~370 файлов | 0 | 9 | 7 | 5 |
| Модули Kanban/Pipeline/Projects | ~140 файлов | 0 | 10 | 5 | 2 |
| Модули Settings/Telegram/Tenants/Discovery | ~130 файлов | 0 | 7 | 8 | 3 |
| gRPC-сервисы (telegram/ai/ml) + proto | ~135 файлов | 0 | 8 | 8 | 4 |
| **Итого** | **~1100 файлов** | **0** | **40** | **34** | **15** |
Общий вердикт: **код высокого качества** — чистая port&adapter-архитектура, 1 тип=1 файл, тенант-
изоляция через схему на тенанта спроектирована сильно, SQL параметризован, XSS/секреты на фронте и в
сервисах чистые. Найдено 0 критических дыр класса «ключ наружу/доступ к чужому тенанту». Ниже — что
требует исправления и что стоит улучшить. Подробности по зонам — в рабочем журнале сессии (5 отчётов
субагентов с file:line); здесь — консолидированный список.
---
## A. Безопасность (приоритет 1)
1. **[Required] SSRF через baseUrl ИИ-провайдера.** `Deal.Infrastructure/Integrations/AiConnectionChecker.cs`
(проверка `ok:false/true`) + PATCH настроек разрешает тенанту задать произвольный `baseUrl` (в т.ч.
`http://127.0.0.1:...` — подтверждено acceptance-логом task-6). На не-local провайдере ключ API уходит
на указанный адрес → аутентифицированный тенант мультитенантного SaaS получает blind-сканер внутренней
сети/метаданных. Исправить: резолв DNS + запрет private/link-local/loopback при проверке и вызове
(или egress-фильтр); не принимать переопределение хоста для каталоговых провайдеров.
2. **[Required] Rate-limit и анти-брутфорс выключены по умолчанию.** `Deal.Api/Program.cs` (регистрация
лимитера), `RateLimitOptions` дефолт `Enabled=false` → без env в проде нет ни лимитов, ни
`LoginAttemptGuard`. compose.prod форсирует `true`, но дефолт кода опасен при запуске вне compose.
Исправить: стартовая проверка «Production ⇒ RateLimit:Enabled задан явно» (fail-closed).
3. **[Required] CORS fail-open при пустом allowlist.** `Program.cs` (AddCors): пустой
`Security:AllowedOrigins` = любой origin + `AllowCredentials` (задумано для dev). Исправить: в Production
пустой список = отказ на старте; «any origin» только в Development.
4. **[Required] Код инвайта пишется в audit_log сырым.** `JoinEndpoint.cs` — capability-токен в вечном
аудите операторов. Исправить: не логировать код (или его SHA-256).
5. **[Required] Пароль: минимум 4 символа.** `AuthEndpoints.cs`, `JoinEndpoint.cs`. Для публичного SaaS —
минимум 8–10 + проверка на границе; единая константа.
6. **[Required] Политика «ключ не перезаписывается маской» не реализована.** `SettingsService.cs`
(aiConfigs и tgKeys): PATCH со значением-маской (например `sk-1…90ab`, ≥8 симв., без `enc:`) зашифрует
маску и безвозвратно потеряет ключ. Комментарий «пустой/маска → не меняется» не подкреплён кодом.
Исправить: не шифровать значение, содержащее `…` (U+2026) либо пустое; тест на roundtrip.
7. **[Required] DDL прикладной ролью на старте и из tenant-ручки.** `TenantProvisioningService.cs`,
`FtsMaintenance.cs``CREATE SCHEMA/Migrate/INDEX` на каждом старте и `/api/admin/fts/rebuild`.
В проде это нарушение least privilege. Исправить: отдельные креды мигратора и runtime; fts-rebuild —
операторской ручкой.
8. **[Required] TenantId без инварианта формата.** `Deal.SharedKernel/Tenants/TenantId.cs` — значение идёт
в Search Path строки подключения и в DDL; `new TenantId(внешняя_строка)` = connection-string-инъекция.
Сейчас все потоки дают Guid, но тип не защищён. Исправить: конструктор от Guid / валидация 32 hex.
9. **[Required] gRPC-сервисы: нет серверных лимитов на входные данные.** AiServiceImpl, MlServiceImpl,
TelegramServiceImpl — контракты фиксируют лимиты («ядро обрежет»), но сервис их не enforcement:
платные LLM-вызовы на мегабайтных промптах, гигантские SQLite-транзакции. Исправить:
INVALID_ARGUMENT на границе + MaxReceiveMessageSize.
10. **[Required] mTLS по умолчанию выключен — тихая деградация до plaintext.** `MtlsOptions.cs`
отсутствие/опечатка env молча даёт plaintext+только service-token. Исправить: fail-closed для
Production (или warn-on-startup) как для session-ключа.
11. **[Required] Инвайт: не проверяется существование/статус тенанта.** `JoinService.cs` — активация по
«битому» инвайту даёт FK-500 или пользователя на несуществующем тенанте.
12. **[Required] AddUsageAsync не атомарно.** `ITenantLimitStore.cs` — read-modify-write теряет списания
при параллельных ИИ-вызовах. Исправить: `UPDATE ... SET Used=Used+@n`.
13. **[Required] Echo-маска: секрет ≤8 символов отдаётся как есть.** `SettingsService.Mask` — маскировать
всегда (кроме пустого).
## B. Корректность / потеря данных (приоритет 2)
14. **[Required] Потеря данных при параллельных мутациях JSON-массивов проектной карточки.**
`ProjectsService.cs` (add_comment/add_link/remove_link), `ProjectFilesService.cs`: комментарии/ссылки/
файлы дописываются «read → PATCH полной заменой массива» без версии/транзакции; double-click теряет
запись. Исправить: append одним SQL (`jsonb ||`/`array_append`) или optimistic concurrency по `updated_at`.
15. **[Required] Коллизия objectKey файла.** `ProjectFilesService.cs` — «проект/карточка/мс_имя»: две
загрузки в одну мс = перезапись объекта. Исправить: случайный суффикс / id записи в ключе.
16. **[Required] Дедуп-pump не атомарен.** `PipelineWorkerService.cs` — Exists→Claim→create без проверки
результата claim — два конкурентных прохода создадут две карточки. Исправить: повторный Exists/
проверка результата Claim перед созданием.
17. **[Required] Move из trash/archive на доску минует снятие спам-сигнала.** `CardsService.cs`
валидируется только цель; «spam +1» не снимается (unlearn только в restore). Исправить: запрет исхода
из archive/trash/taken в MoveLeadAsync (или симметричный unlearn).
18. **[Required] Параллельные пустые `catch { }` в модулях Telegram/Discovery** — сбои зеркала/превью/
backfill невидимы (ILogger в модулях не используется). Исправить: логировать.
19. **[Required] ChangePassword (фронт) шлёт захардкоженный oldPassword='admin'.** `store.js`,
`SettingsView.vue` — после смены пароля повторная смена невозможна, и пароль живёт в реактивном state.
Исправить: поле «текущий пароль», не хранить пароль в store.
20. **[Required] boot() роняет всё приложение одним сбоем** (фронт). `store.js`: параллельные get без
.catch — падение /api/rates (например) = toast «Сервер недоступен» + разлогин. Исправить:
необязательные секции в индивидуальные .catch; разлогин только при 401.
21. **[Required] applySettings затирает несохранённые промпты** (фронт). `store.js` — автосейв тумблера
применяет полный ответ и перезаписывает textarea промптов. Исправить: применять только запатченные ключи.
22. **[Required] Гонки устаревших ответов поиска** (фронт). `store.js` — старый ответ может перетереть
свежий/очищенный. Исправить: seq-токен/AbortController.
23. **[Required] DeleteExpiredSessionsAsync на каждое разрешение сессии.** `AuthService.cs`,
`OperatorAuthService.cs` — глобальный DELETE по public-таблицам в hot-path каждого запроса.
Исправить: фоновый цикл или «с вероятностью N%»/логин.
24. **[Required] ServiceTokenInterceptor проверяет токен только для unary RPC** — первый же
server-streaming RPC пройдёт без проверки; то же в access-логе. Исправить: все 4 handler'а.
25. **[Required] gRPC-логгер не логирует «прочие» исключения** (только OCE/RpcException) — 500-эквивалент
уходит мимо лога. Исправить: catch (Exception) → log + RpcException.
26. **[Required] Heartbeat/reconnect без таймаута** — зависший ConnectAsync последовательно блокирует
все тенанты и shutdown. Исправить: CancelAfter на попытку.
27. **[Required] QR: отмена RPC до первого URL не отменяет фоновую задачу** — «скрытая» авторизация.
Исправить: отменять саму задачу при отмене ожидания.
28. **[Required] TelegramBackfill fire-and-forget Task.Run из tenant-запроса без in-flight guard**
(параллельные полные перечитывания); фоновые задачи не отслеживаются хостом. Исправить: гейт операции
+ токен остановки хоста.
29. **[Required] int.Parse(apiId)** из пользовательской KV-настройки `TelegramEndpoints.cs`
FormatException маскируется под 400 «не подключён». Исправить: TryParse + понятная ошибка.
## C. Архитектура / дублирование (приоритет 3)
30. **[Required]** 9 независимых реализаций чтения настроек (GetAsync+JsonDocument.Parse+дефолт) в
Settings/IncomingRules/RatesService/Discovery*/DialogsService — расхождение семантики уже видно.
**+** ~8 копий KV-хелперов (ReadBool/ReadInt/ReadString/ReadStringList) и 3 копии LoadRatesAsync в
Kanban/Pipeline/Projects. Исправить: один публичный снапшот настроек в Settings или SharedKernel +
общий RatesCacheReader.
31. **[Required]** Обвязка gRPC-сервисов (ServiceTokenInterceptor/RpcCallLogging/MtlsOptions/MtlsCertificates/
Logging + Host) скопирована в 3 независимых sln. Исправить: общий проект `Deal.Grpc.Hosting`.
32. **[Required]** Большие файлы: PipelineWorkerService (914), KanbanStore (726), DiscoveryStore (632),
ProjectsService (576), ProjectsEndpoints (568), CardsService (475), Program.cs (695), LocalFieldsParser
(438), GrpcTelegramClient (447), TelegramIngressService (409); фронт: SettingsView.vue (1779),
DiscoveryView.vue (1243), store.js (2434). Исправить: декомпозиция (см. ниже).
33. **[Required] Фронт: MoveMenu вешает document-слушатель на каждую карточку** (сотни карточек → сотни
слушателей). Исправить: один глобальный обработчик + id открытого меню в store.
34. **[Required] Фронт: квадратичные пересчёты колонок.** `store.js` — filter+sort на каждую колонку/
счётчик при каждом ре-рендере. Исправить: один computed Map<colId, sorted[]>.
35. **[Nit]** Дублирование доменных констант между модулями (EmptyCommentDetail/JustNowLabel/MlSpamLabel/
DefaultChannelHue/PlannedStage-литералы) и расхождение предиката «активные правила» (Kanban vs
AiClassifyContextBuilder) — вынести в единые реестры.
36. **[Nit]** Middleware сессий (Session vs OperatorSession) и токен-генераторы (SessionTokens/
InviteCodeGenerator/TenantAdminService) дублируются — обобщить.
37. **[Nit]** Легаси-ссылки на строки Python-прототипа в XML-doc (L177191 и т.п.) — устаревают;
оставить «зачем/инвариант», убрать номера строк.
38. **[Nit]** Форматтеры времени и «знание» о контактах/типах файлов в 3–4 местах (фронт) — единый
модуль форматов и словари меток.
39. **[Nit]** `window.prompt` в renameBoard на фоне единого ConfirmDialog; дубликаты 86400000; ширины
колонок sm/md/lg в 3 местах — константы/единый RenameDialog.
## D. Мёртвый код (кандидаты на удаление)
- Фронт: `utils.js` fileTypeInfo/EXT_KINDS/KIND_LABELS (не импортируется); `store.js` — curName/fmtMoney
вне store, moveLead-мёртвая ветка, trashLead-пустой if, openDialog (не используется), checkReminders
(нигде не вызывается); опция «mock»-курсов — проверить, жив ли режим на бэкенде.
- Бэкенд: Kanban DemoLeadFactory недостижимый fallback PrimaryContact; DiscoverySearchErrorCounter —
singleton-счётчик без TTL/эвикции и с межтенантным ключом (переделать per-tenant или чистить).
## E. Что соответствует хорошим практикам (подтверждено)
- Тенант-изоляция сильная: схема на тенанта через Search Path, TenantDbContext запрещён вне tenant-запроса
(fail-fast), AsyncLocal сбрасывается в finally, gRPC-ингресс берёт tenant-id только из metadata, SSE
per-tenant.
- SQL параметризован везде (FromSqlInterpolated/ExecuteSqlInterpolated); массовые операции —
ExecuteUpdate/Delete; комментарии-батчи без N+1; AsNoTracking.
- Секреты не покидают систему: ключи шифруются (enc:+nonce‖ct‖tag), наружу маски; токены сессий — SHA-256
хэши; пароли Argon2id; куки httpOnly+SameSite=Lax; fail-closed service-token (с явным гардом
«пусто≠пусто»); path traversal защищён (SessionStore/ModelPool валидируют tenant-id как имя файла).
- Фронт: XSS-аудит чистый (v-html только через экранирующий renderSourceMessage со схемами http/tg),
токенов в localStorage нет (httpOnly-кука), все target=_blank с rel=noreferrer.
- Чистая архитектура port&adapter в модулях (нет EF/HTTP в Application), DTO-рекорды, DI-Registrar'ы,
направленные зависимости без циклов, константы-каталоги вместо магических строк.
## F. Рекомендуемый порядок исправлений
1. **Безопасность (A1–A13)** — до любого прода. Точечные правки + тесты.
2. **Потеря данных/корректность (B14–B29)** — гонки, дедуп, маски, boot/applySettings фронта.
3. **Архитектура (C30–C34)** — вынос общего grpc-hosting, снапшот настроек, декомпозиция больших файлов,
фронт: leadsByCol-компьютед и глобальный слушатель меню.
4. **Чистка мёртвого кода (D)** + реестры констант (C35–C39) — в рамках рефакторингов, не отдельно.
5. **Заделы (Optional)** — пагинация колонок, виртуализация списков, LRU для кэшей сессий WTelegram,
батчинг провижининга схем, MinIO tenant-префикс, per-request size-лимиты загрузок, single-flight
DiscoveryWorker.
---
## Статус исправлений (2026-09-08, после ревью)
Выполнено в ходе rework-захода (детали — `.superpowers/sdd/deal-stage8-quality-rework/progress.md` и
`docs/superpowers/STATUS.md`). Тесты: core **1135/1135**, telegram **118/118**, ai **52/52**, ml **38/38**,
фронт `npm run build` OK.
**A. Безопасность — закрыто (A1–A13):**
- A1 SSRF: `SettingsService` — baseUrl каталоговых облачных провайдеров не переопределяется (только
local/custom); `AiConnectionChecker` — запрет private/loopback/link-local адресов (в т.ч. 169.254.169.254).
- A2/A3: fail-closed в Production (RateLimit:Enabled обязателен, CORS-allowlist непустой, conn-string без
фолбэка) — стартовые проверки `Program.cs`.
- A4: код инвайта в аудите → SHA-256 `codeHash` (3 события, тесты обновлены).
- A5: пароль минимум 8 (единый `AuthService.MinNewPasswordLength`).
- A6: PATCH с маской ключа («…») больше не шифрует маску (терялся бы ключ); A13: короткие секреты
маскируются всегда (`MaskSecret`), apiId остаётся как есть (не секрет).
- A7: DDL (провижининг схем/миграции) — опциональная мигратор-строка `ConnectionStrings:DealMigrator`
(`ConnectionStringProvider.ForSchemaDdl`); dev/тесты — прежнее поведение.
- A8: `TenantId` — инвариант 32 hex (Guid N), фабрика FromGuid.
- A9: gRPC-сервисы — лимиты входных данных (INVALID_ARGUMENT) + MaxReceiveMessageSize=4MiB.
- A10: mTLS fail-closed в Production (сервисы).
- A11: `JoinService` — целевой тенант обязан существовать и быть активным до резервирования кода.
- A12: атомарный инкремент токенов (`UPDATE ... UsedTokens=UsedTokens+@n`) для Npgsql; EF-путь для InMemory.
- Доп.: int.TryParse apiId; ResolveSession учитывает статус пользователя; очистка протухших сессий — вне
hot-path.
**B. Корректность/потеря данных — закрыто (B14–B29):** атомарные append (comment/link/file) в ProjectStore
(1 SQL), objectKey файла с id записи, дедуп-pump атомарен (Claim→bool), move из trash/archive/taken запрещён,
пустые catch логируются (DiscLog/ILogger), фронт: смена пароля (oldPass), boot с .catch, applySettings не
затирает промпты, seq-токены поиска; интерцепторы gRPC на все 4 вида RPC, логгер catch(Exception), reconnect
с таймаутом, QR-cancel; backfill с in-flight guard + lifetime-токеном.
**C. Архитектура — закрыто:** C30 (единый `TenantSettingsSnapshot` вместо ~9 копий чтения настроек и 3 копий
`LoadRatesAsync`; удалён клон `RateTable.cs`), C31 (общий `src/grpc-hosting/Deal.Grpc.Hosting`; 15 файлов
дублей удалены), C32-декомпозиция (KanbanStore→5, PipelineWorkerService→8, DiscoveryStore→5, ProjectsService→4,
CardsService→3, SettingsService→6, DiscoveryWorkerService→6 partial; фронт: store.js→слайсы store/, вынесены
Telegram/Stop/Scope-вкладки SettingsView, DiscoveryCandidateCard), C33/C34 (MoveMenu, leadsByCol), C36
(`UrlSafeToken`). **Закрыто после ревью (2026-09-09):** C35 — общие реестры
`MlLearningLabels`/`SourceDefaults` в Deal.Contracts (метки обучения ML «spam»/«t:hire»/«t:order» и дефолтный
цвет источника «#666») вместо дублей MlSpamLabel/DefaultChannelHue/DefaultDialogHue/SpamLabel в
Pipeline/Discovery/Telegram/Infrastructure; единый предикат «активные правила» — AiClassifyContextBuilder
переведён на `ColumnRules.HasActiveRules` (Kanban; было расхождение Count>0 vs терм после trim); реестр
`ProjectStages` (9 id-констант вместо литералов) и общий `CardsService.JustNowLabel` (Projects/адаптер
KanbanStore); DiscoverySearchErrorCounter — TTL-эвикция (см. D). **Задел:** полный вынос остальных вкладок
SettingsView (риск без e2e).
**D. Мёртвый код:** удалён (фронт: fileTypeInfo/EXT_KINDS/curName/fmtMoney/openDialog/checkReminders и др.;
бэкенд: недостижимый PrimaryContact DemoLeadFactory и др.). DiscoverySearchErrorCounter — добавлена TTL-эвикция
записей (EntryTtlSeconds=1 ч, ленивая при Next/Reset, часы инъекцией; +4 теста) — задел D закрыт.
@@ -0,0 +1,113 @@
# Аудит документации «Дейл»: сверка с кодом/конфигами
> Исторический документ (аудит документации, 2026-09-10; следующий — `2026-09-11-docs-final-sweep.md`). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> Дата: 2026-09-11
> Проверено: `docs/spec/ТЗ-дейл-новая-архитектура.md`,
> `docs/user-guide/Инструкция-пользователя-Дейл.md`,
> `docs/technical/Техническая-документация-Дейл.md`,
> `docs/api/api-map.md`, плюс `docs/superpowers/STATUS.md`.
> Метод: сверка утверждений с кодом (`src/core/Deal.Api/Endpoints/*`,
> `src/core/Deal.Infrastructure/**`, `src/frontend/src/**`, `src/{ai,ml,telegram}-service`),
> конфигами (`deploy/compose.*.yml`, `appsettings*.json`) и скриптами (`scripts/*.sh`).
> Докер не поднимался, тесты не перезапускались (см. «непроверяемое»).
## Сводка
- Найдено расхождений: **30** (по пунктам таблиц ниже).
- Исправлено прямо в доках: **30**.
- Значимые подтверждённые факты, с которыми доки сходятся: порты (core 5080/5082, telegram 5101,
ai 5102, ml 5103, metrics 9464, postgres 5433, minio 9000/9001, grafana 3001), единые домены
`/api/cards` + `/api/containers`, оператор-консоль `#/operator` и активация `#/join`,
ключи Telegram — у оператора (`global_settings`), команды запуска.
## Расхождения (файл:строка → в доке → реальность → исправлено)
### `docs/technical/Техническая-документация-Дейл.md`
| # | Место | В доке | Реальность (код) | Статус |
|---|---|---|---|---|
| 1 | §2 «Структура» (~L43) | проект `Deal.Modules.Projects/` | каталога нет; есть `Deal.Modules.Telegram/` | ✅ исправлено на `Deal.Modules.Telegram` |
| 2 | §3 «Модули core» (таблица, ~L74) | «Выбранные» владеет `Deal.Modules.Projects`; нет Telegram | сервисы «Выбранных» — в `Deal.Modules.Kanban` (`CardsService.Selected`); модуль `Deal.Modules.Telegram` существует | ✅ исправлено + добавлена строка Telegram |
| 3 | §3 (абзац, ~L80) | «`Projects` — сервисами пространства…» | модуля `Projects` нет (перенесено в Kanban) | ✅ исправлено |
| 4 | §4 «Ключевые таблицы public» (~L109-112) | `tenants(…, limits_json)`, `users(…, email, role)`, `invites(id, tenant_id, email, code, expires_at, used_at)`, `app_settings` | `tenants(Id,Name,Status,CreatedAt)`, `users(…,Login,…)`, `invites(Code PK,Email,TenantId,Status,ExpiresAt,ActivatedAt,CreatedById,CreatedAt)`, `global_settings`; таблицы `app_settings` нет | ✅ исправлено |
| 5 | §4 сноска (~L122) | `Operators`, `OperatorSessions` | таблицы — `operators`, `operator_sessions` (миграция `SystemSaaS`) | ✅ исправлено |
| 6 | §6 «Файлы» (~L202) | ключ объекта = `tenant_<id>/<card_id>/<file_id>` | `CardsService` строит `projects/<card_id>/<file_id>_<unixMs>_<safeName>` | ✅ исправлено |
| 7 | §8 «Развёртывание» (сноска, ~L304) | «корневой `docker-compose.yml` — наследие LeadRadar» | файл перенесён в `archive/leadradar-legacy/`; в корне его нет | ✅ исправлено |
| 8 | §11 этап 5 (~L510) | модуль/таблица `Deal.Modules.Projects`/`ProjectCards` без пометки | упразднены с этапа 9 | ✅ добавлена пометка «историческое состояние» |
| 9 | §11 TODO (~L619-620) | «OpenAPI-карта снимается с LeadRadar», «миграции на 1000 схем — в плане этапа 0» | api-map и контракты есть; пакетная миграция реализована (этап 12) | ✅ исправлено |
| 10 | §13.4a (~L717) | секреты включают `tgKeys.apiHash` в настройках тенанта, маска `apiHashSet` | `tgKeys` у тенанта нет; ключи — у оператора (`global_settings`, `GET/PUT /api/operator/settings/telegram-keys`) | ✅ исправлено + пометка |
| 11 | §13.5 «Проверка схем» (~L888) | схема тенанта содержит `Boards`, `ProjectCards`; public — неполный | `Boards`/`ProjectCards` удалены (этап 9); актуальны `Containers`, `Dialogs`, `Disc*` и т.д. | ✅ исправлено на актуальный список |
| 12 | §13.6 «Тесты» (~L905) | `dotnet test` ожидает **1203 PASS** | актуальный core — **1275** | ✅ исправлено |
| 13 | §13.7 env (~L976) | core в compose задаёт `DEAL_DEMO=1` | в `compose.dev.yml` `DEAL_DEMO` нет; демо-ручки удалены | ✅ исправлено |
| 14 | §13.7 smoke (~L994) | `POST /api/demo/simulate-lead``/api/leads/{id}/trash` | `dev-smoke.sh`: `POST /api/cards``POST /api/cards/{id}/trash` | ✅ исправлено |
| 15 | §13.7 ручные проверки (~L1061) | `PATCH /api/settings tgKeys` | ключи — у оператора (вариант A) | ✅ исправлено |
| 16 | §13.8 (~L1074) | `public.Operators`/`OperatorSessions` | `operators`/`operator_sessions` | ✅ исправлено |
| 17 | §13.8 (~L1111) | «приостановка тенанта (вход **401**…)» | вход приостановленного тенанта — **403** (`AuthEndpoints`) | ✅ исправлено |
| 18 | §13 заголовок (~L636) | «актуально для этапов 0–10» | актуально по этап 12 | ✅ исправлено |
| 19 | §13.4e (~L841) | исторический раздел этапа 5 без пометки | операции переехали в `/api/cards*`, модуль/таблица удалены | ✅ добавлена пометка |
| 20 | §16 «Добивка» (~L1339) | core-тесты **1245/1245** | актуально **1275/1275** | ✅ исправлено |
### `docs/user-guide/Инструкция-пользователя-Дейл.md`
| # | Место | В доке | Реальность | Статус |
|---|---|---|---|---|
| 21 | §1 «Особенности» (~L32-34) | демо-кнопки («демо-карточка», «демо-сообщение») при `DEAL_DEMO=1` | во фронте демо-кнопок нет, ручки `POST /api/demo/*` и флаг удалены | ✅ исправлено |
### `docs/api/api-map.md`
| # | Место | В доке | Реальность | Статус |
|---|---|---|---|---|
| 22 | §3.1 (~L59) | `change-password` — минимум **4** символа | `AuthEndpoints` — минимум **8** | ✅ исправлено |
| 23 | §4.1 (~L248) | `objectKey: "cards/c_…/pf_…"` | формат `projects/<cardId>/<fileId>_<ms>_<name>` | ✅ исправлено |
| 24 | §5 «Прочие домены» (~L400) | Operator + join = **21** | 24 операторских ручки + `/api/join` = **25** | ✅ исправлено |
### `docs/superpowers/STATUS.md`
| # | Место | В доке | Реальность | Статус |
|---|---|---|---|---|
| 25 | (~L30) | core **1203/1203 PASS** | 1275 | ✅ исправлено |
| 26 | (~L52) | «демо `DEAL_DEMO`» | демо удалено | ✅ исправлено |
| 27 | (~L56) | `public.Operators/OperatorSessions` | `operators`/`operator_sessions` | ✅ исправлено |
| 28 | (~L76) | «демо-пространство, `DEAL_DEMO=1`» | dev-seed `admin/admin`, демо удалено | ✅ исправлено |
| 29 | (~L90) | «settings/boards/demo-карточка» (live-приёмка) | актуальные ручки — `/api/settings`, `/api/cards` | ✅ исправлено + историческая пометка |
| 30 | (~L94) | «simulate-lead → карточка inbox» | `dev-smoke.sh`: `POST /api/cards` → карточка `planned` | ✅ исправлено |
> Нумерация строк приблизительная (после правок сместилась).
## Проверено и сходится (выборка)
- **Порты**: core HTTP 5080 / gRPC-ингресс 5082, telegram-service 5101, ai-service 5102,
ml-service 5103, metrics 9464 (`METRICS_PORT`), postgres host-порт 5433, minio 9000/9001,
grafana `127.0.0.1:3001`, prometheus `127.0.0.1:9090` — совпадают с `deploy/compose.*.yml`
и Dockerfile.
- **Команды**: `docker compose -f deploy/compose.dev.yml up -d --build`, `scripts/dev-smoke.sh`,
`scripts/test.sh` (+ `npm run lint:i18n`), фронт `npm run dev` — совпадают.
- **Единый API**: `/api/cards` + `/api/containers`; домены `/api/leads|projects|boards|columns`
удалены — совпадает с `Endpoints/*` и `api-map`.
- **Оператор-консоль**: hash-роутер `#/` / `#/operator` / `#/join?code=…`
`src/frontend/src/router.js`; ключи Telegram — `global_settings` + `OperatorSettingsEndpoints`.
- **БД**: `Containers` вместо `Boards`, `ProjectCards` нет, `Cards` с модульными JSON-полями;
публичные таблицы `audit_log`/`token_usage_events`/`global_settings`/`rate_limit_counters`
и lowercase `operators`/`operator_sessions` — подтверждено EF-конфигами и миграциями.
- **Файлы**: `objectKey = projects/<cardId>/<fileId>_<ms>_<name>``CardsService.Files`.
- **Наблюдаемость**: `/metrics` на отдельном HTTP/1.1-эндпоинте :9464, Serilog, promtail/loki/grafana —
подтверждено `DealMetricsHosting`, `compose.prod.yml`.
## Осталось / непроверяемое
- **Счётчики тестов сервисов** (telegram 125, ai 52, ml 38): перезапуск тестов не выполнялся
(запрет на долгие процессы). В доках трогали только core-счётчик (1203/1245 → 1275) по
ground-truth задания; сами цифры сервисов не подтверждались кодом.
- **Точное число операторских ручек (25)** — подсчёт по `Endpoints/Operator*` + `JoinEndpoint`;
группировка может отличаться от авторской (ранее было 21 — вероятно, до этапа 12).
- **Исторические разделы-журналы** (§11 этапы 17, §13.4c/4d/4e, live-приёмки в STATUS/планах)
намеренно сохраняют легаси-термины (`Boards`, `/api/leads`, `/api/projects`, `DEAL_DEMO`,
`ProjectCards`). Добавлены точечные пометки «историческое состояние»; полный перепис
не выполнялся (вне правил задачи).
- **Планы/архитектурные доки** (`docs/superpowers/plans/*`, `docs/architecture/*`) содержат
легаси-термины (`Boards`, `ProjectCards`, `docker-compose.yml`) — вне периметра аудита.
- **Живые контуры** (Telegram-вход, реальные LLM-вызовы, mTLS-рукопожатие, backup/restore на
docker-стеке) не проверялись — нужны креды/Docker; в доках они помечены ⚠ Manual.
- **Дубли/внутренние противоречия**: техдок §11 этап 5 и §13.4e описывают снятый контур
«Выбранных» как историю; при следующей редакции их, возможно, стоит свернуть в ссылку на §3.
@@ -0,0 +1,329 @@
# Аудит соответствия ТЗ «Дейл (Deal) — новая архитектура»
> Исторический документ (аудит соответствия ТЗ, 2026-09-10). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> Дата: 2026-09-10
> Проверяется: `docs/spec/ТЗ-дейл-новая-архитектура.md` (§1–§12) + расширенные требования этапов 8–12.
> Метод: **только исходный код и артефакты репозитория** (`C:\telbase`). Док-документам на слово
> не верим — каждое утверждение подкреплено файлом/символом. Единственное запущенное — линтер
> `npm run lint:i18n` (быстрый, read-only); остальное не запускалось.
> Проект не git; правок кода/доков не вносилось, создан только настоящий отчёт.
## Сводка
| Статус | Кол-во |
|---|---|
| ✅ реализовано | 131 |
| ⚠️ частично | 12 |
| ❌ отсутствует | 1 |
| Всего проверено пунктов | 144 |
Топ-находок — в разделе «Найденные пропуски/расхождения».
(Каждая строка таблицы = один проверяемый пункт ТЗ/расширенных требований.)
---
## §1. О продукте
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 1.1 | Приём сообщений из источников в реальном времени | ✅ | `src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs` (PushMessage + mark-read), `Hosting/RealtimeMonitorService.cs` |
| 1.2 | Отсев мусора (реклама/скам/служебное/дубли/устаревшее) | ✅ | `PipelineRejectConstants.cs` (stage labels `stop/spam_ml/spam_ai/filter_ai/dup/stale`), `IncomingRules.cs`, `PipelineWorkerService.Checks.cs` |
| 1.3 | Структурирование в карточки по профилю (сфера/стек/бюджет/локация) | ✅ | `Pipeline/AiCardMapper.cs`, `Parse/LocalFieldsParser.cs`, `PipelineCardWriter.cs` |
| 1.4 | Раскладка по колонкам-фильтрам | ✅ | `Kanban/ColumnRules/ColumnRules.cs`, `CardsService` (ContainerAccepts) |
| 1.5 | Самообучение на действиях (ML) | ✅ | `Kanban/CardsService.Operations.cs` (PushAsync на move/trash/restore), `MlOutboxFlushScheduler` |
| 1.6 | Discovery — поиск/подключение источников | ✅ | `Deal.Modules.Discovery/*`, `DiscoveryWorkerService.Search/Evaluate/Join` |
## §2. Термины
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 2.1 | Тенант владеет схемой БД/настройками/ML | ✅ | `Data/TenantContext.cs`, модель на тенанта (`TenantDb` миграции), модель ML per-tenant (`ml.proto`, `data/ml/<tenantId>.sqlite`) |
| 2.2 | Аккаунт Telegram (1 на тенанта) | ✅ | `Telegram/Sessions/TenantSession.cs` («1 аккаунт на тенанта») |
| 2.3 | Источник (канал/группа/чат) | ✅ | `Deal.Modules.Telegram/Application/ITelegramStore.cs`, `DialogEntity` |
| 2.4 | Сырое сообщение → очередь | ✅ | `Pipeline/Application/Models/QueuedMessage.cs`, `PipelineIngestService.cs` |
| 2.5 | Карточка — ядро + модули | ✅ | `Deal.Modules.Cards/Application/Card.cs`, интерфейсы `IContentCard/IBudgetedCard/IContactCard/IFileCard/ITzCard/IRemindableCard/…` |
| 2.6 | Типы источника (локально/ссылка/файл/Telegram/импорт/API/ИИ/составной) | ✅ | `Deal.Modules.Cards/Application/ILocalSource.cs`, `IWebSource.cs`, `ITelegramSource.cs`, `IApiSource.cs`, `IFileSource.cs`, `IRowSource.cs`, `IAiSource.cs`, `ICompositeSource.cs` |
| 2.7 | Контейнер + политика | ✅ | `Kanban/Application/Models/ContainerPolicyDto.cs`, `ContainersService.cs` |
| 2.8 | Отсев с причиной | ✅ | `Pipeline/Application/Models/RejectedItemDto.cs`, `PipelineProcessingService.Rejected` |
## §3. Роли и доступ
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 3.1 | Оператор: тенанты/инвайты/лимиты/health/impersonation с аудитом | ✅ | `Endpoints/Operator*`, `OperatorTenantsEndpoints.Impersonate`, `AuditEvents.ImpersonationStarted/Stopped` |
| 3.2 | Тенант: вход по инвайту, пароль, TG-аккаунт, обработка, дашборд | ✅ | `Tenants/Application/JoinService.cs`, `AuthService.cs`, `Endpoints/JoinEndpoint.cs` |
| 3.3 | Регистрация только по инвайту | ✅ | `IInviteStore`, `InviteCodeGenerator` (16 симв., 72 ч), публичной регистрации нет |
| 3.4 | Логин email+пароль, email уникален в SaaS | ✅ | `AuthService`, `users` (public), уникальность email |
| 3.5 | `tenantId` — в сессии | ✅ | `Models/SessionDto.cs`, кука `deal_session`; JWT не используется (сессии) — допустимо формулировкой «сессии/JWT» |
| 3.6 | Вход оператора изолирован от тенантов | ✅ | `Configuration/OperatorCookieOptions.cs` (`deal_operator_session`), `OperatorAuthEndpoints` |
## §4. Подключение Telegram-аккаунта
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 4.1 | Оператор **глобально** задаёт `api_id`/`api_hash` | ⚠️ | Ключи хранятся в **настройке тенанта** `tgKeys` (`SettingsKeys.TgKeys`, `Deal.Api/Telegram/TelegramKeysService.cs`) и задаются в UI тенанта (`settings/TelegramTab.vue`). Глобальной (операторской) настройки/ручки нет — расхождение с §4.1/§8 |
| 4.2 | Подключение: QR или телефон+код | ✅ | `TelegramTab.vue` (qr/phone/code/password), `TelegramEndpoints` (start-qr/start-phone/submit-code/password), `TenantSession.StartQrAsync` |
| 4.3 | Сессия сохраняется, статус подключения показан | ✅ | `Sessions/SessionStore.cs`, `SessionFileCipher.cs` (AES-GCM), `TgStatusService`, `GET /api/tg/status` |
| 4.4 | 1 аккаунт на тенанта (схема допускает расширение) | ✅ | `TenantSession` (один на тенанта), `SessionFarm` |
| 4.5 | Список диалогов подтягивается при подключении и обновляется на экране + в фоне | ✅ | `Dialogs/RealtimeSweep.cs` (SyncDialogs каждые 30 с), `TelegramEndpoints` `/dialogs/refresh`, `ChannelsView.vue` |
| 4.6 | Вкл/выкл мониторинга по источнику | ✅ | `DialogsService.SetMonitorAsync`, `TelegramEndpoints` `/dialogs/{id}/monitor` |
| 4.7 | «Новый чат → мониторинг автоматически» (вкл/выкл) | ✅ | `SettingsKeys.AutoMonitorNew`, `DialogsService.SyncFromTelegramAsync`, `TelegramStore.SyncFromTelegramAsync` |
| 4.8 | Удалённые/покинутые источники исчезают | ✅ | `TelegramStore.SyncFromTelegramAsync` (удаление отсутствующих) |
| 4.9 | «Перечитать»: догон ~10 сообщений включённых источников, анти-бан-паузы | ✅ | `Dialogs/BackfillService.cs` (`MessagesLimit=10`, паузы 1.53 с / 36 с), `POST /api/tg/dialogs/backfill-all` |
| 4.10 | Полученные сообщения сразу помечаются прочитанными | ✅ | `RealtimeListener.OnMessageReceivedAsync` (MarkReadAsync после Push), `BackfillService` (read-ack) |
| 4.11 | Discovery: задача поиска → ИИ ключевые слова | ✅ | `DiscoveryEndpoints` (generate-keywords), `IAiTools.GenerateKeywordsAsync` |
| 4.12 | Поиск каналов, где аккаунт не состоит | ✅ | `DiscoveryWorkerService.Search.cs`, `IsDialogMonitoredAsync` |
| 4.13 | Каскад: участники → язык → содержание (порог ≥40%) | ✅ | `DiscoveryWorkerService.Evaluate.cs`, `SettingsDefaults.DiscEvalThreshold = 40` |
| 4.14 | Кандидаты «на рассмотрение» с метаданными/fit/темами/метками (закрытая группа) | ✅ | `DiscoveryWorkerService.Constants.cs` (`MarkClosedGroup`…), `FinishReviewAsync`, `Models/DiscoveryTopicDto` |
| 4.15 | Действия: вручную «Вступить» / авто-вступление с квотами (50/сутки, 50–70 с) | ✅ | `DiscoveryBanGuard.cs` (`DiscJoinLimit=50`), `DiscoveryPacer.cs` (`DiscJoinDelayMin/Max=50/70`) |
| 4.16 | «Отклонить» → чёрный список; список исключает во всех задачах; снимается вручную | ✅ | `DiscoveryBlacklistService.cs`, `DiscoveryBlacklistList.vue`, `RemoveBlacklistAsync` |
## §5. Обработка входящих (пайплайн)
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 5.1 | Путь: источник → очередь → стоп-лист → дедуп → ML → ИИ → карточка | ✅ | `PipelineWorkerService.Pump.cs`, `SignificantPath`, `PipelineIngestService` |
| 5.2 | Этап 1: минимальная длина текста | ✅ | `IncomingRules.Evaluate` (`KindLength`), `SettingsDefaults.MinLen=24` |
| 5.3 | Этап 1: стоп-фразы (настраиваемый список) | ✅ | `SettingsKeys.StopPhrases`, `IncomingRules` (`KindStop`), `settings/StopTab.vue` |
| 5.4 | Этап 1: отсев резюме соискателей (настройка) | ✅ | `SettingsKeys.BlockResumes/ResumeMarkers`, `IncomingRules` (`KindResume`, guard «резюме» при маркере найма) |
| 5.5 | Этап 1: тип заявки (только вакансии / только заказы) | ✅ | `SettingsKeys.WantedType`, `IncomingRules` (`KindType`), `hireMarkers` |
| 5.6 | Этап 1: дедуп (нормализованный хэш) | ✅ | `Parse/DedupHasher.cs`, `DedupEntries` (миграция `TenantPipeline`) |
| 5.7 | Этап 1: устаревшее сообщение → отсев | ✅ | `PipelineWorkerService.Checks.cs` `IsStaleAsync` (`ArchiveAfterDays`) |
| 5.8 | ML: уверена → решает сама (спам/колонка); не уверена → ИИ | ✅ | `PipelineWorkerService.Pump.cs` (`run.MlEnabled && !force`), `ml.proto` (take/label/margin) |
| 5.9 | Возврат из отсева (force) идёт мимо ML к ИИ | ✅ | `Pump.cs` (`force` пропускает ML), `PipelineProcessingService.ReturnAsync` (`Force = true`) |
| 5.10 | ИИ-фильтр: не про заявки → отсев; выключатель `aiFilterEnabled` | ✅ | `Pump.cs`, `SettingsKeys.AiFilterEnabled`, `AiFilterResultDto.Skipped` |
| 5.11 | Классификация: структурированный разбор (компания/формат/задача/требования/плюсы/условия/бюджет/стек/контакты/тип) | ✅ | `Parse/ParsedCardContent.cs`, `AiCardMapper.cs`, `ai.proto` ClassifyReply |
| 5.12 | Назначение колонки с проверкой правил | ✅ | `ContainerAccepts`, `AiCardLearning.cs`, `ColumnRules.cs` |
| 5.13 | Глобальный фильтр «без суммы» отдельно для вакансий и заказов | ✅ | `SettingsKeys.BudgetRequiredHire/Order`, `PipelineWorkerService.Checks.cs` `SkipNoBudgetAsync` |
| 5.14 | Глобальные исключения по ключевым словам/технологиям/бюджету/локации | ⚠️ | Глобальных настроек-исключений нет: в `SettingsKeys` только `StopPhrases` (стоп-фразы) и per-column `exclude` (`ColumnExclusions.cs`). Исключений «ключевые слова/технологии/бюджет/локация» отдельного глобального уровня не найдено |
| 5.15 | Карточка — одна строка одной таблицы `Cards`; `ProjectCards` упразднена | ✅ | Миграция `TenantUnifiedCard.cs` (`DropTable("ProjectCards")` + `AddColumn` `StackJson/LinksJson/FilesJson/HistoryJson/TzText/Reminder…`) |
| 5.16 | Комментарии — общая таблица `LeadComments` | ✅ | Миграция `TenantKanban.cs` (`LeadComments`), `KanbanStore.Comments.cs` |
| 5.17 | Единый реестр контейнеров; пространства не пересекаются; «взять в работу» = смена контейнера | ✅ | `ContainerSpaces.cs`, `ContainersService.cs`, `CardsService.Selected.cs` (`TakeAsync`) |
| 5.18 | Исходное сообщение хранится и доступно (открыть в Telegram / форматированно) | ✅ | `CardDrawer.vue` (`sourceMsg`, `tgSourceUrl`, `renderSourceMessage`), `ProcessingView.vue` |
## §6. Дашборд (канбан)
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 6.1 | Колонки: «Неразобранное», пользовательские, «Архив», «Корзина» | ✅ | `CardIds` (inbox/archive/trash), `ContainerKinds`, `KanbanColumns` |
| 6.2 | Пользователь создаёт колонки; ИИ **предлагает** с обоснованием; принять/отклонить/переименовать | ✅ | `AiSuggestEndpoints`, `SuggestHeuristics.cs`, `ContainerColumn.vue` (`acceptSuggestedBoard`, `suggested` badge) |
| 6.3 | Колонка = сложный набор фильтров (ключевые слова/стек/грейд/уровень/цена/бюджет/локация/тип + отрицательные) | ⚠️ | `ContainerRulesDto` содержит только `Mode/Direction/Keywords/Stack/Grade/Exclude/Budget`. Отдельных групп «уровень/цена/локация/тип» нет (частично покрыты `direction`/`keywords`); отрицательные — `exclude` ✅ |
| 6.4 | При помещении указаны критерии попадания | ✅ | `ColumnRules.ComputeHits`, `MatchHitBuilder`, `MatchHitDto` |
| 6.5 | Свежие сверху; drag&drop между колонками с обучением ML | ✅ | `KanbanStore.Cards.cs` (`OrderByDescending(ReceivedAt)`), `composables/dnd.js`, `PushAsync` on move |
| 6.6 | Быстрые действия: комментарий, корзина, контакт, «открыть исходник» | ⚠️ | Комментарий/корзина/контакт — `Card.vue` (кнопки). «Открыть исходник» на самой карточке нет — только в `CardDrawer.vue` и `ProcessingView.vue` |
| 6.7 | Виджеты-счётчики свёрнутых колонок; двигать/менять размер | ✅ | `Sidebar.vue`, `cards.js` (`cycleWidth`, `colExtra`, `reorder`), `COLUMN_WIDTHS` |
| 6.8 | Архив: старше N дней (1–30), очистка через 90 дней | ✅ | `StorageTickService.cs`, `SettingsDefaults.ArchiveAfterDays=14 (кламп 1..30)`, `ArchiveClearDays=90` |
| 6.9 | Корзина: очистка раз в 7 дней; возврат из архива/корзины | ✅ | `SettingsDefaults.TrashClearDays=7`, `CardsService.Operations.cs` (`RestoreCardAsync`) |
| 6.10 | «Выбранные»: стадии Запланировано→…→Готово/Отложено | ✅ | `CardsDefaultContainers.cs` (planned/reply/agree/work/review/ready/hold) |
| 6.11 | «Взять в работу» — переход в контейнер, не клон | ✅ | `CardsService.Selected.cs` `TakeAsync` |
| 6.12 | Модули работы: комментарии/сумма/стек/контакты, ссылки, ТЗ, файлы (S3/MinIO), значки количества | ✅ | `CardsService.Files.cs`, `CardFileKind.cs`, `FileKindDetector.cs`, `CardDrawer.vue` |
| 6.13 | Отложенные: напоминания (срок+время, календарь); выключатель; выключено → не срабатывают | ✅ | `CardsService.Reminders.cs` (`RemindersDisabledDetail`, snooze +24 ч), `HoldReminderDialog.vue`, `SettingsDefaults.RemindersEnabled` |
| 6.14 | История движения — под спойлером | ✅ | `CardDrawer.vue` (`<details>` «История движения», `historyReversed`) |
| 6.15 | Ручное создание карточки (пометка «создано локально») | ✅ | `CardDetailsEndpoints` `POST /api/cards`, `Local` флаг, `Card.vue`/`CardDrawer.vue` бейдж «Локальная» |
| 6.16 | Терминальные зоны «Отклонено»/«Выполнено»; в архив/корзину дашборда не попадают | ✅ | `CardsDefaultContainers.finished/rejected` (terminal), `ContainerPolicyDto.IsTerminal`, `ClearRejected` |
## §7. Вкладка «Обработка»
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 7.1 | Очередь (этап 1 / ожидают ИИ) с автопрокруткой | ✅ | `ProcessingView.vue` (таймер-опрос ~2.6 с, статусы `etap-1-bez-ii`/`ozhidaet-ii`); «автопрокрутка» реализована как авто-обновление |
| 7.2 | Отсев с причиной и источником решения (правила/ML/ИИ/система) + конкретная фраза | ✅ | `PipelineRejectConstants.cs` (`StageLabels`/`SourceLabels`), `RejectedItemDto` (`kw`, `reason`) |
| 7.3 | Метаданные, «Открыть исходник», «Исходное сообщение (форматированно)» | ✅ | `ProcessingView.vue` (`metaRows`, `sourceUrl`, `srcHtml`) |
| 7.4 | Полнотекстовый поиск по отсеву | ✅ | `PipelineEndpoints` `/rejected?q=` (FTS LIKE), `Store` поиск |
| 7.5 | Возврат из отсева: причины игнорируются, ML/ИИ обучаются, причина возврата | ✅ | `PipelineProcessingService.ReturnAsync` (`Force=true`, `PushAsync(spam,1.0)`, `returnReason`) |
| 7.6 | Автоочистка отсева раз в 3 дня; ручная очистка | ✅ | `PipelineRejectConstants.RetentionDays=3`, `POST /pipeline/rejected/clear`, `DELETE /rejected/{id}` |
| 7.7 | Счётчик обработки в боковой панели; отсев в панели не показывается | ✅ | `Sidebar.vue` (`state.pQueueCounts.total`), отсев — только внутри `ProcessingView.vue` |
## §8. Настройки тенанта
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 8.1 | Telegram: ключи приложения (**оператор**), подключение, авто-мониторинг | ⚠️ | Подключение/авто-мониторинг ✅ (`TelegramTab.vue`, `AutoMonitorNew`). Ключи — настройка **тенанта** `tgKeys`, а не глобальная операторская (см. §4.1) |
| 8.2 | ИИ: провайдер (в т.ч. локальные), модель, ключ зашифрован | ✅ | `AiProviders.cs`, `SettingsService.PatchSecrets.cs` (`enc:`), `ISecretCipher` |
| 8.3 | Промпты: базовый + свой; библиотека по сферам + «мои промпты» | ✅ | `PromptLibraryModal.vue` (`PROMPT_LIBRARY`/`PROMPT_CATEGORIES`, поиск), `SettingsService.PatchMyPrompts.cs`, `AiTab.vue` |
| 8.4 | ИИ вкл/выкл; ИИ-фильтр вкл/выкл | ✅ | `SettingsKeys.AiEnabled/AiFilterEnabled`, `Pump.cs` |
| 8.5 | ML: вкл/выкл, обучение на действиях, **проверка на сообщении/канале**, сброс, самооценка | ⚠️ | `mlEnabled`, обучение (`PushAsync`), predict (сообщение) ✅, сброс ✅ (`/api/ml/reset`), самооценка ✅ (`MlEvalDto`). **Проверка на канале не реализована**: `POST /api/ml/candidates` возвращает пустой список (заглушка), `POST /api/ml/apply` — всегда 404 (`MlEndpoints.cs:130155`) |
| 8.6 | Обработка: стоп-фразы, длина, резюме, тип, домен/ключи, маркеры найма/заказа | ✅ | `SettingsKeys.StopPhrases/MinLen/BlockResumes/WantedType/DomainKeywords/HireMarkers`, `StopTab.vue`/`ScopeTab.vue` |
| 8.7 | Колонки: набор, правила, отрицательные фильтры, исключения | ✅ | `ContainersEndpoints`, `BoardRulesDialog.vue`, `ColumnExclusions.cs` (см. замечание 6.3 по составу групп) |
| 8.8 | Валюта: целевая, источник (4 запроса/сутки), конвертация при приёме + пересчёт старых (кроме архива/корзины), USDT=USD | ✅ | `RatesService.cs` (`RatesFetchInterval` = 6 ч = 4/сутки; USDT→USD), `ConversionRecomputer.cs` (`ConversionExcludedCols` archive/trash) |
| 8.9 | Хранение: срок архивации (1–30), очистка архива/корзины | ✅ | `StorageTickService.cs`, `SettingsDefaults.ArchiveAfterDays/ArchiveClearDays/TrashClearDays`, `StorageTab.vue` |
| 8.10 | Уведомления и напоминания; отложенные — отдельно | ✅ | `NotifyTab.vue`, `SettingsKeys.RemindersEnabled`, `CardsService.Reminders.cs` |
| 8.11 | Звук | ✅ | `NotifyTab.vue` (`soundOn`, `volume`, `testSound`), `utils.js` (Web Audio) — клиентская настройка, без серверного ключа |
| 8.12 | Внешний вид | ❌ | В `SettingsView.vue` вкладок Telegram/AI/Storage/Stop/Scope/ML/Notify/Currency/Profile — раздела «Внешний вид» (тема/оформление) нет; `style.css` содержит единственную тёмную тему |
## §9. Лимиты (бюджет токенов)
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 9.1 | Бюджет токенов на LLM, период настраивается | ✅ | `TenantLimitDto` (`BudgetTokens`, `Period` month/day), `OperatorLimitUpdateRequest` |
| 9.2 | ai-service оценивает вызов в токенах, списывает с бюджета | ✅ | `TokenUsageRecorder.cs`, `BudgetedAiClassifier.cs`, `BudgetedAiTools.cs`, `ai.proto` Usage |
| 9.3 | При исчерпании: fallback + уведомление; приём не блокируется | ✅ | `BudgetedAiClassifier` (Local-фолбэк), `Warned80/NotifiedExhausted`, условия `pipeline` не блокируются |
| 9.4 | Оператор видит расход и меняет бюджет | ✅ | `OperatorLimitsEndpoints` (`/limits`, `/tenants/{id}/limit`), `AnalyticsService` |
## §10. Админка оператора
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 10.1 | Тенанты: создание, инвайты, статус, лимиты, приостановка | ✅ | `OperatorTenantsEndpoints` (create/suspend/unsuspend), `OperatorInvitesEndpoints` |
| 10.2 | Health всех сервисов и очередей | ⚠️ | Сервисы ✅ (`OperatorHealthEndpoints`, ml/ai/telegram по gRPC-пробам). «Очереди» в health нет — глубины очередей публикуются только в метриках (`Observability/DealMetricsCollector.cs``/metrics`) |
| 10.3 | Аудит: входы/выходы, инвайты, impersonation, действия оператора и тенанта | ✅ | `AuditEvents.cs` (login/logout/invite/impersonation/card_*/container_*/settings/channels/telegram), `AuditService` |
| 10.4 | Аналитика: расход токенов (день/тенант/провайдер/модель) + лента действий с фильтрами | ✅ | `AnalyticsService.TokensAsync` (groupBy), `OperatorAnalyticsEndpoints`, `AuditSection.vue`/`AnalyticsSection.vue` |
| 10.5 | Подозрительная активность (по логам безопасности) | ⚠️ | Отдельного разбора/детектора подозрительной активности не найдено; есть счётчики неудачных входов в `AnalyticsService.OverviewAsync` (`failedLogins`) и общие Grafana-дашборды |
| 10.6 | Метрики сервисов (Prometheus/Grafana) | ✅ | `DealMetricsHosting.cs` (`/metrics` :9464), `deploy/observability/prometheus.yml`, `prometheus-rules.yml`, Grafana-дашборды |
| 10.7 | UI: `#/operator` и `#/join` | ✅ | `router.js`, `views/operator/OperatorConsole.vue`, `views/JoinView.vue` |
## §11. Нефункциональные требования
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 11.1 | Безопасность: TLS, mTLS между сервисами | ✅ | `scripts/mtls-certs.sh`, `MtlsCertificates.cs`, `compose.prod.yml` (`DEAL_MTLS_*`), `MtlsOptions.cs` |
| 11.2 | Параметризованный SQL | ✅ | EF Core / Npgsql по всему `Deal.Infrastructure`; ручной SQL — параметризованный (`ExecuteSqlRawAsync` без конкатенации) |
| 11.3 | IDOR/XSS/SSRF/CSRF | ✅ | IDOR — session+tenant-scope middleware; XSS — `renderSourceMessage` (экранирование); SSRF — `AiConnectionChecker.cs` (`IsPrivateEndpoint`, allowlist `AiProviders`), `CbrRateSource` (fixed URL); CSRF — `OriginGuardMiddleware.cs` + SameSite |
| 11.4 | Argon2id | ✅ | `DefaultPasswordHasher.cs` (Isopoh Argon2, Variant Argon2id) |
| 11.5 | Rate limiting (прокси + приложение), счётчики распределённые в БД | ✅ | `StoreBackedFixedWindowRateLimiter.cs`, `IRateLimitCounterStore``RateLimitCounterStore` (public.rate_limit_counters), `LoginAttemptGuard.cs`, `RateLimitPolicies.cs` |
| 11.6 | Cloudflare | ⚠️ | В коде нет интеграции/конфигурации Cloudflare; edge — Caddy (`deploy/caddy/Caddyfile`, TLS `internal`). Требование внешнего периметра, вне репозитория |
| 11.7 | Ежедневные бэкапы (Postgres/файлы/сессии), outbox для событий | ✅ | `scripts/backup.sh`/`restore.sh`/`deal-backup-lib.sh`; outbox — `MlOutboxQueue.cs`, `MlOutboxFlushScheduler.cs` |
| 11.8 | Авто-очистки (retention аудита/лимитов/счётчиков), разлогин suspended | ✅ | `DataRetentionScheduler.cs`, `DataRetentionOptions.cs`; `AuthService.ResolveSessionAsync` (suspended → null) |
| 11.9 | Наблюдаемость: логи → Loki, метрики OTel→Prometheus→Grafana + алерты, `token_usage_events` | ✅ | `Logging/DealLogging.cs`, `deploy/observability/{promtail,loki}.yml`, `prometheus-rules.yml`; миграция `AddTokenUsageEvents` |
| 11.10 | Масштабируемость: модульный монолит + сервисы ml/ai/telegram; k8s позже | ✅ | `Deal.Modules.*`, отдельные проекты `src/{ai,ml,telegram}-service`, `compose.*.yml`; k8s отсутствует (заявлено позже) |
| 11.11 | Производительность: без потерь; анти-бан-паузы не блокируют обработку | ✅ | `PipelineIngestService`/`DedupEntries`, фоновые `PipelineWorkerScheduler`/`BackfillService`, `progressive.js` |
| 11.12 | i18n: строки вынесены, RU по умолчанию, новые языки, переключение на лету с сохранением, форматтеры дат/чисел/валют, фолбэк RU | ⚠️ | Ядро i18n есть (`i18n/index.js`, `ru.js`/`ru.data.js`, `$t`), линтер проходит зелёным (проверено: `npm run lint:i18n` → ✓). Но: **нет UI-переключателя языка, нет второго языка и нет сохранения выбора**`index.js` прямо: «UI-переключателя на этом этапе нет»); даты/числа форматируются жёстко через `toLocale*('ru-RU', …)` (`store/core.js`, `store/settings.js`, `fmtNum` в `store/operator.js`), а не через locale-aware i18n-форматтеры |
## §12. Ограничения и допущения
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| 12.1 | Фронтенд Vue 3 + Vite + Tailwind; единый контракт `/api/cards` + `/api/containers` с этапа 9 | ✅ | `package.json` (vue/vite/tailwind), `api.js`, `CardsEndpoints.cs`, `ContainersEndpoints.cs` |
| 12.2 | Данные LeadRadar тестовые — не мигрируются | ✅ | Отдельные миграции Deal; данных-миграций из LeadRadar нет |
| 12.3 | Kafka, k8s, биллинг-провайдер, саморегистрация — вне рамок | ✅ | В коде отсутствуют |
| 12.4 | 1 Telegram-аккаунт на тенанта; несколько — позже | ✅ | `TenantSession` (1 на тенанта) |
---
## Расширенные требования (этапы 8–12)
| № | Требование | Статус | Доказательство |
|---|---|---|---|
| E1 | Библиотека готовых промптов по специальностям | ✅ | `ru.data.js` `PROMPT_LIBRARY` (IT/дизайн/недвижимость/стройка/услуги/красота/обучение), `PromptLibraryModal.vue` |
| E2 | Категории и поиск в библиотеке | ✅ | `PROMPT_CATEGORIES`, фильтр `query`/`cat` в `PromptLibraryModal.vue` |
| E3 | Раздел «Мои промпты» + свой промпт | ✅ | `SettingsService.PatchMyPrompts.cs`, `AiTab.vue` (`addMyPrompt`/`removeMyPrompt`), лимит ≤100 |
| E4 | Двухэтапный стоп-лист: стоп-фразы без ИИ, затем ИИ-фильтр с возможностью отключить | ✅ | Этап 1 `IncomingRules` (без ИИ); ИИ-фильтр `FilterSafelyAsync` под `AiFilterEnabled` |
| E5 | Исключения внутри колонки | ✅ | `ColumnExclusions.cs` (veto `Exclude`), `BoardRulesDialog.vue` |
| E6 | Discovery: поиск/вступление в каналы и группы | ✅ | `DiscoveryWorkerService.Search/Join`, `DiscoveryOps` (telegram-service) |
| E7 | Discovery: квоты/интервалы, закрытые группы, темы, список на рассмотрение | ✅ | `DiscoveryBanGuard`, `DiscoveryPacer`, `MarkClosedGroup`, `DiscoveryTopicGroup`, статус `review` |
| E8 | «Перечитать каналы»/backfill, пометка прочитанными, мгновенный приём | ✅ | `BackfillService.cs` (10 сообщений, паузы), read-ack; `RealtimeListener.cs` |
| E9 | ML отдельным контейнером | ✅ | `src/ml-service/Deal.Ml/Dockerfile` + `compose.dev.yml`/`compose.prod.yml` (`ml-service`, gRPC :5103) |
| E10 | ML: обучение на действиях пользователя **и** ИИ | ✅ | Пользователь — `CardsService.Operations.cs` (`PushAsync(…,1.0)`); ИИ — `AiCardLearning.cs`, `CardReclassifier.cs` (`AiPushWeight`) |
| E11 | Отдельная настройка проверки ML на сообщении/канале | ⚠️ | Проверка на **сообщении** ✅ (`POST /api/ml/predict`, `MLPanel.vue`); проверка на **канале** ❌ (`/api/ml/candidates` — пустая заглушка, `/api/ml/apply` — 404) |
| E12 | Архив/корзина (сроки, возврат, ручная очистка) | ✅ | `StorageTickService.cs`, `CardsService.Operations.cs`, `clear-col`/`DELETE`, `Restore` |
| E13 | Напоминания «Отложено» (календарь, отключение) | ✅ | `HoldReminderDialog.vue`, `CardsService.Reminders.cs`, `RemindersEnabled` |
| E14 | История карточки под спойлером | ✅ | `CardDrawer.vue` `<details>` «История движения» |
| E15 | Контакты квалифицированные (tg/phone/email/linkedin/site) | ✅ | `Parse/ContactsQualifier.cs` (типы `tg/phone/email/linkedin/whatsapp/site`, дедуп, отбой ботов/сервисных ссылок) |
| E16 | «Открыть исходник» | ✅ | `CardDrawer.vue` (`sourceUrl`), `ProcessingView.vue` |
| E17 | Источник не на карточке (только в деталях) | ✅ | `Card.vue` показывает лишь бейдж «Локальная»/контакты; канал и исходное сообщение — в `CardDrawer.vue` |
| E18 | Бюджет: диапазон/вакансия/валюта + конвертация (4 раза в сутки) | ✅ | `CardBudget.cs`, `BudgetNormalizer.cs`, `RatesService.cs` (6 ч = 4/сутки), `ConversionRecomputer.cs` |
| E19 | Обязательность суммы (опционально для вакансий) | ✅ | `SettingsKeys.BudgetRequiredHire/BudgetRequiredOrder`, `SkipNoBudgetAsync` |
| E20 | Вкладка «Обработка» (очередь + отсев + причины + поиск) | ✅ | `ProcessingView.vue`, `PipelineEndpoints` |
| E21 | Возврат из отсева с обучением | ✅ | `PipelineProcessingService.ReturnAsync` (`PushAsync(spam,1.0)`, `Force`) |
| E22 | Оператор-консоль | ✅ | `views/operator/*` (Tenants/Invites/Limits/Audit/Analytics/Health), `router.js` |
| E23 | Аналитика токенов | ✅ | `AnalyticsService.cs`, `OperatorAnalyticsEndpoints.cs`, `token_usage_events` |
| E24 | Аудит входов/выходов/действий (этап 10) | ✅ | `AuditEvents.cs`, `AuditService.cs`, `AuditSection.vue` |
| E25 | i18n (вынос строк) | ⚠️ | Строки вынесены и линтер зелёный, но нет переключателя языка/второго языка/персистентности и locale-форматтеров (см. 11.12) |
| E26 | Метрики Prometheus | ✅ | `DealMetricsHosting.cs`, `SharedKernel/Observability/DealMetrics.cs`, `prometheus.yml` (таргеты 5/5) |
| E27 | Распределённый rate-limit | ✅ | `RateLimitCounterStore.cs` (Postgres), `StoreBackedFixedWindowRateLimiter.cs`, миграция `RateLimitCounters` |
| E28 | reclassify (реальный, этап 12) | ✅ | `CardsEndpoints` `/reclassify` и `/{id}/reclassify`, `CardReclassifier.cs` (локальный фолбэк), `ReclassifyGate.cs`, audit `card_reclassified` |
---
## Найденные пропуски/расхождения
### ❌ Отсутствует
1. **§8.12 «Внешний вид» (настройки оформления).** В `SettingsView.vue` нет вкладки/раздела внешнего вида;
тема одна (тёмная, `style.css` `@theme`). Отдельной настройки «внешний вид» не найдено.
### ⚠️ Частично
2. **§8.5 / E11 «проверка ML на канале».** `POST /api/ml/candidates` (`MlEndpoints.cs:131141`) возвращает
`{items: []}` с комментарием «До этапа 6 telegram-данных нет» — устаревшая заглушка; `POST /api/ml/apply`
(`MlEndpoints.cs:144155`) всегда отвечает 404 «Исходное сообщение не найдено». Реального разбора
сообщений канала/ручного применения решения ML нет, хотя telegram-данные в системе уже есть
(проверка на сообщении — `POST /api/ml/predict` — работает).
3. **§5.14 «Глобальные исключения по ключевым словам/технологиям/бюджету/локации».** Глобальных настроек
такого исключения в `SettingsKeys` нет: есть только `stopPhrases` (стоп-фразы) и per-column `exclude`
(`ColumnExclusions.cs`). Исключения уровня «технология/бюджет/локация» как общий фильтр не найдены.
4. **§4.1/§8.1 ключи Telegram.** Хранятся как настройка тенанта `tgKeys` (`TelegramKeysService.cs`) и
вводятся в UI тенанта (`TelegramTab.vue`). ТЗ требует, чтобы `api_id`/`api_hash` задавал **оператор
глобально** — глобальной операторской настройки/ручки нет.
5. **§11.12 / E25 i18n.** Строки вынесены в словари (`i18n/locales/ru.js`, `ru.data.js`), `npm run lint:i18n`
проходит. Но отсутствуют: UI-переключатель языка, второй язык, сохранение выбора, «переключение на лету»
`i18n/index.js` явно сказано «UI-переключателя на этом этапе нет»). Форматирование дат/чисел жёстко
`ru-RU` (`store/core.js:232276`, `store/settings.js:351406`, `store/operator.js:356`), не через
locale-aware i18n-форматтеры.
6. **§6.3 состав фильтров колонки.** `ContainerRulesDto` = `Mode/Direction/Keywords/Stack/Grade/Exclude/Budget`.
ТЗ перечисляет также «уровень/цена/локация/тип» отдельными опциями — явных групп нет (частично
покрываются `direction`/`keywords`).
7. **§6.6 «открыть исходник» как быстрое действие карточки.** На `Card.vue` есть комментарий/корзина/контакт,
но ссылки «открыть исходник» нет — она доступна только в `CardDrawer.vue` и `ProcessingView.vue`.
8. **§10.2 health очередей.** `/api/operator/health` проверяет БД и сервисы ml/ai/telegram, но глубины
очередей (пайплайн, MlOutbox) в JSON health не отдаёт — они только в метриках
(`DealMetricsCollector.cs``/metrics`).
9. **§10.5 подозрительная активность.** Специализированного детектора/ленты подозрительной активности по
логам безопасности не найдено; есть лишь счётчик `failedLogins` в обзорной аналитике и общие
Grafana-дашборды.
10. **§11.6 Cloudflare.** В репозитории нет конфигурации/интеграции Cloudflare (edge — Caddy,
`deploy/caddy/Caddyfile`). Требование периметра, вне кода приложения.
11. **§7.1 «автопрокрутка» очереди.** Реализована как периодическое авто-обновление списка (~2.6 с,
`ProcessingView.vue`), а не как буквальная авто-прокрутка. Семантически покрывает требование, но не
дословно.
### Дефекты/легаси, замеченные при проверке (не пункты ТЗ, но влияют на заявленные функции)
12. **`NotifyTab.vue` — сломан список активных напоминаний.** `const holdReminders = computed(() => state.projectCards.filter(...))`
(`settings/NotifyTab.vue:68`), при этом `state.projectCards` больше нигде в `src/` не определяется
(grep даёт ровно одно совпадение — этот файл). После этапа 9 (`projectCards`/`stage` упразднены) обращение
к `state.projectCards.filter` даёт `undefined.filter` → ошибка рендера вкладки «Уведомления».
13. **Легаси-артефакты LeadRadar.** В корне остались `docker-compose.yml` (сервисы `app`/`ml`/`minio`
старого стека), каталог `backend/` (python `app/`) и `mlservice/` (python). Текущая архитектура — `deploy/compose.*.yml`
+ `src/{core,ai,ml,telegram}-service`. Прямого нарушения ТЗ нет, но это риск путаницы (в STATUS.md
«судьба legacy `docker-compose.yml`» помечена как открытый вопрос).
---
## Чего проверка не покрывает
- **Живые внешние интеграции без кредов.** Реальный Telegram-вход (`api_id`/`api_hash`/QR) и реальные
LLM-вызовы не проверялись (нет кредов; см. STATUS.md, п.5 «нужны живые креды»). Проверяется только
наличие кода/контрактов и локальных заглушек.
- **Живой контур Docker/k8s, mTLS-рукопожатие, Grafana/Loki/Prometheus.** Проверены конфиги
(`compose.*.yml`, `deploy/observability/*`) и код обвязки, но не факт поднятия/скрейпа в этой сессии
(сервисы не поднимались).
- **Скрипты бэкапа/восстановления и нагрузочные тесты.** Наличие и читаемость проверены (`scripts/backup.sh`,
`scripts/restore.sh`, `scripts/loadtest/`), но не выполнялись.
- **Корректность чисел в тестах.** Тест-счётчики (STATUS.md: core 1203 и т.п.) не пересчитывались —
тесты не запускались (кроме быстрого `lint:i18n`).
- **UI-поведение в браузере.** Выводы по фронту основаны на чтении `.vue`/`.js`; реальные клики,
drag&drop и рендер не воспроизводились.
- **Внешний периметр (Cloudflare, TLS в проде, DNS, egress-контроль).** Вне репозитория.
- **Соответствие формальным юридическим требованиям/биллингу** — вне рамок ТЗ (заявлено как «позже»).
---
## Обновление (2026-09-10, вечер) — статус после добивки
Часть найденных ⚠️/❌ закрыта в тот же день (детали — `.superpowers/sdd/deal-stage12-observability-hardening/task-tz-*.md`):
| Пункт | Было | Стало |
|---|---|---|
| §8.12 «Внешний вид» | ❌ | ✅ раздел настроек + темы тёмная/светлая/системная (§15 техдока) |
| §8/E11 ML-проверка на канале | ⚠️ заглушка | ✅ `MlReviewService` (`/api/ml/candidates|apply`) |
| §5.14 глобальные исключения | ⚠️ | ✅ `excludeKeywords/Locations/Types/Budget*` на стоп-этапе |
| §6.3 группы фильтров колонки | ⚠️ | ✅ `levels/locations/types/prices` + matchHits |
| §6.6 «открыть исходник» на карточке | ⚠️ | ✅ быстрое действие в `Card.vue` |
| §10.2 health очередей | ⚠️ | ✅ `queues`/`sessions` в `/api/operator/health` |
| §10.5 подозрительная активность | ⚠️ | ✅ `SuspiciousActivityService` + `/api/operator/analytics/suspicious` |
Остаются требующими владельца/кредов (осознанно): глобальные Telegram-ключи оператора (§4.1/§8.1),
переключатель языка (§11.12 — **в бэклоге**, по потребности), живые Telegram/LLM-вызовы, Cloudflare/прод-периметр.
Итог после добивки: core-тесты **1245/1245**; фронт build + `lint:i18n` зелёные.
@@ -0,0 +1,95 @@
# Финальная «подбивка» документации «Дейл» (2026-09-11)
> Дата: 2026-09-11
> Периметр: все `docs/**` (актуальные доки — spec/user-guide/technical/api/STATUS; исторические —
> `plans/*`, `reviews/*`, `specs/*`, старые `architecture/*`).
> Метод: сквозной поиск по проблемным терминам (`Boards`, `ProjectCards`, `Deal.Modules.Projects`,
> `ProjectStages`, корневой `docker-compose.yml`, `DEAL_DEMO`, демо-эндпоинты, `l_`/`pr_`, `app_settings`,
> «лид» как сущность, старые порты/пути/счётчики тестов) + чтение актуальных доков и сверка с кодом
> (`src/**`, `deploy/compose.*.yml`, `scripts/dev-smoke.sh`, `deploy/observability/grafana/dashboards/`).
> Докер не поднимался, тесты не перезапускались. Предшествующий аудит — `2026-09-10-docs-audit.md`
> (30 расхождений, уже помечен как исторический).
## Сводка
- Найдено новых расхождений: **9** (по таблице ниже).
- Исправлено в актуальных доках: **9**.
- Добавлено исторических пометок: **20** файлов.
- Переписывание содержания исторических артефактов не выполнялось (по правилам задачи).
## Расхождения (файл:строка → в доке → реальность → действие)
| # | Файл:строка | В доке | Реальность | Действие |
|---|---|---|---|---|
| 1 | `docs/superpowers/STATUS.md` ~L4 | «Все этапы **010** выполнены (100%)» | таблица этапов — **012**, «Итого 0–12 = 100%» (строка ниже) | ✅ исправлено на 0–12 |
| 2 | `docs/superpowers/STATUS.md` ~L25 | этап 10 — «**ELK**-дашборды» | стек — Loki + promtail + Grafana (`deploy/observability/grafana/dashboards/Deal-*.json`); Elasticsearch/Kibana нет | ✅ «Grafana/Loki-дашборды» |
| 3 | `docs/superpowers/STATUS.md` ~L54 | «Настройки (ключи **AI/Telegram** enc:, промпты, валюты)» у тенанта | ключи Telegram — глобально у оператора (`public.global_settings`); у тенанта только подключение аккаунта | ✅ «ключи AI enc: …; Telegram-ключи — глобально у оператора» |
| 4 | `docs/superpowers/STATUS.md` ~L134 | «судьба legacy `docker-compose.yml`» (открытый вопрос) | файл перенесён в `archive/leadradar-legacy/` (2026-09-10) | ✅ «перенесён в `archive/leadradar-legacy/`» |
| 5 | `docs/superpowers/STATUS.md` ~L141 | «Core-тесты **1245/1245**» | актуально **1275/1275** (в том же разделе ниже уже 1275) | ✅ исправлено на 1275/1275 |
| 6 | `docs/superpowers/STATUS.md` ~L167 | «реестр id-стадий `ProjectStages`» | с этапа 9 каталог — `CardsDefaultContainers` (`Deal.Modules.Cards/Application/CardsDefaultContainers.cs`) | ✅ аннотировано «(с этапа 9 — `CardsDefaultContainers`)» |
| 7 | `docs/superpowers/STATUS.md` ~L7, ~L94 | «dev-smoke **12/12**» (в двух местах) | `scripts/dev-smoke.sh` выполняет **14** проверок (config + 6 контейнеров + login/status/containers/create/list/trash/ML-флашер); таблица этапа 9 уже фиксирует `PASS=14` | ✅ исправлено на 14/14 |
| 8 | `docs/technical/Техническая-документация-Дейл.md` §8 ~L319 | `dev-smoke.sh`: «… → `/api/tg/status`**simulate-lead** → флашер MlOutbox …» | скрипт: `/api/tg/status``POST /api/cards` → trash → флашер MlOutbox | ✅ заменено на `POST /api/cards` → trash |
| 9 | `docs/user-guide/Инструкция-пользователя-Дейл.md` ~L6, L32-34, L41, L262 | «dev/демо-окружение», «демо-пространство с входом `admin`/`admin`» | демо удалено; dev-seed создаёт bootstrap-тенанта `Default` (env `DEAL_BOOTSTRAP_LOGIN/PASSWORD`, дефолт `admin`/`admin`) | ✅ «Dev-окружение», «bootstrap-пространство `Default`» |
## Добавленные исторические пометки
Единая шапка: `> Исторический документ этапа N. Актуальное состояние — docs/superpowers/STATUS.md и docs/technical/Техническая-документация-Дейл.md.`
Планы (`docs/superpowers/plans/`, 15 файлов):
`2026-09-04-channel-discovery.md` (план Discovery прототипа LeadRadar),
`2026-09-05-deal-roadmap.md` (roadmap этапов 07),
`2026-09-05-deal-scaffold.md` (этап 0),
`2026-09-05-deal-stage1-tenancy.md``2026-09-05-deal-stage7-saas.md` (этапы 17),
`2026-09-09-deal-stage9-unified-card.md` (этап 9),
`2026-09-10-deal-stage10-operator-analytics.md` (этап 10),
`2026-09-10-deal-stage11-i18n.md` (этап 11),
`2026-09-10-deal-stage12-observability-hardening.md` (этап 12).
Ревью (`docs/superpowers/reviews/`):
`2026-09-08-code-quality-review.md` (этап 8),
`2026-09-10-tz-compliance-audit.md` (аудит соответствия ТЗ),
`2026-09-10-docs-audit.md` (аудит документации; добавлена ссылка на текущий отчёт).
Специи/архитектура:
`docs/superpowers/specs/2026-09-04-channel-discovery-design.md` (дизайн Discovery прототипа),
`docs/architecture/2026-09-05-deal-architecture-design.md` (архдизайн-черновик),
`docs/architecture/2026-09-09-unified-card.md` (дизайн единой карточки, этап 9).
Не тронуты по существу (актуальны): `docs/architecture/2026-09-10-unified-api-contract.md`,
`docs/architecture/2026-09-10-operator-analytics-contract.md`.
## Проверено и сходится
- **Единый API**: `/api/cards` + `/api/containers`; `api-map` §1/§3.5 корректно фиксирует удаление
`/api/leads|projects|boards|columns` и переименование `new_lead → new_card`; ссылки на «бывшие» домены —
в контексте «удалено», а не как действующие.
- **Ключи Telegram**: spec §4.1/§8, user-guide §3, api-map §6, technical §13.7/§13.10 — везде у оператора
(`/api/operator/settings/telegram-keys`, `public.global_settings`).
- **Оператор-консоль/активация**: `#/operator`, `#/join?code=…` — spec §10, user-guide §11, technical §13.10,
api-map — совпадают.
- **Порты**: core 5080/5082, telegram 5101, ai 5102, ml 5103, metrics 9464, postgres 5433, minio 9000/9001,
grafana 3001, prometheus 9090 — совпадают между spec/user-guide/technical/api и compose-файлами.
- **Core-тесты**: 1275 (technical §13.6/§16, STATUS таблица/итоги) — противоречий в актуальных доках нет.
- **Префиксы id**: `c_` (единый) — api-map §4.1, technical §11/§12/§8; `l_`/`pr_` в актуальных доках отсутствуют
(остались только в помеченных исторических разделах и внешних исторических артефактах).
- **`app_settings`**: в актуальных доках нет; актуальная таблица — `global_settings` (`public`).
- **Исторические артефакты**: `Boards`/`ProjectCards`/`Deal.Modules.Projects`/`ProjectStages`/`DEAL_DEMO`/
демо-ручки/`docker-compose.yml` встречаются только в документах, получивших историческую пометку.
## Осталось спорным / намеренно не тронуто
1. **Ссылка из spec на исторический архдизайн.** `docs/spec/ТЗ-дейл-новая-архитектура.md` (шапка)
указывает среди связанных `docs/architecture/2026-09-05-deal-architecture-design.md` — документ теперь
помечен историческим. Формально не ошибка (файл существует, помечен), но при следующей редакции ссылку,
возможно, стоит заменить на `2026-09-10-unified-api-contract.md`.
2. **`tgKeys` в historical §13.4b технического дока** (`GET /api/settings` перечисляет `tgKeys`): раздел
помечен историческим (§13 шапка + заметка §13.4a о переносе ключей к оператору). По правилам задачи
содержание исторических разделов не переписывалось.
3. **Счётчики тестов сервисов** (telegram 125, ai 52, ml 38): не перепроверялись кодом/прогоном
(запрет на долгие процессы); в актуальных доках они не противоречат друг другу.
4. **«Live SaaS 15/15»** — цифра из исторических приёмок, независимо не подтверждалась.
5. **Число операторских ручек (25)** в api-map §5 — подсчёт по `Endpoints/Operator*` + `/api/join`;
группировка может отличаться от авторской (ранее было 21). Не перепроверялось.
6. **Исторический журнал §11 техдока** (этапы 17) и §13.4c/4d/4e намеренно сохраняют легаси-термины
под пометками; сведение их в ссылки на §3 — задача следующей редакции, а не этой подбивки.
7. **`docs/architecture/2026-09-10-*`** (контракты) по условию задачи не редактировались; они актуальны.
@@ -0,0 +1,212 @@
# Поиск и подключение каналов (Discovery) — дизайн
> Исторический документ (дизайн Discovery прототипа LeadRadar, 2026-09-04). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
Дата: 2026-09-04
Статус: согласован с пользователем (правки от 2026-09-04 учтены)
## 1. Цель
Пользователь даёт системе «задание»: найти Telegram-каналы и группы, в которых мы ещё
**не состоим**, по описанию цели (например, «вакансии и фриланс для разработки») и
подключить их к мониторингу. Система сама ищет кандидатов, оценивает их (по метаданным,
языку и содержанию сообщений) и показывает человеку список «на рассмотрение»; человек
решает — вступить и мониторить или отклонить. Возможен режим авто-вступления в рамках
суточных квот и с паузами против бана.
Ключевое правило: **источники, в которых мы уже состоим (вступили/мониторим), исключаются
сразу и безусловно — независимо от запроса, ключей и настроек задачи.** Это глобальное
правило системы: действует на всех этапах (поиск → оценка → вступление) и для всех задач.
## 2. Ограничения Telegram API (факты, на которых строится дизайн)
1. Глобального «поиска по критериям» в API нет. `contacts.search(q)` возвращает
публичные каналы/группы/боты по **имени/username/запросу** — без фильтров по
участникам, языку и содержимому. Всю дальнейшую фильтрацию делаем сами.
2. Число участников/описание — через `channels.getFullChannel`. Для публичных каналов
доступно без вступления; для групп часто доступно только членам.
3. Чтение истории без вступления: публичные **каналы** — обычно можно; публичные
**группы** — только если история открыта; иначе — только членам.
4. Массовый поиск/чтение/вступления с юзер-аккаунта ограничены эмпирически — нужны
квоты, паузы и обработка `FloodWaitError`.
## 3. Понятия
- **Задача (task)** — конфиг поиска: описание цели, ключи, фильтры, план, режим
авто-вступления, статус/счётчики. Задач может быть несколько.
- **Кандидат (candidate)** — найденный источник (канал/группа, для форумов — оценка по
темам). Проходит стадии: `new → evaluated → review → joined | rejected`.
- **Метки кандидата** — человекочитаемые пометки: «закрытая группа/канал», «форум»,
«не прочитано», «участники не подтверждены», «язык не подтверждён», «есть проходные
темы».
- **Чёрный список** — источники, отклонённые пользователем; поиск их больше не
возвращает (снимается вручную).
- **BanGuard** — единый менеджер квот и пауз для всех действий discovery
(search/read/join/leave), общий для задач.
## 4. Задача: конфигурация и правила создания
Поля задачи:
| Поле | Назначение | По умолчанию |
| --- | --- | --- |
| `name` | название задачи | — |
| `description` | описание цели (что ищем) | — |
| `keywords` | поисковые ключи (генерирует ИИ, редактируются перед стартом) | [] |
| `minSubscribers` | минимум участников (0 = не важно) | 0 |
| `lang` | язык источников (`ru` / `any`) | `ru` |
| `threshold` | доля подходящих сообщений, % | 40 |
| `sampleSize` | сколько сообщений смотреть при оценке | 10 |
| `planJoins` | план вступлений N | 1..50 |
| `autoJoin` | авто-вступление подходящих | false |
| `status` | `draft → running → paused → done | failed` | draft |
Правила создания:
- **Бюджет планов:** сумма `planJoins` всех задач в статусе не `done/failed` + `planJoins`
новой ≤ суточного лимита вступлений (по умолчанию 50). Задача с планом 50 не даёт
создать другую; план 25 оставляет максимум 25.
- Запуск возможен только после генерации/подтверждения ключей.
- При редактировании активной задачи план нельзя увеличить сверх свободного бюджета.
## 5. Пайплайн поиска (каскад фильтров)
Выполняется фоновым воркером задачи строго через BanGuard (по одному действию, с паузами).
Для каждого кандидата фильтры идут **по нарастающей стоимости**; при первом «нет»
источник пропускается и берётся следующий:
1. **Поиск**`contacts.search` по каждому ключу (с паузами). Кандидаты
дедуплицируются по `dialog_id`/username.
2. **«Мы не состоим» — глобальный фильтр, применяется сразу и безусловно:** как только
источник найден (независимо от запроса/ключей), он отбрасывается, если уже есть
в `dialogs` (вступили/мониторим), в чёрном списке или уже обрабатывается/вступил/ждёт
рассмотрения в другой задаче (глобальная дедупликация кандидатов). Остальные фильтры
(участники/язык/контент) применяются уже после этого. Проверка повторяется
непосредственно перед вступлением (между оценкой и join'ом кандидат мог быть добавлен
вручную).
3. **Число участников** — если `minSubscribers` задано:
- значение получено и меньше минимума → пропуск;
- значение получить не удалось → **не пропускаем**, ставим метку «участники не
подтверждены».
4. **Язык** — если `lang=ru`: по выборке сообщений эвристикой кириллицы (без ИИ);
не удалось прочитать → метка «язык не подтверждён» (не пропуск).
5. **Содержимое** — оценка выборки сообщений (см. §6).
Пометки «не подтверждено» — не ошибка, а сигнал человеку на экране рассмотрения.
## 6. Оценка содержимого (по темам, для форумов)
- **Что считается «подходящим сообщением»:** сообщение проходит те же правила, что в
основной системе (этап 1 → ML → ИИ), но **профиль оценки = профиль задачи**
(описание + ключи задачи), а не глобальные настройки дашборда. Оценка ничего не
создаёт: ни карточек, ни очереди, ни обучения ML.
- Если ИИ выключен — оценка локальным разбором/ML.
- **Каналы:** читаем до `sampleSize` последних сообщений; доля подходящих ≥ `threshold`
→ в «на рассмотрение».
- **Открытые группы:** то же; чтение не удалось → «на рассмотрение» с меткой
«открытая группа, не прочитана».
- **Закрытые группы** (нашлись по ключам, история скрыта): сразу «на рассмотрение» с
меткой «закрытая группа/канал» (+ метки неподтверждённых фильтров). Пользователь
вступает сам.
- **Форумы (группы с темами):** группа раскладывается по темам (`reply_to_top_id`):
читаем выборку по активным темам, оценка считается **по темам** («тема: подходит
X из N»). Группа подходящая, если есть ≥1 проходная тема. В превью — список тем с
пометками проходная/нет. Имена тем, если API не отдаёт без членства, подставляем
сниппетом первого сообщения темы.
- Порог «40%» применяется к сообщениям темы/канала; если в выборке меньше 3
содержательных сообщений — кандидат идёт «на рассмотрение» с меткой «мало сообщений».
## 7. «На рассмотрение» и действия человека
Экран по задаче содержит списки: **В обработке / На рассмотрении / Вступили /
Отклонены**, плюс история.
Кандидат на рассмотрении показывает: тип (канал/группа/форум), число участников,
метки, долю «подходит X из N» и **почему подошло** (перечень подходящих сообщений/тем
с причинами — как блок «попала по фильтру» в карточках), превью сообщений (для
форумов — по темам).
Действия:
- **«Вступить и мониторить»** — `channels.joinChannel` (по username), добавление в
`dialogs` с `monitor=1`, backfill последних ~10 сообщений. Ручной клик — **вне квот**.
После вступления источник автоматически попадает под правило «мы состоим» и из
поиска исключается.
- **«Отклонить»** — источник в чёрный список (исключается из поиска во всех задачах).
Если для оценки пришлось вступать — выходим (`channels.leaveChannel`) в рамках квот.
Чёрный список редактируется вручную (можно снять).
- **Закрытые группы:** вместо авто-вступления — кнопка-ссылка `t.me/<username>`; система
замечает вступление при синхронизации диалогов и предлагает добавить источник в
мониторинг (метка «вступили, добавить в мониторинг?»).
## 8. Авто-вступление, квоты и анти-бан (BanGuard)
- Суточный лимит вступлений — **50** (настройка), общий для всех задач, считаются только
автоматические вступления. Ручные — без ограничений.
- Авто-вступление включается на задачу (`autoJoin`). Подходящие кандидаты вступают сами.
- Интервалы между автоматическими вступлениями: **случайно 50–70 секунд**; по одному
действию, без параллелей. Поиск и чтение — мягкие паузы (единицы секунд + джиттер,
переиспользуем значения анти-бана из telegram.py).
- Задача «выполнена» при достижении плана вступлений. Если за сутки упёрлись в общий
бюджет — авто-режим продолжает на следующий день (новый суточный бюджет).
- `FloodWaitError` → пауза по секундам из ответа + запас; авто-вступления останавливаются
до следующего дня при флуде. Общий «стоп-кран» — пауза всего discovery.
- Все квоты/интервалы — настройки в UI.
## 9. Хранилище
| Таблица | Назначение / ключевые поля |
| --- | --- |
| `disc_tasks` | задачи: name, description, keywords(JSON), min_subscribers, lang, threshold, sample_size, plan_joins, auto_join, status, counters (found/evaluated/joined/rejected), created/updated |
| `disc_candidates` | dialog_id/username/name/kind(channel|group|forum)/hue, participants, lang_ru, join_failures, marks(JSON), topics(JSON: {topicId,title,fitCount,total,fitRatio,passed}), fit_ratio, status(new/review/joined/rejected), task_id, times |
| `disc_blacklist` | dialog_id, name, reason, created_at |
| `disc_log` | история задачи: task_id, event(search/evaluate/join/leave/flood/error/review/blacklist), text, created_at |
Дубли кандидатов не создаются; источник, попавший в другую задачу или `dialogs`,
из поиска исключается (правило «мы состоим» — глобальное).
Реализация: у кандидата нет транзитного статуса `evaluated` (счётчик оценённых — на
задаче); `join_failures` — неудачные авто-вступления подряд, после 3 кандидат удаляется.
## 10. API
- `GET/POST/PATCH/DELETE /api/discovery/tasks` (создание с валидацией бюджета планов),
`POST /api/discovery/tasks/{id}/start|pause`
- `POST /api/discovery/tasks/{id}/generate-keywords` — ИИ генерирует ключи по описанию
- `GET /api/discovery/tasks/{id}/candidates?status=review|joined|rejected`
- `POST /api/discovery/candidates/{id}/join` (вступить и мониторить), `.../reject`
- `GET /api/discovery/blacklist`, `DELETE /api/discovery/blacklist/{dialog_id}`
- `GET /api/discovery/tasks/{id}/log`
## 11. UI
Подвкладка **«Поиск»** на экране «Каналы»:
- список задач (статус, прогресс, план/вступили, авто-режим) + «Новая задача»;
- мастер задачи: описание → «Сгенерировать ключи ИИ» → редактирование ключей →
фильтры/план/авто-режим → запуск;
- по задаче: статус-лента (поиск → оценка → вступление), вкладки «В обработке /
На рассмотрении / Вступили / Отклонены», история;
- кандидат на рассмотрении раскрывается с превью и действиями; форум — по темам;
- настройки квот (лимит/интервалы) — в том же экране или «Настройки → Telegram».
## 12. Интеграция с существующим кодом
- Фоновый воркер discovery — отдельный цикл в `main.py` (как `_pipeline_loop`),
сервис `app/services/discovery.py`, Telegram-действия — методы `TelegramManager`
(поиск/join/leave/чтение) с общим pacing.
- Переиспользуем: `rules/stage1/ML/AI` для оценки сообщений (новый лёгкий вызов с
профилем задачи, без записи карточек), список `dialogs` для фильтра «мы состоим»,
синхронизацию диалогов для авто-добавления закрытых групп.
- К основному пайплайну карточек, ML и ТЗ-логике не прикасаемся.
## 13. Вне рамок (сейчас)
- Агрегаторы-каталоги как источник кандидатов.
- «Похожие каналы» (`getChannelRecommendations`) от наших подписок — отдельная опция позже.
- Авто-вступление в закрытые группы по инвайт-ссылкам (глобальным поиском они не находятся).
## 14. Значения по умолчанию (настраиваются в UI)
суточный лимит вступлений = 50; интервал авто-вступлений = 50–70 с; выборка = 10
сообщений; порог = 40%; мин. содержательных сообщений для оценки = 3; язык = ru.
@@ -0,0 +1,86 @@
# Открытые вопросы: вложения источников (media → Storage) и просмотр исходника
Дата: 2026-09-11. Статус: решения владельца получены (см. §0).
## 0. Решения владельца (2026-09-11)
- **А) Медиа-посты пропускаем.** Сообщения без текста (только медиа/вложение) в систему не попадают.
Извлечение вложений Telegram и выгрузка их в Storage не делаются. Generic-контракт по-прежнему умеет
нести `DataRef` — этим смогут пользоваться другие источники (файл/диск/таблица) и ручные вложения карточки.
- **Б) Проверка без живого Telegram** — реализуем с юнит-тестами на фейковой сессии/фейковом Storage,
без реального API.
- **В)** Объяснение термина — в §1.5. **Решение: делаем.** Реализован remote-просмотр: `TelegramService.ReadSource`,
`ITelegramGateway.ReadSourceAsync`, `TelegramSourceContentProvider` (Kind=telegram) в ядре,
`GET /api/cards/{id}/source` и кнопка «Обновить из источника» в подробной карточке.
## 1.5. Что такое «remote-просмотр исходника»
Карточка хранит **ссылку на источник** (`SourceRef`) и **содержимое** (`SourceContent`). Содержимое попадает
в карточку в момент приёма. «Просмотр исходника» — это возможность по кнопке догрузить/показать **оригинальное
сообщение у источника** (то, что было в канале/письме/строке), если контент в карточке устарел или урезан.
Сейчас содержимое уже отдаётся в `CardDto.content` и через `GET /api/cards/{id}/source`. Для локальных
источников этого достаточно. Для **внешних** источников (например Telegram) данные лежат не в ядре, а в
сервисе-владельце; чтобы их догрузить, ядру нужен провайдер `ISourceContentProvider` для `kind`, который
ходит по gRPC к сервису-владельцу (условный RPC `ReadSource(dialogId, msgId)`) и возвращает исходный текст/медиа.
Это и есть «remote-просмотр» — расширение extension-point, которое не требуется до появления реальной
необходимости (напр. если карточки хранят урезанный текст или нужно открыть живой первоисточник).
## 1. Что уже готово (не требует решений)
- Единый контракт источника несёт вложения: `SourceContent.Data: IReadOnlyList<DataRef>`
ссылки на объекты Storage-сервиса (`DataRef.Id/Ref/Kind/MimeType/...`).
- Контракт входящего потока (`src/contracts/sources.proto`, `PushSource`) передаёт
`DataRefProto`/`ContactRefProto` — источник может прислать вложения сразу со ссылками.
- Storage-сервис (`src/storage-service/Deal.Storage`, `storage.proto`) умеет `Upload/Download/Stat/Delete`,
сам определяет `kind`/`mimeType`/размеры (контент-снифинг), бэкенд — MinIO.
- Ядро хранит `SourceContent` карточки (в т.ч. `Data`) и отдаёт его в `CardDto.content` и через
`GET /api/cards/{id}/source` (extension-point `ISourceContentProvider` + `SourceContentResolver`).
- Фронт рендерит вложения: `SourceContentView.vue` (image/video/audio/document/archive по `kind`),
ссылки и контакты — списками.
## 2. Проблемная часть (требует живого Telegram)
Извлечение и выгрузка медиа из Telegram не проверяемы офлайн:
1. **Медиа-сообщения сейчас отбрасываются.** `TlMessageMapper.ToMessage`
(`src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs`) принимает только `Message`
с непустым `message` (текстом). Посты с одним вложением и подписью (`media` + `caption`) не попадают
в поток вообще. Нужно: определять `Message.media`, читать `caption`, тип/размеры/длительность.
2. **Скачивание и выгрузка.** Требуется `client.DownloadMedia(...)` (WTelegram) → поток →
`StorageService.Upload(meta + data)``DataRefProto`. В telegram-сервисе нет gRPC-клиента Storage
и соответствующей конфигурации в compose (endpoint/токен). Проверить можно только с реальным
Telegram-аккаунтом и живым MinIO.
3. **Подпись без текста.** Даже если вложение извлечено, в посте может не быть текста: нужен ли такой
пост «карточкой» (сейчас `PipelineIngestService` пропускает записи без `Content.Text`)? Предлагается
принимать запись, если есть текст **или** вложения/ссылки/контакты, а классификацию медиа-онли
строить по подписи (`caption`) и метаданным. Требуется подтверждение продуктовой логики.
4. **Просмотр исходника из другого контура.** «Открыть исходник» для remote-источников (Telegram — это
лишь один из них) требует провайдера `ISourceContentProvider`, который ходит по gRPC к сервису-владельцу
источника (новый RPC, например `ReadSource(dialogId, msgId)`), возвращая текст/медиа. Это тоже
живой Telegram.
## 3. Предлагаемый план (после подтверждения)
1. `TelegramMessage` расширить моделью `TelegramAttachment` (caption, fileName, mimeType, size, width,
height, durationSec, `Task<Stream> Open(cancellationToken)`), заполнять в `TlMessageMapper` из
`Message.media`/`Document`/`Photo`.
2. В telegram-сервисе добавить `StorageClient` (gRPC, `Deal.Grpc.Storage`) + `SourceAttachmentUploader`:
загрузка каждого вложения → `DataRefProto`.
3. `DialogProtoMapper.ToSourceRequest(message, dataRefs)` — прокинуть `content.data` и `caption`.
4. `PipelineIngestService`: принимать запись при непустом тексте **или** непустых `Data`/`Links`/`Contacts`
(нужно продуктовое решение по п.2.3).
5. `ISourceContentProvider` для `kind="telegram"` — gRPC-провайдер к telegram-сервису (RPC `ReadSource`).
6. Настройки: endpoint/токен Storage в `deploy/compose.dev.yml`/`compose.prod.yml` для telegram-сервиса.
## 4. Что нужно от владельца
- А) Делать ли медиа-сообщения без текста карточками (по подписи/метаданным), или пропускать?
- Б) Для проверки вложений нужны живые Telegram api_id/api_hash и работающий MinIO — будет ли прогон
на вашей стороне, или реализуем «слепо» с юнит-тестами на фейковой сессии и фейковом Storage?
- В) Нужен ли remote-просмотр исходника (`ReadSource`) в этом объёме, или достаточно того, что
содержимое хранится в карточке?
Пока эти пункты не закрыты, они вынесены в `backlog.md` (`TD-STORE-ATTACH`, `TD-SOURCE-PROVIDER`),
а generic-часть (контракт, Storage-сервис, хранение, API, рендер) реализована.
@@ -0,0 +1,193 @@
# Дизайн: единый контракт источника + общий Storage-сервис данных
Дата: 2026-09-11. Статус: реализовано в ядре (домен, Storage-сервис, персистентность, конвейер, wire, фронт); адаптер/провайдер telegram-сервиса и Storage-выгрузка — следующие шаги. Контракт не плодит типы вложений; файлы — в общем Storage.
## 1. Принцип
1. **Единый строго типизированный контракт.** Любой источник (Telegram, WhatsApp, Avito, сайт, файл,
Excel) через адаптер приводит данные к одному типу `SourceItem`. Ядро, AI и ML работают только с ним.
2. **Данные файлов — в общем Storage-сервисе.** Каждый сервис-источник сам выгружает свои данные
(картинки, видео, аудио, документы, любые файлы) в общий Storage с **токеном валидации**. Storage сам
определяет тип и метаданные. В контракте хранится **ссылка** на файл, а не сам файл.
3. **Никаких подтипов вложений в контракте.** Не плодим `ImagePart/VideoPart/...`; есть универсальный
`DataRef` с полем `Kind`, которое заполняет Storage.
4. Ссылки, контакты и прочее, что **не является файлом**, идут отдельными полями контента.
5. В ядре нет Telegram-полей и слова Telegram (только в telegram-сервисе); в комментариях нет упоминаний
задач/этапов/ТЗ.
## 2. Единый контракт (Deal.Modules.Cards)
```csharp
public sealed record SourceItem
{
public required SourceRef Source { get; init; }
public required SourceContent Content { get; init; }
}
public sealed record SourceRef
{
public required string Kind { get; init; } // "telegram", "whatsapp", "avito", "file", "excel", ...
public string? ExternalId { get; init; } // id в источнике (сообщение/строка/файл)
public string? DisplayName { get; init; } // подпись в UI
public string? OriginRef { get; init; } // url / deep-link / путь
public string? Author { get; init; }
public DateTimeOffset ReceivedAt { get; init; }
public IReadOnlyDictionary<string, string>? Extra { get; init; }
}
public sealed record SourceContent
{
public string? Text { get; init; } // основной текст
public string? Html { get; init; } // разметка (если есть)
public string? Author { get; init; } // отправитель
public string? Subject { get; init; } // тема/заголовок
public IReadOnlyList<DataRef> Data { get; init; } = []; // ссылки на файлы в Storage
public IReadOnlyList<string>? Links { get; init; } // ссылки (не файлы)
public IReadOnlyList<ContactRef>? Contacts { get; init; } // контакты
public IReadOnlyDictionary<string, string>? Extra { get; init; } // прочее (не файл/не ссылка/не контакт)
}
```
`DataRef` — ссылка на объект в Storage; тип и метаданные определил Storage (nullable, чтобы не плодить типы):
```csharp
public sealed record DataRef
{
public required string Id { get; init; } // идентификатор объекта в Storage
public required string Ref { get; init; } // ссылка (url/путь) для скачивания/отображения
public string? Kind { get; init; } // определил Storage: image/video/audio/document/archive/other
public string? MimeType { get; init; }
public string? FileName { get; init; }
public long? Size { get; init; }
public int? Width { get; init; }
public int? Height { get; init; }
public double? DurationSec { get; init; }
public string? PreviewRef { get; init; } // превью/thumbnail
public string? Caption { get; init; }
public int? Order { get; init; }
public IReadOnlyDictionary<string, string>? Meta { get; init; } // прочие метаданные от Storage
}
```
`ContactRef`: `Name?`, `Phone?`, `Email?`, `Url?`, `Kind?` (контакт может быть квалифицирован).
## 3. Storage-сервис (общий)
Отдельный сервис (как ai/ml/telegram), владелец — данные. Источники и ядро только ссылаются на объекты.
- **Загрузка:** `Upload(stream, token, fileName?) → DataRef`. Каждый сервис-источник выгружает свои данные
**сам**, передавая **токен валидации** (сервисный токен/mTLS — уже есть в gRPC-обвязке).
- **Определение типа:** Storage сам решает `Kind`/`MimeType`/размеры/длительность (контент-снифинг);
контракт типы не задаёт.
- **Чтение:** `Get(id) → (stream, DataRef)` либо выдача ссылки/временного URL.
- **Бэкенд:** объектное хранилище (MinIO/S3). Путь/бакет — по тенанту.
- **Владение:** единый общий сервис; каждый источник пишет в него со своим токеном, ядро/AI/ML читают по ссылке.
## 4. Адаптеры источников
```csharp
public interface ISourceAdapter { string Kind { get; } SourceItem Normalize(object native); }
```
Владельцы: `telegram` → telegram-сервис; `local` → ручное создание (Cards); `whatsapp`/`avito`/`web`/`file`/
`excel` → соответствующий сервис. Файлы адаптер сам выгружает в Storage и кладёт в контракт `DataRef`.
## 5. Загрузка исходника карточки
Единый способ: по `SourceRef.Kind` — провайдер, возвращающий `SourceContent` (для файла — через Storage по
`DataRef.Ref`, для сообщения — у источника). `ISourceContentProvider { Kind; LoadAsync(SourceRef) }` + реестр.
API ядра: `GET /api/cards/{id}/source` → generic контент.
## 6. Персистентность
В карточках вместо плоских Telegram-колонок:
- `SourceKind` (text); `SourceJson` (jsonb, `SourceRef`);
- `ContentJson` (jsonb, `SourceContent` — текст + `DataRef`-ссылки + прочее);
- `SourceText` (text, FTS);
- `SourceRefUrl` (text?, `OriginRef`).
Конвертер контента общий (без per-source сериализаторов). Миграции: старые удаляем → новый init с нуля.
## 7. Wire и фронт
- `CardDto.Source` = `{ kind, displayName?, originRef?, receivedAt }`.
- `GET /api/cards/{id}/source``{ text?, html?, author?, subject?, data[], links[], contacts[], extra? }`.
- Фронт: generic блок источника + универсальный просмотрщик (по `DataRef.Kind` — картинка/видео/аудио/файл;
ссылки/контакты — списками).
## 8. Этапы
1. Домен: `SourceItem/SourceRef/SourceContent/DataRef/ContactRef`; удалить Telegram-маркеры из Cards.
2. Storage-сервис: контракт gRPC, определение типа, токен валидации, бэкенд MinIO; регистрация.
3. Персистентность: `SourceKind/SourceJson/ContentJson/SourceText/SourceRefUrl`, общий конвертер, новый init,
маппинг KanbanStore.
4. Pipeline: приём `SourceItem`, загрузка вложений в Storage адаптером, без Telegram-полей.
5. Wire/API: generic `Source` в `CardDto`, `GET /api/cards/{id}/source`, провайдеры.
6. Frontend: generic источник + универсальный просмотрщик.
7. Telegram: адаптер + провайдер исходника (только в telegram-сервисе) + выгрузка в Storage.
8. Комментарии: убрать упоминания Telegram из ядра и задачи/этапы — везде.
## 9. Реализация: зафиксированные сигнатуры
### Ядро: домен
- `SourceRefs` (Deal.Modules.Cards/Application/Sources): `Empty`, `DefaultHue = "#666"`,
`HueKey = "hue"`, расширения `DedupeKey()` (вид|оригинал|внешний id), `ResolveHue()`.
- `CardSnapshot`: вместо `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId`
`SourceRef Source` + `SourceContent Content`; `ReceivedAt` остаётся.
- `CardDto`: вместо `Channel`/`SourceMsg`/`SourceDialogId`/`SourceMsgId`/прежнего `Source`
`SourceRef Source` + `SourceContent Content`; `ReceivedAtMs` остаётся. `CardChannelDto`/`CardSourceDto` удалены.
- `ICardStore.GetCardBySourceAsync(SourceRef source, CancellationToken ct)`.
### Ядро: конвейер
- `QueuedMessage { required SourceItem Item; bool Force; }`.
- `QueueItemDto { string Id; SourceRef Source; SourceContent Content; string Text; string Status;
long MsgAtMs; long QueuedAtMs; bool Force; }` (JsonIgnore на Force).
- `RejectRecord { SourceRef Source; SourceContent Content; string Text; long MsgAtMs;
string DecidedBy; string Stage; string Reason; string Kw; string? DeterministicId; }`
(`DeterministicId = r_{Kind}_{OriginRef}_{ExternalId}`).
- `RejectedItemDto`: `Source`/`Content`, `DecidedBy`/`DecidedByLabel` (решение), остальное как было.
- `IPipelineStore.ExistsDuplicateAsync(SourceRef source, CancellationToken ct)`.
- `PipelineChannelDto` удалён.
### Схема БД (схема тенанта)
- Cards: удалить `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId`;
добавить `SourceKind`, `SourceExternalId`, `SourceOriginRef` (text, для запросов), `SourceJson` (text),
`ContentJson` (text), `SourceText` (text). FTS: Title+Summary+SourceText+Contact.
- QueueItems: удалить `DialogId/ChannelName/ChannelHandle/ChannelHue/MsgId`; добавить
`SourceKey` (text, уникальный ключ дедупа), `SourceJson`, `ContentJson`. `Text/MsgAt/Status/Force/CreatedAt/UpdatedAt` остаются.
- RejectedItems: удалить `DialogId/MsgId/ChannelName/ChannelHandle/ChannelHue`; добавить
`SourceKey`, `SourceJson`, `ContentJson`. FTS — по `Text`.
- Миграции tenant: старые удалить, сгенерировать новый init с нуля (данных нет).
### Маппинг источника
- Telegram-адаптер (в ядре — тонкий край приёма): `Kind="telegram"`, `ExternalId=MsgId`,
`OriginRef=DialogId`, `DisplayName=ChannelName`, `Extra["hue"]=ChannelHue` (иначе дефолт),
`ReceivedAt=msgAt`, `Content.Text=Text`, `Content.Author=ChannelName`.
- Дашборды/карточки/конвейер работают только с `SourceRef`/`SourceContent`; Telegram-поля не проходят дальше адаптера.
### Входящий поток (generic, 2026-09-11)
- `src/contracts/sources.proto` → сервис `SourceIngressService.PushSource` с generic-типами
`SourceRefProto`/`SourceContentProto`/`DataRefProto`/`ContactRefProto`; вложения — ссылки на Storage.
- Ядро: `Deal.Api/Sources/SourceIngressGrpcService` (приём) + `SourceProtoMapper` (proto → домен) +
`ISourceIngestObserver` (вторичная обработка принятой записи, сбой наблюдателя не влияет на приём) +
`IngressTenantResolver` (тенант по metadata).
- Из `telegram.proto` удалён `IngressService.PushMessage` (остались `SyncDialogs`/`ReportStatus`);
telegram-сервис шлёт записи через `PushSource` (`kind="telegram"`). Превью каталога/TgMessages сохраняет
`TelegramSourceIngestObserver` (ядро, telegram-модуль — единственное место с telegram-спецификой приёма).
- Любой другой источник (whatsapp/avito/файл/excel) шлёт тот же `PushSource` со своим `source.kind`.
### Remote-просмотр исходника (2026-09-11)
- `TelegramService.ReadSource(ReadSourceRequest{dialog_id, msg_id})` → `ReadSourceReply{found, text?, time?}`
(`src/contracts/telegram.proto`); telegram-сервис достаёт конкретное сообщение
(`ISessionClient.GetMessageAsync` → TL `Messages_GetMessages`). Медиа без текста → `found=false`.
- Ядро: `ITelegramGateway.ReadSourceAsync` + `TelegramSourceContentProvider` (`ISourceContentProvider`,
`Kind="telegram"`, `Deal.Infrastructure/Integrations/Sources`) — резолвится `SourceContentResolver`.
- `GET /api/cards/{id}/source` отдаёт результат провайдера либо сохранённое содержимое карточки.
Фронт: кнопка «Обновить из источника» в подробной карточке (`CardDrawer.vue` → `loadCardSource`).
@@ -0,0 +1,53 @@
# Дизайн: разбиение проектов на логические папки (namespace = папка)
Дата: 2026-09-11. Статус: согласовано владельцем (решения 1–5).
## 1. Цель
Упорядочить код по назначению: вместо «свалки» файлов разных видов в одной папке с единым
`namespace` — подпапки по назначению, при этом `namespace` соответствует пути папки.
## 2. Таксономия папок (по назначению)
| Папка | Что кладём |
| --- | --- |
| `Abstractions/` | интерфейсы `I*.cs` |
| `Services/` | прикладная логика: `*Service`, `*WorkerService.*`, `*Guard`, `*Pacer`, `*Evaluator`, `*Counter`, `*Detector`, `*Normalizer`, `*Matcher`, `*Composer`, `*Cleaner`, `*Classifier`, `*Mapper`, `*Builder`, `*Writer`, `*Recomputer`, `*Suggester`, `*Filler`, `*Generator`, `*Hasher` и аналогичные исполнители |
| `Models/` | доменные типы: сущности, value-объекты, enum, статусы/виды, константные реестры (`*Statuses`, `*Kinds`, `*Prefixes`, `*Keys`, `*Events`, `*Periods`, `*Sources`, `*Field`, `*Defaults`) |
| `Dtos/` | транспортные типы: `*Dto`, `*Request`, `*Response`, `*Patch` |
| `Extensions/` | `*Extensions` |
| `Options/` | `*Options` |
| `Exceptions/` | `*Exception` |
| `Registrars/` | `*ModuleRegistrar` |
Существующие feature-папки (`ColumnRules`, `Parse`, существующие `Models`) сохраняются.
## 3. Правила
1. `namespace` строго соответствует пути папки.
2. Имена типов и публичные контракты не меняются — только расположение и `namespace`.
3. Один тип = один файл (уже соблюдается).
4. Частичные классы (`Foo.cs`, `Foo.Part.cs`) переносятся вместе.
5. Тестовые проекты группируются по областям: `Modules/<X>`, `Api`, `Infrastructure` и т.п.,
`namespace` = `Deal.Tests.Unit.<Область>`.
## 4. Механика переноса (на проект)
1. Классифицировать файлы по таблице §2.
2. Перенести файлы в подпапки и заменить `namespace`.
3. Миграция `using`: в файлах-потребителях заменить несуществующий старый `using <OldNs>;` на
`using` всех новых подпространств (пере-добавление безопасно; при коллизии имён — ручное разрешение).
Файлы внутри проекта-источника получают `using` соседних подпространств.
4. `dotnet build` → исправить остатки (полные имена, `cref`), `dotnet test`.
5. Отдельный коммит (русский) после каждого проекта.
## 5. Порядок
Пилот — `Deal.Modules.Cards` (чистый домен). Далее: остальные `Deal.Modules.*`, затем
`Deal.Infrastructure`, `Deal.Api`, `Deal.Contracts`/`Deal.SharedKernel`, сервисы `telegram/ai/ml`,
затем тестовые проекты. После каждого шага — сборка + тесты + коммит.
## 6. Риски
- Коллизия простых имён при пере-добавлении `using` → разрешается вручную по ошибкам сборки.
- Не забыть `cref`/полные имена в XML-док и `nameof` — выявляются сборкой.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,271 @@
# Дейл (Deal) — Инструкция пользователя
> Версия: 1.3 (этапы 0–12)
> Дата: 2026-09-10
> Назначение: как работать с продуктом — от регистрации до ежедневного использования.
> Описывает итоговое состояние; dev-особенности помечены отдельно.
---
## 1. Регистрация по приглашению
«Дейл» — сервис с доступом по приглашениям: рабочее пространство (тенант) и пользователей заводит
**оператор** (администратор сервиса); самостоятельной регистрации нет.
1. Оператор создаёт приглашение **на вашу почту** — в новое пространство или в уже существующее.
2. Откройте присланную **ссылку приглашения** — откроется страница «Активация приглашения». Введите
**email** (обязательно тот же, на который приглашение), имя пространства (необязательно) и
**пароль** (минимум 8 символов).
3. Нажмите «Активировать аккаунт». Приглашение «на новое пространство» при активации создаёт ваше
рабочее пространство — данные изолированы от других клиентов. Приглашение «в существующее» добавляет
вас пользователем в него.
4. После активации вход — **email + пароль** (система сразу не входит — это отдельный шаг).
Особенности:
- приглашение действует **72 часа**; истекло — оператор пришлёт новое;
- email должен быть свободен: «Этот email уже зарегистрирован» → обратитесь к оператору;
- пароль — **минимум 8 символов**; код в ссылке можно открыть только целиком (без кода страница
сообщит, что ссылка неполная);
- **забыли пароль** — самостоятельного восстановления нет, обратитесь к оператору;
- вход приостановленного пространства невозможен («Учётная запись приостановлена») — вопросы к оператору.
> **Dev-окружение** (разработка/показ): регистрация не нужна — bootstrap-пространство `Default` с входом
> `admin`/`admin`. Демо-ручки/кнопки (`POST /api/demo/*`, флаг `DEAL_DEMO`) удалены — новые карточки
> создаются вручную; в обычной (прод) сборке — штатный контур оператор → инвайт.
---
## 2. Вход в систему
1. Вход — **email + пароль**, созданные при активации приглашения (раздел 1).
2. В dev-окружении — `admin`/`admin` (без приглашения).
3. Выход/смена пароля завершают текущую сессию.
> Все данные (каналы, карточки, настройки) принадлежат только вашему пространству и не видны другим
> клиентам. Оператор видит только служебное: список пространств и их статусы, аудит входов/выходов и
> действий (включая действия пользователей), расход ИИ-бюджета; к содержимому ваших данных и настроек
> доступа у оператора нет.
---
## 3. Первый запуск: подключение Telegram
Приложение Telegram работает через **api_id и api_hash**. Их задаёт **оператор сервиса** глобально
(раздел «Telegram» в оператор-консоли) — тенант ключи не вводит. Запущенные сервисы (полный стек)
поднимает оператор. В dev-окружении без стека раздел показывает неподключённый статус.
1. Убедитесь, что оператор задал ключи приложения (без них подключение недоступно).
2. Перейдите в раздел **«Настройки → Telegram»** и нажмите **«Добавить аккаунт»**.
3. Отсканируйте **QR-код** (или введите телефон + код подтверждения).
4. Дождитесь статуса «Telegram подключён». Сессия сохраняется — повторный вход не нужен.
После подключения система загрузит список ваших каналов и групп. Ключи приложения хранятся зашифрованно
на стороне сервиса.
---
## 4. Каналы и источники
Раздел **«Каналы»** — список диалогов вашего аккаунта и управление мониторингом.
- **Включить мониторинг** у нужного канала/группы — система начнёт читать новые сообщения.
- **«Перечитать»** — догнать последние ~10 сообщений всех включённых источников (например, после подключения).
- **Включить все** — включить мониторинг всех каналов разом.
- Новые чаты, которые вы добавили в Telegram, появятся в списке автоматически.
Если включена настройка «новый чат → мониторинг», они начнут читаться сами.
- Удалённые/покинутые чаты исчезают из списка.
- Прочитанные системой сообщения помечаются прочитанными и в вашем Telegram.
### Поиск новых каналов (Discovery)
Во вкладке **«Каналы → Поиск»** можно найти новые источники по вашей теме:
1. Нажмите **«Новая задача»**, опишите, что ищете (например: «каналы с вакансиями для C#-разработчика»).
2. Нажмите **«Сгенерировать ключи»** — ИИ предложит поисковые слова (можно отредактировать).
3. Задайте параметры: минимум подписчиков, язык, план вступлений, авто-вступление.
4. Запустите задачу. Система найдёт каналы/группы, в которых вы **не состоите**, и оценит их
(участники, язык, содержание — «подходит X из N»).
5. В списке кандидатов выберите действие:
- **«Вступить и мониторить»** — система вступит и начнёт читать;
- **«Отклонить»** — источник уйдёт в чёрный список и больше не будет предлагаться.
6. Закрытые группы/каналы помечаются — в них система вступить не может, решение за вами.
7. **Авто-вступление**: если включено, система вступает сама с паузами и в рамках
суточного лимита (настройки квот — в этом же разделе). Общий лимит делится между задачами.
---
## 5. Дашборд (карточки)
**«Дашборд»** — канбан с колонками. Карточка здесь и на экране «Выбранные» — **одна и та же
сущность**: сняв её с дашборда «В работу», вы не создаёте копию, а переносите карточку в стадии.
- **Неразобранное** — сообщения, которые не подошли ни под одну колонку.
- **Ваши колонки** — например, «WPF», «Фриланс», «Резюме» — куда система складывает подходящие карточки.
- **Архив** и **Корзина** — служебные.
### Карточка
На карточке: тип заявки (вакансия/заказ), время, заголовок, структурированная суть
(компания → формат → о задаче → требования → условия), стек, бюджет (в валюте и в пересчёте),
контакт и быстрые действия. Свежие карточки — сверху.
Действия с карточкой:
- **«Взять в работу»** — перенести карточку на экран «Выбранные» (в стадию «Запланировано»);
это тот же объект, а не копия;
- **клик по карточке** — подробный просмотр (справа);
- **комментарий** — иконка сообщения;
- **в корзину** — иконка корзины;
- **перетащить** в другую колонку — система запомнит (ML обучится) и в следующий раз
похожие заявки положит туда же;
- **контакт** — скопировать или открыть диалог;
- **«Открыть исходник»** — перейти к оригинальному сообщению в Telegram.
В подробном просмотре доступны: полная структура заявки, исходное сообщение (под спойлером),
комментарии, история, действия.
### Колонки
- **Создать колонку** — задайте имя, описание и фильтры (ключевые слова, стек, уровень,
бюджет, локация и т.д.). Все фильтры опциональны и могут сочетаться.
- **Отрицательные фильтры** — что НЕ должно попадать в колонку (например, без английского языка).
- ИИ может **предлагать колонки** по вашим карточкам — вы решаете: принять, переименовать или удалить.
- В карточке видно, **по каким критериям** она попала в колонку.
- Колонки можно сворачивать в виджет-счётчик, двигать, менять ширину, разворачивать на весь экран.
### Архив и корзина
- В **архив** карточки уходят автоматически, если лежат дольше установленного срока
(настройка 1–30 дней). Архив очищается через 90 дней.
- В **корзину** попадают удалённые карточки; очищается раз в 7 дней.
- Из архива/корзины карточку можно **вернуть** на канбан, пока её не очистили.
- Полная ручная очистка архива/корзины — кнопка в шапке колонки (безвозвратно).
---
## 6. «Выбранные» (работа с заявками)
Экран **«Выбранные»** — канбан для **тех же карточек**, которые вы взяли в работу (не копии):
1. Возьмите карточку с дашборда кнопкой **«Взять в работу»** — система перенесёт её в стадию
«Запланировано» (или создайте вручную — будет пометка «создано локально»).
2. Ведите её по стадиям: *Запланировано → Отклик → Согласование → В работе → Проверка → Готово* (или «Отложено»).
3. Перетаскивайте карточки между стадиями; наполняйте модули карточки (сумма, стек, контакты, ссылки,
ТЗ, файлы, комментарии).
В карточке «Выбранных» доступно:
- комментарии и **история движения** (статус, дата, время — под спойлером);
- изменение суммы, стека, контактов;
- прикрепление **ссылок** и **текста ТЗ**;
- прикрепление **файлов** (изображения, документы и др. — тип определяется автоматически);
на карточке видны значки количества файлов и ссылок.
Особенности:
- карточки «Выбранных» **не попадают** в архив/корзину дашборда; свои состояния — «Отклонено» и «Выполнено»;
- **«Отложено»**: при переносе система спросит, через какой срок напомнить и в какое время
(можно выбрать дату в календаре). Если напоминания выключены в настройках — окно не появится.
---
## 7. Обработка (очередь и отсев)
Раздел **«Обработка»** — что происходит с сообщениями до карточек.
- **Очередь** — сырые сообщения, ждущие обработки. Обычно быстро пустеет.
- **Отсев** — что система отклонила и **почему**:
- «правила» — стоп-фраза (указана), резюме, тип заявки, нет суммы;
- «ML» / «ИИ» — модель или ИИ посчитали сообщение спамом/не вашим;
- «система» — устарело или повтор (карточка уже есть).
- У записи: кнопка **«Открыть исходник»** (в Telegram) и исходное сообщение с форматированием.
- **Поиск** по отсеву — полный текст.
- **«Вернуть в обработку»**: если система ошиблась — верните сообщение, и оно создаст карточку.
Причины отсева для него будут проигнорированы, а система обучится на вашем решении.
- Отсев очищается автоматически раз в 3 дня (можно очистить вручную).
---
## 8. Настройки
**Настройки → Telegram:** подключение аккаунта, авто-мониторинг новых чатов.
**Настройки → ИИ:**
- провайдер и модель (можно выбрать один, включая локальные OpenAI-совместимые);
- ключ API (хранится зашифрованно);
- **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам
с поиском и категориями; сохранённые свои промпты («Мои промпты»);
- вкл/выкл ИИ и ИИ-фильтр. Если ML уже уверенно обрабатывает поток — система подскажет,
что ИИ можно отключить.
**Настройки → ML:** включение, обучение на ваших действиях, проверка модели на сообщении/канале,
сброс обучения, показатели самооценки.
**Настройки → Обработка:** стоп-фразы, минимальная длина, блокировка резюме, тип заявок,
ключевые слова вашей сферы. **Глобальные исключения** — ключевые слова/технологии, локации, тип
(вакансия/фриланс/объявление) и диапазон бюджета: такие сообщения отсекаются сразу, ещě до ML и ИИ
(токены не расходуются). Отдельно — «не создавать карточку без суммы» (для вакансий и заказов отдельно).
**Настройки → Валюта и курсы:** валюта отображения, конвертация при получении,
пересчёт старых карточек при смене валюты.
**Настройки → Хранение:** срок до архива (1–30 дней), очистка архива и корзины.
**Настройки → Уведомления:** общие напоминания и уведомления (в т.ч. об отложенных).
**Настройки → Внешний вид:** тема оформления — «Тёмная» (по умолчанию), «Светлая» или «Системная».
Переключается мгновенно и запоминается.
---
## 9. ИИ-бюджет и уведомления
Обработка сообщений использует ИИ (классификация, ИИ-фильтр, генерация ключевых слов). У каждого
пространства — **ИИ-бюджет** (обычно месячный, в токенах), который устанавливает оператор.
- При расходе **80% бюджета** приходит уведомление «ИИ-бюджет израсходован на 80%».
- При **исчерпании** — уведомление «ИИ-бюджет исчерпан — обработка в локальном режиме»: система
автоматически переходит на локальную обработку (правила/ML без ИИ); приём и разбор сообщений
**не останавливается**, но глубина разбора снижается.
- Увеличить бюджет/сменить период может только оператор; после смены предупреждения сбрасываются.
- При **приостановке пространства** вход и ИИ-обработка недоступны — обратитесь к оператору.
- Системные уведомления (архив/очистки, подключение Telegram, бюджет) приходят значком-колокольчиком
в интерфейсе.
## 10. Советы
- Начните с подключения аккаунта → включите 2–3 канала → нажмите «Перечитать».
- Создайте колонки под ваши типичные заказы и задайте им фильтры.
- Переносите карточки руками — система учится и скоро начнёт раскладывать сама.
- Заглядывайте в «Отсев»: если там ваши реальные заказы — верните их, система исправится.
- Проверяйте вкладку «ИИ»: когда ML станет уверенной, можно отключить ИИ и сэкономить токены.
---
## 11. Для оператора сервиса (консоль)
> Этот раздел — для администратора сервиса «Дейл». Обычным пользователям он не нужен.
Оператор управляет сервисом из отдельной консоли: она открывается по адресу основного приложения
с добавлением **`#/operator`** (например, `https://<адрес-сервиса>/#/operator`). Консоль — отдельный
вход со своими учётными данными (логин/пароль выдаёт владелец сервиса).
Разделы консоли:
- **Тенанты** — список рабочих пространств с числом пользователей и статусом. Здесь можно создать
пространство (при необходимости сразу с владельцем — ему выдаётся одноразовый пароль),
**приостановить** и **возобновить** доступ, а также **войти от имени пользователя** пространства
(impersonation) — удобно для поддержки; завершается обычным выходом.
- **Приглашения** — создание приглашения на email (в новое или существующее пространство), отзыв
и копирование **ссылки активации**, которую вы передаёте клиенту.
- **Лимиты ИИ** — сводка расхода ИИ-бюджета по пространствам; можно изменить месячный/дневной бюджет
и период. После смены предупреждения о расходе сбрасываются.
- **Аудит** — лента действий (входы, выходы, приглашения, действия пользователей, изменения по
пространствам и лимитам) с фильтрами по типу события, актору, пространству и периоду; есть пагинация.
- **Аналитика** — обзор за период (число пространств, расход токенов, входы/выходы/неудачные входы),
расход токенов с группировкой по дням/пространствам/провайдерам/моделям и лента действий.
- **Состояние системы** — доступность ядра, базы данных и сервисов (Telegram, ИИ, ML).
> **Dev-окружение:** вход в консоль — `operator`/`operator`. В обычной (прод) сборке учётные
> данные оператора задаются владельцем сервиса при развёртывании.
---
## 12. Язык интерфейса
Интерфейс «Дейла» — на русском. Все тексты (кнопки, подписи, подсказки, пустые состояния, уведомления,
экраны оператора и страница активации) хранятся в словарях-ресурсах, а не в самих экранах.
Переключателя языка нет — интерфейс всегда на русском.