1
Архитектура 10 unified api contract
stepan edited this page 2026-09-13 00:17:00 +03:00
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.

Перенесено из репозитория (docs/architecture/2026-09-10-unified-api-contract.md). Актуальная версия — здесь, в вики.

Дейл — единый 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 (карточка)

Единая сущность во всех дашбордах. Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание) присутствуют всегда, но могут быть пустыми.

{
  "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 принят для совместимости; без параметра — все карточки дашборда (кроме стадий «Выбранных»).

{ "items": [ /* Card... */ ] }

400 {detail:"Неизвестный контейнер"} — если контейнер не существует.

GET /api/cards/counts

Плоские счётчики (совместимо с прежним /api/leads/counts).

{ "new": 3, "inbox": { "count": 5, "new": 2 }, "learning": 12, "ml": 0, "ai": 0 }

GET /api/cards/{cardId}

Карточка. 404 {detail:"Карточка не найдена"}.

POST /api/cards

Создание локальной карточки. Тело:

{ "title": "Новый заказ", "summary": "", "containerId": "planned",
  "stack": [], "budget": null, "contact": "", "tzText": "" }

Алиас containerIdstage. Ответ — созданная Card.

PATCH /api/cards/{cardId}

Частичная правка. Null-поле = «не менять». Тело:

{ "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}/filesmultipart/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 и перечитывает доску только по финальному событию.
{
  "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=

{ "cards": [ /* Card... */ ], "messages": [] }

Container (колонка/стадия/зона)

{
  "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=

{ "items": [ /* Container... */ ] }

space (dashboard/selected) — опциональный фильтр.

POST /api/containers

{ "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

{ "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 — выборка по всем источникам тенанта (очередь/отсев/карточки).

{ "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>.

{ "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 дополнен числовыми полями:

{ "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