Andrey Epifancev 50e588b6bd 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
2026-10-08 23:34:01 +04:00

18 KiB
Raw Blame History

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 recognition.tabular.request или recognition.image.request 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, опубликовать в recognition.result конверт со 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 в recognition.result появляется конверт 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 recognition.result 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 сервиса падает с указанием имени файла