From 3a26f8d4a5fff28c6c8c0856f46f75a02ab2e6f2 Mon Sep 17 00:00:00 2001 From: stepan Date: Sun, 13 Sep 2026 00:03:43 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20docs?= =?UTF-8?q?/=20=D0=B8=D0=B7=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=8F=20=E2=80=94=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README ссылается на вики-страницы (ТЗ, техдок, api-карта, инструкция, код-стайл, бэклог, статус). Код не затронут. --- .gitignore | 3 + README.md | 18 +- 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 ---- 37 files changed, 12 insertions(+), 10378 deletions(-) delete mode 100644 docs/api/api-map.md delete mode 100644 docs/architecture/2026-09-05-deal-architecture-design.md delete mode 100644 docs/architecture/2026-09-09-unified-card.md delete mode 100644 docs/architecture/2026-09-10-operator-analytics-contract.md delete mode 100644 docs/architecture/2026-09-10-unified-api-contract.md delete mode 100644 docs/spec/Код-стайл-Дейл.md delete mode 100644 docs/spec/Код-стайл-аудит-2026-09-11.md delete mode 100644 docs/spec/ТЗ-дейл-новая-архитектура.md delete mode 100644 docs/superpowers/STATUS.md delete mode 100644 docs/superpowers/backlog.md delete mode 100644 docs/superpowers/plans/2026-09-04-channel-discovery.md delete mode 100644 docs/superpowers/plans/2026-09-05-deal-roadmap.md delete mode 100644 docs/superpowers/plans/2026-09-05-deal-scaffold.md delete mode 100644 docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md delete mode 100644 docs/superpowers/plans/2026-09-05-deal-stage2-settings.md delete mode 100644 docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md delete mode 100644 docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md delete mode 100644 docs/superpowers/plans/2026-09-05-deal-stage5-projects.md delete mode 100644 docs/superpowers/plans/2026-09-05-deal-stage6-services.md delete mode 100644 docs/superpowers/plans/2026-09-05-deal-stage7-saas.md delete mode 100644 docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md delete mode 100644 docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md delete mode 100644 docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md delete mode 100644 docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md delete mode 100644 docs/superpowers/plans/2026-09-11-codestyle-остатки.md delete mode 100644 docs/superpowers/reviews/2026-09-08-code-quality-review.md delete mode 100644 docs/superpowers/reviews/2026-09-10-docs-audit.md delete mode 100644 docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md delete mode 100644 docs/superpowers/reviews/2026-09-11-docs-final-sweep.md delete mode 100644 docs/superpowers/specs/2026-09-04-channel-discovery-design.md delete mode 100644 docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md delete mode 100644 docs/superpowers/specs/2026-09-11-source-contract-design.md delete mode 100644 docs/superpowers/specs/2026-09-11-структура-проектов-design.md delete mode 100644 docs/technical/Техническая-документация-Дейл.md delete mode 100644 docs/user-guide/Инструкция-пользователя-Дейл.md diff --git a/.gitignore b/.gitignore index f7d0f31..584271c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ deploy/gitea-runner/data/ # Служебные скрипты не входят в репозиторий (правило: в репе только код) — живут только локально scripts/ + +# Локальный клон вики +.wiki-clone/ diff --git a/README.md b/README.md index 8bcb6b6..48d7e8f 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,18 @@ SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов. -## Документация (актуальное) +## Документация (актуальное) — в вики проекта -- **ТЗ**: `docs/spec/ТЗ-дейл-новая-архитектура.md` -- **Инструкция пользователя**: `docs/user-guide/Инструкция-пользователя-Дейл.md` -- **Техническая документация** (стек, развёртывание, эксплуатация): `docs/technical/Техническая-документация-Дейл.md` -- **Карта API**: `docs/api/api-map.md` -- **Статус и борд состояния**: `docs/superpowers/STATUS.md` -- **Бэклог (техдолг и отложенное)**: `docs/superpowers/backlog.md` -- **Код-стайл (полный свод правил)**: `docs/spec/Код-стайл-Дейл.md` +- **ТЗ**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/ТЗ](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/ТЗ) +- **Инструкция пользователя**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Инструкция-пользователя](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Инструкция-пользователя) +- **Техническая документация** (стек, развёртывание, эксплуатация): [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Техническая-документация](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Техническая-документация) +- **Карта API**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/API-карта](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/API-карта) +- **Статус и борд состояния**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Статус-разработки](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Статус-разработки) +- **Бэклог (техдолг и отложенное)**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Бэклог](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Бэклог) +- **Код-стайл (полный свод правил)**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Код-стайл](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Код-стайл) Исходники: `src/` (`core`, `frontend`, `ai-service`, `ml-service`, `telegram-service`, `contracts`, `grpc-hosting`). -Планы и ledgers этапов: `docs/superpowers/plans/`, `.superpowers/sdd/`. +Планы и ledgers этапов — в вики (раздел «Историческое» в Sidebar). Легаси-прототип LeadRadar и служебные скрипты в репозиторий не входят — живут локально, вне кода. ## Запуск dev-окружения diff --git a/docs/api/api-map.md b/docs/api/api-map.md deleted file mode 100644 index 82753d3..0000000 --- a/docs/api/api-map.md +++ /dev/null @@ -1,463 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 11a34c6..0000000 --- a/docs/architecture/2026-09-05-deal-architecture-design.md +++ /dev/null @@ -1,272 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 9267234..0000000 --- a/docs/architecture/2026-09-09-unified-card.md +++ /dev/null @@ -1,92 +0,0 @@ -# Дейл — единая модель карточки (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 deleted file mode 100644 index ac4ef63..0000000 --- a/docs/architecture/2026-09-10-operator-analytics-contract.md +++ /dev/null @@ -1,252 +0,0 @@ -# Дейл — контракт операторской аналитики и аудита действий (этап 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 deleted file mode 100644 index 309d374..0000000 --- a/docs/architecture/2026-09-10-unified-api-contract.md +++ /dev/null @@ -1,434 +0,0 @@ -# Дейл — единый 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 deleted file mode 100644 index 620a97f..0000000 --- a/docs/spec/Код-стайл-Дейл.md +++ /dev/null @@ -1,276 +0,0 @@ -# Дейл — код-стайл (действующие правила) - -> Единый свод правил стиля кода для всего репозитория (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 deleted file mode 100644 index 4cd1283..0000000 --- a/docs/spec/Код-стайл-аудит-2026-09-11.md +++ /dev/null @@ -1,62 +0,0 @@ -# Аудит кода на соответствие код-стайлу «Дейл» (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 deleted file mode 100644 index 8f0ae37..0000000 --- a/docs/spec/ТЗ-дейл-новая-архитектура.md +++ /dev/null @@ -1,257 +0,0 @@ -# Дейл (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 deleted file mode 100644 index fceca8f..0000000 --- a/docs/superpowers/STATUS.md +++ /dev/null @@ -1,241 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 55f98b6..0000000 --- a/docs/superpowers/backlog.md +++ /dev/null @@ -1,95 +0,0 @@ -# Бэклог (техдолг и отложенные задачи) — «Дейл» - -> Назначение: единый источник отложенного/запланированного. Роудмап черпается отсюда. -> Статусы: **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 deleted file mode 100644 index c7d346c..0000000 --- a/docs/superpowers/plans/2026-09-04-channel-discovery.md +++ /dev/null @@ -1,341 +0,0 @@ -# Поиск и подключение каналов (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 deleted file mode 100644 index 1aae55c..0000000 --- a/docs/superpowers/plans/2026-09-05-deal-roadmap.md +++ /dev/null @@ -1,173 +0,0 @@ -# Дейл (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 deleted file mode 100644 index d02d3ab..0000000 --- a/docs/superpowers/plans/2026-09-05-deal-scaffold.md +++ /dev/null @@ -1,825 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 6d4048c..0000000 --- a/docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md +++ /dev/null @@ -1,136 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 829eb96..0000000 --- a/docs/superpowers/plans/2026-09-05-deal-stage2-settings.md +++ /dev/null @@ -1,430 +0,0 @@ -# Дейл (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 deleted file mode 100644 index e65086a..0000000 --- a/docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md +++ /dev/null @@ -1,535 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 63f2995..0000000 --- a/docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md +++ /dev/null @@ -1,569 +0,0 @@ -# Дейл (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 deleted file mode 100644 index c28d633..0000000 --- a/docs/superpowers/plans/2026-09-05-deal-stage5-projects.md +++ /dev/null @@ -1,528 +0,0 @@ -# Дейл (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 deleted file mode 100644 index c1e1343..0000000 --- a/docs/superpowers/plans/2026-09-05-deal-stage6-services.md +++ /dev/null @@ -1,542 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 731ec63..0000000 --- a/docs/superpowers/plans/2026-09-05-deal-stage7-saas.md +++ /dev/null @@ -1,586 +0,0 @@ -# Дейл (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 deleted file mode 100644 index db57616..0000000 --- a/docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md +++ /dev/null @@ -1,71 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 438d757..0000000 --- a/docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md +++ /dev/null @@ -1,60 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 191dfe0..0000000 --- a/docs/superpowers/plans/2026-09-10-deal-stage11-i18n.md +++ /dev/null @@ -1,71 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 6be3a04..0000000 --- a/docs/superpowers/plans/2026-09-10-deal-stage12-observability-hardening.md +++ /dev/null @@ -1,46 +0,0 @@ -# Дейл (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 deleted file mode 100644 index 03b049e..0000000 --- a/docs/superpowers/plans/2026-09-11-codestyle-остатки.md +++ /dev/null @@ -1,35 +0,0 @@ -# План: закрытие остатков код-стайла (2026-09-11, вечер) - -> Источник: `backlog.md` — `TD-COMMENTS-IFACE` (п.3, п.4), `TD-STYLE-ANALYZERS`, найденное при проверке -> проекта. Правила — `docs/spec/Код-стайл-Дейл.md`, отчёт — `docs/spec/Код-стайл-аудит-2026-09-11.md` §2. -> Ограничения захода: без поднятия Docker-стека и без внешних кредов. - -## Задачи - -1. **Замер остатков** (dry-run, без правок): сканами по тексту и по имени члена проверить дубли - `` реализации ↔ интерфейса; разбивку `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 deleted file mode 100644 index 0f0fd00..0000000 --- a/docs/superpowers/reviews/2026-09-08-code-quality-review.md +++ /dev/null @@ -1,228 +0,0 @@ -# Ревью качества кода «Дейл» (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 deleted file mode 100644 index 0db6ec3..0000000 --- a/docs/superpowers/reviews/2026-09-10-docs-audit.md +++ /dev/null @@ -1,113 +0,0 @@ -# Аудит документации «Дейл»: сверка с кодом/конфигами - -> Исторический документ (аудит документации, 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 deleted file mode 100644 index 7f57e8f..0000000 --- a/docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md +++ /dev/null @@ -1,329 +0,0 @@ -# Аудит соответствия ТЗ «Дейл (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 deleted file mode 100644 index e1e116f..0000000 --- a/docs/superpowers/reviews/2026-09-11-docs-final-sweep.md +++ /dev/null @@ -1,95 +0,0 @@ -# Финальная «подбивка» документации «Дейл» (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 deleted file mode 100644 index 15b180e..0000000 --- a/docs/superpowers/specs/2026-09-04-channel-discovery-design.md +++ /dev/null @@ -1,212 +0,0 @@ -# Поиск и подключение каналов (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 deleted file mode 100644 index 3b02da0..0000000 --- a/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md +++ /dev/null @@ -1,86 +0,0 @@ -# Открытые вопросы: вложения источников (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 deleted file mode 100644 index 5e7a05c..0000000 --- a/docs/superpowers/specs/2026-09-11-source-contract-design.md +++ /dev/null @@ -1,193 +0,0 @@ -# Дизайн: единый контракт источника + общий 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 deleted file mode 100644 index b8e26df..0000000 --- a/docs/superpowers/specs/2026-09-11-структура-проектов-design.md +++ /dev/null @@ -1,53 +0,0 @@ -# Дизайн: разбиение проектов на логические папки (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 deleted file mode 100644 index 1d282f5..0000000 --- a/docs/technical/Техническая-документация-Дейл.md +++ /dev/null @@ -1,1397 +0,0 @@ -# Дейл (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(...)`; в `