From 50e588b6bd0bf59112f11ff6064587c5eb7916be Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 23:34:01 +0400 Subject: [PATCH] =?UTF-8?q?contracts:=20subject'=D1=8B=20NATS=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=20=D0=B2=20=D1=81=D1=82=D0=B0=D1=80=D0=BE=D0=BC=20?= =?UTF-8?q?=D0=90=D0=A0=D0=9C=20=E2=80=94=20files.*=5Fuploaded,=20files.ta?= =?UTF-8?q?bular=5Fparsed=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решение владельца 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- context/03-architecture.md | 28 ++++--- context/09-delivery-plan.md | 2 +- contracts/recognition-request.schema.json | 2 +- .../recognition-result-message.schema.json | 2 +- dev/README.md | 14 ++-- dev/qa-guide.md | 4 +- .../nats-files-subjects/.openspec.yaml | 2 + .../changes/nats-files-subjects/design.md | 49 ++++++++++++ .../changes/nats-files-subjects/proposal.md | 44 +++++++++++ .../specs/recognition-contract/spec.md | 79 +++++++++++++++++++ openspec/changes/nats-files-subjects/tasks.md | 16 ++++ openspec/specs/recognition-contract/spec.md | 2 +- repo-templates/scan-gateway.CLAUDE.md | 2 +- repo-templates/tabular-parser.CLAUDE.md | 4 +- 14 files changed, 221 insertions(+), 29 deletions(-) create mode 100644 openspec/changes/nats-files-subjects/.openspec.yaml create mode 100644 openspec/changes/nats-files-subjects/design.md create mode 100644 openspec/changes/nats-files-subjects/proposal.md create mode 100644 openspec/changes/nats-files-subjects/specs/recognition-contract/spec.md create mode 100644 openspec/changes/nats-files-subjects/tasks.md diff --git a/context/03-architecture.md b/context/03-architecture.md index 6ea9fbf..583f1bd 100644 --- a/context/03-architecture.md +++ b/context/03-architecture.md @@ -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//` и входящий файл — **только под флагом `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` | `:` | [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::` | [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::` | [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` | `:` | [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::` | [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::` | [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 РАИСА. diff --git a/context/09-delivery-plan.md b/context/09-delivery-plan.md index 4c984a1..34e71d8 100644 --- a/context/09-delivery-plan.md +++ b/context/09-delivery-plan.md @@ -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` возвращает пользователя и его эффективные права; все миграции применяются и откатываются; diff --git a/contracts/recognition-request.schema.json b/contracts/recognition-request.schema.json index 780573c..9d59f2c 100644 --- a/contracts/recognition-request.schema.json +++ b/contracts/recognition-request.schema.json @@ -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 = : (не поле тела). Один файл (stored_file) → один job.", + "description": "Тело сообщения на subject files.tabular_uploaded или files.img_uploaded; subject должен соответствовать полю pipeline (tabular → files.tabular_uploaded, image → files.img_uploaded). Заголовок Nats-Msg-Id = : (не поле тела). Один файл (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"], diff --git a/contracts/recognition-result-message.schema.json b/contracts/recognition-result-message.schema.json index 1f20727..7c1cfb5 100644 --- a/contracts/recognition-result-message.schema.json +++ b/contracts/recognition-result-message.schema.json @@ -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:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", + "description": "Тело сообщения на subject files.tabular_parsed. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме 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"], diff --git a/dev/README.md b/dev/README.md index 534de3f..7cbe161 100644 --- a/dev/README.md +++ b/dev/README.md @@ -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: :1`) → -`recognition.result` `ack::1` (`status: processing`) → `result::1` (`status: error`). Публикатор +error_code=NOT_IMPLEMENTED») и на шине: `files.tabular_uploaded` (`Nats-Msg-Id: :1`) → +`files.tabular_parsed` `ack::1` (`status: processing`) → `result::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` не мешает). ## Проверка контракта diff --git a/dev/qa-guide.md b/dev/qa-guide.md index 3a73872..0800209 100644 --- a/dev/qa-guide.md +++ b/dev/qa-guide.md @@ -126,7 +126,7 @@ cd api && .venv/bin/python run_server.py Swagger: . Сервер слушает только `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), а не по имени: шлюз кладёт файл в горячую папку как `.`, и имя фикстуры в нём не сохраняется. diff --git a/openspec/changes/nats-files-subjects/.openspec.yaml b/openspec/changes/nats-files-subjects/.openspec.yaml new file mode 100644 index 0000000..01660ba --- /dev/null +++ b/openspec/changes/nats-files-subjects/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-08 diff --git a/openspec/changes/nats-files-subjects/design.md b/openspec/changes/nats-files-subjects/design.md new file mode 100644 index 0000000..4e86542 --- /dev/null +++ b/openspec/changes/nats-files-subjects/design.md @@ -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/` сервисов. Цена ошибки: при чтении истории встретятся старые имена. diff --git a/openspec/changes/nats-files-subjects/proposal.md b/openspec/changes/nats-files-subjects/proposal.md new file mode 100644 index 0000000..78d5180 --- /dev/null +++ b/openspec/changes/nats-files-subjects/proposal.md @@ -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` — справочное поле. diff --git a/openspec/changes/nats-files-subjects/specs/recognition-contract/spec.md b/openspec/changes/nats-files-subjects/specs/recognition-contract/spec.md new file mode 100644 index 0000000..e9f1730 --- /dev/null +++ b/openspec/changes/nats-files-subjects/specs/recognition-contract/spec.md @@ -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` = `:` + +#### 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::` совпал бы с настоящим + результатом, и ядро отбросило бы настоящий как дубликат); запрос записывается в лог и отбрасывается + +#### 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::`. +Ядро 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::` + +#### 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` может отсутствовать diff --git a/openspec/changes/nats-files-subjects/tasks.md b/openspec/changes/nats-files-subjects/tasks.md new file mode 100644 index 0000000..3c3fdd2 --- /dev/null +++ b/openspec/changes/nats-files-subjects/tasks.md @@ -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) diff --git a/openspec/specs/recognition-contract/spec.md b/openspec/specs/recognition-contract/spec.md index 36c17ef..9fa5769 100644 --- a/openspec/specs/recognition-contract/spec.md +++ b/openspec/specs/recognition-contract/spec.md @@ -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 diff --git a/repo-templates/scan-gateway.CLAUDE.md b/repo-templates/scan-gateway.CLAUDE.md index af1a188..042252b 100644 --- a/repo-templates/scan-gateway.CLAUDE.md +++ b/repo-templates/scan-gateway.CLAUDE.md @@ -7,7 +7,7 @@ (временный `.part` — в `Tmp`), забрать все `Исходящие//*.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, diff --git a/repo-templates/tabular-parser.CLAUDE.md b/repo-templates/tabular-parser.CLAUDE.md index b942918..912dfeb 100644 --- a/repo-templates/tabular-parser.CLAUDE.md +++ b/repo-templates/tabular-parser.CLAUDE.md @@ -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. ## Главное правило