From 195faf1b1f3fdb61688dd373ffaf4ce9780a4089 Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Sat, 12 Sep 2026 23:54:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20docs/=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=B7=D0=B5=D1=80=D0=BA=D0=B0=D0=BB=D0=BE=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20(=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20=D0=9C=D0=A0=20#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью rust: перенос в вики не должен удалять из репозитория то, что нужно агенту для работы (бэклог, статус, планы, код-стайл, спеки). Дублирование вики и репы разрешено и обязательно: вики — актуальные версии для людей, docs/ — зеркало для контекста агентов. README разведён по ролям. --- README.md | 5 +- docs/api/api-map.md | 463 ++++++ .../2026-09-05-deal-architecture-design.md | 272 ++++ docs/architecture/2026-09-09-unified-card.md | 92 ++ .../2026-09-10-operator-analytics-contract.md | 252 +++ .../2026-09-10-unified-api-contract.md | 434 +++++ docs/spec/Код-стайл-Дейл.md | 276 ++++ docs/spec/Код-стайл-аудит-2026-09-11.md | 62 + docs/spec/ТЗ-дейл-новая-архитектура.md | 257 +++ docs/superpowers/STATUS.md | 241 +++ docs/superpowers/backlog.md | 95 ++ .../plans/2026-09-04-channel-discovery.md | 341 ++++ .../plans/2026-09-05-deal-roadmap.md | 173 ++ .../plans/2026-09-05-deal-scaffold.md | 825 ++++++++++ .../plans/2026-09-05-deal-stage1-tenancy.md | 136 ++ .../plans/2026-09-05-deal-stage2-settings.md | 430 +++++ .../plans/2026-09-05-deal-stage3-kanban.md | 535 +++++++ .../plans/2026-09-05-deal-stage4-pipeline.md | 569 +++++++ .../plans/2026-09-05-deal-stage5-projects.md | 528 +++++++ .../plans/2026-09-05-deal-stage6-services.md | 542 +++++++ .../plans/2026-09-05-deal-stage7-saas.md | 586 +++++++ .../2026-09-09-deal-stage9-unified-card.md | 71 + ...6-09-10-deal-stage10-operator-analytics.md | 60 + .../plans/2026-09-10-deal-stage11-i18n.md | 71 + ...10-deal-stage12-observability-hardening.md | 46 + .../plans/2026-09-11-codestyle-остатки.md | 35 + .../reviews/2026-09-08-code-quality-review.md | 228 +++ .../reviews/2026-09-10-docs-audit.md | 113 ++ .../reviews/2026-09-10-tz-compliance-audit.md | 329 ++++ .../reviews/2026-09-11-docs-final-sweep.md | 95 ++ .../2026-09-04-channel-discovery-design.md | 212 +++ .../2026-09-11-source-attachments-вопросы.md | 86 + .../2026-09-11-source-contract-design.md | 193 +++ .../2026-09-11-структура-проектов-design.md | 53 + .../Техническая-документация-Дейл.md | 1397 +++++++++++++++++ .../Инструкция-пользователя-Дейл.md | 271 ++++ 36 files changed, 10373 insertions(+), 1 deletion(-) create mode 100644 docs/api/api-map.md create mode 100644 docs/architecture/2026-09-05-deal-architecture-design.md create mode 100644 docs/architecture/2026-09-09-unified-card.md create mode 100644 docs/architecture/2026-09-10-operator-analytics-contract.md create mode 100644 docs/architecture/2026-09-10-unified-api-contract.md create mode 100644 docs/spec/Код-стайл-Дейл.md create mode 100644 docs/spec/Код-стайл-аудит-2026-09-11.md create mode 100644 docs/spec/ТЗ-дейл-новая-архитектура.md create mode 100644 docs/superpowers/STATUS.md create mode 100644 docs/superpowers/backlog.md create mode 100644 docs/superpowers/plans/2026-09-04-channel-discovery.md create mode 100644 docs/superpowers/plans/2026-09-05-deal-roadmap.md create mode 100644 docs/superpowers/plans/2026-09-05-deal-scaffold.md create mode 100644 docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md create mode 100644 docs/superpowers/plans/2026-09-05-deal-stage2-settings.md create mode 100644 docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md create mode 100644 docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md create mode 100644 docs/superpowers/plans/2026-09-05-deal-stage5-projects.md create mode 100644 docs/superpowers/plans/2026-09-05-deal-stage6-services.md create mode 100644 docs/superpowers/plans/2026-09-05-deal-stage7-saas.md create mode 100644 docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md create mode 100644 docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md create mode 100644 docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md create mode 100644 docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md create mode 100644 docs/superpowers/plans/2026-09-11-codestyle-остатки.md create mode 100644 docs/superpowers/reviews/2026-09-08-code-quality-review.md create mode 100644 docs/superpowers/reviews/2026-09-10-docs-audit.md create mode 100644 docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md create mode 100644 docs/superpowers/reviews/2026-09-11-docs-final-sweep.md create mode 100644 docs/superpowers/specs/2026-09-04-channel-discovery-design.md create mode 100644 docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md create mode 100644 docs/superpowers/specs/2026-09-11-source-contract-design.md create mode 100644 docs/superpowers/specs/2026-09-11-структура-проектов-design.md create mode 100644 docs/technical/Техническая-документация-Дейл.md create mode 100644 docs/user-guide/Инструкция-пользователя-Дейл.md diff --git a/README.md b/README.md index 48d7e8f..c375db5 100644 --- a/README.md +++ b/README.md @@ -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/Инструкция-пользователя) diff --git a/docs/api/api-map.md b/docs/api/api-map.md new file mode 100644 index 0000000..82753d3 --- /dev/null +++ b/docs/api/api-map.md @@ -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` (объект ` → 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__` — сообщение. Контейнеры-стадии/служебные зоны — без префикса (`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: \ndata: \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": "", "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: ""}`. 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: ""}`; 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: }` | +| `POST /containers/reorder` | Порядок контейнеров пространства | `{space, order: ["", …]}` | `{ok: true}`; 400 «Не указан порядок колонок» | +| `GET /containers/state` | Состояние колонок (свёрнутость/ширина, `colState`) | — | `{ "": {"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: ""}` | → карточка; 400 «Переносить можно только…» | +| `POST /cards/{cardId}/trash` | В корзину; учит ML `spam` | — | `{ok: true}`; 404 | +| `POST /cards/{cardId}/restore` | Возврат из архива/корзины | — | `{ok: true, col: ""}` | +| `DELETE /cards/{cardId}` | Удалить навсегда | — | `{ok: true}` | +| `POST /cards/clear-col` | Очистить корзину/архив целиком | `{col: "trash"\|"archive"}` | `{ok: true, cleared: }`; 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: , 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 не активен…». Фронт: `` | +| `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: }` | → карточка; 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:` \| `skip` | `{dialogId, msgId, action}` | `{ok, learned: bool, moved: "trash"\|""\|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, "": {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{:{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__`. + +**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__` (фолбэк из БД) — ключи рендера неустойчивы. +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` 5–9 цифр, `api_hash` непустой; hash шифруется | маска-форма; 400 `{detail}`; 401 | diff --git a/docs/architecture/2026-09-05-deal-architecture-design.md b/docs/architecture/2026-09-05-deal-architecture-design.md new file mode 100644 index 0000000..11a34c6 --- /dev/null +++ b/docs/architecture/2026-09-05-deal-architecture-design.md @@ -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_.*`), системное в `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_.*`. +- Системные таблицы (реестр тенантов, пользователи, инвайты, глобальные настройки, + ключи приложения 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`, комментарии на русском). +- 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** — паттерн надёжной доставки событий через таблицу в той же транзакции. diff --git a/docs/architecture/2026-09-09-unified-card.md b/docs/architecture/2026-09-09-unified-card.md new file mode 100644 index 0000000..9267234 --- /dev/null +++ b/docs/architecture/2026-09-09-unified-card.md @@ -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 : 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, «третьи» дашборды (архитектура готова), разовые миграции. diff --git a/docs/architecture/2026-09-10-operator-analytics-contract.md b/docs/architecture/2026-09-10-operator-analytics-contract.md new file mode 100644 index 0000000..ac4ef63 --- /dev/null +++ b/docs/architecture/2026-09-10-operator-analytics-contract.md @@ -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 не заданы оператором" }`. diff --git a/docs/architecture/2026-09-10-unified-api-contract.md b/docs/architecture/2026-09-10-unified-api-contract.md new file mode 100644 index 0000000..309d374 --- /dev/null +++ b/docs/architecture/2026-09-10-unified-api-contract.md @@ -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` (T4–T6, 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": "" }` + +Перенос карточки. Ответ — обновлённая **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": "" }` → `{ "ok": true }` +### `POST /api/cards/take` `{ "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` → `{ "": { "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:`. + +```json +{ "ok": true, "learned": true, "moved": "trash", "leadId": "c_..." } +``` + +- `skip` — ничего не меняет (`learned:false`, `moved:null`); +- `spam` — учит ML; карточку → в корзину (`moved:"trash"`), сообщение из очереди → в отсев; +- `board:` — учит ML; карточку переносит в колонку (`moved:""`), уже в колонке — только учит. + +Ошибки: `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` | diff --git a/docs/spec/Код-стайл-Дейл.md b/docs/spec/Код-стайл-Дейл.md new file mode 100644 index 0000000..620a97f --- /dev/null +++ b/docs/spec/Код-стайл-Дейл.md @@ -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/`, `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` / `IOptionsSnapshot`; + прямое чтение `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 не пишем. +- **`` не используем** — подробные пояснения «как устроено» не нужны; rationale — только если + поведение действительно неочевидно, коротким обычным комментарием. +- **Никаких упоминаний процесса:** в комментариях запрещены ссылки на таски/этапы/рулинги/планы и + прототип (`Task N`, `Ruling N`, `этап N`, `python L…`, `main.py`, `прототип`, `LEADRADAR_*` и т.п.). +- **Внутренние `//`-комментарии — только для неочевидного поведения** (причина, ограничение, подвох). + Пересказ кода, пошаговая навигация и «что делает следующая строка» — удалять. +- **``/``** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру. +- **`` — только блочный.** Открывающий `` и закрывающий `` — **каждый на + своей строке**; запись в одну строку (`/// текст`) **не допускается**. **[изм.]** + + Правильно: + ```csharp + /// + /// Краткое описание назначения. + /// + public void DoWork() { } + ``` + + Неправильно: + ```csharp + /// Краткое описание. + public void DoWork() { } + ``` +- Прочие теги (``, ``, ``, ``) — по необходимости; ``/`` + можно однострочно, `` — блоком. +- Для функций, создающих исключения, возможные исключения указывать в ``. +- Для примеров использования — ``, ``, ``. +- Для ссылок в документации — ``, ``. +- Спецсимволы 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 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. Интерфейсы + +- **Не дублировать `` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc, + в классе-реализации достаточно `/// ` (или вообще ничего, если 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()`), тестовые переменные + типизируются интерфейсом. Новые 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'ы, скрипты сборки/тестов/бэкапов) в репозиторий не входят** — + правило владельца: в репе только код. Актуальные копии живут локально, вне кода. +- **Проверка на новом коде**: правила ``-блока и «комментарии только на public» проверяемы + статически; задел — линтер (по аналогии с `scripts/i18n-lint.mjs`) и/или анализаторы Roslyn/StyleCop в + `Directory.Build.props`. +- Открытые пункты аудита и решения по ним — `docs/spec/Код-стайл-аудит-2026-09-11.md`. diff --git a/docs/spec/Код-стайл-аудит-2026-09-11.md b/docs/spec/Код-стайл-аудит-2026-09-11.md new file mode 100644 index 0000000..4cd1283 --- /dev/null +++ b/docs/spec/Код-стайл-аудит-2026-09-11.md @@ -0,0 +1,62 @@ +# Аудит кода на соответствие код-стайлу «Дейл» (2026-09-11) + +> Отчёт прохода по всему C#-коду (`src/**/*.cs`, 928 файлов, без `bin/obj`). +> Правила — `docs/spec/Код-стайл-Дейл.md`. Проверка: сборка 4 решений + все тесты. + +## 1. Исправлено (применено и проверено) + +| Пункт | Правило | Было | Стало | Инструмент | +| --- | --- | --- | --- | --- | +| Блочный `` | §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. **Дедупликация `` через `` — закрыто: дублей нет.** Проверено двумя независимыми + сканами (сопоставление по тексту и по имени члена интерфейса: 39 интерфейсов, 229 задокументированных + членов) — реализаций, дублирующих summary интерфейсного члена, в продакшн-коде нет; случаев + «`` + дублирующий 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-файлы). +- Добавлены недостающие ``: `IContainerRules.Keywords`/`Stack`, `ITenantContext.TenantId`/`HasTenant`. +- Переведены на русский англоязычные `//`-комментарии (3 шт. из 18 найденных; остальные — имена + сущностей/заголовки секций тестов, не англоязычный текст). +- Из индекса убраны случайно закоммиченные `archive/**/__pycache__/*.pyc` (2 шт., уже в `.gitignore`). +- STATUS.md: удалён устаревший блок «Осталось (в backlog)» в шапке (пункты закрыты generic-контрактом источника). +- Дочистка по контрольному скану краткости: удалены 73 очевидных `` + («Токен отмены.» — пересказ сигнатуры, §5) в 17 файлах; ужаты 3 summary (2 многосентенционных, 1 длинное). + Контроль: `` — 0, inline-`` — 0, многосентенционных summary — 0, TODO — 0. diff --git a/docs/spec/ТЗ-дейл-новая-архитектура.md b/docs/spec/ТЗ-дейл-новая-архитектура.md new file mode 100644 index 0000000..8f0ae37 --- /dev/null +++ b/docs/spec/ТЗ-дейл-новая-архитектура.md @@ -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-аккаунт на тенанта; несколько аккаунтов — позже (схема готова). diff --git a/docs/superpowers/STATUS.md b/docs/superpowers/STATUS.md new file mode 100644 index 0000000..fceca8f --- /dev/null +++ b/docs/superpowers/STATUS.md @@ -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).** Дедупликация +> ``: дублей нет (сканы по тексту и по имени члена — 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 `` +> членам интерфейсов; переведены 3 англоязычных комментария; из индекса убраны 2 `__pycache__/*.pyc`; +> STATUS.md — удалён устаревший блок «Осталось (в backlog)» в шапке. Дочистка по контрольному скану +> краткости: удалены 73 очевидных `` (пересказ сигнатуры) в 17 файлах, ужаты 3 summary +> (многосентенционные/длинные); контроль: `` 0, inline-`` 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 | +| **Итого** | **этапы 0–12 = 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. + +## Что система умеет СЕЙЧАС (проверяемо) + +- **Ядро (этапы 0–6)**: вход 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; обратимо, + на сборку/запуск не влияет). diff --git a/docs/superpowers/backlog.md b/docs/superpowers/backlog.md new file mode 100644 index 0000000..55f98b6 --- /dev/null +++ b/docs/superpowers/backlog.md @@ -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`; в 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_*`), удалены блоки ``, `` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE | +| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `` только блочно — 5286 шт.; (2) приватные XML-доки понижены — 2028+12; (3) дедупликация ``→`` — дублей нет (сканы); (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) дедупликация `` — дублей нет (см. 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/расход токенов) | ТЗ §8–9 | 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` не блокируют разработку; выполняются, когда владелец даёт креды/хост. diff --git a/docs/superpowers/plans/2026-09-04-channel-discovery.md b/docs/superpowers/plans/2026-09-04-channel-discovery.md new file mode 100644 index 0000000..c7d346c --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-channel-discovery.md @@ -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", )`. + - `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": [...]}` (8–16 строк 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 8–9 (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`; все имена настроек и функций совпадают между задачами. diff --git a/docs/superpowers/plans/2026-09-05-deal-roadmap.md b/docs/superpowers/plans/2026-09-05-deal-roadmap.md new file mode 100644 index 0000000..1aae55c --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-deal-roadmap.md @@ -0,0 +1,173 @@ +# Дейл (Deal) — Roadmap этапов (все этапы 0–7 выполнены; 2026-09-08) + +> Исторический документ (roadmap этапов 0–7, 2026-09-08). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`. + +> Назначение: зафиксировать план продолжения разработки «Дейл» по этапам. Каждый этап исполняется +> как отдельный SDD-план (файл в `docs/superpowers/plans/`, ledger в `.superpowers/sdd//`), +> задача-за-задачей с ревью. Проект НЕ 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. Задачи 1–15 приняты: 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 1–16, 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 (этапы 0–7 = 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`; без магических чисел; 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-чек-лист +вынесен отдельно). diff --git a/docs/superpowers/plans/2026-09-05-deal-scaffold.md b/docs/superpowers/plans/2026-09-05-deal-scaffold.md new file mode 100644 index 0000000..d02d3ab --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-deal-scaffold.md @@ -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_.*`. Сервисы 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`; без 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 + + + net10.0 + latest + enable + enable + true + latest + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + +``` + +- [ ] **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; + +/// Маркер модуля Pipeline: используется для DI-сканирования и тестов. +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; + +/// Идентификатор тенанта. Инвариант: непустой. +public readonly record struct TenantId(string Value) +{ + public string Value { get; } = string.IsNullOrWhiteSpace(Value) + ? throw new ArgumentException("TenantId не может быть пустым", nameof(Value)) + : Value; + + /// Имя схемы Postgres для тенанта. + 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(() => new TenantId("")); + } +} +``` + +- [ ] **Step 3: Запустить тесты** + +Run: `dotnet test tests/Deal.Tests.Unit` +Expected: 3 теста PASS. + +- [ ] **Step 4: `ITenantContext.cs`** + +```csharp +namespace Deal.SharedKernel.Tenants; + +/// Контекст текущего тенанта запроса. +public interface ITenantContext +{ + TenantId? TenantId { get; } + + bool HasTenant { get; } + + /// Имя схемы текущего тенанта или null для системного контекста (public). + string? SchemaName { get; } +} +``` + +- [ ] **Step 5: `TenantContext.cs` (реализация в Infrastructure)** + +```csharp +using Deal.SharedKernel.Tenants; + +namespace Deal.Infrastructure.Data; + +/// Контекст тенанта на AsyncLocal: пробрасывается через весь запрос. +public sealed class TenantContext : ITenantContext +{ + private static readonly AsyncLocal 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; + +/// Строит строку подключения к Postgres с учётом схемы тенанта. +public sealed class ConnectionStringProvider +{ + private readonly string _baseConnectionString; + + public ConnectionStringProvider(IConfiguration configuration) + { + _baseConnectionString = configuration.GetConnectionString("DealPostgres") + ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан"); + } + + /// Строка подключения; при tenantId не null добавляет search_path к схеме тенанта. + 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(); +builder.Services.AddSingleton(); + +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; + +/// Тенант в системной схеме public. +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; + +/// Базовый DbContext. Системные сущности — в схеме public. +public sealed class DealDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Tenants => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(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; + +/// Фабрика для dotnet-ef (миграции). Читает строку подключения из env. +public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory +{ + 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() + .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(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; + +/// Миграции схем тенантов. Чистые функции формирования SQL. +public static class TenantSchemaMigrator +{ + /// SQL создания схемы тенанта. Имя экранируется (не интерполируется из ввода). + public static string CreateSchemaSql(string schemaName) + { + var escaped = schemaName.Replace("\"", "\"\""); + return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\""; + } + + /// Имена схем тенантов из БД. + 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, безопасность сервисов, лимиты — отдельные планы следующих этапов. diff --git a/docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md b/docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md new file mode 100644 index 0000000..6d4048c --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md @@ -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`; без регионов и snake_case-хелперов. +- namespace `Deal.*`. Секретов в коде нет (dev-пароль по умолчанию — только seed, из env `DEAL_BOOTSTRAP_*`). +- Сущности тенантов — в схеме `tenant_`; системные — в `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_` (SQL `TenantSchemaMigrator.CreateSchemaSql`), (2) открыть контекст на строке подключения с + `Search Path=tenant_` и `MigrationsHistoryTable("__TenantMigrationsHistory", "tenant_")`, (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, операторская админка, инвайты, лимиты токенов, валюты — следующие этапы. diff --git a/docs/superpowers/plans/2026-09-05-deal-stage2-settings.md b/docs/superpowers/plans/2026-09-05-deal-stage2-settings.md new file mode 100644 index 0000000..829eb96 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-deal-stage2-settings.md @@ -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 (L142–152), §3.7 (L187–199), §4.6 (L315–341), §4.7 (L343–346), +§4.10 (L363–365), правила L7–24, п.9 «экономия» (L399); `docs/spec/ТЗ-дейл-новая-архитектура.md` +§8 (L165–179), §5 (L89–121, фильтры), §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` (L36–77, L188–198), `backend/app/routers/ml_routes.py`, +`backend/app/services/ml_client.py`, `backend/app/routers/dashboard_routes.py` (admin/check-message +L267–284), `backend/app/services/pipeline.py` (stage1_plain L94–124), `backend/app/constants.py` +(L30–50, L54–245), `backend/app/crypto.py`, `backend/app/config.py` (L48–51); +фронт: `src/frontend/src/store.js` (boot L565–628, applySettings L343–397, applyMlStatus L487–502, +schedulePersist L1737–1766, refreshRates L1844–1848), `src/frontend/src/data.js` (L6–141 дефолты +промптов; `AI_PROVIDERS` L17–80; `PROMPT_LIBRARY` L180–200 — библиотека по сферам живёт ТОЛЬКО +во фронте, бэкенд её не отдаёт), `views/SettingsView.vue` (вкладки L39–49), `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`; без регионов. +- 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` L94–141). + +## Зафиксированные решения (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` L110–185). +- **Ruling 2 (шифрование секретов):** AES-256-GCM (`System.Security.Cryptography.AesGcm`), + nonce 12 байт, tag 16 байт. Ключ — env `DEAL_ENCRYPTION_KEY` (32 байта, urlsafe-Base64); + при отсутствии в dev — файл `/data/encryption.key` (генерируется при первом + старте, лог-warning; путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`). Формат значения в + БД: `enc:` + Base64(nonce‖ct‖tag). Расшифровка повреждённого/чужого значения → пустая строка + + warning (совместимость `crypto.decrypt_text`, `crypto.py` L52–61). Порт `ISecretCipher` — + в модуле Settings, адаптер `AesGcmSecretCipher` — в Infrastructure. +- **Ruling 3 (маски и публичная форма):** маска `mask(v)`: пусто → `""`, `len≤8` → как есть, + иначе `v[:4]+"…"+v[-4:]` (`settings_routes.py` L28–32). `aiConfigs` наружу — + `{id: {baseUrl, model, keySet, keyMasked}}`; `tgKeys` — `{apiId: <маска>, apiHashSet: bool}`. + Список `providers` — статический из модуля (`id,name,base,local,models`; зеркало + `constants.AI_PROVIDERS` L170–186; `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` L86–91). Mock-курсы — константа `MockRates` + (`constants.py` L41–50). Обновление: лениво на GET при протухании/смене источника, синхронно + на `POST /rates/refresh`, фоново-запуск на PATCH `rateSource` (`settings_routes.py` L186–192). + Массовый пересчёт карточек (`recompute_conversions`) — этап 3 (таблицы leads нет); в этапе 2 — + только чистый `ConvertAmount`. +- **Ruling 7 (проверка AI):** реальный HTTP, без LLM-вызовов, 1:1 `settings_routes.py` L195–219: + нет ключа → `{ok:false, message:"Не задан API-ключ"}`; локальный провайдер → `{ok:true, + message:"Локальный сервер «» (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` L36–58). +- **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` L106–111, в 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` L22–42). +- 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` L1–70; `backend/app/config.py` L48–51. + +**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` L189–245 + (включая дефолтные стоп-фразы L55, `minLen=24`, hire/level/resume-маркеры L144–167, + `aiConfigs` для каждого провайдера с первым `model`, `tgKeys={apiId:"",apiHash:""}`). +- Create: `S/Application/DefaultPrompts.cs` — константы `DefaultAiPrompt`, `DefaultCardPrompt`, + `DefaultAiFilterPrompt` — тексты КОПИРУЮТСЯ из `src/frontend/src/data.js` L94–141 (фронт — + источник; в `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` L170–186). +- Create: `S/Application/MockRates.cs` (константа, `constants.py` L41–50) + `RatesFetchInterval = 6h`. +- Create: `S/Application/ISettingsStore.cs` — порт: `Task GetAsync(string key, ct)`, + `Task> 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 L315–341; `constants.py`; `data.js` L6–141. + +**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 body, ct)` с клампами и валидацией (см. ниже), ответ — полный + public-снимок (фронт затирает локальный state ответом — api-map L147, L341). +- Create: `T/…/FakeSettingsStore.cs` (in-memory Dictionary), `T/SettingsServiceTests.cs`. + +**Семантика PATCH (референс `settings_routes.py` L75–192):** +- 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()`; + регистрация `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 L146–147, §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 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` L195–219; `ai.py` L36–58 (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 +L180–200; `PromptLibraryModal.vue` не ходит в API; наружу идут только промпты-строки и +`myPrompts`). + +**Files:** +- Test: `T/PromptDefaultsTests.cs` — дефолтные тексты начинаются/содержат маркеры из + `data.js` (например `aiPrompt` содержит «Ты — классификатор входящих сообщений» и + плейсхолдеры `{domain}`/`{keywords}`; `cardPrompt` — «О заявке»; `aiFilterPrompt` — «страж + входящих»); `fill_prompt`-подстановка (аналог `ai.fill_prompt` L63–77): пустой 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?> 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` L77–83); `ConvertAmount(amount, fromCur, toCur)` + — USDT→USD (L86–103). Ленивое обновление на 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` L43–59). +- 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 L149–150; `settings_routes.py` L224–232. + +**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:}}`; `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), 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` L70–75 + `ml_client.snapshot()` L138–150). +- 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` L66–91, L112–171; `ml_client.py` L127–150; +`mlservice/model.py` (predict L184–293, status L325–345 — эталон полей для этапа 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` + L94–124) поверх `ISettingsStore`: минимальная длина (`minLen`), стоп-фразы (casefold, ответ — + конкретная фраза), блокировка резюме (`blockResumes` + `resumeMarkers` с контекстным guard + «вакансия… присылайте резюме» — не режем, `pipeline._resume_reason` L644–654), тип заявки + (`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` L267–284): если этап-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` L267–284; `pipeline.py` +L94–124; фронт: `SettingsView.vue` L142–155 (тестер), `store.js` L1723–1725. + +**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-эндпоинты; библиотека промптов (фронтовая); звук/вид (фронт). diff --git a/docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md b/docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md new file mode 100644 index 0000000..e65086a --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md @@ -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 (L60–121), §2 SSE (L27–44), правила (L7–24, п.9 «экономия» L399, +кривые места L390–400); §4.1 карточка (L228–257), §4.2 доска (L259–278), §4.6 colState (L333); +`docs/spec/ТЗ-дейл-новая-архитектура.md` §5 «Карточка» (L112–121), §6 «Дашборд (канбан)» (L121–135); +roadmap (этап 3, L46–53); референс-семантика: `backend/app/routers/dashboard_routes.py` целиком, +`backend/app/services/leads.py`, `rules.py`, `suggest.py`, `rates.py` (L62–74, L106–130), +`backend/app/services/ml_client.py`, `backend/app/services/pipeline.py` (L433–514, L540–586), +`backend/app/sse.py`, `backend/app/main.py` (L43–53 фоновые циклы), `backend/app/constants.py` +(PALETTE L12–16, DAY_MS L249–254); фронт: `src/frontend/src/store.js` (boot L565–628 — какие группы +обязаны отвечать 200; SSE L650–688; действия лидов L833–975; доски L977–1183; поиск L1185–1206; +tickAuto L1855–1863, rebuildFts L1884–1889; colMeta/orderedCols L188–223), `src/frontend/src/api.js` +(openEvents L62–104 — слушает только 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`; без регионов; без магических + чисел; 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|`, как прототип); приложение + валидирует существование досок. `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 L163–174`), возврат + `restore` (L204–222), назначение при создании (этап 4/демо). Вычисление — чистые функции + `ColumnRules` (перенос `rules.py`: `match_text` L176–209, `score_text` L212–227, `excluded_terms`/ + `is_excluded` L230–248, `board_accepts` L251–268, `hits` L271–296, `hits_for_board` L311–319, + `has_active_rules` L322–338, `describe` L341–368, `extract_amounts` L93–144 с grade-алиасами L20–27 + и `content_text` L51–54). Для `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 L114–123`) НЕ заводим — фронт запускает предложение только + кнопкой, а `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 L76–82): + перенос на доску (не inbox) → push(text, ``, 1.0); корзина из канбана → push(text, `"spam"`, + 1.0); возврат из корзины → push(text, `"spam"`, −1.0) (`move_lead` L177–191, `trash_lead` L194–201, + `restore_lead` L204–222). `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 L62–104`) и которые в этапе возникают: `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` L509–551: 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 (L86–91), обновляет `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` L62–74); (2) PATCH + `/settings` — если в теле присутствовали `targetCurrency`/`conversionOn` (синхронно, + `settings_routes.py` L186–192). + Первичный пересчёт «при поступлении» (бюджет → целевая валюта, `ai.py budget_to_target` L342–352) — + чистый `BudgetNormalizer` (используется демо-путём и этапом 4). +- **Ruling 8 (з) — архив/корзина: тик и фоновый цикл.** Чистый `StorageTickService` (модуль) + повторяет `tick_storage` (`leads.py L454–493`): автоархив (`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` L496–504; тексты 1:1 «Автоархив: N карточек»/«Архив очищен: N + (90 дн.)»/«Корзина очищена: N (7 дн.)», иконки clock/trash). Фоновый цикл — `StorageTickScheduler` + (Api, IHostedService): каждые 30 с обходит все тенанты системного репозитория, на каждый — + собственный scope с `ITenantContext` (паттерн TenantBootstrapService + guard RatesRefreshScheduler); + аналог `_storage_loop` `main.py L43–53`. +- **Ruling 9 — служебные точки фронта (boot).** `boot()` фронта (`store.js L571–581`) требует 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"]` (L74–104). + Сортировка — ReceivedAt DESC. Удаление карточки навсегда = Cards + LeadComments (cascade), + CardMoves/MlOutbox не трогаем (`_hard_delete` L225–234). +- **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 L261–277), `BoardRulesDto.cs` (+`BudgetRangeDto.cs`), + `BoardPatchDto.cs`, `CardDto.cs` (§4.1 L230–254; `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()` (Ruling 7). Modify: `K/Deal.Modules.Kanban.csproj` — ProjectReference на + `Deal.Modules.Settings` и `Deal.Contracts`. + +**Источники:** Rulings 1–2, 7; api-map §4.1/§4.2; `leads.py` (структуры); `pipeline.py lead_to_dict` +L540–586. + +**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, L47–54), `AmountParser.cs` + (extract_amounts L93–144: «к/К», символы/слова валют, «от…до»/«до…»/«A–B», «$1 200»), + `GradeAliases.cs` (L20–27), `ColumnMatcher.cs` (match/score/has_active_rules L176–227, L322–338), + `ColumnExclusions.cs` (excluded/is_excluded L230–248), `MatchHitBuilder.cs` (hits L271–296, метки + «Направление»/«Слова»/«Стек»/«Грейд/уровень»/«Бюджет», `word` для грейдов), `RulesDescriber.cs` + (describe L341–368 — для note), `BudgetInRange.cs` (конвертация валюты при сравнении — чистый + интерфейс курсов). +- Create: `K/Application/BudgetNormalizer.cs` — clean_budget (`ai.py L316–326`: одна сумма → from=to, + «до X» → from null; from=0 → null) + conv-поля «при поступлении» (budget_to_target L342–352: + conversionOn/targetCurrency, курсы через интерфейс курсов). +- Test: `T/ColumnRulesTests.cs`, `T/AmountParserTests.cs`, `T/BudgetNormalizerTests.cs` (кейсы из + правил прототипа: alias «mid»→middle, исключение veto, budget-диапазон с конвертацией USDT=USD, + «2к», «от 0 до 100» и т.п.). + +**Источники:** `rules.py` целиком (L15–368), `ai.py L316–352`; 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()`. +- 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 L40–49). 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` (L40–49, L110–124, L138–150), 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 L49–67 (ORDER BY suggested, pos; дефолты + collapsed из поля), create_board L74–104 (цвет/позиция/ширина/visibleFields; name + `strip() or «Новая колонка»`), patch_board L107–121 (404-семантика через результат; allowed: + name/description/color/width/collapsed/prompt/keywords/visibleFields/suggested/rules/note), + delete_board L124–130 (карточки → inbox isNew, prevCol=inbox; вернуть moved), reorder_boards L133–135, + get/set_col_state L138–146 (KV colState через ISettingsStore; словарь JSON). +- Test: `T/BoardsServiceTests.cs` (fake IKanjStore): создание (pos/цвет/width/visibleFields), патч + (JSON-поля), удаление (moved→inbox), colState merge/значения. + +**Источники:** `leads.py` L49–146; api-map §3.2 доски L66–70, §4.2; Rulings 1/10; `constants.py` +PALETTE L12–16. + +**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 (L151–160): маппинг CardDto (receivedAt ms, time от ReceivedAt, budget, + converted, contacts fallback `qualify_contact`-проверка, ch, comments из LeadComments, matchHits); + - move_lead (L177–191): валидация `to ∈ inbox ∪ доски` (иначе 400 «Переносить можно только на доски + или в «Неразобранное»»), `_move` L163–174 (matchHits пересчёт через ColumnRules для досок), + журнал CardMoves(action=move) + PushAsync (текст = sourceMsg или title) при to≠inbox; + - trash_lead (L194–201): журнал(action=trash) + Push spam 1.0 (кроме карточек уже в archive/trash); + - restore_lead (L204–222): назад в prevCol (валидный), isNew=true, archivedAt=null, matchHits, + журнал(action=restore); возврат из корзины — Push spam −1.0; + - delete_forever (L225–234), clear_col (L237–247: только trash|archive, 400 «Очищать можно только + корзину или архив», вернуть cleared); + - mark_seen (L250–256: id|col|all); add_comment (L259–265: 400 «Пустой комментарий», LeadComments + вставка, журнал(action=comment)); + - counts (L268–279): по Cards (col + isNew) + learning/ml/ai из IMlClient.StatusAsync; + - search (L509–551, LIKE-вариант) — вызывается эндпоинтом напрямую или через сервис (см. Task 8). +- Create: `K/Application/CardMapper.cs` (CardEntity/сырые строки → CardDto; чистая функция; + `human_age` L528–537), `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` L151–279, L509–551; `pipeline.py lead_to_dict` L540–586; `rules.py` +hits_for_board; api-map §3.2 лиды L83–95, §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 L66–101; ответы/детали — 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` L62–104; api-map §2, L43; `store.js boot` +L571–581; §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 L454–493 + notify_tick_stats L496–504; `dashboard_routes.py` +L327–337 (admin_tick), L261–264 (fts_rebuild); api-map L103–112; `store.js tickAuto` L1855–1863, +rebuildFts L1884–1889; 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()`. + +**Источники:** `main.py _storage_loop` L43–53; `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 L106–130, refresh L62–74, _resolve_rate L86–91; +`settings_routes.py` L186–192; 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 L77–89` + создание + карточки: нормализация бюджета (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` L287–324; `pipeline.py _store_lead` L433–514; 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()`; + `A/Program.cs` — map. + +**Источники:** `suggest.py` целиком (константы L48–52, suggest L76–163, keywords L166–193, + _make_note/_store_suggested/_assign_ids/_rollback L196–248); api-map L120–121; `store.js + suggestColumns` L1097–1113; 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 «Карточка» (L112–121) — Task 7/13 (поля, «О заявке»-summary — приходит + структурой из pipeline/demo; блоки Компания→Условия — формат summary, композиция — этап 4); + ТЗ §6 (L121–135) — Tasks 1–14 (колонки/фильтры/отрицательные — Task 3/6; «почему в колонке» — + Task 7; свежие сверху/виджеты/ширина/colState — Task 6/8; drag&drop+ML — Task 7; ИИ-предложения — + Task 14; архив/корзина — Tasks 10/11); api-map §3.2 (L60–121) — 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. diff --git a/docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md b/docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md new file mode 100644 index 0000000..63f2995 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md @@ -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 (L176–186), §2 SSE (L33–43), правила (L7–24; п.9 «экономия» L399, +кривые места L390–400, п.1 SSE L43); §4.5 очередь и отсев (L306–314), §4.1 карточка (L228–257), §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 «Вкладка „Обработка"» +(L150–161); roadmap (этап 4, L57–62); референс-семантика прототипа: `backend/app/services/pipeline.py` +(целиком: enqueue L53–85, stage1_plain L94–124, clean_short/block L148–193, _skip_no_budget L196–218, +compose_summary L225–284, нормализация L294–341, контакты L344–430, _store_lead L433–514, локальные +поля L661–798, воркер L803–1183), `backend/app/services/processing.py` (целиком: record L66–101, +purge_expired L104–117, clear_all/return_to_queue L120–193, list_queue/list_rejected/stats L201–320), +`backend/app/routers/processing_routes.py` (целиком), `backend/app/services/fts.py` (целиком), +`backend/app/services/leads.py` (L225–247 _hard_delete/clear_col, L454–504 tick_storage + notify, +L509–551 search), `backend/app/services/ai.py` (L261–267 normalize_dedup, L316–352 clean_budget/ +budget_to_target), `backend/app/services/ml_client.py` (L26–28 веса, L160–162 is_enabled), +`backend/app/routers/dashboard_routes.py` (L261–284, L327–337), `backend/app/constants.py`, +`backend/app/db.py` (L88–92 dedup, L226–268 pipeline_msg/rejected_msgs); +фронт: `src/frontend/src/views/ProcessingView.vue` (вся вкладка: счётчики L221–243, очередь L297–400, +отсев L402–556, canReturn/return), `src/frontend/src/store.js` (pipeline-секция L1210–1343: loadPipelineQueue +L1213–1223 limit=120, loadRejected L1226–1246 limit=80 offset, refreshPipelineStats L1249–1258, +deleteRejectedItem L1284–1295, returnRejected L1300–1318, clearRejectedAll L1320–1333, SSE L676–679 — +обработчик 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 L88–92/L226–268): `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, иначе `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 L53–85: + 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` L433–514, 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` (L433–514) в чистый + `CardComposer` модуля Pipeline: title = clean_short(raw.title, 140) или clean_short(text, 140); summary = + compose_summary (блоки «О заявке» Компания→Формат→О задаче→Требования→Будет плюсом→Условия, 1:1 с + cardPrompt и compose_summary L225–284; локальный путь без структуры — «О задаче: …», _local_summary + L294–314; футер-хинты L288–291) ≤2000 через clean_block; stack = normalize_stack ≤12 (L332–341); + бюджет: нормализованный из разбора (`BudgetNormalizer.Normalize`), иначе fallback из первой суммы + `AmountParser.Parse` по исходнику/суммари (L459–468), конверсия один раз при поступлении — + `BudgetNormalizer.ToTarget` (conversionOn/targetCurrency/ratesCache, USDT=USD, мок-фолбэк, как + ConversionRecomputer/CardsService.LoadRatesAsync); контакты: `ContactsQualifier.Build` из разбора или + текста (L389–421, ≤6, типы tg/phone/email/linkedin/whatsapp/site, отбрасывание ботов/сервисных t.me/ + «постовых» сайтов L344–386), primary_contact (tg→phone→whatsapp→email→linkedin→site, L424–430, ≤200); + ch-поля канала; sourceMsg = text[:4000]; sourceDialogId/sourceMsgId; prevCol=inbox; matchHits = + ComputeHits доски, если назначена и прошла BoardAccepts (иначе колонка сбрасывается в inbox — страховка + L449–450); 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 с L969–1061 (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 L104–106 и 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 — «смысловые колонки до ИИ не назначаем», L954–958). aiEnabled=false → + тот же локальный разбор напрямую (прототип L1081–1096), без вызова порта. Возврат (force): ИИ-фильтр + пропускается (L1097–1100), вердикт «спам» ИИ отменяется (L1117–1121). Счётчики решений: KV + `mlDecisions`/`aiDecisions` инкрементирует модуль Pipeline после pump (`ml=mlStored+mlDrop, + ai=aiStored+aiDrop`, ml_client.track_decisions L153–157) через 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 L527–529; Rejected: Text — fts.py + `_FTS_TARGETS` L23–27) `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 L509–551 (`messages:[]` + — api-map п.3). Поиск отсева `GET /pipeline/rejected?q=` — FTS-кандидаты (`SearchTsv @@ plainto_tsquery`) + ∪ LIKE-дополнение по `lower(text)/reason/kw/ch_name` (processing.list_rejected L246–277, лимиты + 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 L148–156 / clean_block + L158–193 — markdown-ссылки, **__`~~, ||, голые URL, эмодзи-диапазоны, «C#»-защита, схлопывание, обрезка + по границе), `MessageListNormalizer` (normalize_list L317–329, normalize_stack L332–341), + `ContactsQualifier` (L344–430: qualify_contact/build_contacts/primary_contact + регэкспы/наборы L345–347, + L597–604), `DedupHasher` (normalize_dedup ai.py L261–267: `[^\wа-яё]+` → SHA1 hex), `SummaryComposer` + (compose_summary + _local_summary + футер-хинты L288–291), `LocalFieldsParser` (_local_fields L718–798: + метки `Стек/Грейд/Контакты/Бюджет` L591–596 через `_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)` (L1155–1180). Результат pump — `PipelinePumpResult`: счётчики {staged, + rulesStored, mlStored, mlDrop, typeDrop, aiStored, aiDrop, aiFail, noBudget} (1:1 имена wire-ключами + admin/tick pipeline-словаря) + `IReadOnlyList CreatedCards` (для SSE, Ruling 9) + счётчики + решений для KV. Воркер-гейт «не параллелить pump одного тенанта» — `PipelinePumpGate` (Api, singleton, + Interlocked/ConcurrentDictionary; аналог asyncio.Lock L40). Очистка отсева: `PipelineProcessingService.PurgeExpiredAsync` + (RejectedAt старше 3 суток, processing.purge_expired L104–117) вызывается из тика (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 L503–504). +- **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 L488–493). Публикация тостов — 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:, 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 L88–92, L226–268; 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 отсев L310–313: +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 L26–46). +- 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 L306–314; processing.py L26–46, L218–320; 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 L79–101: детерминированный id + `r__` либо `r_`+hex; пустой текст — no-op); `SearchIdsAsync` — FTS-кандидаты + `plainto_tsquery('russian', q)` по `SearchTsv` (rank DESC) + LIKE-дополнение по + lower(text)/reason/kw/ch_name (limit*2 каждое), объединение без дублей (processing L252–270); + `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()`. + +**Источники:** processing.py L66–117, L246–312; leads.py _hard_delete L225–247; 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 L148–193 + регэкспы/ + наборы эмодзи/футер-хинты L131–145, L288–291), `MessageListNormalizer.cs` (normalize_list/normalize_stack + L317–341 + стоп-слова стека L604–610), `ContactsQualifier.cs` (L344–430 + _contacts_from L666–679, + _norm_phone L661–663), `DedupHasher.cs` (ai.py L261–267), `SummaryComposer.cs` (compose_summary L225–284 + + _local_summary L294–314), `LocalFieldsParser.cs` (_local_fields L718–798 + _field_of L686–698, метки + L591–596, маркеры/токены L597–612; hireMarkers/levelTerms/resumeMarkers — через ISettingsStore + + SettingsDefaults, нормализация как в IncomingRules), `AmountRangeBudgetFallback.cs` (fallback бюджета из + `AmountParser.Parse`, L459–468). +- 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 L131–341, L591–798; ai.py L261–267; 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 L218–241: limit clamp 1..500), queue_counts (L207–215), + rejected_count, list_rejected (L246–312: q-путь FTS+LIKE/страницы/лимиты, no-q путь по RejectedAt DESC), + `ReturnAsync` (processing.return_to_queue L128–193: 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 L53–85; processing.py L66–193, L201–320; processing_routes.py L17–74; 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 L190–192: + выключен → skipped, включён при недоступном ИИ → pass+skipped, L1103–1106); классификатор — + `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()` (секция + AddDealIntegrations). +- Test: `T/LocalAiClassifierTests.cs` — фильтр-пропуск; классификатор детерминирован (одинаковый текст → + одинаковый DTO); бюджет «до 2к$» → {from:null, to:2000, cur:USD}; контакты квалифицированы. + +**Источники:** ai.py L188–198, L316–352; 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 L433–514, L540–586; 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` L920–1183): проход 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-путь не учит, L1017–1021); + (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 L803–1183; ml_client.py L26–28; 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 L196–198 всегда 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 L178–186; §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:, queue:}` + (dashboard_routes.py L327–337); публикации: тосты 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 L503–504; тест `T/StorageToastPublisherTests.cs` дополняется). + +**Источники:** dashboard_routes.py L261–264, L327–337; leads.py L486–504; fts.py L48–67; 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()`. + +**Источники:** main.py `_pipeline_loop`/`_storage_loop` (L43–53); 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 L509–551; 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…, бюджет 1600–2200$, @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 (L84–121) — путь сообщения 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 (L150–161) — очередь/отсев/причины/ + поиск/возврат/автоочистка/счётчики — 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). diff --git a/docs/superpowers/plans/2026-09-05-deal-stage5-projects.md b/docs/superpowers/plans/2026-09-05-deal-stage5-projects.md new file mode 100644 index 0000000..c28d633 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-deal-stage5-projects.md @@ -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 (L153–174), §2 SSE (L33–43: `reminder_due` = `{id, title, stage}`), правила +(L7–24: контент-типы multipart/octet-stream, 410/404, «кривые места» L390–400 — п.5 reminder_due, п.6 +DELETE-400, п.9 экономия: `/projects/reminders` НЕ реализуем), §4.3 проектная карточка (L280–300), §4.4 +стадии (L302–304), §3.2 admin/tick reminders (L103–112), §4.6 remindersEnabled (L328, L340); +`docs/spec`/ТЗ.md §4.8 «Выбранные» (L119–132); roadmap (этап 5, L69–73); референс-семантика прототипа: +`backend/app/services/projects.py` (целиком: _insert/_row_to_card L31–100, create_local_card L103–124, +take_lead_to_projects L127–156, patch_card L159–199, add_comment L194–199, move_stage L202–216, +clear_stage L223–231, напоминания L236–282), `backend/app/routers/projects_routes.py` (целиком), +`backend/app/services/files.py` (целиком: KIND_BY_EXT/KIND_LABELS L13–28, detect L31–45, add_file L57–75, +get_file_entry L78–83, remove_file L86–94), `backend/app/services/object_store.py` (целиком: configured, +put/get/remove, локальный fallback L54–79), `backend/app/services/leads.py` (L151–156, L526–545 — взятые +исключены из списков/поиска), `backend/app/db.py` (L103–125 — таблица projects), `backend/app/constants.py` +(L16–27 — PIPELINE_STAGES), `backend/app/sse.py`, `backend/app/main.py` (L47–53 — 30-с цикл с +check_reminders), `backend/app/routers/dashboard_routes.py` (admin_tick L327–337); +фронт: `src/frontend/src/views/ProjectsView.vue` (колонки по PIPELINE_STAGES data.js), `components/ +{ProjectColumn,ProjectCard,ProjectDrawer,HoldReminderDialog,ReminderNotice}.vue`, `store.js` (boot L571–593; +startProject L1954–1966; moveProject L1968–1980; patchProject/addProjectComment/addProjectLink/removeProjectLink +L1985–2027; addProjectFiles/removeProjectFile L2031–2053; setHoldReminder/clearHoldReminder/snooze/ +clearDueReminder L2060–2146; startRealtime L670–674 — reminder_due), `api.js` (openEvents L62–83 — слушает +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 L71–100). Индексы: `(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 L17–27). Пользовательских стадий/досок проектного канбана в прототипе НЕТ. +- **Ruling 2 (б) — миграция/владелец/границы.** Владелец схемы — модуль `Deal.Modules.Projects` (Ruling 1); + EF-адаптер `ProjectStore` — в `Deal.Infrastructure`; регистрация `AddProjectsModule()` + `AddScoped` + (в 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 L1976–1980 сам открывает окно, если + `state.remindersEnabled`, и никакого напоминания при move не шлёт; бэкенд получает напоминание отдельным + `POST /{card}/reminder {at}` (setHoldReminder L2060–2089: «через N дней (1–30)» или «дата+время» — расчёт `at` + полностью на клиенте, epoch-ms). Семантика 1:1 с projects.py L236–282: (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 L210–213); (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 L103–112); (6) цикл проверки — 30 с в + существующем `StorageTickScheduler` (main.py L47–53: тик → тосты → check_reminders), отдельный hosted-сервис НЕ + заводим; ручной путь — `POST /api/admin/tick` (dashboard_routes.py L327–337). Настройка — уже готовый публичный + ключ 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 L54–79: `_local_path` строит путь из objectKey и не даёт выйти за root), + `MinioFileStorage` — MinIO S3-клиент (NuGet `Minio`; ленивая проверка/создание бакета при первом put — + object_store.py L26–51; креды `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 L13–28 (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 L174–179); отсутствие objectKey у записи → 410 «Файл не сохранён + в объектном хранилище»; GetAsync == null → 404 «Файл не найден в MinIO» (фиксированная строка прототипа); + запись/карточка не найдены → 404 «Карточка не найдена» (прототип на этом пути отдаёт 500 — для .NET выбираем + корректный 404, фронт таких запросов не шлёт). Upload — multipart/form-data, поле `files` (несколько файлов), + ответ `{items: [файл]}`; фронт после upload/delete перечитывает карточку (store.js L2031–2053). +- **Ruling 5 (д) — «взять в работу».** Эндпоинт `POST /api/projects/take {leadId}` принадлежит модулю Projects + (api-map §3.5 L161 — не leads). Поток 1:1 с take_lead_to_projects (projects.py L127–156): (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 L151–156, L526–545; этапы 3–4). Обратного пути + «Выбранные → дашборд» НЕТ (ТЗ L124–125). «Отклонено»/«Выполнено» — терминальные стадии проектного канбана; + проектные карточки в архив/корзину дашборда не попадают (отдельная таблица, автоархив StorageTickService + оперирует только Cards) — StorageTickService/Kanban НЕ меняем. +- **Ruling 6 (е) — ручное создание.** `POST /api/projects` с телом {title, summary, stack?, budget?, contact, + tzText?, stage?} (projects_routes.py L20–28): local=true, history=[{type:"createdLocal"}], title — Trim(), + stage = переданный, если в каталоге ProjectStages, иначе "planned" (create_local_card L103–124). Фронт шлёт + `{title:''}` (store.js L1909–1915) — пустой заголовок допустим (1:1). +- **Ruling 7 (ж) — история движения.** Пишется ТОЛЬКО на создание (запись {id `h_`, at, type:"created"|"createdLocal"}) + и на каждую смену стадии (запись {id, at, stage:<новая>}) — move_stage L207–215; правка полей, комментарии, + ссылки, файлы, напоминания в историю НЕ пишутся (1:1 прототип). Хранится JSON-массивом в карточке; фронт + показывает под спойлером «История движения» (ProjectDrawer). Ответы мутаций несут полную `history`. +- **Ruling 8 (з) — SSE `reminder_due`.** Событие `reminder_due` несёт `{id, title, stage}` (api-map §2 L33–42; 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 L223–231; при пустой стадии {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 L61–70: 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 L58–63). Настройки модуль читает портом + 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 L103–125 (таблица projects); projects.py L31–100 (_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 L17–27/§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 L280–300, §4.4 L302–304; projects.py L31–100; constants.py L16–27; db.py L103–125; +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 L38–42); маппинг строки ↔ 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 L266–268: fired не важен — чистим все протухшие). +- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped()`. + +**Источники:** projects.py L31–100, L159–199, L202–231, L264–282; 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` (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 L103–124, L127–156, L159–231; projects_routes.py L78–121; leads.py L151–156; +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 L124–128, projects.py add_comment L194–199). `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 L124–150; projects.py add_comment L194–199, patch_card L159–187; 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//_`). +- Create: `P/Application/FileKindDetector.cs` — чистый детектор: `Detect(name, mime) → ProjectFileKind {Kind, + Label}`; MIME-префиксы image/video/audio; иначе расширение по наборам (1:1 files.py L13–28: 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 + L54–79), `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 L13–45; object_store.py L26–107; ТЗ §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 L57–94; object_store.py L61–107; projects_routes.py L153–186; 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 L155–168; §4.3; projects_routes.py L15–150. + +**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 L164–179); DELETE `/api/projects/{cardId}/files/{fileId}` → `{ok:true}` (404 карточки). + Скачивание: один файл — в ответ Stream (Results.Stream сам диспозит). +- Modify: DI-проверка — AddDealFileStorage вызван в Program.cs (Task 6; если Task 6 не успел — здесь). + +**Контракт:** api-map L7–10 (multipart/octet-stream), L169–171; projects_routes.py L155–186; store.js L2031–2053. + +**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 L236–282; projects_routes.py L189–211; api-map L172–174, §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 + (L327–337): (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 L103–112). Ошибки проверки напоминаний не роняют тик (лог + reminders:[]). +- Modify: `A/AdminTickResultDto.cs` — `Reminders: IReadOnlyList` → типизированный + `IReadOnlyList` (XML-doc: этап 5 — реальный список). +- Modify: `A/Program.cs` — регистрация ProjectReminderService уже через AddProjectsModule (Task 8). + +**Источники:** dashboard_routes.py L327–337; projects.py check_reminders L264–274; main.py L47–53; 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=now−1 мин) → 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 L36–44); ошибки + ветки логируются (тик тенанта продолжается, паттерн существующего catch). Порядок 1:1 с _storage_loop main.py + L47–53 (тик → тосты → напоминания). Класс-комментарий обновить. +- Modify: `A/Program.cs` — (регистрация уже есть) AddHostedService остаётся; DI singleton + SseBroker уже зарегистрирован. +- Test: `T/StorageTickSchedulerTests.cs` — дополнить: тик тенанта вызывает CheckDueAsync и публикует reminder_due + по due-записям (fake ProjectReminderService + реальный SseBroker с подпиской, как в существующих тестах + тостов); disabled → событий нет. + +**Источники:** main.py L47–53; projects.py check_reminders L264–274; StorageTickScheduler.cs L152–192; +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 (L119–132): стадии-канбан и терминальные статусы — 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 L571–593) — Task 8. Roadmap этапа 5 (L69–73) — все задачи. +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 (не в прототипе). diff --git a/docs/superpowers/plans/2026-09-05-deal-stage6-services.md b/docs/superpowers/plans/2026-09-05-deal-stage6-services.md new file mode 100644 index 0000000..c1e1343 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-deal-stage6-services.md @@ -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.2–7 (L145–187: gRPC-контракты, сервисы, пул +моделей, учёт токенов, mTLS+service-token); `docs/api/api-map.md` §3.3/3.7/3.8 (L123–142, L187–217), §2 SSE (L27–43), +§4.6/4.8/4.9/4.10 (настройки, каналы/discovery, статус), «кривые места» п.4/п.8/п.9 (L390–400); roadmap этапа 6 +(L82–91); ТЗ §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` через `` (генерация 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 L95–97: «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/.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 L35–36, + ban_guard.search_pause L78–80); mark-as-read сразу после приёма/чтения. Внешний анти-бан (суточная квота + авто-вступлений, паузы 50–70 с, flood-день, стоп-кран) — владение core (воркер Discovery), счётчики в tenant-БД. +- **Ruling 4 (в) — ml-service: алгоритм и сохраняемость.** НЕ ONNX и НЕ ML.NET: переносим **инкрементальную + наивно-байесовскую модель по терминам** 1:1 с `mlservice/model.py` (tokenize L78–87, upsert L105–131, + predict L184–293, adaptive margin L42–55, самооценка eval L296–322, status/reset L325–354). Обоснование: + (1) python-прототип уже даёт работающее онлайн-обучение на русском тексте без дата-сайентиста, порт-контракт + Deal (`MlPredictResultDto`/status) спроектирован 1:1 под его ответы; (2) ONNX Runtime не умеет онлайн-обучение + (нужен экспорт/переобучение вне процесса), ML.NET — не для инкрементального обучения; (3) сохраняемость весов = + три таблицы. Хранилище — **SQLite-файл на тенанта** `data/ml/.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`, модель лениво грузится по первому обращению, у каждой — свой 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 L26–28. +- **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 + L50–54) → `{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 L175–183); + ошибки провайдера наружу как `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 + L56–82). `ResetAsync`: сервис Reset + `ClearOutboxAsync` (1:1 reset_model L110–124). Кэш статуса сервиса 15 с + (python L30–31, 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 L76–86) и `TgMessages` + (Id `m__` PK, DialogId, Text, MsgAt, LeadId nullable; L67–74). Порт `ITelegramStore` + DTO + (диалог §4.8 L349, сообщение превью L351) + `DialogsService`: `List`, `SetMonitor` (первое включение → фон + Backfill), `SetMonitorAll` (1:1 L548–567), `SyncFromTelegram(entries)` — авто-мониторинг новых по `autoMonitorNew`, + обновление имени/типа, удаление отсутствующих (1:1 `_persist_dialogs` L468–503), `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, L7–59) + пишет превью в 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 L1419–1508). +- **Ruling 9 (д/е) — Discovery: модуль, таблицы, порт ИИ-инструментов.** Новый модуль `DC` (чистый) — владелец + таблиц (миграция `TenantDiscovery`): `DiscTasks`/`DiscCandidates`/`DiscBlacklist`/`DiscLog` 1:1 db.py L136–196 + (+idx L177/196; json-колонки marks/topics/keywords text). DTO §4.8 L353–355; `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 с + исключениями «уже мониторится»/«чёрный список»/«кандидат есть», L409–453; set_candidate; mark_joined/rejected + L497–567; blacklist), лог. Новый порт `C/Integrations/IAiTools.cs`: `GenerateKeywordsAsync(description)` → + `{ok, keywords, error}`, `EvaluateFitAsync(text, description, keywords)` → `{fit, reason}` — локальные реализации + на этапе 6 не нужны (Disco-воркер сам падает в эвристику при сбое/aiEnabled=false, python L187–194); порт + реализуется gRPC-адаптером `GrpcAiTools` за тем же флагом `Services:Ai:UseLocal=false`. +- **Ruling 10 (е) — Discovery: воркер, оценка, анти-бан.** `DiscoveryWorkerScheduler` (5 с, per-tenant, эталон + PipelineWorkerScheduler) + `DiscoveryWorkerService.TickAsync` — одно действие за тик, порядок шагов 1:1 + discovery_worker.tick L444–484: (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 L229–237). (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 (L154–173, marks + L85–89). +- **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//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 L78–87), +`ML/Deal.Ml/Model/OnlineNaiveBayes.cs` (upsert/learn/batch/predict/status/reset/_maybe_eval, математика L184–323: +score термина w<1→1.0 иначе 1+(w−1)/(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-математики L184–293. + +**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 L96–117), `AI/Deal.Ai/Llm/JsonExtractor.cs` (extract_json L175–183), +`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` (L80–183); 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 L188–258; discovery_routes L36–47/189–211; discovery_eval L50–54/153–194; 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, L209–222), `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 L82–222, L286–329; 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; L331–390), `TG/Deal.Telegram/Dialogs/RealtimeSweep.cs` (30 с: догон +непрочитанных по unread_count, паузы, read-ack, L392–456), `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 L244–283, L331–456, L505–620; 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, пауза 2–4 с, +кэш entities, выходные id подписанные, kind «канал»/«группа»/«чат», L624–664), GetInfo (participants via +GetFullChannel/GetFullChat, is_forum, L666–716), ReadForEval (обычная лента; форумы — темы GetForumTopics + на +тему get_messages(reply_to), per-topic 3..10, cap 5 тем; ошибки → ok:false no_history; L718–816), Join (по +username, FloodWait → RpcException RESOURCE_EXHAUSTED + код flood, L818–839), Leave (L841–848). Тесты: нормализация +kind/username; формат ответов (fake-слой TL не трогаем — тесты на чистых мапперах ответов). + +**Источники:** telegram.py L622–873; 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 L7–59; паттерн scope/SetTenant — PipelineWorkerScheduler L169–213. + +**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 L67–86; telegram.py `_persist_dialogs`/list_dialogs/set_monitor* L468–581; api-map §4.8 L349–351; +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 L1352–1508 (фронт-флоу); +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 L63–77; доски non-suggested с правилами/ +ключами — python L226–243; примеры разметки по CardMoves/learning-истории, ≤8, L201–215), `PL/Application/ +AiRawLeadMapper.cs` (json-ответ модели → AiParsedLeadDto 1:1 python: title ≤140, стек normalize, бюджет +BudgetNormalizer=clean_budget L316–339, контакты ContactsQualifier=build_contacts L389–421, типы/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 L61–258; pipeline.py `_store_lead` L433–514; 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 L30–31/56–135; 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 L234–381), +`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 (создание/кандидаты/чёрный список/лог L234–608), db.py L136–196, 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 +L62–83), `DC/Application/DiscoveryEvaluator.cs` (фит: короткие → нет; ML-спам при mlEnabled (IMlClient.Predict); +ИИ EvaluateFit при aiEnabled (IAiTools), сбой → эвристика; форумы по темам — group_by_topic L96–117 + passed +L229–237), `DC/Application/DiscoveryWorkerService.cs` (шаги 1–4 tick L444–484 через 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 L2171–2370; 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 9–11); каналы-эндпоинты и QR/статус/марк-as-рид/мониторинг/ +«Перечитать»/ключи/авто-мониторинг — Task 10/13/14 (Rulings 3/7/8); SSE system_status/toast/new_lead — Task 12/14 +(Ruling 13); compose-dev — Task 2–4/20; безопасность dev (service-token, mTLS-решение) — Rulings 1/2, Task 2–4/12. +Roadmap-скоуп (L82–91) покрыт; ТЗ §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). diff --git a/docs/superpowers/plans/2026-09-05-deal-stage7-saas.md b/docs/superpowers/plans/2026-09-05-deal-stage7-saas.md new file mode 100644 index 0000000..731ec63 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-deal-stage7-saas.md @@ -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 (деплой, L233–243), §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 L82–84, 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()`, `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-.*`; 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 5–7, сквозные в 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). diff --git a/docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md b/docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md new file mode 100644 index 0000000..db57616 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md @@ -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, 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 (до этого новые типы живут рядом со старыми). diff --git a/docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md b/docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md new file mode 100644 index 0000000..438d757 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md @@ -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` зелёный. diff --git a/docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md b/docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md new file mode 100644 index 0000000..191dfe0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md @@ -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/плюрализация — вместе с языком. + +## Границы + +- Машинный автоперевод не делаем — словари добавляются вручную. +- Локализация писем/внешних уведомлений — если появятся, отдельной задачей. diff --git a/docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md b/docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md new file mode 100644 index 0000000..6be3a04 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md @@ -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 — приёмка и **полная остановка** + в конце (правило «без хвостов»). diff --git a/docs/superpowers/plans/2026-09-11-codestyle-остатки.md b/docs/superpowers/plans/2026-09-11-codestyle-остатки.md new file mode 100644 index 0000000..03b049e --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-codestyle-остатки.md @@ -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, без правок): сканами по тексту и по имени члена проверить дубли + `` реализации ↔ интерфейса; разбивку `var`; латинские комментарии; членов интерфейсов без + дока; TODO; переводы строк по расширениям. +2. **`var` для встроенных типов**: `.editorconfig` → `csharp_style_var_for_built_in_types = false:warning` + (гейт ломает сборку), остаток выправить `dotnet format style --diagnostics IDE0008` по 5 решениям. + «Очевидный тип» и «прочие» — оставить `silent` (субъективно, ~1600 замен). +3. **Дедупликация ``→``**: по результатам замера — либо codemod, либо закрытие «дублей нет». +4. **Переводы строк**: решение политики + нормализация (`.gitattributes`, `.editorconfig`, конверсия файлов, + `git add --renormalize`); проверить, что `.sh` — LF (Linux CI). +5. **Попутные доки/комментарии**: недостающие `` членам интерфейсов; англоязычные `//`-комментарии; + повторный прогон `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` не прогонялись. diff --git a/docs/superpowers/reviews/2026-09-08-code-quality-review.md b/docs/superpowers/reviews/2026-09-08-code-quality-review.md new file mode 100644 index 0000000..0f0fd00 --- /dev/null +++ b/docs/superpowers/reviews/2026-09-08-code-quality-review.md @@ -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. +35. **[Nit]** Дублирование доменных констант между модулями (EmptyCommentDetail/JustNowLabel/MlSpamLabel/ + DefaultChannelHue/PlannedStage-литералы) и расхождение предиката «активные правила» (Kanban vs + AiClassifyContextBuilder) — вынести в единые реестры. +36. **[Nit]** Middleware сессий (Session vs OperatorSession) и токен-генераторы (SessionTokens/ + InviteCodeGenerator/TenantAdminService) дублируются — обобщить. +37. **[Nit]** Легаси-ссылки на строки Python-прототипа в XML-doc (L177–191 и т.п.) — устаревают; + оставить «зачем/инвариант», убрать номера строк. +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 закрыт. diff --git a/docs/superpowers/reviews/2026-09-10-docs-audit.md b/docs/superpowers/reviews/2026-09-10-docs-audit.md new file mode 100644 index 0000000..0db6ec3 --- /dev/null +++ b/docs/superpowers/reviews/2026-09-10-docs-audit.md @@ -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_//` | `CardsService` строит `projects//__` | ✅ исправлено | +| 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//__` | ✅ исправлено | +| 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//__` — `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 этапы 1–7, §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. diff --git a/docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md b/docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md new file mode 100644 index 0000000..7f57e8f --- /dev/null +++ b/docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md @@ -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/.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.5–3 с / 3–6 с), `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` (`
` «История движения», `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:130–155`) | +| 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` `
` «История движения» | +| 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:131–141`) возвращает + `{items: []}` с комментарием «До этапа 6 telegram-данных нет» — устаревшая заглушка; `POST /api/ml/apply` + (`MlEndpoints.cs:144–155`) всегда отвечает 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:232–276`, `store/settings.js:351–406`, `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:6–8`), при этом `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` зелёные. diff --git a/docs/superpowers/reviews/2026-09-11-docs-final-sweep.md b/docs/superpowers/reviews/2026-09-11-docs-final-sweep.md new file mode 100644 index 0000000..e1e116f --- /dev/null +++ b/docs/superpowers/reviews/2026-09-11-docs-final-sweep.md @@ -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 | «Все этапы **0–10** выполнены (100%)» | таблица этапов — **0–12**, «Итого 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 этапов 0–7), +`2026-09-05-deal-scaffold.md` (этап 0), +`2026-09-05-deal-stage1-tenancy.md` … `2026-09-05-deal-stage7-saas.md` (этапы 1–7), +`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 техдока** (этапы 1–7) и §13.4c/4d/4e намеренно сохраняют легаси-термины + под пометками; сведение их в ссылки на §3 — задача следующей редакции, а не этой подбивки. +7. **`docs/architecture/2026-09-10-*`** (контракты) по условию задачи не редактировались; они актуальны. diff --git a/docs/superpowers/specs/2026-09-04-channel-discovery-design.md b/docs/superpowers/specs/2026-09-04-channel-discovery-design.md new file mode 100644 index 0000000..15b180e --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-channel-discovery-design.md @@ -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/`; система + замечает вступление при синхронизации диалогов и предлагает добавить источник в + мониторинг (метка «вступили, добавить в мониторинг?»). + +## 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. diff --git a/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md b/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md new file mode 100644 index 0000000..3b02da0 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md @@ -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` — + ссылки на объекты 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 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, рендер) реализована. diff --git a/docs/superpowers/specs/2026-09-11-source-contract-design.md b/docs/superpowers/specs/2026-09-11-source-contract-design.md new file mode 100644 index 0000000..5e7a05c --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-source-contract-design.md @@ -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? 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 Data { get; init; } = []; // ссылки на файлы в Storage + public IReadOnlyList? Links { get; init; } // ссылки (не файлы) + public IReadOnlyList? Contacts { get; init; } // контакты + public IReadOnlyDictionary? 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? 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`). diff --git a/docs/superpowers/specs/2026-09-11-структура-проектов-design.md b/docs/superpowers/specs/2026-09-11-структура-проектов-design.md new file mode 100644 index 0000000..b8e26df --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-структура-проектов-design.md @@ -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/`, `Api`, `Infrastructure` и т.п., + `namespace` = `Deal.Tests.Unit.<Область>`. + +## 4. Механика переноса (на проект) + +1. Классифицировать файлы по таблице §2. +2. Перенести файлы в подпапки и заменить `namespace`. +3. Миграция `using`: в файлах-потребителях заменить несуществующий старый `using ;` на + `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` — выявляются сборкой. diff --git a/docs/technical/Техническая-документация-Дейл.md b/docs/technical/Техническая-документация-Дейл.md new file mode 100644 index 0000000..1d282f5 --- /dev/null +++ b/docs/technical/Техническая-документация-Дейл.md @@ -0,0 +1,1397 @@ +# Дейл (Deal) — Техническая документация + +> Версия: 2.0 (этапы 0–12: единая карточка, оператор-консоль и аналитика, i18n, метрики/устойчивость) +> Дата: 2026-09-10 +> Содержание: полный стек, структура, конфигурация, развёртывание, эксплуатация. + +--- + +## 1. Обзор стека + +| Слой | Технология | +|---|---| +| Язык | C# (современный, актуальная LTS .NET) | +| Бэкенд-ядро | Модульный монолит `core` (ASP.NET Core: Web API, gRPC, SSE) | +| База данных | PostgreSQL (одна БД, схема на тенанта) | +| ORM/доступ | EF Core (основной) + Dapper (тяжёлые запросы, где нужно) | +| Миграции | Механизм миграций на все схемы тенантов | +| Очередь/шина | Outbox-паттерн в Postgres; порт `IEventBus`; Kafka — позже | +| ML-сервис | .NET + ONNX Runtime, пул моделей per-tenant | +| AI-сервис | .NET, фасад LLM-провайдеров (OpenAI-совместимые), учёт токенов | +| Telegram | .NET (WTelegramClient/аналог), ферма сессий, анти-бан | +| Файлы | MinIO (S3-совместимое хранилище) | +| Фронтенд | Vue 3 + Vite + Tailwind | +| Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) | +| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus → Grafana | +| Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) | +| Контейнеры | Docker / docker compose (VPS); k8s — позже | +| Бэкапы | Ежедневные: pg_dump + MinIO + сессии | +| CI | Сборка, тесты, SAST, сканирование зависимостей и образов | + +--- + +## 2. Структура репозитория + +``` +src/ + core/ # модульный монолит (один sln, один процесс) + Deal.sln + Deal.Api/ # host: /api-контракт, gRPC-сервер, SSE, DI-композиция + Deal.Modules.Cards/ # модель единой карточки + каталог контейнеров (этап 9) + Deal.Modules.Pipeline/ + Deal.Modules.Kanban/ # таблицы Cards/Containers, правила, комментарии, архив/корзина + сервисы «Выбранных» + Deal.Modules.Telegram/ # каталог диалогов/каналов и превью сообщений (этап 6) + Deal.Modules.Discovery/ + Deal.Modules.Settings/ + Deal.Modules.Tenants/ + Deal.SharedKernel/ + Deal.Infrastructure/ + Deal.Contracts/ + tests/ + ml-service/ # Deal.Ml.sln + ai-service/ # Deal.Ai.sln + telegram-service/ # Deal.Telegram.sln + contracts/ # общие .proto + deploy/ # compose.dev.yml / compose.prod.yml (развёртывание) + frontend/ # Vue 3 + Vite +``` + +Принципы: +- один процесс = один sln; +- `core` — единственное место с бизнес-логикой и БД; +- сервисы stateless по отношению к данным тенантов (ML получает текст — отдаёт решение); +- `.proto` — общий язык между процессами (в `contracts/`, подключается shared-файлами). + +--- + +## 3. Модули core + +| Модуль | Проект | Владеет | +|---|---|---| +| Карточки (ядро) | `Deal.Modules.Cards` | модель единой карточки (`ICard`/`ISource`/`IContainer`/`ICardMover`), каталог контейнеров по умолчанию, единые префиксы id | +| Пайплайн | `Deal.Modules.Pipeline` | очередь, отсев, dedup | +| Канбан (дашборд) | `Deal.Modules.Kanban` | таблицы `Cards` и `Containers`, правила колонок, комментарии, `CardMoves`, `MlOutbox` | +| «Выбранные» | `Deal.Modules.Kanban` (`CardsService.Selected`) | сервисы стадий/напоминаний/файлов/ссылок над теми же строками `Cards` | +| Discovery | `Deal.Modules.Discovery` | задачи поиска, кандидаты, чёрный список | +| Настройки | `Deal.Modules.Settings` | настройки тенанта, промпты, валюты | +| Тенанты | `Deal.Modules.Tenants` | реестр тенантов, пользователи, инвайты, лимиты (в `public`) | +| Каналы/Telegram | `Deal.Modules.Telegram` | каталог диалогов, превью сообщений, мониторинг источников | + +После этапа 9 (единая карточка): модель/контейнеры — в `Deal.Modules.Cards`; `Kanban` владеет таблицами +`Cards`/`Containers` и сервисами пространства «Выбранные» (`CardsService.Selected`) над теми же строками +(отдельных модуля `Deal.Modules.Projects` и таблицы `ProjectCards` больше нет). + +Зависимости между модулями — только через публичные интерфейсы модуля-владельца. +Доменные события — через `IEventBus` (outbox). Правила: +- внутри модуля таблицы — его собственность; +- чужие таблицы не читаем/не пишем SQL напрямую; +- общие справочники живут в модуле-владельце. + +--- + +## 4. Мультитенантность и БД + +### Схемы +- `public`: тенанты, пользователи, инвайты, ключи приложения, глобальные настройки. +- `tenant_.*`: все данные тенанта (карточки, колонки, настройки, обучение и т.д.). + +### Доступ +- `tenantId` — из сессии/JWT (core) или из gRPC-метаданных (сервисы). +- DAL формирует `search_path` = `tenant_`; пул соединений на схему. +- Изоляция проверяется: принадлежность объекта тенанту до любого действия (IDOR-защита). + +### Миграции +- Миграции пишутся один раз (как для одной схемы) и применяются механизмом + «ко всем схемам тенантов»: список схем из `public.tenants`, применение по очереди, + версия миграции хранится на схему. Детали — в плане реализации этапа 0. + +### Ключевые таблицы `public` +``` +tenants(Id, Name, Status, CreatedAt) +users(Id, Login, TenantId, PasswordHash, Status, CreatedAt) -- Login = email пользователя +invites(Code PK, Email, TenantId, Status, ExpiresAt, ActivatedAt, CreatedById, CreatedAt) +global_settings(Key, Value, UpdatedAt) +-- этап 7: оператор/SaaS +token_usage_events(id, tenant_id, at, provider, model, kind, prompt_tokens, completion_tokens, total_tokens, detail_json) +-- этап 12: глобальные настройки сервиса (секреты шифруются) +global_settings(key, value, updated_at) +``` + +> `token_usage_events` — история расхода токенов (этап 10, T2; подробнее — §13.10). +> `global_settings` — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram +> (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`), которые задаёт **оператор** глобально +> (ручки `GET/PUT /api/operator/settings/telegram-keys`); тенант ключи не видит/не задаёт. +> Операторские таблицы этапа 7 (`operators`, `operator_sessions`, `tenant_limits`, `audit_log`) и их +> контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа — +> `public.rate_limit_counters` (см. §10). + +### Ключевые таблицы схемы тенанта (пример) +``` +QueueItems, RejectedItems, DedupEntries, +Cards, Containers, CardMoves, LeadComments, MlOutbox, +DiscTasks, DiscCandidates, DiscBlacklist, DiscLog, +Dialogs, TgMessages, settings +``` + +### Фактическая схема на конец этапа 1 (2026-09-05) + +Реализованный фундамент (см. раздел 13 «Быстрый старт»). Списки выше — целевой вид будущих этапов; +ниже — то, что реально создано миграциями этапа 1. + +- `public` (системный контекст, миграция `InitialSystem`): +``` +tenants(Id uuid PK, Name varchar(200), Status text, CreatedAt timestamptz) -- реестр тенантов +users(Id uuid PK, Login varchar(200) UNIQUE, TenantId uuid → tenants, -- учётные записи + PasswordHash text, Status text default 'active', CreatedAt timestamptz) +sessions(TokenHash varchar(64) PK, UserId uuid → users ON DELETE CASCADE, -- сессии: кука deal_session, + Login varchar(200), ExpiresAt timestamptz, CreatedAt timestamptz) -- срок 30 дней +``` +- Схема тенанта `tenant_` (миграция `InitialTenant` применяется на схему): +``` +settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) -- настройки тенанта +``` +- История миграций: `public.__EFMigrationsHistory` и `__TenantMigrationsHistory` в схеме тенанта. +- Имена таблиц/колонок — по конвенции EF Core (PascalCase). Схемы тенантов создаются и мигрируются + автоматически (`TenantProvisioningService`); при старте API создаётся дефолтный тенант и admin + (`TenantBootstrapService`). + +--- + +## 5. Сервисы и контракты + +### gRPC-контракты (`src/contracts/*.proto`, общий проект `Deal.Proto`) +- `telegram.proto` (пакет `deal.telegram.v1`) — два сервиса: `TelegramService` — команды ядра к + telegram-service (GetStatus, StartPhone, StartQr, SendCode, SendPassword, Logout, RefreshDialogs, + SetMonitor, SetMonitorAll, Backfill, ReadRecent, Search, GetInfo, ReadForEval, Join, Leave); + `SourceIngressService.PushSource` (generic-контракт источников, `sources.proto`) и `IngressService` + (SyncDialogs, ReportStatus) — исходящий поток telegram-service → core; + сервер — gRPC-ингресс core :5082). +- `ml.proto` (пакет `deal.ml.v1`) — `MlService`: Predict (text → {take,label,scores,margin,type}), + Status, Reset, TrainBatch; всё с metadata `tenant-id`. +- `ai.proto` (пакет `deal.ai.v1`) — `AiService`: Filter, Classify, GenerateKeywords, EvaluateFit; + ответ + расход токенов (usage → `TokenUsageRecorder`, §6). +- Полный состав RPC/полей и семантика ошибок — §13.7 и шапки `.proto`. + +### Безопасность сервисов +- Каждый RPC несёт metadata `tenant-id` + `service-token`; интерцепторы всех процессов fail-closed + сверяют токен с env `DEAL_SERVICE_TOKEN` (gRPC-health освобождён). Принадлежность + (сессия/модель тенанта) проверяется сервисом по своей модели — полю в теле не доверяем. +- Dev — gRPC plaintext + общий service-token (compose.dev); mTLS — за флагом `DEAL_MTLS_*` + (взаимные сертификаты, цепочка → CA; меняется только транспорт, контракты — нет, Ruling 6 этапа 7). +- telegram-service: сессии привязаны к тенанту (файлы AES-256-GCM, ключ `DEAL_TELEGRAM_SESSION_KEY`); + команда исполняется только на сессии своего tenantId; проверка принадлежности диалога; join + под квотами тенанта; исходящие сообщения помечены tenantId на входе. + +--- + +## 6. Ключевые сквозные механизмы + +### Outbox / IEventBus +- Событие и бизнес-эффект пишутся в одной транзакции; фоновый диспетчер доставляет + события подписчикам (в процессе) и/или в сервисы (gRPC). +- Реализация сменная (outbox → Kafka) без правки бизнес-логики. + +### Лимиты токенов (ИИ-бюджет; фактически — этап 7, §13.8) +- ai-service возвращает usage; `TokenUsageRecorder` инкрементит `public.tenant_limits` (период + месяц/день, ленивый reset), пишет историю в `public.token_usage_events` (этап 10, T2) и инкрементит + метрики `deal.ai.*`/`deal.ml.*` (§7); кроме того ведётся lifetime-KV `aiTokenUsage`. +- Коллекции `public.token_usage_events` подчищает фоновый `DataRetentionScheduler` (§10); + накопительные поля прошедших периодов `tenant_limits` сбрасываются там же. +- Гейт-декораторы `BudgetedAiClassifier`/`BudgetedAiTools` (только при `Services:Ai:UseLocal=false`): + исчерпание/приостановка → Local-фолбэк (приём не блокируется); SSE-тосты на 80/100% бюджета. + +### Файлы +- MinIO (S3): бакет на продукт (`deal-files`); ключ объекта строит `CardsService` — + `projects//__` (`tenant_/…`-префикса нет). +- Тип файла определяется автоматически (MIME + расширение). +- Доступ к файлу — только через core с проверкой tenantId. + +### Напоминания +- Фоновый планировщик (в core): проверка due-напоминаний отложенных карточек; + при срабатывании — уведомление (SSE/тост). +- Если напоминания отключены — запланированные не срабатывают и очищаются. + +### SSE +- События фронту (4 типа): `new_card` (полная карточка), `toast` (`{text,icon}`), `reminder_due` + (`{id,title,containerId}`), `system_status` (объект `tg.status()`). `new_lead` переименован в + `new_card`; `pipeline_stats`/`boards_changed`/`leads_reclassified` прототипа не реализованы. +- Поток `GET /api/events` — per-tenant (SseBroker), ping-комментарий каждые 15 с. Публикуют только + эндпоинты/планировщики Api-слоя (модули — чистые). + +--- + +## 7. Наблюдаемость (фактический стек — этапы 7/10, Ruling 7) + +- **Serilog.AspNetCore во всех 4 процессах** (core + telegram/ai/ml): консоль в формате JSON + (`CompactJsonFormatter`; в Development — текст) + rolling-файл `data/logs/deal-<процесс>.json` + (30 дней; env `DEAL_LOG_LEVEL`/`DEAL_LOGS_DIR`). Секреты/пароли/ключи не логируются; + gRPC-health не логируется. +- **Access-логи**: HTTP — `HttpAccessLogMiddleware` (первый в конвейере после ForwardedHeaders — + длительность и статус всего пути; с BL-LOG-ACTOR — `actor` и `tenant` из сессии); gRPC-ингресс — + `RpcCallLoggingInterceptor` (health освобождён). +- **PROD-стек логов**: docker-логи контейнеров → `promtail` → `loki` (retention 7 суток) → `grafana` + (`127.0.0.1:3001` — только оператору по SSH-туннелю). Поднимается профилем `observability` + файла `deploy/compose.prod.yml` (живой подъём — ⚠ Manual): + `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml --profile observability up -d` + (нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка — + `docker compose -f deploy/compose.prod.yml --profile observability down`. +- **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в + контейнер): `datasources/datasources.yml` — датасорс Loki (uid `loki`, URL `http://loki:3100`); + `dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы + `deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не + сохраняются (`allowUiUpdates: false`). +- **Метки Promtail** (`deploy/observability/promtail.yml`): `service` (имя compose-сервиса), + `container` (имя контейнера), `stream`; пайплайн дополнительно поднимает метку `level` из + Serilog-поля `@l` (`Information`/`Warning`/`Error`/`Fatal`) — только для deal-процессов по + `service`-селектору, логи прочих контейнеров хоста не парсятся. Запросы Grafana — LogQL, JSON + разбирается на лету: `{service="core"} | json | StatusCode >= 500`. +- **Дашборды** (папка «Дейл», источник — Loki): + - `Deal-Health` — активность логов и строки Error/Fatal по процессам (доступность сервиса); + - `Deal-Auth` — успешные/неудачные входы и выходы (контур тенант/оператор по пути + HTTP-код из + access-лога core) и активация инвайтов (`/api/join`); + - `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента; + - `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса; + - `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram). + **Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и + `tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через + `GET /api/operator/audit` / экран «Аудит» оператор-консоли. +- Алерты Prometheus (этап 12) — правила `deploy/observability/prometheus-rules.yml` (см. подраздел + «Метрики»); исчерпание ИИ-бюджета по-прежнему доставляется SSE-тостом тенанту — отдельной + бюджетной метрики в Prometheus нет (метки метрик низкокардинальные, без tenantId/бюджета). + +### Метрики (Prometheus + Grafana — этап 12, пакет A) + +- **Экспорт из 4 процессов**: OpenTelemetry → экспортёр Prometheus, общая настройка — `Deal.Grpc.Hosting` + (`DealMetricsHosting`) для сервисов и `Deal.Api/Observability/DealMetricsHosting.cs` для ядра. + Инструментация даёт готовые метрики без ручного кода: входящие запросы `http.server.request.duration` + (RPS/латентность/ошибки по route, включая gRPC-вызовы) и исходящие HTTP-клиенты `http.client.*`. +- **Эндпоинт `/metrics`** — на **отдельном HTTP/1.1 Kestrel-эндпоинте :9464** у всех 4 процессов + (gRPC-порты :5101–:5103/:5082 слушают только HTTP/2, обычный GET-scrape по ним невозможен). Порт + переопределяется env `METRICS_PORT`; наружу не публикуется (scrape — внутри compose-сети). Формат — Prometheus. +- **Прикладные метрики** (meter `Deal`, `deal.*`; метки низкокардинальные — без tenantId/userId/cardId): + - `deal_ai_calls_total` / `deal_ai_tokens_total{type=prompt|completion}` — вызовы и токены платного ИИ; + - `deal_ml_calls_total` / `deal_ml_tokens_total` — вызовы и оценка токенов локального ML; + - `deal_audit_events_total{event,actor}` — события аудита по типу/актору; + - `deal_pipeline_queue_depth`, `deal_ml_outbox_depth` — суммарные глубины очередей (пайплайн, MlOutbox) + по всем тенантам; `deal_sessions_active` — активные непросроченные сессии пользователей и операторов; + - `deal_ai_budget_used_ratio{tenant}` — доля израсходованного ИИ-бюджета периода (0..1) по тенантам + (осознанное исключение из низкокардинального правила: бюджеты пер-тенантные, алерт должен знать тенанта). + Gauge-значения собирает фоновый `DealMetricsCollector` ядра (каждые 15 с) через существующие + сервисы/хранилища (`PipelineProcessingService.QueueCountsAsync`, `IMlLearningStore.CountOutboxAsync`, + `public.sessions`/`operator_sessions`); инкремент счётчиков токенов/аудита — там же, где пишутся + `token_usage_events` (`TokenUsageRecorder`) и `audit_log` (`AuditService`). +- **Scrape/Prometheus**: сервис `prometheus` (образ `prom/prometheus:v3.5.0`) в профиле `observability` + compose.prod; конфиг `deploy/observability/prometheus.yml` — job `deal` с таргетами + `core/telegram-service/ai-service/ml-service:9464` (target-метка `service`), retention 15 суток + (volume `deal_prometheus_data`). UI — `127.0.0.1:9090` (оператору по SSH-туннелю). В dev тот же сервис + добавлен в `deploy/compose.dev.yml` (профиль `observability`, UI `localhost:9090`). +- **Grafana-провижининг**: `datasources/datasources.yml` — датасорсы Loki (uid `loki`, default) и + Prometheus (uid `prometheus`, `http://prometheus:9090`); дашборд `Deal-Metrics-Overview` (uid `deal-metrics`) + в папке «Дейл»: RPS/p95/5xx по сервисам, токены и вызовы AI/ML, глубины очередей, активные сессии, + события аудита. Правки — файлами в `deploy/observability/grafana/dashboards/*.json`. +- **Правила алертов Prometheus** (`deploy/observability/prometheus-rules.yml`, подключены через + `rule_files` в `prometheus.yml`): сервис недоступен (`up{job="deal"} == 0`), рост 5xx + (`http_response_status_code=~"5.."`), лаг очереди pipeline/ML-outbox (`deal_pipeline_queue_depth`, + `deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила + бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится. +- Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml + --profile observability up -d` → Prometheus `/targets` (все 4 UP) → Grafana → папка «Дейл» → + `Deal-Metrics-Overview`. Быстрая проверка экспортёра без Grafana: `curl http://<процесс>:9464/metrics` + изнутри сети. + +--- + +## 8. Развёртывание (факт: dev-compose + prod-compose, один VPS) + +Два compose-стека: `deploy/compose.dev.yml` (разработка/демо) и `deploy/compose.prod.yml` (прод; +единственный наружу — Caddy). Команды/детали — §13.7 (dev-стек этапа 6), §13.8 (этап 7, быстрый +сценарий оператора), §13.9 (бэкапы). + +> Примечание: наследие LeadRadar (DuckDB + MinIO + Python-ml) и его прежний корневой `docker-compose.yml` +> вынесены в `archive/leadradar-legacy/` и к стеку Дейла не относятся; актуальные стеки — только +> `deploy/compose.dev.yml` и `deploy/compose.prod.yml`. + +### Dev-стек (`deploy/compose.dev.yml`) + +| Контейнер | Порт | Назначение | +|---|---|---| +| `deal-postgres` | 5433 | Postgres 16, БД `deal` (host-порт; внутри 5432) | +| `deal-minio` | 9000/9001 | MinIO (вложения; в Local-режиме необязателен) | +| `deal-core` | 5080 / 5082 | Deal.Api: HTTP `/api` + gRPC-ингресс telegram | +| `deal-telegram-service` / `deal-ai-service` / `deal-ml-service` | 5101/5102/5103 | автономные сервисы этапа 6 | + +`docker compose -f deploy/compose.dev.yml up -d --build` — весь стек в сквозном gRPC-режиме +(`Services__*__UseLocal=false`); `sh scripts/dev-smoke.sh` — одна команда (подъём → health → login → +`/api/tg/status` → `POST /api/cards` → trash → флашер MlOutbox → `/api/ml/status`; trap → down). Host-режим +(core с хоста, Local-заглушки) — §13.1–13.6. + +### Prod-стек (`deploy/compose.prod.yml`) + +- Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` — + `tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты), + статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь). +- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml-service` (mTLS-env, Ruling 6), + `postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с + PEM `deal-client.crt/.key`)/`pg_isready`. +- Профиль `observability`: `loki`/`promtail`/`grafana` + `prometheus` (метрики — этап 12, пакет A; см. §7). + Секреты — только из `.env.prod` + (шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`). + Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins` + (`DEAL_ALLOWED_ORIGINS`), куки Secure, `ForwardedHeaders` доверяет Caddy (`KnownNetworks`). +- mTLS внутреннего gRPC — флаг `DEAL_MTLS_ENABLED=1` + сертификаты `deploy/certs/` + (`scripts/mtls-certs.sh`); основной HTTP :5080 остаётся http — TLS терминирует Caddy. + +### Порядок первого запуска (prod) + +1. Установить docker + docker compose на VPS. +2. Скопировать `deploy/.env.prod.example` → `.env.prod`; заполнить секреты: пароли БД/MinIO, + `DEAL_SERVICE_TOKEN`, `DEAL_ENCRYPTION_KEY`, `DEAL_TELEGRAM_SESSION_KEY`, `DEAL_ALLOWED_ORIGINS` + (origin фронта); опционально креды оператора `DEAL_OPERATOR_LOGIN/PASSWORD`, `DEAL_DEFAULT_AI_BUDGET`, + `DEAL_MTLS_*`. Полный список — шапка `.env.prod.example`. +3. Применить системные миграции к БД стека (команда §13.2, строка подключения — прод-БД). +4. `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` + (наблюдаемость — добавить `--profile observability`). Старт core: провижининг схем всех тенантов, + bootstrap оператора (Production без env — warning и пропуск, Ruling 1). +5. Проверить: оператор `POST /api/operator/auth/login` → создать тенанта → инвайт → `POST /api/join` + (быстрый сценарий — §13.8); health — `/api/health`, `/api/operator/health`. +6. Авто-проверка: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml config` (rc=0). + Живой подъём PROD-стека — ⚠ Manual (нужен docker). + +### Переменные окружения (prod; без дефолтных значений) +``` +DEAL_PG_PASSWORD=... MINIO_ROOT_USER=... MINIO_ROOT_PASSWORD=... +DEAL_SERVICE_TOKEN=... DEAL_ENCRYPTION_KEY=... (32 байта base64) +DEAL_TELEGRAM_SESSION_KEY=... (32 байта base64, AES-GCM сессий) +DEAL_ALLOWED_ORIGINS=https://deal.example DEAL_OPERATOR_LOGIN=... DEAL_OPERATOR_PASSWORD=... +DEAL_MTLS_ENABLED=0|1 DEAL_MTLS_CERT_PASSWORD=... DEAL_DEFAULT_AI_BUDGET=... +``` +Секреты — только через env/secret-хранилище, не в коде и не в репозитории. + +### CI/CD +- Единый прогон: `scripts/ci.sh` (BL-CI, 2026-09-11) — сборка всех 5 решений (`scripts/build.sh`), + тесты всех сервисов (`scripts/test.sh`: core/telegram/ai/ml/storage + `npm run lint:i18n`), + скан уязвимых NuGet-зависимостей (`dotnet list package --vulnerable --include-transitive`), + сборка фронта (`npm ci && npm run build`). Сборка — 0 warnings/0 errors (`TreatWarningsAsErrors`). +- Готовый workflow: `.github/workflows/ci.yml` (setup-dotnet 10 + setup-node 20 → `sh scripts/ci.sh`); + первый прогон в удалённом CI — при публикации репозитория. +- Нагрузочный прогон: `scripts/loadtest/` (bash+curl и k6-вариант; логин admin/admin → контейнеры/карточки; + RPS/avg/p95; см. README рядом). +- Доставка на VPS: сборка образов → `docker compose ... up -d --build`. +- Результат локального прогона `scripts/ci.sh` (2026-09-11): core 1315, telegram 130, ai 52, ml 38, + storage 9 — всё PASS; фронт `lint:i18n`/`build` зелёные. k8s — вне этапа (задел). + +--- + +## 9. Бэкапы и восстановление (факт — scripts/backup.sh, Ruling 8; детали §13.9) + +- **Ежедневный бэкап** — `scripts/backup.sh`: (1) Postgres — `pg_dump -Fc` всех схем (public + tenant_*); + (2) MinIO-бакет `deal-files` — `mc mirror`; (3) файловые данные — tar каталогов/томов + (attachments, telegram-сессии AES-GCM, ml-модели); (4) retention 14 копий. Планировщик — вне + контейнера: cron «0 2 * * *»/systemd-примеры — §13.9. Запуск — `bash scripts/backup.sh` (из корня). +- **Восстановление** — `scripts/restore.sh` (pg → minio → data; pg-шаг пересоздаёт БД целиком, + minio/data — overlay): остановить сервисы → `bash scripts/restore.sh [TS|pg|minio|data]` → поднять. + Порядок и требования — §13.9. +- Рекомендация Ruling 8: раз в месяц — тест восстановления на отдельном инстансе/томах. +- Потеря данных при ежедневном бэкапе допустима ≤ 24 ч (SLA тестового этапа). +- Реальный прогон `backup.sh` и restore-тест — ⚠ Manual (нужен docker-стек; здесь — `sh -n`, + error-path-проверки, offline-проверка retention). + +--- + +## 10. Безопасность (эксплуатационная сводка — фактическая, этап 7) + +- **Rate limiting** (Ruling 5): секция `RateLimit` (`Enabled=false` — код-дефолт/dev/тесты, `true` + в PROD). Политики: `auth` — 10/мин на IP для `/api/auth/login` и `/api/operator/auth/login`; + `api` — 600/мин на тенанта/IP; интерцептор gRPC-ингресса :5082 — 600/мин/тенанта (health + освобождён); ответ 429 `{detail}`. С этапа 12 лимитер **store-backed**: состояние счётчиков — в + `public.rate_limit_counters` (атомарный upsert), т.е. общее для всех инстансов core. **Попытки + входа** — `LoginAttemptGuard` на том же хранилище (окно ip|login: 5 неудач за 15 мин → 429 «Слишком + много попыток входа…»; успех сбрасывает счётчик; `Enabled=false` — no-op). +- **Origin-проверка мутаций** — `OriginGuardMiddleware`: не-GET/HEAD/OPTIONS `/api` с заголовком + Origin обязаны иметь Origin = «свой» origin (схема + Host) либо из `Security:AllowedOrigins`; + несовпадение → 403. CORS — явный allowlist; SameSite=Lax httpOnly-кук — первый рубеж CSRF. +- **Прокси-заголовки** — `UseForwardedHeaders` (X-Forwarded-For/X-Forwarded-Proto, один доверенный + hop) только за Caddy: `ForwardedHeaders:Enabled=true` + KnownProxies/KnownNetworks (пустые списки + не допускаются — loopback-фолбэк, fail-fast на невалидных значениях). Без этого за Caddy audit-IP + (Ruling 4) и rate-limit-по-IP схлопываются в бакет прокси. +- **Security-заголовки**: core — `SecurityHeadersMiddleware` (X-Content-Type-Options: nosniff, + X-Frame-Options: DENY, Referrer-Policy: no-referrer); CSP/HSTS — на Caddy (фронт-статика; CSP для + Vue требует настройки nonce — документируется в шапке Caddyfile). В PROD куки Secure=true. +- **mTLS** — за флагом `DEAL_MTLS_*` только для внутреннего gRPC (серверный сертификат + обязательный + клиентский, цепочка → CA из `DEAL_MTLS_CA_PEM`); основной HTTP :5080 остаётся http — TLS + терминирует Caddy. Живое рукопожатие — ⚠ Manual. +- **Приостановка тенанта** (Ruling 10(5)): вход — **403** «Учётная запись приостановлена…» (не 401: + неверные учётные данные не раскрывают статус); ИИ-расход заморожен бюджетным гейтом. С этапа 12 + активные сессии приостановленного тенанта **разлогиниваются сразу**: `AuthService.ResolveSessionAsync` + проверяет статус тенанта (включая impersonation) и отказывает в сессии. Impersonation оператором + suspended-тенанта разрешена (полностью аудируется; ИИ всё равно заморожен). +- **Аудит** — append-only `public.audit_log`: пишет только `AuditService` (без Update/Delete), + секреты не попадают; чтение — только оператор (`GET /api/operator/audit`). С этапа 12 retention + 180 дней обеспечивает фоновый `DataRetentionScheduler` (раз в сутки; секция `DataRetention`), + там же — сброс накопительных полей `tenant_limits` прошедших периодов и уборка окон счётчиков + `rate_limit_counters`. +- Криптография/код: пароли Argon2id; секреты настроек AES-256-GCM (`enc:`, ключ + `DEAL_ENCRYPTION_KEY`); сессии Telegram AES-256-GCM (`DEAL_TELEGRAM_SESSION_KEY`); SQL + параметризуется; секреты в логи/аудит не пишутся. +- **Hardening контейнеров (BL-IMG-HARDEN, 2026-09-11):** прикладные образы (core/telegram/ai/ml/storage) + работают non-root (пользователь `deal`, UID 10001) с `HOME=/tmp`; в compose заданы `read_only: true`, + `tmpfs: /tmp`, `security_opt: no-new-privileges`, `cap_drop: ALL` и лимиты `mem_limit`/`cpus` (якорь + `x-service-hardening`). Данные — в именованных volume (`/app/data` core, `/data/sessions` telegram, + `/data/ml` ml); логи stateless-сервисов — `DEAL_LOGS_DIR=/tmp/logs` (tmpfs), у core — volume + `/app/data/logs`. Проверено `docker compose config` (dev и prod, включая профиль observability); + живой подъём с этими ограничениями — ⚠ Manual. +- **Вне этапа (не настроено; заделы §11/roadmap):** Cloudflare (конфигурация вне кода — шапка + Caddyfile), k8s, биллинг, UI админок, саморегистрация. + +--- + +## 11. Известные ограничения и TODO + +**Выполнено на этапе 1 (2026-09-05):** + +- доступ и сессии: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, + `POST /api/auth/change-password`; httpOnly-кука `deal_session` (30 дней); +- мультитенантность и миграции: системный контекст (`public`: `tenants`/`users`/`sessions`, миграция + `InitialSystem`), схемы `tenant_` с настройками тенанта (`settings`, миграция `InitialTenant` + на схему), автоматический провижининг схем и bootstrap дефолтного тенанта + admin при старте API. + +**Выполнено на этапе 2 (2026-09-06) — модуль Settings (экран «Настройки» обслуживается бэкендом):** + +- дерево настроек тенанта 1:1 с прототипом: `GET/PATCH /api/settings` (дефолты модуля, перекрытые + переопределениями в таблице `settings` тенанта; PATCH мягкий — невалидные поля пропускаются, + ответ — полный снимок; секреты наружу только масками `keyMasked`/`apiId`; внутренние ключи + `ratesCache`/`mlDecisions`/`aiDecisions` не публикуются); +- шифрование секретов AI/Telegram: AES-256-GCM, в БД — `enc:` + Base64 (ключ — env/file, см. §13.4a); +- проверка подключения ИИ: `POST /api/ai/check` (локальный провайдер / HTTP-проверка облачного); +- курсы валют: `GET /api/rates`, `POST /api/rates/refresh` (кэш `ratesCache` в settings; `mock`/ЦБ); +- ML-панель на детерминированной заглушке: `GET /api/ml/status`, `POST /api/ml/reset|predict` + (candidates → `{items:[]}`, apply → 404 — нет telegram-данных до этапа 6); +- тестер фильтров: `POST /api/admin/check-message` (этап-1 правила из настроек: длина/стоп-фразы/ + резюме/тип; ИИ-фильтр тестера на этапе 2 всегда skipped); +- 175 unit-тестов PASS; интеграционная приёмка — curl-сценарий на :5080 + psql. + +**Выполнено на этапе 3 (2026-09-06) — модуль Kanban (дашборд/канбан), см. §13.4c:** + +- миграция `TenantKanban` — таблицы схемы тенанта `Boards`, `Cards`, `LeadComments`, `CardMoves`, + `MlOutbox` (PascalCase-конвенция; колонки карточки по ТЗ §5: `Title`/`Summary`/`StackJson`/ + `BudgetFrom`/`BudgetTo`/`BudgetCur`/`ConvFrom`/`ConvTo`/`ConvCur`/`ContactsJson`/`ChannelName`/…/ + `ReceivedAt`/`SourceMsg`/`SourceDialogId`/`PrevCol`/`MatchHitsJson`/`ArchivedAt`); +- модуль `Deal.Modules.Kanban`: `BoardsService`/`CardsService` (доски, переносы, архив/корзина, + комментарии, counts, поиск), правила колонок `ColumnRules` (matchHits — «почему карточка в колонке»), + `StorageTickService` + фоновый `StorageTickScheduler` (цикл 30 с, автоархив по `archiveAfterDays`), + пересчёт конверсий `ConversionRecomputer` (listener на смену курсов/`targetCurrency`), демо-фабрика, + эвристика ИИ-предложений `SuggestHeuristics`; +- эндпоинты: `/api/boards` (+reorder/PATCH/DELETE), `/api/columns/state`, `/api/leads` (+counts/ + {id}/move/trash/restore/DELETE/clear-col/mark-col-seen/mark-all-seen/comments/reclassify-заглушка), + `GET /api/search?q=`, `GET /api/events` (SSE), `/api/admin/tick` + `/api/admin/fts/rebuild` + (заглушка {ok,ready}), демо `POST /api/demo/simulate-lead|age-lead` (флаг `DEAL_DEMO`), + `POST /api/ai/suggest-columns|keywords`, boot-заглушки `GET /api/projects` и `GET /api/tg/status`; +- SSE-события: `new_lead` (карточка) и `toast` (текст+иконка) — публикуют только эндпоинты; + потоки per-tenant (SseBroker), ping каждые 15 с; +- демо-режим: `DEAL_DEMO=1` (Development включает и без env) — simulate из демо-пула 1:1 с прототипом, + age-lead состаривает карточку досок и тикает автоархив; без флага — 404 «Демо-режим отключён»; +- ML-контракт `IMlClient.PushAsync` + локальная детерминированная реализация `LocalMlClient` + (MlOutbox/learning; реальный сервис — этап 6); +- **410 unit-тестов PASS**; сквозная приёмка этапа — curl-сценарий на :5080 + psql (Task 15: + PASS=94 FAIL=0: boot-группы, демо-карточки ×14 + SSE new_lead/toast, доски/правила/matchHits, + move/trash/restore/комментарий, mark-col-seen, поиск, suggest-columns/keywords, age-lead + автоархив + фоновым циклом, admin/tick, пересчёт конверсий 9250 RUB / 100 USD / 92.59 EUR). + +**Выполнено на этапе 4 (2026-09-06) — модуль Pipeline (вкладка «Обработка»), см. §13.4d:** + +- миграция `TenantPipeline` — таблицы схемы тенанта `QueueItems` (очередь `p_`, статус new/filtered), + `RejectedItems` (отсев `r__`, аудит возврата returned/returnReason) и `DedupEntries` + (нормализованный SHA1-хэш, мягкая ссылка `LeadId` на карточку, чистится при жёстком удалении); + в той же миграции — FTS-колонки `Cards.SearchTsv`/`RejectedItems.SearchTsv` (russian tsvector STORED + GIN); +- модуль `Deal.Modules.Pipeline` (чистый, без EF/HTTP): ядро разбора `MessageTextCleaner`/ + `MessageListNormalizer`/`ContactsQualifier`/`DedupHasher`/`SummaryComposer`/`LocalFieldsParser` + (1:1 pipeline.py), приём `PipelineIngestService` (гвард dialog+msgId), обработка/возврат/очистки + `PipelineProcessingService`, pump `PipelineWorkerService.PumpOnceAsync` 1:1 с `_pump_unlocked` + (устарело → правила → дедуп → ML → ИИ → карточка; счётчики wire 1:1), `CardComposer` + + `PipelineCardWriter` (карточка через публичный `IKanjStore.AddCardAsync` + связь дедупа); +- порт `IAiClassifier` + детерминированный `LocalAiClassifier` (до реального ai-service этапа 6), + ML-слой — существующий `IMlClient` (локальная модель не готова — все сообщения к ИИ-ветке); +- эндпоинты: `GET /api/pipeline/stats|queue|rejected` (+`q` FTS ∪ LIKE), `POST /rejected/clear`, + `DELETE /rejected/{id}`, `POST /rejected/{id}/return` (400 dup/повтор/нет текста; снятие веса + «спама» у ML), демо `POST /api/demo/ingest` (флаг `DEAL_DEMO`), реальные `POST /api/admin/tick` + (storage+purgedRejected+pipeline+queue+SSE new_lead/тосты) и `POST /api/admin/fts/rebuild`; +- фоновые циклы: `PipelineWorkerScheduler` (pump 2 с, общий гейт с ручным тиком) и автоочистка отсева + (3 суток) в `StorageTickScheduler` (30 с) + SSE-тост «Отсев очищен: N записей (3 дн.)»; +- FTS-поиск карточек `GET /api/search?q=` (tsvector + LIKE, ts_rank, лимит 12, `messages:[]`); +- **535 unit-тестов PASS**; build 0 warnings / 0 errors; сквозная приёмка этапа — curl-сценарии на + :5080 + psql (Task 13 — финал: PASS=74 FAIL=0: нули при старте, ingest → карточка с полями §4.1, + отсевы правил/dup/нет суммы/устарело на реальных записях, очередь, stats, psql-строки и dedup-связь, + поиск отсева FTS (морфология «работой») и LIKE (имя канала), return dup → 400, return → очередь → + карточка, повторный return → 400, DELETE, clear, поиск карточек, fts/rebuild, удаление карточки + чистит DedupEntries, purge 3 дн. → SSE-тост, logout → 401). + +**Выполнено на этапе 5 (2026-09-07) — модуль Projects («Выбранные»), см. §13.4e:** + +> Историческое состояние: с этапа 9 модуль `Deal.Modules.Projects` и таблица `ProjectCards` упразднены +> (сервисы «Выбранных» перешли в `Deal.Modules.Kanban`/`CardsService.Selected`, данные — в `Cards`); +> ниже — как было на этапе 5. + +- миграция `TenantProjects` — таблица схемы тенанта `ProjectCards` (PascalCase; partial UNIQUE + `IX_ProjectCards_LeadId` по `LeadId` — «лид можно взять в работу один раз»); владелец — чистый модуль + `Deal.Modules.Projects` (без EF/HTTP; зависимости — Contracts/Settings/Kanban-порты, реверса нет); +- стадии `ProjectStages` 1:1 с PIPELINE_STAGES (planned → reply → work → hold → ready, терминальные + finished/rejected), DTO карточки §4.3; история движения — в `HistoryJson` (создание `created`/ + `createdLocal` + каждая смена стадии, Ruling 7), комментарии/ссылки/файлы — JSON-поля карточки; +- сервисы: `ProjectsService` (список/чтение/ручное создание/take/патч presence-aware (`budget:null`)/ + move+история/clear-rejected/комментарии/ссылки), `ProjectFilesService` (детект типа `FileKindDetector` + MIME+расширение → порт `IFileStorage` → мета в карточку), `ProjectReminderService` (set/clear/snooze/ + фоновая проверка due); порт `IFileStorage` + адаптеры `LocalFileStorage` (дефолт: `data/attachments` под + ContentRoot) и `MinioFileStorage` (секция `Storage:Minio`/env `DEAL_MINIO_*`, compose-сервис + `deal-minio` :9000/:9001, бакет `deal-files` лениво); +- эндпоинты: 16 шт. `/api/projects*` — список/создание/take/clear-rejected/GET/PATCH/move/comments/links + (add/remove)/files (upload/download/delete)/reminder (set/clear/snooze); boot-заглушка `GET /api/projects` + **снята** (остался `/api/tg/status` — этап 6); `GET /api/projects/reminders` и `DELETE /api/projects/{id}` + сознательно не реализованы (Ruling 9); +- напоминания: настройка `remindersEnabled` (дефолт true; выключено → set 400); фоновый + `StorageTickScheduler` (30 с) и ручной `POST /api/admin/tick` (`reminders:[{id,title,stage}]`) помечают + due-строки hold `ReminderFired=true` и публикуют SSE `reminder_due {id,title,stage}` (публикации — только + Api, Ruling 8); move с hold снимает напоминание; snooze = +24 ч; +- **620 unit-тестов PASS**; build 0 warnings / 0 errors; сквозная приёмка этапа — curl-сценарии на :5080 + + psql (Task 13 — финал: PASS=75 FAIL=0: take-семантика (col=taken/is_new=false, исчезновение из /leads и + /api/search, идемпотентность, partial-UNIQUE дубля), PATCH полей и `budget:null`, move по стадиям с + историей, комментарии/ссылки, напоминание hold → SSE `reminder_due` фоновым циклом БЕЗ ручного tick + + psql ReminderFired, файлы upload/download(байты)/delete + объекты на диске, clear-rejected, сортировка + UpdatedAt DESC, logout → 401). + +**Выполнено на этапе 6 (2026-09-07) — сервисы telegram/ai/ml + Discovery + каналы, см. §13.7:** + +- контракты `src/contracts/*.proto` (общий проект `Deal.Proto`, Grpc.Tools); каждый RPC — metadata + `tenant-id`+`service-token`, интерцепторы fail-closed (health освобождён); dev-безопасность — общий + service-token без mTLS (Ruling 2); mTLS и prod-compose — этап 7; +- три автономных процесса в `src/{telegram,ml,ai}-service` (свои sln, net10.0): telegram-service (:5101, + ферма сессий 1 акк/тенант, AES-256-GCM-файлы `/data/sessions`, фазы idle/code/password/qr/ready, + диалоги/мониторинг/backfill с анти-бан-паузами, канал в core `SERVICES__CORE__INGRESS`), ai-service + (:5102, LLM-фасад OpenAI-совместимых+Anthropic без БД: Filter/Classify/GenerateKeywords/EvaluateFit, + usage-токенов), ml-service (:5103, инкрементальный наивный Байес 1:1 `mlservice/model.py`, SQLite на + тенанта `/data/ml/.sqlite`, пул per-tenant); +- core: gRPC-ингресс telegram :5082 (`PushMessage`→очередь/превью, `SyncDialogs`, `ReportStatus`→SSE), + модуль Telegram (Dialogs/TgMessages, `ITelegramGateway`+GrpcTelegramClient за флагом), эндпоинты /api/tg + (14 шт., реальный статус вместо boot-заглушки, QR-SVG), GrpcAiClassifier/GrpcAiTools (контекст/промпты/ + маппер/usage), GrpcMlClient + MlOutboxFlushScheduler (10 с, TrainBatch 10/≤100), модуль Discovery + (DiscTasks/Candidates/Blacklist/Log, план-бюджет, воркер 5 с с каскадом оценки и авто-join под бан-гардом, + эндпоинты /api/discovery 13 шт., generate-keywords); +- флаги `Services:{Ml,Ai,Telegram}:UseLocal` — код-дефолт Local (true), compose.dev.yml задаёт false + (полный стек «по-настоящему»); сервисы ходят в core-ингресс через `SERVICES__CORE__INGRESS`; +- **830 unit-тестов PASS** (Deal.Tests.Unit), 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-тесты + (PushMessage→карточка, флашер, ai-фильтр/классификация/инструменты). + +**Выполнено на этапе 7 (2026-09-08) — SaaS-контур, Tasks 1–16 (финал), см. §13.8/§13.9:** + +- оператор/сессии (`public.Operators/OperatorSessions`, кука `deal_operator_session`, bootstrap env + DEAL_OPERATOR_*; dev-only дефолт operator/operator) + ручки `/api/operator/*` (auth/tenants/invites/ + limits/audit/health — API-only); инвайты и активация `POST /api/join`; лимиты ИИ-бюджета + (`tenant_limits`, декораторы-гейт, SSE-тосты 80/100%) с дефолт-бюджетом; append-only аудит-поток; + rate limiting (приложение + интерцептор gRPC-ингресса, `LoginAttemptGuard`); Origin-проверка мутаций и + security-заголовки; mTLS за флагом DEAL_MTLS_* (сертификаты scripts/mtls-certs.sh); Serilog JSON во всех + 4 процессах (консоль + rolling-файл data/logs, access-логи HTTP/gRPC); compose.prod (caddy, mTLS-env, + профиль observability: promtail/loki 7 сут./grafana 127.0.0.1:3001) + .env.prod.example. +- **Бэкапы (Ruling 8, Task 15)** — `scripts/backup.sh` (pg_dump -Fc БД deal: docker exec deal-postgres + или прямой pg_dump при DEAL_PG_HOST; mc mirror бакета MinIO `deal-files` — хостовый mc или разовый + контейнер minio/mc; tar файловых данных DEAL_TAR_DIRS: attachments/telegram_sessions/ml — либо + docker-volume'ы через DEAL_TAR_VOLUMES; retention 14 дней по дате в имени; лог + trap-очистка) и + `scripts/restore.sh` (dropdb+createdb → pg_restore, обратный mc mirror, распаковка архивов). + Команды/порядок/cron-пример «0 2 * * *» — §13.9. Реальный прогон и restore-тест — ⚠ Manual (нужен docker-стек). +- **Финальный прогон (Task 16)**: 1123 unit-теста PASS в core (Deal.Tests.Unit), telegram 114/114, + ai 50/50, ml 36/36 PASS; build 0 warnings / 0 errors всех четырёх sln; `docker compose + -f deploy/compose.prod.yml config` rc=0 (+ профиль observability); `sh -n` dev-smoke/backup/restore/ + mtls-certs rc=0. Живые приёмки (curl-сценарий SaaS, подъём стека, бэкап/restore, mTLS, реальные + сервисы) — ⚠ Manual, чек-лист в task-16-report.md. + +**Выполнено на этапе 9 (2026-09-10) — «единая карточка» (см. `docs/architecture/2026-09-09-unified-card.md`, `docs/architecture/2026-09-10-unified-api-contract.md`):** + +- **Модель**: карточка — один агрегат во всех дашбордах. Ядро (`ICard`: id/title/source) + опциональные + модули-роли (`IContentCard`/`IBudgetedCard`/`IContactCard`/`IAttributedCard`/`ICommentableCard`/ + `ILinkCard`/`IFileCard`/`ITzCard`/`ITraceableCard`/`IRemindableCard`/`ILocatedCard`); источник — + иерархия `ISource` (`ITelegramSource`/`IRowSource`/`IApiSource`/`IAiSource`/`ICompositeSource` и простые); + единый переход `ICardMover`. Вид карточки — композиция модулей, а не класс-наследник (`Deal.Modules.Cards`). +- **БД**: одна таблица `Cards` — `ProjectCards` упразднена; единый реестр `Containers` вместо таблицы + `Boards` и колонок-строк. Модульные данные — колонки той же строки (`StackJson`/`LinksJson`/`FilesJson`/ + `HistoryJson`/`TzText`/`ReminderAt`), комментарии — `LeadComments`; `CardMoves`, `MlOutbox`, + `DedupEntries`, `QueueItems`, `RejectedItems` — без изменений. Полнотекстовые `SearchTsv` — у `Cards` и `Containers`. +- **Контейнеры**: поля `space` (`dashboard`/`selected`), `kind` (`board`/`stage`/`service`/`terminal`), + `rules`, `policy`, `counts`. Стадии «Выбранных» — контейнеры `kind=stage/terminal` каталога + `CardsDefaultContainers` (`planned`…`finished`/`rejected`); служебные зоны — `inbox`/`archive`/`trash`. + Карточка живёт в одном пространстве; «взять в работу» — перенос карточки в `planned`, а не клон. +- **API**: единый контракт `/api/cards` + `/api/containers`; ручки `/api/leads`, `/api/projects`, + `/api/boards`, `/api/columns` удалены; SSE `new_card` вместо `new_lead`. Единый префикс id — `c_`. + Полная карта — `docs/api/api-map.md`. +- **Фронт**: один слайс карточек (`src/frontend/src/store/cards.js`) и единый канбан для дашборда и + «Выбранных» (пространство определяется контейнером карточки). + +Остаётся TODO (после этапа 7, Tasks 1–16): + +- Живые проверки (⚠ Manual, нужен docker/креды): применение system-миграции `SystemSaaS` и сквозная + SaaS-curl-приёмка (оператор → тенант → инвайт → /api/join → лимиты/гейт → аудит → suspend → resume → + IDOR-негативы); подъём compose.prod.yml и dev-smoke `sh scripts/dev-smoke.sh`; mTLS-рукопожатие + контейнеров; реальные Telegram/LLM-вызовы (с кредами); прогон `scripts/backup.sh` и restore-тест + (`scripts/restore.sh`). +- Заделы (сознательно вне этапа 7; часть закрыта этапами 8–12): UI операторской админки и страницы активации + инвайта (сейчас API-only); OTel-метрики/Prometheus и дашборды метрик (закрыто этапом 12, пакет A — §7); + multi-instance rate-limit и бэкенд попыток входа (закрыто этапом 12 — `public.rate_limit_counters`); + мгновенный разлогин suspended-сессий (закрыто этапом 12); реклассификация «Неразобранного» на реальном + ИИ (закрыто этапом 12 — reclassify с локальным фолбэком); purge-автоматика audit_log и auto-purge + истории tenant_limits (закрыто этапом 12 — `DataRetentionScheduler`); экспорт/импорт ML-моделей; + мультиаккаунтность Telegram на тенанта; саморегистрация/биллинг-провайдер/планы; k8s/Cloudflare-конфигурация. +- Карта `/api` — `docs/api/api-map.md` + контракты `docs/architecture/2026-09-10-unified-api-contract.md` + и `docs/architecture/2026-09-10-operator-analytics-contract.md` (актуальны на этап 12). +- Пакетная миграция схем тенантов (сотни/тысячи) — реализована на этапе 12 (`POST + /api/operator/maintenance/tenants/migrate`, §13.10/§16; см. также §4/§7); с BL-SCALE-1000 (2026-09-11) + обход шардирован страницами (`ITenantRepository.ListPageAsync`, `DefaultPageSize=200`) с параллелизмом + внутри страницы и изоляцией сбоев. +- Kafka — отложена. + +--- + +## 12. Глоссарий + +См. дизайн-док (§Приложение). Дополнительно: +- **search_path** — механизм Postgres выбора текущей схемы. +- **outbox** — таблица событий в той же транзакции, что и бизнес-изменение. +- **карточка (card)** — единая сущность всех дашбордов (ядро + модули); id с префиксом `c_`. +- **контейнер (container)** — колонка/стадия/зона единого реестра; `space` + `kind` + `rules`/`policy`. +- **пространство (space)** — `dashboard` или `selected`; карточка живёт ровно в одном. + +--- + +## 13. Быстрый старт (dev; актуально для этапов 0–12 — финальное состояние) + +> Для этапов 0–7 ниже приведены исторические списки эндпоинтов (в т.ч. `/api/leads`, `/api/projects`, +> `/api/boards`). С этапа 9 (2026-09-10) актуальны единые `/api/cards` и `/api/containers` — см. +> `docs/api/api-map.md` и `docs/architecture/2026-09-10-unified-api-contract.md`. + +Проверенный путь (2026-09-07, Windows + sh, .NET 10, Postgres 16 в Docker): системный контекст +(`public`), контекст тенанта (схема с `settings` + таблицами канбана, пайплайна и «Выбранных»), auth `/api/auth`, настройки +тенанта (Settings-модуль этапа 2), канбан этапа 3 (`/api/boards`, `/api/leads`, `/api/events` SSE, +демо `/api/demo/*`), пайплайн этапа 4 (вкладка «Обработка» `/api/pipeline/*`, демо-ingest, воркер 2 с, +FTS `/api/search` + `/api/pipeline/rejected?q=`, реальные `/api/admin/tick` и `/api/admin/fts/rebuild`), +«Выбранные» этапа 5 (вкладка Projects: `/api/projects` — стадии/напоминания/файлы/ссылки/история, файлы +через порт `IFileStorage` — Local `data/attachments` по умолчанию или MinIO `deal-minio` при конфигурации, +SSE `reminder_due` фоновым 30-с циклом), +провижининг схем и bootstrap дефолтного тенанта с admin при старте API. Логин/пароль по умолчанию — +`admin`/`admin` (env `DEAL_BOOTSTRAP_LOGIN`/`DEAL_BOOTSTRAP_PASSWORD`). + +Разделы 1–6 ниже — «классический» host-путь этапов 1–5: core запускается с хоста на Local-заглушках +(код-дефолт `Services:*:UseLocal=true`), сервисы этапа 6 не нужны. Полный dev-стек этапа 6 (три сервиса + +core в docker, сквозной gRPC-режим) — §13.7. + +### 1. Postgres + +```sh +# хранилища для host-режима (core с хоста); весь стек (сервисы этапа 6 + core) — §13.7 +docker compose -f deploy/compose.dev.yml up -d postgres minio +``` + +Полный стек поднимается той же командой без аргументов (`... up -d --build`): postgres + minio + +telegram/ai/ml-сервисы + core в сквозном gRPC-режиме (`Services__*__UseLocal=false` заданы в compose), см. §13.7. + +Контейнер `deal-postgres`: наружный порт **5433**, БД `deal`, пользователь `deal` +(пароль `deal_dev_password`). Тот же compose-файл поднимает **`deal-minio`** (MinIO для вложений этапа 5): +порты **9000** (S3 API) / **9001** (консоль), бакет `deal-files` создаётся лениво при первом upload. +Dev-режим по умолчанию работает БЕЗ MinIO — `LocalFileStorage` (каталог `data/attachments` под ContentRoot +Deal.Api); MinIO-режим включается секцией `Storage:Minio` или env-алиасами `DEAL_MINIO_ENDPOINT`/ +`DEAL_MINIO_ACCESS_KEY`/`DEAL_MINIO_SECRET_KEY`/`DEAL_MINIO_BUCKET`/`DEAL_MINIO_SECURE` (см. §4e). + +### 2. Системные миграции (`public`) + +Из `src/core`: + +```sh +dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext +``` + +Применяет `InitialSystem` — публичные таблицы `tenants`, `users`, `sessions` +(история — `public.__EFMigrationsHistory`). Строка подключения — `ConnectionStrings:DealPostgres` +(`Deal.Api/appsettings.Development.json`; перекрывается env `ConnectionStrings__DealPostgres`). + +### 3. Запуск API + +Из `src/core`: + +```sh +dotnet run --project Deal.Api --urls http://localhost:5080 +``` + +При старте `TenantBootstrapService` (идемпотентно) создаёт дефолтного тенанта +`00000000-0000-0000-0000-000000000001` (имя `Default`) с его схемой +`tenant_00000000000000000000000000000001` и таблицей `settings` (миграция `InitialTenant`), а также +пользователя `admin` — логин/пароль из env `DEAL_BOOTSTRAP_LOGIN` / `DEAL_BOOTSTRAP_PASSWORD`, +по умолчанию `admin` / `admin`. Схемы провижинируются для всех тенантов реестра; повторные старты +дублей не создают. + +### 4. Проверка auth + +```sh +curl -i -X POST http://localhost:5080/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"login":"admin","password":"admin"}' +``` + +→ `{"ok":true,"login":"admin"}` (HTTP 200) и httpOnly-кука `deal_session` (SameSite=Lax, **30 дней**; +срок — константа `AuthService.SessionLifetimeDays`, перекрывается `Cookies__Days`). + +Прочие эндпоинты: `GET /api/auth/me`, `POST /api/auth/logout`, `POST /api/auth/change-password`; +health — `GET /api/health` → `{"ok":true,"service":"deal"}`. + +### 4a. Шифрование секретов настроек (ключи AI/Telegram) + +Секреты (`aiConfigs[].apiKey`) хранятся в `settings.ValueJson` шифротекстом: +`enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б). Ключ шифрования — env +`DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev берётся/создаётся файл +`/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`) — +при генерации лог-warning. Невалидный env-ключ — ошибка при старте. Наружу секреты не отдаются: +в GET/PATCH `/api/settings` только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть) +и `keySet`. + +> Исторический раздел (этап 2). С этапа 12 ключей Telegram (`tgKeys`/`apiId`/`apiHash`) в настройках +> тенанта нет — они задаются **оператором** глобально (таблица `public.global_settings`, +> `GET/PUT /api/operator/settings/telegram-keys`; hash шифруется тем же AES-256-GCM). + +### 4b. Эндпоинты этапа 2 (настройки тенанта; сессия `deal_session` обязательна, иначе 401) + +- `GET /api/settings` — публичный снимок дерева настроек: дефолты модуля, перекрытые + переопределениями из `settings` тенанта; включает списки `providers`/`aiConfigs`/`tgKeys`/`myPrompts`. + `PATCH /api/settings` — частичное обновление (невалидное поле мягко пропускается, ответ — полный + снимок). Побочные эффекты: при `rateSource` — фоновый refresh курсов. Внутренние ключи + (`ratesCache`, `mlDecisions`, `aiDecisions`) в GET/PATCH не участвуют. +- `POST /api/ai/check` — проверка подключения активного провайдера (`aiProvider` + `aiConfigs`, ключ + расшифровывается): локальный провайдер → `ok:true` «Локальный сервер…»; облачный — HTTP `GET + {base}/models`; без ключа → «Не задан API-ключ». +- `GET /api/rates` / `POST /api/rates/refresh` — курсы к RUB (`base` = `RUB`); источник по `rateSource` + (`mock` — константа, `cbr` — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; `USDT`=`USD`); кэш — внутренняя + настройка `ratesCache` `{rates, source, updatedAtMs}`. +- `GET /api/ml/status`, `POST /api/ml/reset|predict` — ML-панель на детерминированной заглушке + `LocalMlClient` (этап 6 заменит на gRPC без правки эндпоинтов): `ready:false`, predict неготовой + модели — «не уверен», `candidates` → `{items:[]}`, `apply` → 404 (telegram-данных нет до этапа 6). +- `POST /api/admin/check-message` — тестер фильтров входящих: `{stage1:{pass,reason}, stage2:{pass, + reason, skipped}, passed}`; этап-1 правила из настроек (длина/стоп-фразы/резюме/тип); ИИ-фильтр + тестера на этапе 2 всегда `skipped:true`. + + > **Актуально с 2026-09-11:** тестер стал сухим прогоном по всему конвейеру + > (`PipelineWorkerService.DryRunAsync`): `{passed, wouldCreateCard, targetContainer, matchHits, parsed, + > stages[]}` — стоп-правила → глобальные исключения → ML (спам/тип) → ИИ-фильтр/классификация → «без + > суммы»; без записи в систему. UI — вкладка настроек «Стоп-слова». + +### 4c. Эндпоинты этапа 3 (канбан/дашборд; сессия `deal_session` обязательна, иначе 401) + +> На этапе 4 `POST /api/admin/tick` стал реальным (pipeline/pump/purge-отсева) и +> `POST /api/admin/fts/rebuild` — реальным `{ok, ready}` (см. §4d); описание ниже — состояние этапа 3. +> +> На этапе 5 `GET /api/projects` — реальный список «Выбранных» (см. §4e); boot-заглушка `/projects` +> снята, из boot-заглушек остался только `GET /api/tg/status` (telegram — этап 6). + +- Доски: `GET/POST /api/boards` (голый массив / создание), `PATCH /api/boards/{id}` (name/width/ + collapsed/keywords/rules/suggested…), `POST /api/boards/reorder`, `DELETE /api/boards/{id}` + (карточки → inbox). Правила колонки — `{mode: all|any, direction[], keywords[], stack[], grade[], + exclude[], budget}`; совпавшие термины попадают в `matchHits` карточки (1:1 с rules.py). +- Карточки: `GET /api/leads?col=inbox||archive|trash` (свежие сверху, полный §4.1), + `GET /api/leads/counts` (плоская форма `{new, learning, ml, ai}` + per-column `{count, new}`), + `GET /api/leads/{id}`, `POST /leads/{id}/move|trash|restore`, `DELETE /api/leads/{id}`, + `POST /api/leads/clear-col` (trash|archive), `mark-col-seen|mark-all-seen`, `POST /leads/{id}/comments`. + Поиск: `GET /api/search?q=` (lower-LIKE по title/summary/contact/source_msg → `{leads, messages:[]}`). +- Служебные: `POST /api/admin/tick` → `{storage:{archived,purgedArchive,purgedTrash,purgedRejected}, + reminders:[], pipeline:{}, queue:0}` (+SSE-тосты статистики); `POST /api/admin/fts/rebuild` — + заглушка `{ok:true, ready:true}` (FTS-индекс — этап 4). +- Boot-заглушки фронта: `GET /api/tg/status` → idle-форма §4.9 (telegram — этап 6; заглушка + `GET /api/projects` → `{items:[]}` снята на этапе 5 — реальный список см. §4e). +- SSE: `GET /api/events` — text/event-stream канала тенанта; события `new_lead` (полная карточка, + после simulate) и `toast` `{text, icon}` (демо-лид sparkles, автоархив/тик clock, ИИ-предложения + sparkles); ping `: ping` каждые 15 с. Публикуют только эндпоинты Api (модуль чист). +- Демо-режим (флаг `DEAL_DEMO=1`; Development включает и без env): `POST /api/demo/simulate-lead` + (карточка из демо-пула 1:1 с прототипом → inbox + SSE new_lead/toast), `POST /api/demo/age-lead` + (состаривание самой старой карточки досок + тик автоархива + SSE-toast). Без флага — 404 + «Демо-режим отключён». +- ИИ-предложения (эвристика этапа 3, реальный ИИ — этап 6): `POST /api/ai/suggest-columns` + (накопите ≥6 карточек в «Неразобранном» → доски `suggested:true` с note «Эвристика (этап 3):…» и + раскладкой карточек; повторный вызов — cooldown 20 мин `lastSuggestAt`), `POST /api/ai/suggest-keywords` + (частотные маркеры по текстам). +- Конверсии (Ruling 7): курсы — `POST /api/rates/refresh` (mock/ЦБ), кэш `ratesCache` в settings; + пересчёт `ConvFrom/ConvTo/ConvCur` активных карточек (не archive/trash/taken) выполняется + синхронно по listener'ам: после refresh курсов и при `PATCH /api/settings {targetCurrency,…}`. + +### 4d. Эндпоинты этапа 4 (pipeline/вкладка «Обработка»; сессия обязательна, иначе 401) + +Таблицы этапа — миграция `TenantPipeline` в схеме тенанта (владелец — модуль `Deal.Modules.Pipeline`): +`QueueItems` (очередь, id `p_…`, статус `new`|`filtered`), `RejectedItems` (отсев, детерминированный id +`r__` либо `r_`+hex; колонка `SearchTsv` — `to_tsvector('russian', text)` STORED + GIN) и +`DedupEntries` (SHA1-хэш нормализованного текста, PK; `LeadId` — мягкая ссылка на `Cards`, чистится при +жёстком удалении карточки). В той же миграции — FTS-колонка `Cards.SearchTsv` (Title+Summary+SourceMsg+ +Contact) + GIN-индекс; tsvector-колонки авто-актуальны (перестроение не требуется). + +- Приём сообщений (этап 4 — только демо; этап 6 — gRPC telegram-service): `POST /api/demo/ingest` + `{text, dialogId?, channelName?, channelHandle?, channelHue?, msgId?, msgAt?}` (флаг `DEAL_DEMO=1`, иначе + 404 «Демо-режим отключён») → `{ok, id:p_…, queue:{new,ai,total}}`; пустой текст — 400 «Текст сообщения + пуст»; повтор `dialogId+msgId` уже в очереди — `id:null` (гвард Telethon-дублей); нет dialogId — no-op. +- Разбор очереди: фоновый `PipelineWorkerScheduler` каждые **2 с** (per-tenant pump, общий гейт с ручным + тиком) и `POST /api/admin/tick`. Конвейер 1:1 с прототипом: «устарело» (msgAt старше `archiveAfterDays` + при `autoArchive`) → правила этапа-1 (`IncomingRules`: длина/стоп-фразы/резюме/тип) → дедуп по тексту → + ML-слот (`IMlClient`, локальная модель не готова — «не уверен») → ИИ-слот (`LocalAiClassifier` до этапа 6; + `aiEnabled=false` — локальный разбор) → карточка/отсев. Счётчики решений pump — в ответе тика и KV + `mlDecisions`/`aiDecisions`. +- Карточка из сообщения (CardComposer, через публичный `IKanjStore.AddCardAsync`): title, блок «О заявке» + (summary), stack ≤12, бюджет (нормализованный + конверсия в целевую валюту при поступлении), контакты + (квалификация, ≤6, primary), поля канала `ch`, `sourceMsg`/`sourceDialogId`/`sourceMsgId`; колонка — + inbox либо доска по `BoardAccepts`+правилам; после успешной классификации карточка получает + `isVacancy`/`isVacancyKnown`; создание карточки связывает хэш в `DedupEntries` с её id. + +> **Актуально с 2026-09-11: единый контракт источника.** Карточка, очередь и отсев работают с generic-типом +> `SourceItem` (`SourceRef` + `SourceContent`). В таблицах `Cards`/`QueueItems`/`RejectedItems` вместо +> Telegram-колонок (`ChannelName`/`SourceMsg`/`SourceMsgId`…) хранятся `SourceKind`/`SourceExternalId`/ +> `SourceOriginRef`/`SourceJson`/`ContentJson` (карточка — ещё `SourceText` и FTS по нему; очередь/отсев — +> `SourceKey` для дедупа). Вложение — `DataRef` (ссылка на общий Storage-сервис); контакты/ссылки — поля +> `SourceContent`. Telegram-поля остались только в тонком адаптере приёма telegram-сервиса. Tenant-миграции +> пересозданы с нуля (данных нет). Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`. +- Отсев — источник решения `stop|ml|ai|stale|dup` (подписи «правила/ML/ИИ/система») и этап + `length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup` (подписи UI: «короткое сообщение», + «стоп-фраза», «резюме соискателя», «нет суммы», «устарело», «повтор»…), причина ≤500, kw — сработавшая + фраза. Возврат (`force`) повторно проводит сообщение мимо правил/устарелости/ИИ-фильтра и снимает у ML + вес «спама» для spam-отсева. +- Вкладка «Обработка» (фронт на поллинге; SSE `pipeline_stats` не публикуем — Ruling 9): + `GET /api/pipeline/stats` → `{queue:{new,ai,total}, rejected}`; `GET /api/pipeline/queue?limit=` + (≤500, дефолт 100) → `{items, counts:{new,ai,total}, rejected}`; `GET /api/pipeline/rejected?q=&offset=&limit=` + → `{items,total,offset,limit}`; `q` — FTS-кандидаты (`SearchTsv @@ plainto_tsquery('russian')`, ts_rank) ∪ + LIKE-дополнение по `lower(text)/reason/kw/ch_name` (страница из объединения, offset/limit ≤500). +- Возврат и очистки: `POST /api/pipeline/rejected/{rejId}/return {reason}` → `{id, returned:true, returnedAt}`; + 404 «Запись не найдена»; 400 «Сообщение уже возвращено в обработку» / «Повтор: карточка с таким текстом + уже есть в системе — возвращать нечего» (источник dup) / «В записи нет текста сообщения». `DELETE + /api/pipeline/rejected/{rejId}` → `{ok:true}` (404 не шлём); `POST /api/pipeline/rejected/clear` → + `{ok, cleared}`. Автоочистка отсева — **3 суток** (`RejectedAt`), выполняется в тике правил хранения + (ручной tick и фоновый `StorageTickScheduler` 30 с), при ненулевой очистке — SSE-тост + «Отсев очищен: N записей (3 дн.)». +- `POST /api/admin/tick` (этап 4): ответ `{storage:{archived, purgedArchive, purgedTrash, purgedRejected}, + reminders:[], pipeline:{staged, rulesStored, mlStored, mlDrop, typeDrop, aiStored, aiDrop, aiFail, + noBudget}, queue}`; после pump — SSE `new_lead` по созданным карточкам и тосты статистики. `POST + /api/admin/fts/rebuild` → `{ok:true, ready:true}` (идемпотентно: `CREATE INDEX IF NOT EXISTS` + `ANALYZE` + `Cards`/`RejectedItems`). +- Поиск карточек `GET /api/search?q=` (q ≥ 2): один SQL — `SearchTsv @@ plainto_tsquery('russian', q)` OR + `lower(title/summary/source_msg/contact) LIKE '%q%'`, порядок `ts_rank DESC, ReceivedAt DESC`, лимит 12; + ответ `{leads, messages:[]}` — русская морфология (например, q=работа находит «работой»). + +### 4e. Эндпоинты этапа 5 (Projects/«Выбранные»; сессия `deal_session` обязательна, иначе 401) + +> Исторический раздел (как было на этапе 5). С этапа 9 все перечисленные операции живут под +> `/api/cards*`, модуль `Deal.Modules.Projects` и таблица `ProjectCards` упразднены — актуальный контракт +> см. §3.5/§5 и `docs/api/api-map.md`. + +Таблица этапа — миграция `TenantProjects` в схеме тенанта (владелец — чистый модуль +`Deal.Modules.Projects`): `ProjectCards` — 1:1 с таблицей `projects` прототипа. Колонки (PascalCase): +`Id` (`pr_…`), `Stage` (каталог `ProjectStages` 1:1 с PIPELINE_STAGES: planned → reply → work → hold → +ready, терминальные finished/rejected), `Local`, `LeadId` (**partial UNIQUE** `IX_ProjectCards_LeadId` — +лид может быть взят в работу ровно один раз), `Title`, `Summary`, `StackJson`, `BudgetFrom`/`BudgetTo`/ +`BudgetCur`, `Contact`, `TzText`, JSON-поля `CommentsJson`/`LinksJson`/`FilesJson`/`HistoryJson` +(история — только создание `created`/`createdLocal` и смены стадии, Ruling 7) и напоминание +`ReminderAt` (timestamptz)/`ReminderFired`; времена наружу — epoch-ms, список — `UpdatedAt DESC`. + +- Взять в работу: `POST /api/projects/take {leadId}` → проектная карточка `local=false`, `stage=planned`, + `leadId`+`title/summary/stack/budget/contact` скопированы из лида, комментарий «Взял в работу из лида.», + история `created`. Лид помечается `col='taken', is_new=false` через публичный порт Kanban + (`IKanjStore.MarkTakenAsync`) — исчезает из `/api/leads` и `/api/search`, не попадает в архив/корзину + тика; повторный `take` идемпотентен (возвращает ту же карточку), partial-UNIQUE `LeadId` страхует гонки. +- Карточки: `GET /api/projects` (`?stage=` фильтр) → `{items:[…]}` (UpdatedAt DESC), `POST /api/projects` + (ручное создание `{title,…,stage?}`; стадия — каталог, по умолчанию planned; `local=true`, + `createdLocal`), `GET/PATCH /api/projects/{cardId}` (PATCH presence-aware: `budget:null`/`stack:null` + очищают поле; 404 «Карточка не найдена»), `POST /api/projects/{cardId}/move {stage}` (валидация + каталогом: 400 «Неизвестная стадия»; смена стадии дописывает историю `{id h_, at, stage}` и сбрасывает + напоминание), `POST /api/projects/clear-rejected` → `{ok, cleared}` (единственный hard-delete — стадия + «Отклонено»). +- Комментарии и ссылки: `POST /{cardId}/comments {text}` (400 «Пустой комментарий»; ответ `{comments}`), + `POST /{cardId}/links {url,name?}` (схема добавляется: example.com → https://example.com; name = url по + умолчанию), `DELETE /{cardId}/links/{linkId}`. Значки-счётчики — из массивов карточки §4.3. +- Напоминания «Отложено»: `POST/DELETE /{cardId}/reminder {at}` (прошлое допустимо — «выстрелит» на + ближайшей проверке; включённость — настройка `remindersEnabled`, дефолт true; при выключенной set → 400 + «Напоминания об отложенных выключены в настройках») и `POST /{cardId}/reminder/snooze` (now + 24 ч). + Срабатывание: фоновый `StorageTickScheduler` каждые **30 с** (и ручной `POST /api/admin/tick` → + `reminders:[{id,title,stage}]`) вызывает `ProjectReminderService.CheckDueAsync` — due-строки + `stage='hold'` помечаются `ReminderFired=true` и публикуются SSE-событием `reminder_due {id,title,stage}` + в канал тенанта (баннер фронта; публикации — только из Api, Ruling 8). Любой move с hold снимает + напоминание (`ReminderAt`/`ReminderFired` очищаются). +- Файлы: `POST /{cardId}/files` (multipart, поле `files`, 1–N) → `{items:[{id pf_, name, size, kind, label, + objectKey}]}`; тип — `FileKindDetector` по MIME+расширению (`document`/«Документ», `image`/«Изображение» + и т.д.); объект кладётся через порт `IFileStorage` (Contracts/Integrations): `LocalFileStorage` + (дефолт, корень `data/attachments`, key → путь `projects//_`) или `MinioFileStorage` + (включается секцией `Storage:Minio`/env `DEAL_MINIO_*`; бакет `deal-files` создаётся лениво; compose - + сервис `deal-minio` :9000/:9001). `GET /{cardId}/files/{fileId}/download` — `attachment` + (Content-Length/Type из дескриптора; локально MIME пуст → `application/octet-stream`, 1:1 прототип), + `DELETE /{cardId}/files/{fileId}` → `{ok:true}` (мета + объект). +- Сознательно НЕ реализованы (Ruling 9, api-map п.9/п.6): `GET /api/projects/reminders` (список + активных напоминаний — у фронта UI нет) и `DELETE /api/projects/{id}` (удаление проектной карточки + отключено; hard-delete — только clear-rejected). Всего 16 эндпоинтов `/api/projects*`. +- Демо: `DEAL_DEMO=1` включает демо-эндпоинты (simulate/ingest) этапов 3–4; сам контур «Выбранных» + работает без флага (сессии + `admin/admin`). + +### 5. Проверка схем (psql) + +```sh +docker exec deal-postgres psql -U deal -d deal -c '\dn' +docker exec deal-postgres psql -U deal -d deal -c '\dt public.*' +docker exec deal-postgres psql -U deal -d deal -c '\dt tenant_*.*' +``` + +Ожидается: схемы `public` и `tenant_00000000000000000000000000000001`; в `public` — `tenants`, `users`, +`sessions`, `invites`, `operators`, `operator_sessions`, `tenant_limits`, `audit_log`, +`token_usage_events`, `global_settings`, `rate_limit_counters`, `__EFMigrationsHistory`; в схеме тенанта — +`settings`, `Cards`, `Containers`, `LeadComments`, `CardMoves`, `MlOutbox`, `QueueItems`, `RejectedItems`, +`DedupEntries`, `Dialogs`, `TgMessages`, `DiscTasks`/`DiscCandidates`/`DiscBlacklist`/`DiscLog` +и `__TenantMigrationsHistory`. +Ключевые колонки `Cards` (PascalCase): `Id`, `Col`, `IsNew`, `Title`, `Summary`, `StackJson`, `BudgetCur`, +`ConvCur`, `ReceivedAt`, `PrevCol`, `MatchHitsJson`, `ArchivedAt`, `SearchTsv` (tsvector STORED); +`RejectedItems` — `Stage`/`Reason`/`Kw`/`Source`/`Returned`/`SearchTsv`; `DedupEntries` — `Hash` +(PK)/`LeadId`. Правила контейнеров — в `Containers.RulesJson`, состояние +колонок — в `settings` (ключ `colState`). + +### 6. Тесты и сборка (из корня репозитория) + +```sh +sh scripts/build.sh # сборка всех 5 решений (0 warnings / 0 errors) +sh scripts/test.sh # тесты всех сервисов + lint:i18n (core 1315, telegram 130, ai 52, ml 38, storage 9) +sh scripts/ci.sh # полный CI-прогон: build + test + скан уязвимостей + сборка фронта +``` + +Сквозные приёмки этапов — curl-сценарии на :5080 в `.superpowers/sdd/deal-stage{4,5}-projects/` +(task-N-curl-acceptance.sh/.log): этап 4 — финальный Task 13 PASS=74 FAIL=0; этап 5 — финальный Task 13 +PASS=75 FAIL=0 (карточки `ProjectCards`, лид `col=taken`, файлы на диске `data/attachments`, +SSE `reminder_due` фоновым циклом БЕЗ ручного tick). + +Каждая из четырёх sln собирается 0 warnings / 0 errors (`TreatWarningsAsErrors`): `src/core/Deal.sln`, +`src/telegram-service/Deal.Telegram.sln`, `src/ml-service/Deal.Ml.sln`, `src/ai-service/Deal.Ai.sln`. +Приёмки этапа 6 (`.superpowers/sdd/deal-stage6-services/`): in-proc gRPC-тесты (ингресс PushMessage→ +карточка, флашер MlOutbox, ai-фильтр/классификация/инструменты) + curl-сценарии Task 14 (/api/tg: +PASS=20 FAIL=0) и Task 19 (/api/discovery: PASS=37 FAIL=0). + +Финальный прогон этапа 7 (Task 16, docker выключен): core 1123/1123 PASS, telegram 114/114, ai 50/50, +ml 36/36 PASS; build 0/0 всех четырёх sln; `docker compose -f deploy/compose.prod.yml config` rc=0; +`sh -n` scripts/dev-smoke.sh/backup.sh/restore.sh/mtls-certs.sh rc=0. Живые приёмки (SaaS-curl-сценарий +этапа 7, подъём compose.dev/prod, бэкап/restore, mTLS, реальные сервисы) — ⚠ Manual, чек-лист — +`.superpowers/sdd/deal-stage7-saas/task-16-report.md`. + +### 7. Этап 6 — автономные сервисы telegram/ai/ml + Discovery + каналы (полный dev-стек) + +Реализация — `src/telegram-service`, `src/ml-service`, `src/ai-service` (отдельные sln/процессы, .NET 10, +общий код — только `.proto` через `src/contracts/Deal.Proto.csproj`, Task 1); core остаётся единственным +владельцем БД и бизнес-логики (сервисы не знают домен и не ходят в tenant-БД). Контракты, сервисы и +интеграция — план этапа 6 (`.superpowers/sdd/deal-stage6-services/`), Rulings 1–13. + +#### Порты и процессы (`deploy/compose.dev.yml`) + +| Контейнер | Порт | Назначение | +|---|---|---| +| `deal-postgres` | **5433** | БД (host-порт; внутри — 5432) | +| `deal-minio` | **9000/9001** | S3-API / консоль (файлы вложений; в Local-режиме необязателен) | +| `deal-core` (Deal.Api) | HTTP **5080**, gRPC-ингресс **5082** | портал `/api` + приём PushSource/SyncDialogs/ReportStatus | +| `deal-telegram-service` | **5101** | Telegram: сессии/QR/диалоги/мониторинг/backfill/discovery-операции | +| `deal-ai-service` | **5102** | LLM-фасад: Filter/Classify/GenerateKeywords/EvaluateFit | +| `deal-ml-service` | **5103** | инкрементальная модель per-tenant: predict/train/status/reset | + +Порт каждого сервиса — env `GRPC_PORT` (контейнерный 5101/5102/5103), порт ингресса core — env +`GRPC_INGRESS_PORT` (5082). Health-проверки контейнеров — встроенный gRPC-health (`grpc_health_probe` +в образе, `/bin/grpc_health_probe`), Deal-RPC health не трогают. + +#### gRPC-контракты и безопасность (Rulings 1/2/13) + +- `src/contracts/{telegram,ai,ml}.proto` — пакеты `deal.telegram.v1`/`deal.ai.v1`/`deal.ml.v1` + (csharp_namespace `Deal.Grpc.Telegram/Ai/Ml`); общий проект кодогенерации `Deal.Proto` + (`Grpc.Tools`, client+server в одном проходе; каждый процесс собирает свою sln вместе с ним). +- Каждый RPC несёт metadata `tenant-id` + `service-token`; серверный интерцептор каждого процесса + fail-closed сверяет токен с env `DEAL_SERVICE_TOKEN` (единый для всех процессов в compose; отказ — + `UNAUTHENTICATED`; `grpc.health.v1.Health` освобождён). Принадлежность (сессия/модель тенанта) + проверяется сервисом по своей модели — полю не доверяется. Ошибки домена — `INVALID_ARGUMENT`/ + `NOT_FOUND`/`UNAVAILABLE`/`RESOURCE_EXHAUSTED` (flood) с текстом 1:1. +- Dev — gRPC plaintext без mTLS (Ruling 2); mTLS-сертификаты, их генерация и prod-compose — этап 7. + +#### Флаги интеграций core (Ruling 6) + +- Код-дефолт — `Services:{Ml,Ai,Telegram}:UseLocal=true` (`appsettings.json`): Local-адаптеры + (`LocalMlClient`, `LocalAiClassifier`/`LocalAiTools`, `LocalTelegramGateway`) — core работает без сервисов + (host-путь §13.1–13.6, этапы 2–5). +- `deploy/compose.dev.yml` задаёт для core `Services__{Ml,Ai,Telegram}__UseLocal: "false"` + эндпоинты + `http://ml-service:5103` / `http://ai-service:5102` / `http://telegram-service:5101` — полный стек + «по-настоящему». Выбор реализации — на старте (рантайм-переключения нет); фолбэки: недоступность + ml/ai — локальные пути воркеров (ml predict — «не уверен», ai — локальный разбор), telegram — idle-форма + эндпоинтов. + +#### Env (compose.dev.yml; dev-дефолты `${VAR:-…}`, перекрываются `.env`/экспортом) + +- Общие: `DEAL_SERVICE_TOKEN` (единый service-token core+сервисов), `DEAL_ENCRYPTION_KEY` (32 байта base64, + AES-GCM секретов настроек core; fail-closed), creds БД/минио ниже. +- core: `ConnectionStrings__DealPostgres` (host `postgres`, порт 5432 внутри compose), `Storage__Minio__*` + (или env-алиасы `DEAL_MINIO_*`), `Services__*__{UseLocal,Endpoint}`, `GRPC_INGRESS_PORT=5082`, + `ASPNETCORE_URLS=http://0.0.0.0:5080`. +- telegram-service: `GRPC_PORT`, `DEAL_SERVICE_TOKEN`, `DEAL_TELEGRAM_SESSION_KEY` (32 байта base64, + **обязателен** — fail-closed: сессии только шифрованные AES-256-GCM, файлы `/data/sessions` на volume + `deal_tg_sessions`), `DEAL_TELEGRAM_SESSION_DIR=/data/sessions`, `SERVICES__CORE__INGRESS` + (`http://core:5082` в compose; для core с хоста — `DEAL_CORE_INGRESS=http://host.docker.internal:5082`). +- ml-service: `GRPC_PORT`, `DEAL_ML_DATA_DIR=/data/ml` (volume `deal_ml_data`; SQLite-файлы моделей + `/data/ml/.sqlite`). +- ai-service: `GRPC_PORT` (stateless — промпты/конфиг провайдера приходят в теле запроса, volume не нужен). + +#### Полный стек и smoke-проверка + +```sh +cd /c/telbase +docker compose -f deploy/compose.dev.yml up -d --build # весь стек (первый прогон собирает 4 образа) +sh scripts/dev-smoke.sh # сквозной smoke и авто-очистка (trap → down) +docker compose -f deploy/compose.dev.yml down # погасить стек вручную (volumes сохраняются) +``` + +`scripts/dev-smoke.sh`: подъём стека → health всех контейнеров → login admin/admin → `GET /api/tg/status` +(idle-форма через GrpcTelegramClient) → `GET /api/containers?space=dashboard` → `POST /api/cards` +(локальная карточка в `planned`) → `POST /api/cards/{id}/trash` +(сигнал spam → строка MlOutbox) → ожидание флашера `MlOutboxFlushScheduler` (TrainBatch в ml-service) → +`GET /api/ml/status`: `reachable:true`, `stats.outbox:0`, класс `spam` в модели. Скрипт ничего не оставляет +в фоне (trap EXIT → `docker compose down`, временные файлы удаляются). + +#### Каналы-вкладка `/api/tg` (модуль Telegram; Rulings 3/7/8) + +- Таблицы схемы тенанта (миграция `TenantTelegram`): `Dialogs` (каталог каналов: Name/Handle/Kind/Hue, + Monitor, LastText/LastAt, Backfilled) и `TgMessages` (превью сообщений, `LeadId` nullable) — владелец + чистый модуль `Deal.Modules.Telegram` (`ITelegramStore` + `DialogsService`, порт-гейт + `ITelegramGateway` 16 команд). +- gRPC-ингресс core (`Deal.Api/Telegram/TelegramIngressService`, :5082): `PushMessage` → + `PipelineIngestService.EnqueueAsync` (тот же контракт, что demo-ingest) + превью в `TgMessages`; + `SyncDialogs` → синхронизация каталога/мониторинга; `ReportStatus` → KV `tgStatus`/`tgAccount` + SSE + `system_status`/тосты переходов. + + > **Актуально с 2026-09-11:** приём записей вынесен из `TelegramIngressService` в generic + > `Deal.Api/Sources/SourceIngressGrpcService` (`sources.proto` → `PushSource`, proto → домен через + > `SourceProtoMapper`, тенант — `IngressTenantResolver`, превью — `TelegramSourceIngestObserver`). + > `TelegramIngressService` обслуживает только `SyncDialogs`/`ReportStatus`. +- Эндпоинты 1:1 api-map §3.3 (14 шт.): статус (§4.9 — live-поля фазы, `account` из KV, `monitored` из + `count(Dialogs WHERE Monitor)`, `keysSet`), start-phone/start-qr/send-code/send-password/logout, + QR-image (SVG, Net.Codecrete.QrCodeGenerator; 404 «QR не активен — начните вход по QR»), dialogs/refresh/ + monitor-all/backfill-all/{id}/monitor/{id}/backfill (сервер-only)/preview. Boot-заглушка `GET /api/tg/status` + снята (Task 14). telegram-service реализует команды (сессии по тенантам 1:1, фазы idle|code|password|qr| + ready, auto_resume+heartbeat, backfill с паузами 1.5–3 с/сообщение, discovery-операции). + +#### Discovery (Rulings 9–11) + +- Таблицы схемы тенанта (миграция `TenantDiscovery`): `DiscTasks`/`DiscCandidates`/`DiscBlacklist`/ + `DiscLog` (+json-колонки marks/topics/keywords); владелец — чистый модуль `Deal.Modules.Discovery` + (сервисы задач/кандидатов/чёрного списка/лога, `DiscoveryPlanGuard` — план ≤ `discJoinLimit`, бюджет + активных задач). +- Фоновый `DiscoveryWorkerScheduler` (5 с, per-tenant, одно действие за тик): план достигнут → done; + поиск следующего ключа через gateway (`Search`); оценка `new`-кандидата каскадом (info → выборка → + язык/число сообщений → ML-спам (если mlEnabled) → ИИ `EvaluateFit` (если aiEnabled) → эвристика по + ключам; форумы — по темам); авто-вступление `review` при autoJoin с паузами и квотами (50–70 с, + лимит авто-вступлений/сутки по `DiscLog`, стоп-кран `discFloodDay`/`discPaused`), join_failures ≥3 → + удаление задачи. Внешний анти-бан — владение core; внутренние паузы сервиса — telegram-service. +- Эндпоинты 1:1 api-map §3.8 (13 шт.): tasks CRUD+start/pause+generate-keywords (мягкая ошибка + `{keywords:[],error}` HTTP 200), candidates по статусам, join/reject (ручные, вне квот), blacklist, log. + +#### ML-модель ml-service (Ruling 4) + +- Порт python `mlservice/model.py` 1:1: инкрементальный наивный Байес по терминам (`OnlineNaiveBayes`, + tokenize/upsert/predict/adaptive margin/самооценка eval), НЕ ONNX/ML.NET. Пороги: `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`). +- Хранилище — SQLite на тенанта (`/data/ml/.sqlite`, таблицы classes/terms/eval_log, запись + транзакциями); пул `ConcurrentDictionary` с lazy-load и lock на модель. Веса + сигналов обучения 1.0 (пользователь) / 0.4 (ИИ) / 0.6 (правила). +- Core: `PushAsync` ВСЕГДА пишет в `MlOutbox`; фоновый `MlOutboxFlushScheduler` (10 с, только при + `UseLocal=false`) выгружает батчами по 10 (≤100/цикл) через RPC `TrainBatch`, строки удаляются после + успеха; недоступность сервиса — строки остаются. `Reset` = Reset RPC + `ClearOutboxAsync`. Кэш статуса + 15 с → `reachable` в `/api/ml/status`. + +#### AI-фасад ai-service (Ruling 5) + +- Без БД: core передаёт в теле запроса заполненные промпты (подстановка `{domain}`/`{keywords}`), конфиг + провайдера (id/base/model/apiKey/api_style — расшифрованный из `aiConfigs`) и текст. Методы: `Filter` + → {pass,reason}; `Classify` → {ok,json} (json-строку маппит core в `AiParsedLeadDto`, строгий маппинг); + `GenerateKeywords` → {keywords}; `EvaluateFit` → {fit,reason} (Discovery). +- Транспорт: OpenAI-совместимые `POST {base}/chat/completions` (Bearer) и Anthropic + `POST {base}/v1/messages` (x-api-key); temperature 0.2, таймауты 90/60 с, retry max_retries=2 (паузы + 0.8/2 с), извлечение JSON из markdown. Ошибки провайдера наружу — `UNAVAILABLE` («ИИ (имя) не ответил + корректно — повторите попытку через несколько секунд»); учёт токенов `usage` (оценка ≈chars/4 при + отсутствии) → KV `aiTokenUsage` (лимиты/бюджеты — этап 7). Без ключа LLM сервис недоступен — воркер + ядра падает в локальные пути (фолбэк по замыслу). + +#### Ручные проверки этапа 6 (нужны креды) + +- Telegram-вход: ключи приложения (`api_id`/`api_hash`) задаёт **оператор** глобально + (`PUT /api/operator/settings/telegram-keys`, hash шифруется) → `POST /api/tg/start-qr` → QR-скан → + фаза `ready` («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/ + discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A). +- LLM: `PATCH /api/settings` `aiConfigs`/`aiProvider` (напр. DeepSeek или локальный OpenAI-совместимый) → + `POST /api/ai/check`; реальная классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`. +- Сквозной smoke стека — `scripts/dev-smoke.sh` (одна команда; Docker Desktop должен быть поднят). + +### 8. Этап 7 — SaaS-контур (Tasks 1–14; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod + +Кратко (детали — планы `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Rulings 1–11 и отчёты +`.superpowers/sdd/deal-stage7-saas/task-*-report.md`; api-map — раздел «Реализовано в Deal» (Task 16); +живые проверки — ⚠ Manual, чек-лист task-16-report.md): + +- **Оператор** (`public.operators`/`operator_sessions`, кука `deal_operator_session`, срок 12 ч): bootstrap из env + `DEAL_OPERATOR_LOGIN`/`DEAL_OPERATOR_PASSWORD` (Development без env — `operator`/`operator`; Production без env — + warning и пропуск). Ручки — `/api/operator/auth/*` (login/logout/me); отдельный `OperatorSessionMiddleware` — + тенантные ручки операторских сессий не видят и наоборот (401/403). +- **Инвайты/регистрация**: оператор создаёт инвайт (код 16 симв., срок 72 ч, email-unique; список/отзыв — + `/api/operator/invites`), пользователь активирует публичной ручкой **`POST /api/join`** `{code, email, name?, password}` + — создание пользователя (Argon2id) и, для инвайта «на новый тенант», тенанта с провижинингом схемы. +- **Лимиты ИИ-бюджета** (`public.tenant_limits`; период месяц/день, ленивый reset): списание — `TokenUsageRecorder` + (успешные RPC ai-service), гейт-декораторы `BudgetedAiClassifier`/`BudgetedAiTools` (исчерпание/`suspended` → + Local-фолбэк), SSE-тосты 80/100% (`BudgetAlertScheduler`, 60 с). Дефолт-бюджет нового тенанта — env + `DEAL_DEFAULT_AI_BUDGET` (константа 10 000 000 токенов/месяц). +- **Операторские ручки** `/api/operator/*`: тенанты (список/создание/статус/impersonation), лимиты (просмотр/смена + бюджета + usage), аудит (append-only `public.audit_log`), health (core/БД/ml/ai/telegram). С этапа 10 у них есть + UI — оператор-консоль и страница активации инвайта (см. §13.10). +- **Rate limiting** (Ruling 5): секция `RateLimit`, `Enabled=false` в dev/тестах; PROD включает env из compose.prod: + политики api/auth (600/10 в минуту на тенанта/IP), интерцептор gRPC-ингресса :5082 (600/мин/тенанта, health + освобождён), `LoginAttemptGuard` (5 неудач/15 мин → 429). Ответ 429 — `{detail}`. +- **mTLS** (Ruling 6): env `DEAL_MTLS_*` (`Enabled=false` default) — Kestrel внутренних gRPC-эндпоинтов + (+ ингресс core) и исходящие каналы core/telegram-service. Сертификаты — `scripts/mtls-certs.sh` → + `deploy/certs/` (PFX процессов, общий `deal-client.pfx` + PEM `deal-client.crt/.key` для grpc_health_probe). + Живое рукопожатие — ⚠ Manual. +- **Логи/наблюдаемость** (Ruling 7; метрики — этап 12, пакет A): Serilog.AspNetCore во **всех 4 процессах** — консоль JSON + (CompactJsonFormatter; в Development — текст) + rolling-файл `data/logs/deal-<процесс>.json` (30 дней; env + `DEAL_LOG_LEVEL`/`DEAL_LOGS_DIR`). Access-логи: HTTP (HttpAccessLogMiddleware) и gRPC + (RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics` + (HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7. + PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель), + метрики → Prometheus (`127.0.0.1:9090`) → Grafana; профиль `observability` compose.prod. +- **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml + (mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика + `src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка + `deploy/caddy/Caddyfile`), профиль `observability` (loki/promtail/grafana/prometheus). Секреты — только из `.env.prod` + (шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск: + `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`); + авто-проверка — `... config` rc=0. +- **Быстрый сценарий оператора** (после подъёма): login оператора → создать тенанта → инвайт → `POST /api/join` + (или инвайт «на существующего тенанта») → вход тенанта и работа `/api` → оператор: лимиты/usage/health/аудит, + приостановка тенанта (вход 403, ИИ-гейт заморожен). Dev-прогон без docker-сервисов — как §1–3 (core с + Postgres :5433; операторские ручки/лимиты/аудит живут в том же процессе, AI — Local-режим). + +### 9. Бэкапы и восстановление (Task 15, Ruling 8) — scripts/backup.sh / restore.sh + +Реализация ежедневных бэкапов и восстановления — `scripts/backup.sh` + `scripts/restore.sh` +(общие env-дефолты/хелперы — `scripts/deal-backup-lib.sh`). Планировщик — **вне контейнера** +(cron/systemd, примеры ниже): скрипты ничего не ставят. Реальный прогон и restore-тест — +⚠ Manual (нужен поднятый docker-стек; здесь — синтаксис `sh -n` и error-path-проверки). + +**Что входит в бэкап (4 источника данных Ruling 8):** + +1. **Postgres** — БД `deal` целиком (схемы `public` + `tenant_*`): `pg_dump -Fc` (custom, сжатие) → + `$BACKUP_DIR/pg/backup-.dump`. По умолчанию — `docker exec deal-postgres` (локальный socket, + пароль не нужен); при заданном `DEAL_PG_HOST` — прямое `pg_dump` с хоста. +2. **MinIO** — бакет `deal-files` (вложения карточек): `mc mirror` → `$BACKUP_DIR/minio/backup-/` + (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR). mc берётся с хоста, если есть в PATH; иначе — разовый + контейнер `minio/mc` в docker-сети контейнера MinIO. Секреты передаются env-алиасом `MC_HOST_deal` + (в конфиг mc не пишутся). Endpoint: docker-режим — `http://minio:9000` (алиас compose-сервиса, + работает и в compose.dev, и в compose.prod; в prod-сети DNS `deal-minio` НЕ существует — там нет + container_name); host-режим (dev, порт 9000 опубликован) — `http://localhost:9000`. Нестандартная + схема — env `DEAL_MINIO_ENDPOINT`. compose.prod порты MinIO не публикует — для prod не ставьте + хостовый mc (он не достанет MinIO), docker-режим работает из коробки. При Local-хранилище + (MinIO не поднят) — `DEAL_MINIO_SKIP=1`. +3. **Файловые данные** — tar каталогов `DEAL_TAR_DIRS` внутри `DEAL_DATA_DIR` → + `$BACKUP_DIR/data/backup-.tar.gz`. Дефолт: `attachments` (вложения Local-фолбэка), `telegram_sessions` + (сессии telegram-service; контейнерный путь — `/data/sessions`, шифрованы AES-GCM — архив без доп. + шифрования, доступ только root), `ml` (SQLite-модели ml-service). Для docker-томов задайте + `DEAL_TAR_VOLUMES` (список имён, напр. `deploy_deal_api_data deploy_deal_tg_sessions deploy_deal_ml_data`; + `docker volume ls | grep deal_`) — тар выполнит busybox-контейнер. +4. **Retention** — удаление снапшотов старше `RETENTION_DAYS` (дата `YYYYMMDD` из имени файла/каталога, + дефолт 14). При ежедневном запуске хранится ~15 копий (эквивалент `find -mtime +14`). + +**НЕ входит:** сам `BACKUP_DIR` (не кладите его внутрь тарируемых каталогов), docker-образы и +compose-конфиги, логи (`data/logs` — собственная rolling-ротация 30 дней), БД LeadRadar/прочие. + +**Структура и запуск (из корня репозитория):** + +```sh +# ежедневный бэкап: консоль + $BACKUP_DIR/logs/backup-YYYYMM.log; rc=0 при успехе +bash scripts/backup.sh +# восстановление (сначала остановите сервисы, см. ниже): всё из последнего снапшота / +# из снапшота с конкретной меткой / только шаг: +bash scripts/restore.sh # all — pg + minio + data из последнего pg-снапшота +bash scripts/restore.sh 20260908-021500 # TS вида YYYYMMDD-HHMMSS (из имени файла backup-…) +bash scripts/restore.sh pg|minio|data [TS] +# структура: $BACKUP_DIR/{pg,minio,data}/backup-YYYYMMDD-HHMMSS{,.dump,/,…}, logs/ +``` + +> Скрипты используют bash-специфику (`set -o pipefail`) — запускать именно `bash …` (или исполняемый +> файл `./scripts/backup.sh`), НЕ `sh …` (на системах с dash/sh=bash-не-гарантированно). + +`BACKUP_DIR` по умолчанию — `<репозиторий>/data/backups` (env `BACKUP_DIR`/`DEAL_BACKUP_DIR`). +Dev-дефолты env соответствуют `deploy/compose.dev.yml` (БД `deal`/user `deal`; MinIO +`deal_minio`/`deal_minio_secret`, бакет `deal-files`); прод-имена секретов читаются как fallback +(`DEAL_MINIO_ACCESS_KEY` ← `MINIO_ROOT_USER`, `DEAL_MINIO_SECRET_KEY` ← `MINIO_ROOT_PASSWORD`; +`DEAL_PG_PASSWORD` совпадает с compose.prod). Полная таблица env — шапка `scripts/deal-backup-lib.sh`. + +**Планировщик (вне контейнера; запуск от пользователя с доступом к docker):** + +```sh +# cron — ежедневно в 02:00 («0 2 * * *»): +0 2 * * * /opt/deal/scripts/backup.sh >> /opt/deal/data/backups/cron.log 2>&1 +# prod-вариант: секреты из deploy/.env.prod читаются сами (MINIO_ROOT_*, DEAL_PG_PASSWORD), +# BACKUP_DIR вынести из data/. prod НЕ публикует порты MinIO → mc в docker-режиме, endpoint по +# умолчанию http://minio:9000 (алиас сервиса); DEAL_MINIO_ENDPOINT задавать не нужно: +# 0 2 * * * cd /opt/deal && BACKUP_DIR=/var/backups/deal \ +# bash scripts/backup.sh >> /var/backups/deal/cron.log 2>&1 +# dev: хостовый mc + опубликованный порт 9000 → http://localhost:9000; docker-режим — http://minio:9000. +# +# systemd: /etc/systemd/system/deal-backup.{service,timer} +# [Unit] Description=Deal daily backup +# [Service] Type=oneshot; ExecStart=/opt/deal/scripts/backup.sh +# [Timer] OnCalendar=*-*-* 02:00:00; Persistent=true +# [Install] WantedBy=timers.target → systemctl enable --now deal-backup.timer +``` + +**Восстановление — порядок** (сводка — техдок §9): + +```sh +# 1) остановить core и сервисы (БД/тома не должны быть заняты): +docker compose -f deploy/compose.dev.yml stop core telegram-service ml-service # dev +# prod: docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml stop +# 2) восстановить данные (шаг 1 → 3: pg → minio → data при restore all): +bash scripts/restore.sh +# 3) поднять сервисы обратно: +docker compose -f deploy/compose.dev.yml start core telegram-service ml-service +``` + +Восстановление — **overlay**: pg-шаг пересоздаёт БД целиком (dropdb+createdb, `pg_restore +--exit-on-error` — rc=1 при любой ошибке), а minio/data дописывают ПОВЕРХ текущих данных: +файл/объект, которого нет в снапшоте, останется. Строгий снимок бакета 1:1 — `DEAL_MINIO_MIRROR_REMOVE=1` +(`mc mirror --remove`); для data-каталогов/томов при необходимости очистите целевой каталог/том вручную +перед распаковкой. + +Рекомендация Ruling 8: **раз в месяц** — тест восстановления на отдельном инстансе/томах +(поднять копию стека, `restore.sh`, curl-приёмка `/api`). Потеря данных при ежедневном бэкапе +допустима ≤ 24 ч (SLA тестового этапа). Требования: bash + GNU date (coreutils), docker; +секреты скрипты не логируют; параллельный запуск `backup.sh` не поддерживается. + +### 10. Этап 10 — оператор-консоль, аналитика и аудит действий (Tasks 1–7) + +Кратко (детали — план `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`, отчёты +`.superpowers/sdd/deal-stage10-operator-analytics/task-*-report.md`, контракт +`docs/architecture/2026-09-10-operator-analytics-contract.md`; api-map — §6; наблюдаемость — §7): + +- **Фронт: hash-роутер без зависимостей** (`src/frontend/src/router.js`; `vue-router` не добавлялся). + Три верхнеуровневых экрана: **`#/`** — основное приложение (как раньше), **`#/operator`** — консоль + оператора (подразделы `#/operator/
`), **`#/join?code=…`** — активация инвайта. Разбор hash + синхронный (первый рендер сразу на нужном экране). Операторская ссылка на активацию формируется + функцией `joinLink(code)` — `origin+pathname#/join?code=<код>`. +- **Оператор-консоль** (`src/frontend/src/views/operator/*`): вход оператора (отдельная ручка и кука, экран + выводит подсказку dev-дефолта `operator / operator`); разделы «Тенанты» (список/создание/suspend/resume/ + impersonate), «Приглашения» (создание/отзыв/копирование ссылки), «Лимиты ИИ» (сводка/правка), + «Аудит» (фильтры/пагинация), «Аналитика» (обзор/токены/действия), «Состояние системы». Внешних + chart-библиотек нет — визуализации на Tailwind-компонентах. +- **Impersonation**: `POST /api/operator/tenants/{id}/impersonate` выпускает tenant-сессию целевого + пользователя (обычный механизм `AuthService`) и **ставит httpOnly-куку `deal_session` в том же ответе** + (`Deal.Api/Http/SessionCookieWriter.cs`) — оператор сразу попадает в тенант; завершение — обычный + `POST /api/auth/logout` (аудит `impersonation_stopped`). +- **Страница активации инвайта** (`src/frontend/src/views/JoinView.vue`, `#/join?code=…`): форма `email`, + имя пространства (необязательно), пароль (**минимум 8 символов**) → `POST /api/join`; кука не ставится — + после успеха пользователь входит обычным `POST /api/auth/login`. Тексты причин отказа берутся с сервера + как есть (код не найден/истёк/использован/отозван, email не совпал/занят, тенант не найден/приостановлен). +- **История расхода токенов** (`public.token_usage_events`, миграция `20260910152246_AddTokenUsageEvents`): + `Id` (bigint identity), `TenantId` (uuid → `public.tenants`, Restrict), `At` (timestamptz), `Provider`, + `Model`, `Kind` (`ai|ml`, text), `PromptTokens`/`CompletionTokens`/`TotalTokens` (bigint), `DetailJson` + (text). Индексы `(TenantId, At)` и `(At)`. Запись — единая точка `TokenUsageRecorder` в момент списания + (успешный RPC ai-service — провайдер/модель из конфигурации; локальный ML-вызов — `kind=ml`, + оценка токенов ≈ chars/4). Агрегат `public.tenant_limits` остаётся для гейта; история — для аналитики. +- **Операторская аналитика** (read-only; `src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs`): + `GET /api/operator/analytics/overview`, `/tokens` (`groupBy=day|tenant|provider|model`, неизвестное — 400), + `/activity` (лента аудита с фильтрами и пагинацией). Все ответы — camelCase, время ISO-8601 (`from`/`to` + включительно), без операторской сессии — 401 «Требуется вход оператора». `GET /api/operator/audit` + расширен фильтром `actorId` и `offset` (ответ `{items, total}` без изменений). Полная форма запросов/ответов — + в контракте (ссылка выше). +- **Аудит действий** (этап 10, T1): единая точка `AuditService`/`AuditAppender` (append-only + `public.audit_log`; актор `tenant`/`operator`/`system`; секреты не пишутся). К SaaS-событиям этапа 7 + добавлены: `tenant_logout`, `operator_logout`, `invite_joined`, действия карточек (`card_created`, + `card_moved`, `card_trashed`, `card_restored`, `card_deleted`, `card_comment_added`), контейнеры + (`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`, + `telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится). +- **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 — + метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`, см. §7). +- **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080` + с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`) + → **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production — + env `DEAL_OPERATOR_*`); **активация:** `http://localhost:5173/#/join?code=<код>`; основное приложение — + `http://localhost:5173/#/`. Prod-сценарий — §13.8. +- **Ограничения:** реальные Telegram/LLM-креды — ⚠ Manual (по решению владельца); access-лог с + BL-LOG-ACTOR содержит `actor`/`tenant` (IP в строке нет) — полный аудит действий в `public.audit_log`. + +## 14. Локализация интерфейса (i18n, этап 11) + +- **Назначение.** Все пользовательские строки фронтенда вынесены из компонентов и логики в словари-ресурсы + (единый источник текстов). Язык один — русский; переключатель языка и второй язык — **в бэклоге** + (делаем, когда возникнет потребность). +- **Модуль** `src/frontend/src/i18n/`: + - `index.js` — ядро: `t(key, params)` с подстановкой `{name}`, реактивный `locale` (по умолчанию `ru`), + `setLocale(code)`, `registerLocale(code, dict)`, `availableLocales()`, `useI18n()`. Фолбэк: активный + язык → `ru` → сам ключ. Плагин Vue даёт шаблонам `$t(...)`; в `