contracts: subject'ы NATS как в старом АРМ — files.*_uploaded, files.tabular_parsed (#74)

Решение владельца 08.10.2026: запросы — files.tabular_uploaded / files.img_uploaded
(было recognition.{tabular|image}.request), ack и результаты обоих сервисов —
files.tabular_parsed (было recognition.result); files.img_parsed не используется,
старый АРМ гасится. Тела сообщений, Nats-Msg-Id, queue group'ы и schema_version
не меняются.

Change nats-files-subjects: дельта recognition-contract (3 требования), Purpose
спеки; описания subject'ов в contracts/*.schema.json; 03-architecture (схема,
таблица потоков, NATS платформы), 09-delivery-plan, dev/README, dev/qa-guide,
repo-templates. DBML v5.17 не поднимается (R-3, deferred).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-08 23:34:01 +04:00
parent a1eaf60120
commit 50e588b6bd
14 changed files with 221 additions and 29 deletions

View File

@ -12,9 +12,9 @@
PostgreSQL │ │ S3 (uploads/ results/ exports/ archive/)
│ │
NATS (core) ◀─────────────────┘ outbox │
recognition.tabular.request │
recognition.image.request │
recognition.result ───────────────────▶ arm-core (inbox, дедуп по msg_id)
files.tabular_uploaded │
files.img_uploaded │
files.tabular_parsed ─────────────────────▶ arm-core (inbox, дедуп по msg_id)
│
┌───────────────┴───────────────┐
┌────────▼────────┐ ┌─────────▼────────┐ ┌──────────────────┐
@ -27,7 +27,7 @@
- **arm-core** — монолитное ядро: справочники, файлы/документы, маппинг результатов распознавания, консолидация
(вычисления), согласование, ЕХ, аудит, права. Один Alembic, одна схема.
- **tabular-parser** — читает файл из S3 `uploads/`, находит на листах ОСВ (§1) и формы ББ/ОФР (§12) **по содержимому**,
пишет нормализованный JSON в S3 `results/`, публикует `recognition.result`. Внутри — `BookParser` (оркестратор книги:
пишет нормализованный JSON в S3 `results/`, публикует `files.tabular_parsed`. Внутри — `BookParser` (оркестратор книги:
лимиты, чтение, дедлайн, вид каждого листа): листы ОСВ идут в конвейер `osv.py`, листы форм — в конвейер форм
`statements/` (блоки форм, колонки, шапка, строки, соотношения §7, склейка продолжений); документы результата — сначала
ОСВ, затем формы. Контракт результата —
@ -43,7 +43,7 @@
`Output/<job_id>/` и входящий файл — **только под флагом `SCAN_CLEANUP`, по умолчанию выключен** (права на удаление
на SMB-шаре контура не подтверждены, OQ-144), нормализовать в тот же контракт результата (`OcrNormalizer` по
`05-domain-rules.md` §11/§11.1: один `statementDocument` на таблицу ответа, справочники `api/rules/` с редакциями
форм), опубликовать `recognition.result`. Таймаут 5 мин → `TIMEOUT`. Раскладка папок — как у проверенного в контуре
форм), опубликовать `files.tabular_parsed`. Таймаут 5 мин → `TIMEOUT`. Раскладка папок — как у проверенного в контуре
PDFWorker старого АРМ (`RSHB/armka_file_worker`, решение владельца 06.10.2026, OQ-137) с двумя отличиями: временный
файл лежит в `Tmp`, а не рядом с назначением, очистка под флагом. Эмулятор ИС Сканирование (dev) ищет пару PDF + JSON
по sha256 содержимого входящего файла, а не по имени. Реализация — arm-scan-gateway #6 (change
@ -65,16 +65,16 @@
| Поток | Subject | Кто пишет | Полезная нагрузка | `Nats-Msg-Id` | Схема |
|---|---|---|---|---|---|
| запрос | `recognition.tabular.request` / `recognition.image.request` | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `<job_id>:<attempt>` | [request](../contracts/recognition-request.schema.json) |
| ack | `recognition.result` | tabular-parser / scan-gateway | конверт со `schema_version`, `status = processing`, `job_id`, `file_id`, `service {name, version}` | `ack:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json) |
| результат | `recognition.result` | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `service {name, version}`, `metrics` (опц.), `raw_s3_key` (опц., только scan-gateway); при `done` / `needs_check` обязательны `result_s3 {bucket, key}` и `result_schema_version`, при `error` — `error {code, message}` | `result:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json); файл в S3 — [result](../contracts/recognition-result.schema.json) |
| запрос | `files.tabular_uploaded` / `files.img_uploaded` | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `<job_id>:<attempt>` | [request](../contracts/recognition-request.schema.json) |
| ack | `files.tabular_parsed` | tabular-parser / scan-gateway | конверт со `schema_version`, `status = processing`, `job_id`, `file_id`, `service {name, version}` | `ack:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json) |
| результат | `files.tabular_parsed` | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `service {name, version}`, `metrics` (опц.), `raw_s3_key` (опц., только scan-gateway); при `done` / `needs_check` обязательны `result_s3 {bucket, key}` и `result_schema_version`, при `error` — `error {code, message}` | `result:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json); файл в S3 — [result](../contracts/recognition-result.schema.json) |
`group_schema` в конверт не входит — ядро берёт его из `documents[].group_schema` файла результата при маппинге.
**На платформе NATS без JetStream** (ответ 03.10.2026; подтверждено 06.10.2026 кодом `RSHB/armka_file_worker`, который
работает в контуре на `subscribe`/`publish` + `flush` без `jetstream()`): нет durable consumer'ов, серверного дедупа и redelivery.
Поэтому гарантии доставки — на уровне приложения: сервисы подписаны queue group'ами (`tabular-parser`, `scan-gateway`),
ядро — queue group `arm-core` на `recognition.result`; сервис сразу по получении корректного запроса публикует ack `processing`;
ядро — queue group `arm-core` на `files.tabular_parsed`; сервис сразу по получении корректного запроса публикует ack `processing`;
ядро, не получив ack за `ACK_TIMEOUT_SECONDS` (30), публикует запрос повторно с тем же `Nats-Msg-Id`, не более
`OUTBOX_MAX_REPUBLISH` (3) раз, затем `dlq`. Сервис держит process-local множество `job_id` в работе и на повторный
запрос отвечает ack'ом без второй обработки. Публикация ack и результата сервисом при временной ошибке повторяется
@ -330,9 +330,11 @@ FastAPI» платформы РАИСА/aurora): дерево импортиро
- **NATS** — `nats-py`; аутентификация nkeys-seed + JWT (`NATS_SEED`, `NATS_JWT`) через `user_jwt_cb`/`signature_cb`;
соединение и подписки создаются в lifespan и живут в `app.state.nats` / `app.state.nats_subs`. Core NATS без
JetStream: подписки с queue group, заголовки `Nats-Msg-Id` ставим сами, ack и повторы — на уровне приложения.
Учётные данные одни на все сервисы АРМ: один JWT с правами по subject'ам (ответ владельца 06.10.2026), поэтому новые
subject'ы (`recognition.*`) должны быть внесены в права этого JWT платформой; старый АРМ и `armka_file_worker`
работают на семействе `files.*` (`files.tabular_uploaded`, `files.tabular_parsed`, `files.img_parsed`).
Учётные данные одни на все сервисы АРМ: один JWT с правами по subject'ам (ответ владельца 06.10.2026).
Subject'ы — **как в старом АРМ** (решение владельца 08.10.2026): на проде заведены `files.tabular_uploaded`,
`files.img_uploaded`, `files.tabular_parsed`, `files.img_parsed`; используем три — запросы в `files.tabular_uploaded`
и `files.img_uploaded`, ack и результаты обоих сервисов в общий `files.tabular_parsed`; `files.img_parsed` не
используется. Старый АРМ гасится, общих подписчиков на этих subject'ах у нас с ним не будет.
- **SMB** (горячие папки ИС Сканирование) — `smbprotocol`/`smbclient`, сессия регистрируется в lifespan; нужен только `scan-gateway`.
- **Внешняя аутентификация** — пакет `raisa_fastapi_protected_api` из внутреннего Nexus (`AuthorizationMiddleware`,
`ProtectedSettings`, …), импортируется только при `DEBUG=false` (в контуре временно `DEBUG=true`, см. «Аутентификация и авторизация», п. 6); фронтовая пара — `raisa-fastapi-protected-web`.
@ -409,7 +411,7 @@ FastAPI» платформы РАИСА/aurora): дерево импортиро
## Что ожидаем от платформы (для запроса ресурсов)
По ответам платформы (03.10.2026): PostgreSQL **16** (одна БД, схема `dkb` — создавать схемы можно), NATS **без
JetStream** (3 subject `recognition.*`; аутентификация nkeys/JWT, как в шаблоне), S3-совместимое хранилище (один бакет;
JetStream** (3 subject `files.*` — как в старом АРМ, решение владельца 08.10.2026; аутентификация nkeys/JWT, как в шаблоне), S3-совместимое хранилище (один бакет;
префиксы-роли + lifecycle по префиксам), Python 3.11 в базовом образе, Nexus (pip/npm, пакеты `raisa_*`) — только внутри
банка, CI/CD — пайплайн платформы (мы отдаём команды `make check` / `make check-full`). Секреты — через переменные
окружения (`OPENBAO__*`). Открыто: версия Node, параметры JWT РАИСА.

View File

@ -34,7 +34,7 @@
для BASIC/EXTENDED (OQ-46).
- Auth: валидация JWT + dev-эмитент; `app_user` seed (по одному на роль); зависимость `require(permission)`.
- `audit_event` helper; `s3_object` клиент; outbox-паблишер (ack `processing`, повторная публикация без ack, `dlq`) и
подписка `recognition.result` (дедуп по `result_msg_id`, пустой маппер документов).
подписка `files.tabular_parsed` (дедуп по `result_msg_id`, пустой маппер документов).
- Проверки качества вместо CI (в Gitea CI нет, пайплайн платформы вне репозиториев): `make check` (ruff, mypy, pytest,
контракт) и `make check-full` (+ интеграционные тесты против compose, цикл миграций, `check_dbml_vs_orm`) — `docs/dev/checks.md`.
- DoD: `GET /api/v1/me` возвращает пользователя и его эффективные права; все миграции применяются и откатываются;

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-request/v1",
"title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0",
"description": "Тело сообщения на subject recognition.tabular.request или recognition.image.request; subject должен соответствовать полю pipeline (tabular → recognition.tabular.request, image → recognition.image.request). Заголовок Nats-Msg-Id = <job_id>:<attempt> (не поле тела). Один файл (stored_file) → один job.",
"description": "Тело сообщения на subject files.tabular_uploaded или files.img_uploaded; subject должен соответствовать полю pipeline (tabular → files.tabular_uploaded, image → files.img_uploaded). Заголовок Nats-Msg-Id = <job_id>:<attempt> (не поле тела). Один файл (stored_file) → один job.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"],

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result-message/v1",
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0",
"description": "Тело сообщения на subject recognition.result. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"description": "Тело сообщения на subject files.tabular_parsed. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "status", "service"],

