Change слит PR #77; спеки обновлены (subject'ы files.tabular_uploaded, files.img_uploaded, files.tabular_parsed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
173 lines
18 KiB
Markdown
173 lines
18 KiB
Markdown
# recognition-contract Specification
|
||
|
||
## Purpose
|
||
Контракт обмена между ядром `arm-core` и stateless-сервисами распознавания (`tabular-parser`, `scan-gateway`):
|
||
тела сообщений NATS `files.tabular_uploaded`, `files.img_uploaded` и `files.tabular_parsed`, файл результата в S3 и правила
|
||
версионирования и идемпотентности. Сервисы не знают БД; ядро — единственный писатель (DECISION-4).
|
||
|
||
## 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` может отсутствовать
|
||
|
||
### Requirement: Файл результата
|
||
Файл результата SHALL соответствовать `docs/contracts/recognition-result.schema.json` v1.2: один файл (`stored_file`)
|
||
→ один `job_id` → массив `documents` из `osvDocument` и/или `statementDocument`; денежные значения — строки
|
||
`^-?\d+(\.\d{1,2})?$`; `confidence` — 0–100. Пустой массив `documents` допустим только при `status = error`.
|
||
У строки `osvDocument.rows[]` SHALL быть необязательное поле `extra` — объект «заголовок колонки → строка или null» для
|
||
колонок внутри таблицы, не сопоставленных с шестью суммами и ИНН (§1.2 «не терять»). У шапки `osvDocument.header` SHALL
|
||
быть необязательные `period_start`, `period_end` (дата или null — границы периода по шапке; для квартала и года —
|
||
первое и последнее число, для диапазона месяцев — его границы) и `is_from_year_start` (boolean или null; `is_cumulative`
|
||
не переопределяется). `statementDocument.document_type` SHALL допускать `DISCLOSURE` (расшифровка или иная форма
|
||
0710xxx: документ без `columns` и `lines`). У `statementDocument` SHALL быть необязательный объект `checks`:
|
||
`relations_failed` и `soft_relations_failed` — массивы `{column_index, relation, expected, actual}` (нарушенные
|
||
соотношения §7 по колонке; `expected`/`actual` — `money`), `duplicate_line_codes` — массив строк; отсутствие объекта
|
||
означает «не проверялось», пустые массивы — «проверено, сошлось». Потребители, не знающие новых полей и значений,
|
||
игнорируют их (MINOR). Производитель v1.2 пишет `schema_version = "1.2"`; файлы v1.0 и v1.1 остаются валидными.
|
||
|
||
#### Scenario: Результат валидируется примерами
|
||
- **WHEN** CI сервиса запускает тесты контракта
|
||
- **THEN** все файлы `docs/contracts/examples/result-*.json` проходят схему файла результата, а все
|
||
`request-*.json` / `result-message-*.json` — схемы сообщений
|
||
|
||
#### Scenario: Лишняя колонка сохранена
|
||
- **WHEN** в ОСВ между ИНН и суммами есть колонка «КПП»
|
||
- **THEN** у строк контрагентов `extra = {"КПП": "<значение>"}`, файл проходит схему v1.2 и схему v1.0 (поле
|
||
игнорируется)
|
||
|
||
#### Scenario: Форма из Excel с проверками
|
||
- **WHEN** tabular-parser разобрал книгу с балансом на двух листах, ОФР и пояснениями (`examples/result-statement-excel.json`)
|
||
- **THEN** файл проходит схему v1.2: `BALANCE` с `locator` из двух имён листов и `checks` с нарушением в
|
||
`soft_relations_failed`, `INCOME` с пустыми массивами `checks`, `DISCLOSURE` без `columns`/`lines`; тот же файл
|
||
без `checks` и с `document_type = OTHER` вместо `DISCLOSURE` проходит схему v1.1
|
||
|
||
#### Scenario: Границы периода ОСВ
|
||
- **WHEN** в шапке ОСВ период «Октябрь 2024 г. – Сентябрь 2025 г.»
|
||
- **THEN** `period_start = "2024-10-01"`, `period_end = "2025-09-30"`, `is_from_year_start = false`; файл проходит схему
|
||
v1.2, а потребитель v1.1 читает его без ошибок
|
||
|
||
### Requirement: Версионирование контракта
|
||
Поле `schema_version` SHALL быть строкой `MAJOR.MINOR`. Добавление необязательных полей или значений enum —
|
||
`MINOR` (1.1, обратно совместимо); удаление/переименование полей, смена типов или семантики — `MAJOR` (2.0).
|
||
Сервис SHALL отвергать запрос с неизвестным `MAJOR` (`error.code = SCHEMA_MISMATCH`); ядро SHALL принимать результат
|
||
с тем же `MAJOR` и любым известным или новым `MINOR`.
|
||
|
||
#### Scenario: Несовместимая версия запроса
|
||
- **WHEN** сервис, поддерживающий `1.x`, получает запрос `schema_version = "2.0"`
|
||
- **THEN** публикуется результат `status = error`, `error.code = SCHEMA_MISMATCH` **без ack `processing`**: ответом на
|
||
запрос служит сам финальный конверт, ядро закрывает им задание (`published → error`), и повторной доставки запроса не
|
||
будет. Конверт публикуется только если из тела извлекаются корректные `job_id` и `file_id`; иначе — как для нечитаемого
|
||
запроса (ответа нет, ядро закроет задание по `NO_ACK` / `dlq`). `attempt` для `Nats-Msg-Id` берётся из тела запроса; при его
|
||
отсутствии реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает,
|
||
`scan-gateway` подставляет 1) — см. OQ-71
|
||
|
||
### Requirement: Идемпотентность результатов без брокера
|
||
Публикующий SHALL ставить заголовок `Nats-Msg-Id` на каждое сообщение (запрос `<job_id>:<attempt>`, ack
|
||
`ack:<job_id>:<attempt>`, результат `result:<job_id>:<attempt>`). Поскольку серверного дедупа нет, ядро SHALL считать
|
||
результат дубликатом, если `recognition_job.result_msg_id` уже равен `Nats-Msg-Id` сообщения, и MUST NOT менять задание
|
||
повторно; потерянный результат SHALL обнаруживаться по `reply_deadline` (`timeout`).
|
||
|
||
#### Scenario: Результат доставлен дважды
|
||
- **WHEN** сервис опубликовал один и тот же результат два раза (повтор публикации после сетевой ошибки)
|
||
- **THEN** задание изменено один раз, второе сообщение проигнорировано
|
||
|
||
### Requirement: Повторы публикации ответов сервисом
|
||
Сервис SHALL публиковать ack и результат с заголовком `Nats-Msg-Id` и при временной ошибке публикации (потеря соединения,
|
||
таймаут подтверждения сервера) повторять публикацию с тем же `Nats-Msg-Id`: **одна первая попытка и до
|
||
`PUBLISH_RETRIES` повторов** (значение по умолчанию 3, то есть не более 4 попыток), с паузой между попытками; пауза —
|
||
деталь реализации. Повторы исчерпаны — сервис пишет error-лог и не публикует это сообщение снова; задание закрывается
|
||
на стороне ядра (не пришёл результат — `reply_deadline` → `timeout`; не пришёл ack — повтор запроса и `dlq`, но результат,
|
||
дошедший без ack, ядро принимает и в статусе `published`). Дубликаты, появившиеся из-за повтора,
|
||
безопасны: ядро дедуплицирует их по `Nats-Msg-Id`.
|
||
|
||
#### Scenario: Временный сбой публикации
|
||
- **WHEN** публикация результата упала с временной ошибкой, а следующая попытка успешна
|
||
- **THEN** сообщение опубликовано с прежним `Nats-Msg-Id`; ядро изменит задание один раз, даже если первая попытка тоже дошла
|
||
|
||
#### Scenario: Повторы исчерпаны
|
||
- **WHEN** все 1 + `PUBLISH_RETRIES` попыток публикации результата неуспешны
|
||
- **THEN** сервис пишет error-лог и продолжает работу (процесс не падает), а ядро закрывает задание по `reply_deadline`
|
||
|
||
### Requirement: Копия контракта в сервисе идентична источнику
|
||
Каждый сервис, читающий или пишущий контракт, SHALL хранить копию файлов схем в `api/contracts/` и проверять в `make check`
|
||
их байтовую идентичность файлам `docs/contracts/` (`../docs` или путь `ARM_KA_DOCS`; при недоступности — пропуск с предупреждением).
|
||
|
||
#### Scenario: Копия устарела
|
||
- **WHEN** в `docs/contracts/` изменена схема, а копия в сервисе — нет
|
||
- **THEN** тест идентичности в `make check` сервиса падает с указанием имени файла
|