22 KiB
recognition-contract Specification
Purpose
Контракт обмена между ядром arm-core и stateless-сервисами распознавания (tabular-parser, scan-gateway):
тела сообщений NATS на subject'ах запроса и ответа (настройки окружения OPENBAO__NATS__NATS_*_SUBJECT, по умолчанию
files.tabular_uploaded, files.img_uploaded и files.tabular_parsed), файл результата в S3 и правила версионирования и
идемпотентности. Сервисы не знают БД; ядро — единственный писатель (DECISION-4).
Requirements
Requirement: Сообщение запроса распознавания
Тело сообщения на subject запроса tabular или image (настройки окружения NATS_TABULAR_REQUEST_SUBJECT /
NATS_IMAGE_REQUEST_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без предшествующего ackprocessingи не падает;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-Idresult:<job_id>:<attempt>совпал бы с настоящим результатом, и ядро отбросило бы настоящий как дубликат); запрос записывается в лог и отбрасывается
Scenario: Примеры контракта — иллюстрации
- WHEN запрос берётся из
docs/contracts/examples/request-*.json(вrequest-image.jsonreply_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 ответа (настройка окружения NATS_RESULT_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без ackprocessing: ответом на запрос служит сам финальный конверт, ядро закрывает им задание (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сервиса падает с указанием имени файла
Requirement: Subject'ы NATS — настройки окружения
Subject'ы обмена SHALL задаваться секретами группы OPENBAO__NATS__ с одинаковыми ключами во всех трёх сервисах:
NATS_TABULAR_REQUEST_SUBJECT (по умолчанию files.tabular_uploaded), NATS_IMAGE_REQUEST_SUBJECT (по умолчанию
files.img_uploaded), NATS_RESULT_SUBJECT (по умолчанию files.tabular_parsed). У ядра и сервисов одного окружения
значения MUST совпадать. Каждый сервис SHALL при старте проверять все три имени: непустое, без пробельных символов, без
шаблонов * и >, без пустых токенов, три имени попарно различны; при нарушении сервис MUST NOT стартовать.
Учётные данные NATS (OPENBAO__NATS__NATS_JWT, OPENBAO__NATS__NATS_SEED) SHALL задаваться по окружению теми же
секретами, и их значения MUST NOT попадать в логи, repr настроек и тексты ошибок валидации конфигурации. Каждый
сервис SHALL писать используемые subject'ы в лог при старте и отдавать их в /healthcheck объектом subjects
(ключи tabular_request, image_request, result — только те, что использует сервис) независимо от состояния NATS.
Queue group'ы и schema_version от настроек subject'ов не зависят.
Scenario: Умолчания
- WHEN сервис стартует без ключей
OPENBAO__NATS__NATS_*_SUBJECT - THEN обмен идёт по
files.tabular_uploaded,files.img_uploaded,files.tabular_parsed
Scenario: Набор тестового окружения
- WHEN всем трём сервисам заданы одинаковые
OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT,NATS_IMAGE_REQUEST_SUBJECT,NATS_RESULT_SUBJECT,NATS_JWT,NATS_SEEDтестового окружения - THEN ядро публикует запросы и получает ack и результаты только по этим subject'ам; subject'ы по умолчанию не
используются,
recognition_job.request_subjectсодержит subject тестового окружения
Scenario: Недопустимый набор
- WHEN
NATS_RESULT_SUBJECTсовпадает с одним из subject'ов запроса, или имя содержит*,>или пробел - THEN сервис не стартует с ошибкой конфигурации, называющей ключ
Scenario: Разъехавшиеся настройки видны
- WHEN у ядра и сервиса заданы разные subject'ы ответа
- THEN
/healthcheck.subjects.resultядра и сервиса различаются; JWT и seed в ответе/healthcheckотсутствуют
Scenario: Ошибка конфигурации не раскрывает JWT
- WHEN сервис не стартует из-за ошибки валидации настроек при заданных
NATS_JWTиNATS_SEED - THEN текст ошибки и лог не содержат значений JWT и seed