View File

@ -75,9 +75,9 @@ docker compose --profile services up -d --wait arm-core tabular-parser scan-gate
| Сервис | Порт хоста (`.env`) | Порт в контейнере | Что делает |
|---|---|---|---|
| `arm-core` | 8000 (`ARM_CORE_PORT`) | 8000 | ядро: API, outbox/inbox `recognition_job`, подписка `recognition.result` |
| `tabular-parser` | 8001 (`TABULAR_PARSER_PORT`) | 8000 | подписка `recognition.tabular.request` (queue group `tabular-parser`) |
| `scan-gateway` | 8002 (`SCAN_GATEWAY_PORT`) | 8000 | подписка `recognition.image.request` (queue group `scan-gateway`), горячая папка — том `hotfolder` |
| `arm-core` | 8000 (`ARM_CORE_PORT`) | 8000 | ядро: API, outbox/inbox `recognition_job`, подписка `files.tabular_parsed` |
| `tabular-parser` | 8001 (`TABULAR_PARSER_PORT`) | 8000 | подписка `files.tabular_uploaded` (queue group `tabular-parser`) |
| `scan-gateway` | 8002 (`SCAN_GATEWAY_PORT`) | 8000 | подписка `files.img_uploaded` (queue group `scan-gateway`), горячая папка — том `hotfolder` |
| `scan-emulator` | — | — | эмулятор ИС Сканирование: следит за `Input/` тома `hotfolder`, ответы — из `../../fixtures/ocr` (только чтение) |
| `arm-core-migrate` | — | — | одноразовый: `alembic upgrade head` и `python -m scripts.seed --dev`, затем `arm-core` стартует |
@ -211,8 +211,8 @@ dpsql -c "select status, error_code, error_message, published_at::time(3), start
`published → processing → error` проходит за десятки миллисекунд (в прогонах 14–51 мс), опросом его не поймать; последовательность видна по
`published_at < started_at < finished_at` (`started_at` пишет ack), в логе `arm-core` («Запрос распознавания
опубликован» → «Сервис подтвердил получение запроса (ack): задание в processing» → «Результат распознавания принят …
error_code=NOT_IMPLEMENTED») и на шине: `recognition.tabular.request` (`Nats-Msg-Id: <job_id>:1`) →
`recognition.result` `ack:<job_id>:1` (`status: processing`) → `result:<job_id>:1` (`status: error`). Публикатор
error_code=NOT_IMPLEMENTED») и на шине: `files.tabular_uploaded` (`Nats-Msg-Id: <job_id>:1`) →
`files.tabular_parsed` `ack:<job_id>:1` (`status: processing`) → `result:<job_id>:1` (`status: error`). Публикатор
опрашивает outbox раз в 5 с, поэтому от вставки до публикации проходит до 5 с.
**2. `image` → эмулятор ИС → `INVALID_DOCUMENT`, сырой ответ в `raw/`:**
@ -393,7 +393,7 @@ postgres nats minio`, затем `make test-integration` (или `make check-ful
> **Не запускайте интеграционные тесты `tabular-parser` и `scan-gateway` при поднятом профиле `services`.** Их
> сквозные тесты (`api/tests/integration/test_roundtrip.py`, у `scan-gateway` ещё `test_nats_minio_integration.py`)
> публикуют запросы в боевые `recognition.tabular.request` / `recognition.image.request`, а экземпляр сервиса из теста
> публикуют запросы в боевые `files.tabular_uploaded` / `files.img_uploaded`, а экземпляр сервиса из теста
> подписан в ту же queue group (`tabular-parser` / `scan-gateway`), что и контейнер. NATS отдаёт каждое сообщение группы
> одному подписчику — запрос теста может уйти контейнеру, и тест упадёт по таймауту ожидания ack (так прямо сказано в
> docstring теста `scan-gateway`). Риск подтверждён чтением кода тестов, тесты при поднятом профиле не запускались.
@ -401,7 +401,7 @@ postgres nats minio`, затем `make test-integration` (или `make check-ful
> и уникальный subject результатов на тест. Перед тестами сервисов:
> `docker compose --profile services stop tabular-parser scan-gateway scan-emulator`; проверить, что чужих подписчиков
> нет: `curl -s 'http://127.0.0.1:8222/subsz?subs=1' | grep -o '"subject": *"recognition[^"]*"'` — строк
> `recognition.tabular.request` / `recognition.image.request` быть не должно (`recognition.result` — подписка контейнера
> `files.tabular_uploaded` / `files.img_uploaded` быть не должно (`files.tabular_parsed` — подписка контейнера
> `arm-core`, тестам `arm-core` не мешает).
## Проверка контракта

View File

@ -126,7 +126,7 @@ cd api && .venv/bin/python run_server.py
Swagger: <http://localhost:8000/docs>. Сервер слушает только `127.0.0.1`, так и оставьте.
Для сценариев загрузки ОСВ (6.6) нужны ещё `NATS_ENABLED=true` (так в `api/.env.example`) и **tabular-parser**; поднимайте
только его, без второго экземпляра ядра (он забирает часть сообщений `recognition.result`):
только его, без второго экземпляра ядра (он забирает часть сообщений `files.tabular_parsed`):
```bash
docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build --no-deps tabular-parser
@ -480,7 +480,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "/путь/к/файлу.x
PDF и изображения обрабатывает `scan-gateway`; ядро с Среза 3b маппит формы из PDF (`statementDocument`) в документы так же, как из Excel
(раздел 6.9), но на стенде это требует профиля `services` с эмулятором, поэтому здесь сервис проверяется отдельно. Сервис и нормализатор проверяются отдельно: поднимите профиль `services`
(`scan-gateway` и `scan-emulator` в `docs/dev/docker-compose.yml`) и пройдите сценарий из README `scan-gateway` («Полный круг против compose и эмулятора»:
файл в MinIO → `recognition.image.request` в NATS → ack и итоговый конверт).
файл в MinIO → `files.img_uploaded` в NATS → ack и итоговый конверт).
Что загружать на стенде: **PDF из `fixtures/ocr` — `aep_3.pdf` и `aep_6.pdf`**. Эмулятор узнаёт их **по содержимому**
(sha256), а не по имени: шлюз кладёт файл в горячую папку как `<uuid>.<ext>`, и имя фикстуры в нём не сохраняется.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-08

View File

@ -0,0 +1,49 @@
## Context
Решение владельца 08.10.2026: subject'ы NATS — как в старом АРМ. На проде заведены `files.img_uploaded`,
`files.img_parsed`, `files.tabular_uploaded`, `files.tabular_parsed`; владелец: «можно использовать три топика, давай
будем использовать только tabular_parsed. старый арм мы погасим».
| Поток | Было | Стало |
|---|---|---|
| запрос `tabular` | `recognition.tabular.request` | `files.tabular_uploaded` |
| запрос `image` | `recognition.image.request` | `files.img_uploaded` |
| ack и результат (оба сервиса) | `recognition.result` | `files.tabular_parsed` |
Схема обмена не меняется: два subject'а запроса по `pipeline` и один общий subject ответа, на который ядро подписано
queue group `arm-core`. Сопоставление ответа с заданием — по `job_id` и `Nats-Msg-Id`, не по subject.
## Goals / Non-Goals
Goals: переименовать subject'ы во всех трёх сервисах и в документах, не меняя тел сообщений и поведения.
Non-goals: см. proposal.
## Decisions
1. Общий subject ответа — `files.tabular_parsed` для обоих сервисов (прямое указание владельца). Имя исторически
«табличное», но смысл у нас — «ответ сервиса распознавания»; это оговорено в `03-architecture.md`.
2. Старый АРМ гасится (владелец) — двойной доставки запроса в обработчики старого АРМ (`armka_file_worker`) не будет.
## Risks / Trade-offs
- Если старый АРМ не погасят к выкату, его обработчики получат копию каждого запроса (в core NATS каждая queue group
получает своё сообщение): файл уйдёт в ИС Сканирование дважды, в `files.tabular_parsed` придут ответы чужого формата
(ядро отбросит их по схеме с записью в лог).
- Выкат сервисов не по одному: сервис со старыми именами не увидит запросов ядра с новыми.
## Decisions made without discussion
1. 08.10.2026 — **R-1:** маршрут — OpenSpec change в каждом из четырёх репозиториев (требования спек называют
subject'ы, спека — источник истины), код — по `.bounded` без плана SDD (механическое переименование констант).
Цена ошибки: низкая, процессная.
2. 08.10.2026 — **R-2:** `schema_version` не меняется. Требование «Версионирование контракта» относится к полям тел
сообщений; тела не меняются, subject'ы — часть окружения. Цена ошибки: низкая — потребители не различают версии
по subject'у.
3. 08.10.2026 — **R-3:** DBML v5.17 не поднимается ради двух заметок (`DECISION-4` в шапке, note колонки
`recognition_job.request_subject`): структура не меняется, а новая версия тянет копию модели, `entities`, ссылки в
`CLAUDE.md` сервисов и `04-data-model-guide.md`. Заметки обновить в следующей версии DBML — **deferred**. Цена
ошибки: до следующей версии заметки в DBML называют старые subject'ы.
4. 08.10.2026 — **R-4:** subject'ы остаются константами в `api/src/contract/subjects.py`, в настройки не выносятся
(OQ-83 — отдельно). Цена ошибки: смена имён на платформе снова потребует релиза трёх сервисов.
5. 08.10.2026 — **R-5:** исторические записи не переписываются: строки OQ-69/OQ-83 в `07-open-questions.md`, архив
OpenSpec, планы `docs/superpowers/plans/` сервисов. Цена ошибки: при чтении истории встретятся старые имена.

View File

@ -0,0 +1,44 @@
## Why
На проде платформа завела subject'ы NATS старого АРМ: `files.tabular_uploaded`, `files.img_uploaded`,
`files.tabular_parsed`, `files.img_parsed`. Наш контракт публикует запросы в `recognition.{tabular|image}.request`,
а ack и результаты — в `recognition.result`; этих subject'ов в правах общего JWT нет. Решение владельца 08.10.2026:
subject'ы — «правильно как в старом АРМ», используем три (`files.img_parsed` не используем), старый АРМ гасится.
Срез: вне плана срезов `09-delivery-plan.md` — подготовка к контуру (инфраструктура NATS платформы).
## What Changes
- Запрос `tabular` — subject `files.tabular_uploaded` (было `recognition.tabular.request`).
- Запрос `image` — subject `files.img_uploaded` (было `recognition.image.request`).
- Ack `processing` и результаты обоих сервисов — общий subject `files.tabular_parsed` (было `recognition.result`).
- Тела сообщений, `Nats-Msg-Id`, queue group'ы, `schema_version` и файл результата не меняются.
- Документы: `03-architecture.md` (схема, таблица потоков, NATS платформы), `09-delivery-plan.md`, `dev/README.md`,
`dev/qa-guide.md`, `repo-templates/*.CLAUDE.md`, описания subject'ов в `contracts/*.schema.json`.
Читают и пишут контракт: `arm-core` (публикует запросы, подписан на результат), `tabular-parser` и `scan-gateway`
(подписаны на свой запрос, публикуют ack и результат). Дочерние change'и `nats-files-subjects`:
RSHB/arm-core#77, RSHB/arm-tabular-parser#28, RSHB/arm-scan-gateway#22. Issue этого репозитория — RSHB/arm-ka-docs#74.
## Non-goals
- Subject'ы не выносятся в настройки (префикс для тестов — OQ-83, отдельно).
- `files.img_parsed` не используется, раздельные subject'ы результата по pipeline не вводятся.
- DBML v5.17 не поднимается: заметки `DECISION-4` и `recognition_job.request_subject` обновятся в следующей версии
модели (design, решение 3).
- `schema_version` контракта не меняется (design, решение 2).
## Capabilities
### New Capabilities
### Modified Capabilities
- `recognition-contract`: имена subject'ов запроса и результата.
## Impact
- Сервисы: `arm-core`, `tabular-parser`, `scan-gateway` — константы `api/src/contract/subjects.py`, тесты, спеки, README.
- Копии схем `api/contracts/*.schema.json` в сервисах обновляются побайтно из `docs/contracts/`.
- Выкат: все три сервиса одновременно (старые и новые имена несовместимы на шине). Задания, опубликованные до выката и
не получившие ack, ядро повторит уже в новый subject: subject вычисляется по `pipeline` при каждой публикации,
`recognition_job.request_subject` — справочное поле.

View File

@ -0,0 +1,79 @@
## MODIFIED Requirements
### Requirement: Сообщение запроса распознавания
Тело сообщения на subject `files.tabular_uploaded` или `files.img_uploaded` SHALL соответствовать
`docs/contracts/recognition-request.schema.json` v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id` (uuid =
`recognition_job.id`), `file_id` (uuid = `stored_file.id`), `pipeline` (`tabular` | `image`), `file_type`
(`XLS` | `XLSX` | `XLSB` | `PDF` | `PNG` | `JPG`), `original_name`, `s3 {bucket, key}` исходника в роли `uploads`,
`reply_deadline` (date-time, UTC), `attempt` (≥ 1). Subject SHALL соответствовать `pipeline`.
#### Scenario: Публикация запроса ядром
- **WHEN** ядро переводит `recognition_job` из `queued` в `published`
- **THEN** в subject, соответствующий `recognition_job.pipeline`, публикуется сообщение, проходящее схему запроса, с
заголовком `Nats-Msg-Id` = `<job_id>:<attempt>`
#### Scenario: Сообщение не по схеме
- **WHEN** сервис получает запрос, не проходящий схему (нет `s3.key`, неизвестный `file_type`), но из тела извлекаются
корректные `job_id` и `file_id`
- **THEN** сервис публикует результат со `status = error`, `error.code = SCHEMA_MISMATCH` **без предшествующего ack
`processing`** и не падает; `attempt` для `Nats-Msg-Id` результата берётся из тела запроса, при его отсутствии
реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает,
`scan-gateway` подставляет 1) — см. OQ-71
#### Scenario: Нечитаемый запрос
- **WHEN** сервис получает сообщение, из которого нельзя извлечь `job_id` (не JSON, не объект, `job_id` отсутствует или
не uuid), а значит нельзя собрать валидный конверт
- **THEN** сервис ответа не публикует: запрос записывается в лог и отбрасывается; ядро не получит ack, повторит
публикацию `OUTBOX_MAX_REPUBLISH` раз и переведёт задание в `dlq` (`error_code = NO_ACK`)
#### Scenario: Невалидный повтор запроса, уже находящегося в работе
- **WHEN** сервис получает запрос, не проходящий схему, но с `job_id`, который уже в работе или обработан этим процессом
- **THEN** конверт `SCHEMA_MISMATCH` не публикуется (его `Nats-Msg-Id` `result:<job_id>:<attempt>` совпал бы с настоящим
результатом, и ядро отбросило бы настоящий как дубликат); запрос записывается в лог и отбрасывается
#### Scenario: Примеры контракта — иллюстрации
- **WHEN** запрос берётся из `docs/contracts/examples/request-*.json` (в `request-image.json` `reply_deadline` лежит в
прошлом)
- **THEN** он проходит схему: схема проверяет для `reply_deadline` лишь формат `date-time`, но не сравнивает срок с
текущим временем; пример — иллюстрация формы сообщения, а не готовое к исполнению задание; проверка срока — дело
сервиса (`scan-gateway` отвечает `error TIMEOUT`, если `reply_deadline` истёк до отправки файла в ИС Сканирование)
### Requirement: Подтверждение взятия в работу (ack без JetStream)
Получив корректный запрос (прошедший проверку схемы и `MAJOR`), сервис SHALL немедленно, до чтения файла из S3, опубликовать в `files.tabular_parsed` конверт со
`status = processing`, `job_id`, `file_id`, `service {name, version}` и заголовком `Nats-Msg-Id = ack:<job_id>:<attempt>`.
Ядро SHALL перевести задание `published → processing` и записать `started_at`, `service_name`, `service_version`. Если
ack не получен за `ACK_TIMEOUT_SECONDS` (по умолчанию 30), ядро SHALL опубликовать запрос повторно с тем же
`Nats-Msg-Id` и тем же `attempt`, не более `OUTBOX_MAX_REPUBLISH` (по умолчанию 3) раз, после чего перевести задание в `dlq`.
#### Scenario: Сервис жив
- **WHEN** запрос опубликован и сервис подписан
- **THEN** в течение `ACK_TIMEOUT_SECONDS` в `files.tabular_parsed` появляется конверт `processing`, задание получает
`status = processing` и `started_at`
#### Scenario: Сервис недоступен
- **WHEN** ни один инстанс сервиса не подписан на subject запроса
- **THEN** ядро публикует запрос повторно каждые `ACK_TIMEOUT_SECONDS`; после `OUTBOX_MAX_REPUBLISH` повторов задание
получает `status = dlq`, `request_msg_id` не меняется
#### Scenario: Повторный запрос пришёл в работающий сервис
- **WHEN** сервис получает запрос с `job_id`, который уже обрабатывается или обработан этим процессом
- **THEN** сервис публикует ack повторно и не начинает вторую обработку
### Requirement: Сообщение результата распознавания
Тело сообщения на subject `files.tabular_parsed` SHALL соответствовать `docs/contracts/recognition-result-message.schema.json`
v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `file_id`, `status` (`processing` | `done` | `needs_check` |
`error`), `service {name, version}`; при `status ∈ {done, needs_check}` обязательны `result_s3 {bucket, key}` и
`result_schema_version`; при `status = error` обязателен `error {code, message}`; опциональны `raw_s3_key`
(только `scan-gateway`) и `metrics`. Поле `group_schema` в конверт NOT включается — источник `recognition_job.group_schema`
— файл результата.
#### Scenario: Успешный результат
- **WHEN** сервис завершил обработку файла
- **THEN** файл результата записан в S3 по ключу `results/{file_id}/{job_id}/result.json`, а затем опубликован конверт
со `status = done | needs_check`, `result_s3` указывает на этот файл, заголовок `Nats-Msg-Id` = `result:<job_id>:<attempt>`
#### Scenario: Ошибка обработки
- **WHEN** сервис не смог обработать файл (нечитаем, таймаут внешней системы, тип не поддерживается)
- **THEN** публикуется конверт со `status = error` и `error.code` из перечня схемы (`UNSUPPORTED_FORMAT`, `UNREADABLE`,
`TIMEOUT`, `SCHEMA_MISMATCH`, `NOT_IMPLEMENTED`, `FILE_READ_ERROR`, `INVALID_FORMAT`, `INVALID_DOCUMENT`,
`RECOGNITION_FAILED`, `UNKNOWN_ERROR`); `result_s3` может отсутствовать

View File

@ -0,0 +1,16 @@
## 1. Контракт (docs)
- [x] 1.1 `contracts/recognition-request.schema.json`, `contracts/recognition-result-message.schema.json`: subject'ы в описаниях
- [x] 1.2 Дельта спеки `recognition-contract` (три требования), Purpose спеки
## 2. Сервисы (дочерние change'и `nats-files-subjects`)
- [ ] 2.1 arm-core#77: `api/src/contract/subjects.py`, копии схем, тесты, спека `recognition-job-exchange`, README
- [ ] 2.2 arm-tabular-parser#28: то же, спека `tabular-recognition-service`
- [ ] 2.3 arm-scan-gateway#22: то же, спека `image-recognition-gateway`
## 3. Документы контекста
- [x] 3.1 `context/03-architecture.md`: схема, таблица потоков, NATS платформы (решение владельца 08.10.2026)
- [x] 3.2 `context/09-delivery-plan.md`, `dev/README.md`, `dev/qa-guide.md`, `repo-templates/*.CLAUDE.md`
- [x] 3.3 `07-open-questions.md` не меняется: исторические строки (design, решение 5)

View File

@ -2,7 +2,7 @@
## Purpose
Контракт обмена между ядром `arm-core` и stateless-сервисами распознавания (`tabular-parser`, `scan-gateway`):
тела сообщений NATS `recognition.{tabular|image}.request` и `recognition.result`, файл результата в S3 и правила
тела сообщений NATS `files.tabular_uploaded`, `files.img_uploaded` и `files.tabular_parsed`, файл результата в S3 и правила
версионирования и идемпотентности. Сервисы не знают БД; ядро — единственный писатель (DECISION-4).
## Requirements

View File

@ -7,7 +7,7 @@
(временный `.part` — в `Tmp`), забрать все `Исходящие/<job_id>/*.json` (по одному json на документ), сохранить склеенный
сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить горячую папку (только при `SCAN_CLEANUP=true`),
нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument`), опубликовать
`recognition.result`. Таймаут 5 мин → `TIMEOUT`. **Нет доступа к БД.**
`files.tabular_parsed`. Таймаут 5 мин → `TIMEOUT`. **Нет доступа к БД.**
## Правила
- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14): confidence → 0–100,

View File

@ -4,8 +4,8 @@
## Что это
Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам (§1 правил) и ББ/ОФР из Excel (§12). Вход — NATS
`recognition.tabular.request` + файл в S3 `uploads/`; выход — JSON по `../docs/contracts/recognition-result.schema.json`
в S3 `results/` + NATS `recognition.result`. **Нет доступа к БД.** Не знает про организации, периметры, план счетов —
`files.tabular_uploaded` + файл в S3 `uploads/`; выход — JSON по `../docs/contracts/recognition-result.schema.json`
в S3 `results/` + NATS `files.tabular_parsed`. **Нет доступа к БД.** Не знает про организации, периметры, план счетов —
возвращает структуру файла; нормализация и резолв — в arm-core.
## Главное правило