Andrey Epifancev acaa5021ce chore(openspec): архив contract-identity-window и unified-normalization-tp (#50, #54)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:26:49 +04:00

20 KiB
Raw Blame History

tabular-recognition-service Specification

Purpose

Stateless-сервис разбора табличных файлов (XLS/XLSX/XLSB): принимает задания из NATS без JetStream, подтверждает их, читает исходник из S3, отдаёт результат в S3 и конверт в NATS по контракту v1.1, не имея доступа к БД и фронта. С Среза 2a реализация по умолчанию разбирает ОСВ (спека osv-parsing); заглушка NullParser — только для тестов воркера.

Requirements

Requirement: Сервис не зависит от БД и фронта

Репозиторий MUST NOT содержать каталоги web/, alembic/, src/db/, src/models/, src/repositories/; зависимости SQLAlchemy, asyncpg, alembic, smbprotocol, raisa_fastapi_protected_api MUST NOT присутствовать в requirements.txt; настройки OPENBAO.DATABASE и OPENBAO.SMB MUST NOT требоваться для старта.

Scenario: Старт с минимальным окружением

  • WHEN сервис запускается с .env, содержащим только OPENBAO__MINIO__*, OPENBAO__NATS__NATS_URL, DEBUG=true
  • THEN GET /healthcheck отвечает 200 с полями uptime, version, nats

Requirement: Подтверждение запроса до обработки

Сервис SHALL быть подписан на files.tabular_uploaded в queue group tabular-parser. Получив запрос, проходящий recognition-request.schema.json, сервис SHALL до чтения файла из S3 опубликовать в files.tabular_parsed конверт status = processing с job_id, file_id, service {name: "tabular-parser", version} и заголовком Nats-Msg-Id = ack:<job_id>:<attempt>. Повторный запрос с job_id, который уже обрабатывается или обработан этим процессом, SHALL получить повторный ack и MUST NOT обрабатываться второй раз.

Scenario: Ack раньше результата

  • WHEN опубликован запрос из docs/contracts/examples/request-tabular.json
  • THEN первым сообщением сервиса в files.tabular_parsed является конверт processing, и только затем — результат

Scenario: Повторный запрос во время обработки

  • WHEN тот же запрос доставлен второй раз, пока первый обрабатывается
  • THEN публикуется второй ack processing, файл из S3 повторно не читается, результат публикуется один раз

Requirement: Результат по контракту

После подтверждения сервис SHALL прочитать объект s3.key из бакета s3.bucket, вызвать парсер, записать файл результата (по recognition-result.schema.json v2.0, schema_version = "2.0") в results/{file_id}/{job_id}/result.json и опубликовать конверт результата (по recognition-result-message.schema.json v2.0, result_schema_version = "2.0") с заголовком Nats-Msg-Id = result:<job_id>:<attempt>. Ack processing SHALL нести schema_version = "2.0"; запрос recognition-request остаётся v1.0. service.statements_version файла результата SHALL быть коммитом копии пакета arm_statements (файл VERSION копии; сервис без него не стартует) и записываться при наличии statementDocument. metrics.warnings конверта SHALL содержать предупреждения уровня файла структурами {code, severity, message, locator?}, не строками. Статус конверта — по общему правилу: done ⇔ все документы done ∧ нет предупреждений файла уровня error. При неудаче публикации сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку.

Scenario: Запрос с заглушкой парсера (Срез 0)

  • WHEN воркеру явно передана заглушка NullParser (только тесты воркера; реализация по умолчанию — реальный парсер), запрос подтверждён и объект существует в uploads/
  • THEN в files.tabular_parsed появляется конверт status = error, error.code = NOT_IMPLEMENTED, job_id/file_id из запроса, metrics.duration_ms ≥ 0; файл результата может отсутствовать

Scenario: Запрос с реальным парсером

  • WHEN запрос подтверждён и объект в uploads/ существует, но не является книгой Excel (сигнатура не XLS/XLSX/XLSB)
  • THEN в files.tabular_parsed появляется конверт status = error, error.code = UNSUPPORTED_FORMAT (повреждённая книга — UNREADABLE), job_id/file_id из запроса, metrics.duration_ms ≥ 0; файл результата может отсутствовать; корректная ОСВ в том же потоке даёт done (см. «Парсер — заменяемый интерфейс»)

Scenario: Результат и ack версии 2.0

  • WHEN обработан запрос schema_version = "1.0" с книгой, содержащей баланс
  • THEN ack processing и конверт результата несут schema_version = "2.0", result_schema_version = "2.0", файл результата проходит схему v2.0, service.statements_version равен коммиту копии arm_statements

Scenario: Предупреждение файла структурой

  • WHEN в книге лист «Примечания» без признаков ОСВ и формы
  • THEN metrics.warnings конверта содержит {code: "SHEET_OTHER", severity: "info", message, locator: "sheet:Примечания"}

Scenario: Повтор публикации

  • WHEN первая публикация конверта результата завершается ошибкой соединения, вторая — успешно
  • THEN конверт опубликован один раз с тем же Nats-Msg-Id, обработка не повторяется

Requirement: Ошибки транспорта превращаются в результат

Сервис SHALL отвечать конвертом status = error в случаях: тело не проходит схему или MAJOR версии ≠ 1 → SCHEMA_MISMATCH; объект S3 не читается → FILE_READ_ERROR; исключение парсера → UNKNOWN_ERROR (сообщение без трассировки). Сервис MUST NOT падать по этим причинам. SCHEMA_MISMATCH SHALL публиковаться (без ack), когда из тела читаемы job_id и file_id (uuid), а attempt — из тела или из заголовка запроса Nats-Msg-Id = <job_id>:<attempt>, и этот job_id сервису ещё не известен (не в работе и не среди обработанных). Иначе валидный конверт собрать нельзя либо он ушёл бы под тем же Nats-Msg-Id, что и настоящий результат: сервис SHALL записать в лог ошибку (error_category=schema_mismatch, причина) — уровнем error, если идентификаторы не читаемы, уровнем warning, если job_id уже известен, — и MUST NOT публиковать ни ack, ни результат; ядро обнаружит такое задание по dlq/timeout (явная запись в контракт — открытый вопрос «невалидный запрос без читаемых job_id/file_id/attempt» в open-questions-proposal.md). FILE_READ_ERROR и UNKNOWN_ERROR публикуются всегда: сервис MUST NOT молчать по ним.

Scenario: Нет объекта в S3

  • WHEN s3.key запроса указывает на несуществующий объект
  • THEN публикуется конверт error.code = FILE_READ_ERROR, процесс продолжает работу

Scenario: Несовместимая версия

  • WHEN запрос содержит schema_version = "2.0"
  • THEN публикуется конверт error.code = SCHEMA_MISMATCH, ack не публикуется

Scenario: Невалидный запрос без читаемых идентификаторов

  • WHEN тело запроса не JSON либо в нём не читаются job_id или file_id, либо attempt нет ни в теле, ни в заголовке Nats-Msg-Id
  • THEN в лог пишется ошибка error_category=schema_mismatch, ни ack, ни результат не публикуются, процесс продолжает работу

Scenario: Невалидный запрос с уже известным job_id

  • WHEN запрос не проходит схему, а его job_id уже в работе или среди обработанных
  • THEN в лог пишется предупреждение error_category=schema_mismatch, ни ack, ни результат не публикуются; обработка или результат задания с этим job_id не меняются

Requirement: Парсер — заменяемый интерфейс

Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (get_parser()) SHALL быть оркестратором книги BookParser: он читает книгу один раз, классифицирует листы по содержимому и отдаёт листы ОСВ конвейеру спеки osv-parsing, листы форм и их продолжения — конвейеру спеки statement-parsing (адаптер Excel и копия пакета arm_statements); прочие листы дают предупреждение файла SHEET_OTHER. Файл результата SHALL писаться с schema_version = "2.0"; документы книги — ОСВ в порядке листов, затем формы в порядке листов. Заглушка NullParser (error NOT_IMPLEMENTED) SHALL использоваться только в тестах воркера и MUST NOT быть реализацией по умолчанию. Реализация по умолчанию SHALL создаваться один раз при старте приложения (словари ОСВ читаются и проверяются там же, словари форм — default_rules() пакета). Каталог api/rules/ SHALL содержать словари ОСВ (osv_headers, osv_groups, periods, data_types, osv_checks) и README; словарей форм ББ/ОФР в сервисе нет — они часть копии arm_statements.

Scenario: Подмена реализации в тесте

  • WHEN в тесте воркеру передан парсер, возвращающий один osvDocument
  • THEN файл результата с этим документом записан в S3, конверт имеет status = done и result_s3 на этот файл

Scenario: Реальная ОСВ через воркер

  • WHEN в интеграционном тесте в S3 лежит rb/2024-FY/ОСВ_66 за 2024.xls и опубликован запрос file_type = XLS
  • THEN после ack публикуется конверт done с result_schema_version = "2.0", файл результата проходит схему v2.0 и содержит один osvDocument со status = done и account_code_raw = "66"

Scenario: Реальные формы через воркер

  • WHEN в интеграционном тесте в S3 лежит книга statements-xlsx/npz/2025-Q3/…xlsx и опубликован запрос file_type = XLSX
  • THEN после ack публикуется конверт done с result_schema_version = "2.0", файл результата проходит схему v2.0 и содержит два statementDocument (BALANCE, INCOME) со строками line_status, ref, checks и пустыми checks.relations_failed; расхождения итогов в пределах округления (1600 = 1700 на 1.94 / 9.02 / 2.48, 2100/2200/ 2300) — в soft_relations_failed (OQ-138); service.statements_version равен коммиту копии пакета

Scenario: Книга без ОСВ и форм

  • WHEN в S3 лежит книга Excel, ни один лист которой не имеет признаков ОСВ или формы
  • THEN публикуется конверт error, error.code = INVALID_DOCUMENT, сообщение называет число просмотренных листов

Scenario: Сервис без версии общего кода не стартует

  • WHEN в копии arm_statements нет файла VERSION
  • THEN require_statements_version() бросает RuntimeError с текстом «копия arm_statements без VERSION», сервис не стартует

Requirement: Копия контракта идентична источнику

Каждый файл api/contracts/**/*.json SHALL быть байт в байт равен своей паре в ../docs/contracts/ (или в contracts каталога из ARM_KA_DOCS) по правилу спеки docs recognition-contract, требование «Копия контракта в сервисе идентична источнику»: схема с $id …/v<M> — с <имя>.v<M>.schema.json, если такой файл есть в docs, иначе с основным; пример — с examples/v<M>/<имя>.json, если он есть, иначе с examples/<имя>.json; файлы docs другого MAJOR не сверяются. pydantic-модели SHALL валидировать все примеры копии api/contracts/examples/*.json и порождать JSON, проходящий схему копии. Проверка входит в make check.

Scenario: make check

  • WHEN выполняются тесты контракта при доступном ../docs (или ARM_KA_DOCS)
  • THEN идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением

Scenario: Окно перехода на контракт 2.0

  • WHEN в docs основной recognition-result.schema.json уже v2.0, рядом recognition-result.v1.schema.json и examples/v1/, а копия сервиса — v1.2
  • THEN копия сверяется с файлами .v1 и examples/v1/ (запрос — с основным файлом), тест проходит

Scenario: Копия устарела

  • WHEN в docs изменён файл, с которым сверяется копия, а копия — нет
  • THEN тест идентичности падает, сообщение называет файл

Requirement: Соединение с NATS по правилам шаблона, без JetStream

При пустых NATS_SEED/NATS_JWT сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT из OPENBAO__NATS__NATS_SEED/NATS_JWT (значения — секреты). Сервис MUST NOT требовать stream'ов или consumer'ов JetStream; при разрыве соединения SHALL переподключаться автоматически и восстанавливать подписку; /healthcheck SHALL отражать состояние (nats = ok | down; off — NATS выключен флагом NATS_ENABLED=false).

Scenario: NATS перезапущен

  • WHEN контейнер NATS перезапущен во время работы сервиса
  • THEN в течение 20 с после старта NATS подписка восстановлена, nats = ok, новый запрос обрабатывается

Requirement: Subject'ы и учётные данные NATS из настроек окружения

Сервис SHALL брать subject'ы обмена из OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT, NATS_IMAGE_REQUEST_SUBJECT, NATS_RESULT_SUBJECT (умолчания files.tabular_uploaded, files.img_uploaded, files.tabular_parsed): подписан на tabular_request (queue group tabular-parser), публикует ack и результат в result. При старте SHALL проверяться все три имени (непустое, без пробельных символов, без * и >, без пустых токенов, попарно различны); при нарушении приложение MUST NOT стартовать. Значения NATS_JWT и NATS_SEED MUST NOT попадать в логи, repr настроек и тексты ошибок валидации. Используемые subject'ы SHALL писаться в лог при старте и отдаваться в /healthcheck объектом subjects с ключами tabular_request, result при любом значении nats (ok | down | off).

Scenario: Умолчания

  • WHEN приложение стартует без ключей OPENBAO__NATS__NATS_*_SUBJECT
  • THEN /healthcheck.subjects содержит имена files.* по умолчанию, обмен идёт по ним

Scenario: Subject'ы окружения

  • WHEN заданы OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT=test.files.tabular_uploaded, OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT=test.files.img_uploaded, OPENBAO__NATS__NATS_RESULT_SUBJECT=test.files.tabular_parsed
  • THEN подписки и публикации идут только по этим subject'ам, /healthcheck.subjects их показывает

Scenario: Недопустимый subject

  • WHEN OPENBAO__NATS__NATS_RESULT_SUBJECT совпадает с subject'ом запроса или содержит >
  • THEN приложение не стартует, ошибка называет ключ

Scenario: JWT не раскрывается

  • WHEN заданы NATS_JWT и NATS_SEED, а валидация настроек падает на другом поле
  • THEN текст ошибки и лог не содержат значений JWT и seed; repr настроек показывает их скрытыми

Scenario: Тесты на своих subject'ах

  • WHEN тесты roundtrip (tests/integration/test_roundtrip.py) запускаются при работающих контейнерах стенда на том же NATS
  • THEN тесты используют уникальные subject'ы из настроек и не получают и не забирают сообщения стенда