Files
Deal/docs/architecture/2026-09-10-unified-api-contract.md
T
Rustam Khalimov 195faf1b1f
ci / build-test (pull_request) Successful in 2m56s
ci / build-test (push) Successful in 2m52s
Восстановить docs/ как зеркало для агентов (ревью МР #11)
Ревью rust: перенос в вики не должен удалять из репозитория то, что нужно
агенту для работы (бэклог, статус, планы, код-стайл, спеки). Дублирование
вики и репы разрешено и обязательно: вики — актуальные версии для людей,
docs/ — зеркало для контекста агентов. README разведён по ролям.
2026-09-12 23:54:28 +03:00

435 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Дейл — единый API-контракт этапа 9 (cards + containers)
> Дата: 2026-09-10
> Статус: контракт для портирования фронта (T6). Источник истины для `src/frontend`.
> Связанные документы: `docs/architecture/2026-09-09-unified-card.md`,
> `docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md` (T4T6, R5).
## Общие правила
- **Только два домена API**: `/api/cards` (карточки) и `/api/containers` (колонки/стадии/зоны).
Старые ручки `/api/leads`, `/api/projects`, `/api/boards` **удалены**.
- Все ответы и тела запросов — JSON **camelCase**.
- Время на wire — **epoch-ms** (`int64`, UTC). Внутри — `DateTimeOffset` (UTC).
- Ошибки — объект `{ "detail": "текст" }`. Коды: `400` (некорректный ввод), `401` (нет сессии),
`404` (объект не найден), `422` (тело не разобрано).
- Аутентификация — сессионная кука (как раньше). Без сессии — `401 {detail}`.
- Контейнер — единый реестр колонок/стадий/зон. Карточка ссылается на контейнер полем
`containerId` (алиас прежнего `col`). Пространства: `dashboard` (дашборд) и `selected`
(«Выбранные»). Карточка живёт в одном пространстве: её `containerId` однозначно определяет,
где она показана.
- Виды контейнеров (`kind`): `board` (пользовательская колонка-фильтр), `stage` (стадия
«Выбранных»), `service` (inbox/archive/trash), `terminal` (finished/rejected).
## SSE (`GET /api/events`)
Поток `text/event-stream`, канал тенанта сессии. Типы событий:
| `event` | `data` | Когда |
|---|---|---|
| `new_card` | объект **Card** (см. ниже) | создана карточка (пайплайн, демо, тик) |
| `reminder_due` | `{ "id", "title", "containerId" }` | наступило напоминание |
| `toast` | `{ "text", "icon" }` | статистика тика / служебное уведомление |
| `cards_reclassified` | промежуточный — `{ "progress": true, "done", "total", "moved", "kept", "trashed", "skipped" }`; финал — `{ "progress": false, "reclassified", "moved" }` | прогресс/завершение переклассификации «Неразобранного» |
`new_lead` больше не публикуется (переименован в `new_card`).
---
## Card (карточка)
Единая сущность во всех дашбордах. Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание)
присутствуют всегда, но могут быть пустыми.
```json
{
"id": "c_1a2b3c4d5e6f",
"containerId": "inbox",
"col": "inbox",
"isNew": true,
"local": false,
"title": "Разработка интернет-магазина",
"summary": "Компания: ...\nЗадача: ...",
"source": {
"kind": "telegram",
"displayName": "Канал заказов",
"originRef": "123456789",
"receivedAt": 1726000000000
},
"sourceMsg": "Ищу разработчика...",
"sourceDialogId": "123456789",
"sourceMsgId": 4242,
"stack": ["vue", "dotnet"],
"budget": { "from": 100000, "to": 200000, "cur": "RUB" },
"converted": { "from": 100000, "to": 200000, "cur": "RUB" },
"contact": "@client",
"contacts": [{ "type": "tg", "value": "@client" }],
"channel": { "name": "Канал заказов", "handle": "@orders", "hue": "#8b8ff8" },
"matchHits": [{ "label": "Стек", "term": "vue", "word": null }],
"comments": [{ "id": "cm_...", "by": "Вы", "text": "Позвонил", "time": "5 мин" }],
"links": [{ "id": "pl_...", "name": "Бриф", "url": "https://example.com" }],
"files": [
{ "id": "pf_...", "name": "brief.pdf", "size": 10240, "kind": "document",
"label": "Документ", "objectKey": "cards/c_.../pf_..." }
],
"history": [
{ "id": "h_...", "at": 1726000000000, "type": "created", "stage": null },
{ "id": "h_...", "at": 1726003600000, "type": null, "stage": "planned" }
],
"tzText": "Сделать каталог и корзину",
"reminder": { "at": 1727000000000 },
"prevCol": "inbox",
"isVacancy": false,
"isVacancyKnown": false,
"time": "5 мин",
"receivedAt": 1726000000000,
"createdAt": 1726000000000,
"updatedAt": 1726000000000
}
```
Поля:
| Поле | Тип | Описание |
|---|---|---|
| `id` | string | короткий id карточки, префикс `c_` |
| `containerId` | string | контейнер карточки (`inbox`/`archive`/`trash`/стадия/`b_...`) |
| `col` | string | **алиас** `containerId` (совместимость со старым фронтом) |
| `isNew` | bool | точка «новое» (снимается просмотром/переносом) |
| `local` | bool | карточка создана локально (без внешнего источника) |
| `title` / `summary` | string | заголовок / блок «О заявке» |
| `source` | object | происхождение: `kind` (`local`/`telegram`/`web`/`file`/`row`/`api`/`ai`/`composite`/`other`), `displayName`, `originRef`, `receivedAt` |
| `sourceMsg` / `sourceDialogId` / `sourceMsgId` | string / string / int64? | исходное сообщение (текст, диалог, id) |
| `stack` | string[] | стек/направления |
| `budget` | object? | `{from,to,cur}` по исходному сообщению |
| `converted` | object? | `{from,to,cur}` бюджет в целевой валюте |
| `contact` | string | «быстрый» контакт |
| `contacts` | object[] | `{type,value}` |
| `channel` | object | `{name,handle,hue}` (прежний `ch`) |
| `matchHits` | object[] | `{label,term,word?}` — почему карточка в контейнере |
| `comments` | object[] | `{id,by,text,time}` |
| `links` | object[] | `{id,name,url}` |
| `files` | object[] | `{id,name,size,kind,label,objectKey}` |
| `history` | object[] | `{id,at,type\|stage}` — ровно один из `type`/`stage` |
| `tzText` | string | техническое задание |
| `reminder` | object? | `{at}` (epoch-ms) |
| `prevCol` | string | предыдущий контейнер (возврат из archive/trash) |
| `isVacancy` / `isVacancyKnown` | bool | маркер/подтверждение «найм» |
| `time` | string | human-метка от `receivedAt` |
| `receivedAt` / `createdAt` / `updatedAt` | int64 | epoch-ms |
### `GET /api/cards?containerId=`
Список карточек. `containerId` — фильтр по контейнеру; алиас `col` принят для совместимости; без
параметра — все карточки дашборда (кроме стадий «Выбранных»).
```json
{ "items": [ /* Card... */ ] }
```
`400 {detail:"Неизвестный контейнер"}` — если контейнер не существует.
### `GET /api/cards/counts`
Плоские счётчики (совместимо с прежним `/api/leads/counts`).
```json
{ "new": 3, "inbox": { "count": 5, "new": 2 }, "learning": 12, "ml": 0, "ai": 0 }
```
### `GET /api/cards/{cardId}`
Карточка. `404 {detail:"Карточка не найдена"}`.
### `POST /api/cards`
Создание локальной карточки. Тело:
```json
{ "title": "Новый заказ", "summary": "", "containerId": "planned",
"stack": [], "budget": null, "contact": "", "tzText": "" }
```
Алиас `containerId``stage`. Ответ — созданная **Card**.
### `PATCH /api/cards/{cardId}`
Частичная правка. Null-поле = «не менять». Тело:
```json
{ "title": "...", "summary": "...", "contact": "...", "tzText": "...",
"stack": ["..."], "budget": { "from": 1, "to": 2, "cur": "RUB" } }
```
Ответ — обновлённая **Card**.
### `POST /api/cards/{cardId}/move` `{ "to": "<containerId>" }`
Перенос карточки. Ответ — обновлённая **Card**.
`400 {"Переносить можно только в существующий контейнер или в «Неразобранное»"}` (несуществующий
контейнер/служебный источник), `404`.
### `POST /api/cards/{cardId}/trash` → `{ "ok": true }`
### `POST /api/cards/{cardId}/restore` → `{ "ok": true, "col": "inbox" }`
### `DELETE /api/cards/{cardId}` → `{ "ok": true }`
### `POST /api/cards/clear-col` `{ "col": "trash"|"archive" }` → `{ "ok": true, "cleared": 4 }`
### `POST /api/cards/clear-rejected` → `{ "ok": true, "cleared": 0 }`
### `POST /api/cards/mark-all-seen` → `{ "ok": true }`
### `POST /api/cards/mark-col-seen` `{ "col": "<containerId>" }` → `{ "ok": true }`
### `POST /api/cards/take` `{ "cardId": "<cardId>" }`
«Взять в работу»: карточка (не клон) переносится в контейнер `planned` пространства
`selected`. Ответ — обновлённая **Card**. Алиас поля — `leadId`. `404` — карточки нет.
### Комментарии
`POST /api/cards/{cardId}/comments` `{ "text": "..." }``{ "comments": [ /* ... */ ] }`
`400 {detail:"Пустой комментарий"}`, `404`.
### Ссылки
- `POST /api/cards/{cardId}/links` `{ "url": "...", "name": "..." }` → обновлённая **Card**
- `DELETE /api/cards/{cardId}/links/{linkId}` → обновлённая **Card**
### Файлы
- `POST /api/cards/{cardId}/files``multipart/form-data`, поле `files` (одно или несколько)
→ обновлённая **Card**
- `GET /api/cards/{cardId}/files/{fileId}/download` → бинарный поток
- `DELETE /api/cards/{cardId}/files/{fileId}` → обновлённая **Card**
### Напоминания
- `POST /api/cards/{cardId}/reminder` `{ "at": 1727000000000 }` → обновлённая **Card**
`400 {detail:"Поле at (epoch-ms) обязательно"}`
- `DELETE /api/cards/{cardId}/reminder` → обновлённая **Card**
- `POST /api/cards/{cardId}/reminder/snooze` → обновлённая **Card**
### `POST /api/cards/{cardId}/reclassify` и `POST /api/cards/reclassify`
Переклассификация карточки/«Неразобранного»: повторный прогон через тот же конвейер, что и пайплайн
(ИИ-фильтр → классификация → сборка контента → правила колонок; без создания новой карточки).
- Single: `{cardId}` — любая карточка с исходным текстом; `404 {detail:"Карточка не найдена"}`.
- Batch: тело `{ "ids": ["c_..."] }` опционально; без `ids` — все карточки `inbox`.
- При включённом ИИ используется порт `IAiClassifier`; при выключенном (`aiEnabled=false`) или недоступности
сервиса — детерминированный локальный разбор (без кредов сервис не падает). `usedAi` показывает путь.
- Одна переклассификация за раз (single-flight): при занятом проходе `{ "started": false, "busy": true }`.
- Аудит — событие `card_reclassified` (только при `reclassified > 0`).
- Во время пакетного прохода публикуются промежуточные SSE `cards_reclassified` с `{ "progress": true,
"done", "total", "moved", "kept", "trashed", "skipped" }` (каждые 5 карточек и на последней); по завершении —
финальное `{ "progress": false, "reclassified", "moved" }`; фронт показывает `done/total` и перечитывает
доску только по финальному событию.
```json
{
"started": true,
"busy": false,
"attempted": 3,
"reclassified": 3,
"moved": 1,
"kept": 1,
"trashed": 1,
"skipped": 0,
"usedAi": false,
"reason": null
}
```
| Поле | Тип | Описание |
|---|---|---|
| `started` | bool | Проход выполнен (target непуст); `false` — пусто/занято |
| `busy` | bool | Проход уже выполняется другим запросом |
| `attempted` | int | Сколько карточек отобрано (batch — inbox, либо `ids ∩ inbox`) |
| `reclassified` | int | Успешно обработано (`moved + kept + trashed`) |
| `moved` | int | Ушло в смысловую колонку |
| `kept` | int | Осталось в «Неразобранном» |
| `trashed` | int | Отправлено в корзину (спам/не прошло ИИ-фильтр) |
| `skipped` | int | Пропущено (нет исходного текста) |
| `usedAi` | bool | True — разбор хотя бы одной карточки через порт ИИ; false — локальный разбор |
| `reason` | string? | Причина, если проход не выполнен/пусто; иначе `null` |
### `GET /api/search?q=`
```json
{ "cards": [ /* Card... */ ], "messages": [] }
```
---
## Container (колонка/стадия/зона)
```json
{
"id": "b_1a2b3c4d5e6f",
"name": "WPF",
"description": "Заказы по WPF",
"color": "#818cf8",
"order": 0,
"space": "dashboard",
"kind": "board",
"collapsed": false,
"suggested": false,
"note": "",
"rules": {
"mode": "any",
"direction": [],
"keywords": ["wpf"],
"stack": [],
"grade": [],
"exclude": [],
"budget": { "from": 0, "to": 0, "cur": "RUB" }
},
"policy": { "canRestore": true, "isTerminal": false, "retentionDays": null },
"counts": { "total": 4, "new": 1 }
}
```
| Поле | Тип | Описание |
|---|---|---|
| `id` | string | `b_...` (board), `planned…rejected` (stage/terminal), `inbox`/`archive`/`trash` (service) |
| `name` | string | имя для отображения |
| `description` | string | описание (подсказка ИИ/ML) |
| `color` | string | hex |
| `order` | int | позиция в пространстве |
| `space` | string | `dashboard` / `selected` |
| `kind` | string | `board` / `stage` / `service` / `terminal` |
| `collapsed` | bool | свёрнутость колонки на дашборде |
| `suggested` | bool | ИИ-предложение, ждёт решения пользователя |
| `note` | string | заметка/обоснование ИИ |
| `rules` | object? | правила попадания (null — фильтра нет) |
| `policy` | object | `{canRestore,isTerminal,retentionDays}` |
| `counts` | object | `{total,new}` — счётчики карточек контейнера |
`rules` (объект фильтров колонки): `mode` (`all`/`any`), `direction`, `keywords`, `stack`, `grade`,
`exclude`, `budget` (`{from,to,cur}`) и добавленные этапом 12 группы `levels` (уровень), `locations`
(локация/язык), `types` (`vacancy`/`freelance`/`announcement`), `prices` (`{from,to,cur}`). Все группы
опциональны; старый сохранённый `rules` без новых групп разбирается как прежде (обратная совместимость).
---
### `GET /api/containers?space=`
```json
{ "items": [ /* Container... */ ] }
```
`space` (`dashboard`/`selected`) — опциональный фильтр.
### `POST /api/containers`
```json
{ "name": "WPF", "description": "", "color": null,
"space": "dashboard", "kind": "board", "suggested": false, "note": "",
"rules": { "mode": "any", "keywords": ["wpf"] } }
```
`400 {detail:"Укажите название колонки"}` при отсутствующем/null `name`.
Ответ — `{ "id": "b_..." }`.
### `PATCH /api/containers/{containerId}`
Null-поле = «не менять». Тело: `name`, `description`, `color`, `collapsed`, `suggested`,
`note`, `rules`, `policy`. Ответ — `{ "id": "..." }`, `404 {detail:"Контейнер не найден"}`.
### `POST /api/containers/{containerId}/accept`
Принять ИИ-предложение (`suggested=false`), ответ — обновлённый **Container**.
### `DELETE /api/containers/{containerId}`
Удаление контейнера; его карточки переносятся в `inbox` новыми.
Ответ — `{ "ok": true, "movedToInbox": 4 }`.
### `POST /api/containers/reorder`
```json
{ "space": "dashboard", "order": ["b_...", "b_...", "inbox"] }
```
Ответ — `{ "ok": true }`.
### Состояние колонок (UI)
- `GET /api/containers/state` → `{ "<containerId>": { "collapsed": true, "width": "md" } }`
- `PATCH /api/containers/{containerId}/state` `{ "collapsed": true }` → `{ "collapsed": true }`
(только не-null поля после merge).
---
## ML (проверка на сообщении/канале, §8)
Все ручки — под сессией тенанта (`401 {detail:"Требуется авторизация"}`).
### `POST /api/ml/candidates`
Тело: `{ "dialogId": "d_...", "limit": 10 }` — `limit` клампится `1..60` (дефолт 10);
пустой `dialogId` — выборка по всем источникам тенанта (очередь/отсев/карточки).
```json
{ "items": [
{ "id": 12345, "dialogId": "d_...", "text": "исходный текст (до 600 симв.)",
"time": 1757500000000, "lead": true, "verdict": "card", "col": "b_...",
"stage": null, "reason": null,
"pred": { "take": true, "label": "b_...", "scores": { "b_...": 0.83 } } }
] }
```
| Поле | Тип | Описание |
|---|---|---|
| `id` | int | id исходного сообщения (`msgId`) — его принимает `/apply` |
| `dialogId` | string | id диалога-источника |
| `text` | string | исходный текст (до 600 символов) |
| `time` | int? | время сообщения, epoch-ms (null — неизвестно) |
| `lead` | bool | по сообщению уже есть карточка |
| `verdict` | string | `card` / `rejected` / `queued` — текущее состояние |
| `col` | string? | колонка карточки (для `verdict=card`) |
| `stage` | string? | этап отсева / статус очереди |
| `reason` | string? | причина отсева (для `verdict=rejected`) |
| `pred` | object? | мнение ML `{take,label,scores}` (null — не ответил/не готов) |
### `POST /api/ml/apply`
Тело: `{ "dialogId": "d_...", "msgId": 12345, "action": "spam" }` —
`action`: `skip` | `spam` | `board:<containerId>`.
```json
{ "ok": true, "learned": true, "moved": "trash", "leadId": "c_..." }
```
- `skip` — ничего не меняет (`learned:false`, `moved:null`);
- `spam` — учит ML; карточку → в корзину (`moved:"trash"`), сообщение из очереди → в отсев;
- `board:<id>` — учит ML; карточку переносит в колонку (`moved:"<id>"`), уже в колонке — только учит.
Ошибки: `404 {detail:"Исходное сообщение не найдено"}` — сообщение не найдено ни в карточках, ни в
отсеве, ни в очереди; `400 {detail:"Неизвестная доска"}` (нет такого контейнера);
`400 {detail:"Неизвестное действие"}`.
---
## Операторский health (глубины очередей, §10.2)
`GET /api/operator/health` дополнен числовыми полями:
```json
{ "ok": true, "core": { "db": "ok" },
"services": [ /* ... */ ],
"queues": { "pipeline": 12, "mlOutbox": 3 },
"sessions": { "active": 5 } }
```
`queues.pipeline` — суммарная глубина очереди обработки (new+filtered), `queues.mlOutbox` — очередь
обучения ML по всем тенантам; `sessions.active` — активные непросроченные сессии.
---
## Удалённые ручки
| Было | Стало |
|---|---|
| `GET/POST /api/leads`, `/api/leads/{id}`, `/counts`, `/move`, `/trash`, `/restore`, `/comments`, `/mark-*-seen`, `/clear-col`, `/reclassify` | `/api/cards...` |
| `GET/POST /api/projects`, `/api/projects/{id}`, `/take`, `/move`, `/comments`, `/links`, `/files`, `/reminder`, `/clear-rejected` | `/api/cards...` |
| `GET/POST/PATCH/DELETE /api/boards`, `/reorder` | `/api/containers...` |
| `GET /api/columns/state`, `PATCH /api/columns/{id}/state` | `/api/containers/state`, `/api/containers/{id}/state` |
| SSE `new_lead` | SSE `new_card` |