Andrey Epifancev 55dbf8d16d chore(arm_statements): копия arm-statements@25b2bb8 (#50)
Копия api/arm_statements: 3390f92..25b2bb8 (1 коммитов), скрипт docs/scripts/sync-statements.sh.
Коммиты arm-statements:
- 72e2e72 fix(#8): шаг 2 — нумерация со сбоем OCR под заголовком, повтор шапки со склеенной нумерацией (AURORA-1517)

statements-check scan-gateway: 0 — копия синхронна с arm-statements@origin/main (25b2bb8)

Co-Authored-By: sync-statements.sh <noreply@aepif.ru>
2026-10-22 16:45:32 +04:00

scan-gateway

Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений (PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его и публикует результат в files.tabular_parsed. Контракт обмена — ../docs/contracts/ (v2.0: statementDocument с status, line_status, needs_review, структурными предупреждениями; schema_version = "2.0" в конвертах, включая ack, и в файле результата), архитектура — ../docs/context/03-architecture.md.

Границы сервиса (DECISION-4):

  • нет базы данных: единственный писатель в БД — arm-core; шлюз видит только NATS, S3 и горячие папки;
  • нет фронта; HTTP — только GET /healthcheck;
  • сырой ответ ИС лежит в S3 (raw/{job_id}.json) и в БД не регистрируется;
  • саму ИС Сканирование мы не пишем: для разработки есть эмулятор (api/src/scan_emulator/).

Состояние: сквозной путь работает, ответ ИС нормализуется общим кодом arm_statements в statementDocument v2.0 (раздел «Нормализация»); раскладка горячих папок — как в проверенном в контуре armka_file_worker (<job_id>.<ext>). Известные ограничения — в конце файла. Правила для агента и соглашения репозитория — CLAUDE.md; требования и решения — openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/ (design.md: таблица «Decisions made without discussion») и архив Среза 0 openspec/changes/archive/2026-10-04-slice-0-skeleton/.

Поток обработки

files.img_uploaded (NATS, queue group scan-gateway)
  1. разбор и проверка запроса        ──нет/не по схеме──► error SCHEMA_MISMATCH (без ack)
  2. ack: files.tabular_parsed, status=processing            (до чтения S3 и до ожидания слота)
  3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
  4. файл из S3 (s3.bucket/s3.key)
  5. горячая папка: <Input>/<job_id>.<ext>                  (сразу, wb, существующий перезаписывается)
  6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда
     не менялся SCAN_SETTLE_SECONDS
  7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
  8. очистка горячей папки (только при SCAN_CLEANUP=true)
  9. нормализатор (OcrNormalizer) ──► (done/needs_check) S3 results/{file_id}/{job_id}/result.json
 10. files.tabular_parsed: итоговый конверт (done | needs_check | error), в нём raw_s3_key

Каждое задание заканчивается опубликованным конвертом: сбой превращается в error с кодом (таблица ниже), а не в молчание. Если не удалось даже опубликовать (после повторов) — запись в лог уровня error, ядро закроет задание по своему reply_deadline. Падение шлюза после ack теряет задание до reply_deadline (без JetStream иначе нельзя).

Повторный запрос с job_id, который уже в работе или в LRU обработанных (DONE_CACHE_SIZE), получает повторный ack (с attempt повторного запроса); файл в ИС второй раз не отправляется.

Протокол горячих папок

Реализации интерфейса HotFolder (api/src/gateway/hotfolder.py: put, poll, cleanup, healthy):

Режим Реализация Для чего
local (по умолчанию) LocalHotFolder, обычный каталог SCAN_HOTFOLDER_PATH разработка, тесты, эмулятор
smb SmbHotFolder (api/src/gateway/smb.py, smbprotocol), шара из OPENBAO__SMB__* платформа

Раскладка совпадает с проверенной в контуре (RSHB/armka_file_worker, OQ-137): Input/<job_id>.<ext> → Output/<job_id>/*.json.

  • Входящий файл: <SCAN_INPUT_DIR>/<job_id>.<ext>. <ext> — суффикс после последней точки последнего компонента original_name запроса (разделители пути / и \ учитываются), в нижнем регистре, 1–16 букв и цифр ASCII (aep_5.PDF → <job_id>.pdf): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не попадает. Нет допустимого расширения — RECOGNITION_FAILED без записи в горячую папку.
  • Запись — сразу в Input, как в старом АРМ (решение владельца 09.10.2026, change hotfolder-direct-write): файл открывается на запись с усечением (wb) и пишется целиком; временного файла, служебного каталога и переименования нет. В режиме smb — smbclient.open_file(<Input>\<имя>, "wb"): файл открыт монопольно, пока идёт запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). Если Input/<job_id>.<ext> уже существует (повтор задания ядром после потери ack в другом процессе), он перезаписывается содержимым объекта S3, задание продолжается опросом Output/<job_id>/. Ошибка посреди записи → RECOGNITION_FAILED; недописанный файл снимается только при SCAN_CLEANUP=true (иначе остаётся в Input). Каталог Input создаётся при необходимости (без родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня — RECOGNITION_FAILED.
  • Ответ ИС — каталог <SCAN_OUTPUT_DIR>/<job_id>/: ИС кладёт туда все распознанные документы задания одновременно, по одному *.json на файл (Output/<job_id>/*.json). Шлюз читает все *.json каталога и склеивает их в один ответ; прочие файлы каталога игнорируются. job_id проверяется на безопасность пути. В одном json может быть несколько таблиц разных форм (баланс, ОФР); нормализатор делает из каждой таблицы отдельный документ.
  • Готовность набора — по стабилизации (settle): в каталоге есть хотя бы один *.json, а имена и размеры *.json не менялись SCAN_SETTLE_SECONDS (по умолчанию 2 с). Отсчёт идёт от первого опроса, который увидел окончательный набор (а не от времени изменения файлов), поэтому ответ приходит не раньше чем через SCAN_SETTLE_SECONDS после такого опроса и не позднее SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS после первого опроса, увидевшего набор; 0 — готов любой непустой набор. Появление или изменение файла сбрасывает отсчёт; набор, который так и не стабилизировался до дедлайна, даёт TIMEOUT.
  • Таймаут ожидания: min(reply_deadline, момент записи файла + SCAN_TIMEOUT_SECONDS); опрос каждые SCAN_POLL_SECONDS (ожидание settle входит в этот срок). Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется.
  • Повторной отправки в ИС нет (OQ-15): после TIMEOUT входящий файл остаётся в каталоге, поздний ответ ИС теряется (сырой файл остаётся на шаре).

Очистка горячей папки (SCAN_CLEANUP)

Очистка включается SCAN_CLEANUP=true; по умолчанию выключена: на SMB-шаре контура права на удаление не подтверждены (открытый вопрос Среза 3a). Для dev-стенда (compose, эмулятор) флаг стоит включать.

  • Когда. Воркер вызывает HotFolder.cleanup сразу после того, как набор ответа прочитан, склеен и сохранён в raw/{job_id}.json — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС).
  • Что удаляется (всё, что есть): всё содержимое каталога Output/<job_id>/ (не только *.json) и сам каталог, Input/<job_id>.<ext>, Input/<job_id>.<ext>.done (след эмулятора). Tmp шлюз не трогает. Отсутствующий файл или каталог не ошибка. В режиме local каталог ответа удаляется рекурсивно; в режиме smb удаляются файлы каталога, а подкаталог внутри Output/<job_id>/ не даст снять каталог (rmdir → STATUS_DIRECTORY_NOT_EMPTY): ошибка очистки поднимается и логируется, остальные пункты (входящий файл, .done) в этом задании не удаляются.
  • Когда не вызывается: TIMEOUT (ИС может ещё писать ответ), FILE_READ_ERROR, RECOGNITION_FAILED записи во входящий каталог, ошибка записи raw/ в S3. Входящий файл и каталог ответа тогда остаются.
  • Ошибки удаления (например, PermissionError на шаре): одна строка в логе (warning) на задание; конверт, metrics.warnings и /healthcheck не меняются — исход задания определён разбором ответа.
  • Флаг управляет всем удалением: при SCAN_CLEANUP=false сервис не удаляет ничего — ни каталог ответа, ни входящий файл и .done, ни недописанный при ошибке записи входящий файл — и не пишет об этом в лог (решение №27). Остатки Tmp/.*.part от версий шлюза до change hotfolder-direct-write сервис не трогает — убрать вручную.
  • Права на SMB: для записи учётной записи шары нужно создание и перезапись файлов в Input, для чтения ответа — листинг и чтение Output; для очистки — удаление в Input и Output (включая каталог <job_id>). Пока права на удаление не подтверждены, оставляйте SCAN_CLEANUP=false.

Эмулятор ИС в режиме local

Для файла <uuid>.<ext> эмулятор ждёт SCAN_EMULATOR_DELAY и кладёт в Output/<uuid>/ набор эталонных ответов. Пара ищется по sha256 содержимого входящего файла, а не по имени: эмулятор при старте индексирует SCAN_FIXTURES_DIR (для каждого файла, не являющегося *.json, — sha256 → основа имени без расширения, aep_6.pdf → aep_6; индекс пересчитывается, когда меняется список файлов каталога). При совпадении хеша в наборе все <основа>.json и <основа>_<суффикс>.json → <uuid>.json и <uuid>_<суффикс>.json (именно <основа>_*, а не префикс <основа>*: aep_3 не забирает aep_30*; в каждом файле file_uid = <uuid>). Промах по хешу: набор SCAN_EMULATOR_DEFAULT (основа имени, например aep_6), если он задан и такой набор есть, в логе отметка «по умолчанию»; иначе один ответ-ошибка INVALID_DOCUMENT с текстом <первые 12 символов sha256>: нет пары по содержимому в фикстурах. Набор собирается во временном каталоге Tmp и появляется в Output/<uuid>/ атомарно целиком (rename каталога). Затем входящий файл переименовывается в ….done. Файлы с точки, *.part, *.tmp, *.done игнорируются.

Склеенный ответ ИС (raw/{job_id}.json)

В S3 до нормализации сохраняется склейка всех *.json каталога ответа (raw_s3_key — одна строка raw/{job_id}.json):

{
  "job_id": "<job_id>",
  "files": [
    {"name": "<job_id>.json", "response": {"file_uid": "...", "error": {"code": "", "message": ""}, "data_ocr": {}}, "parse_error": null},
    {"name": "<job_id>_ofr.json", "response": {"file_uid": "...", "error": {"code": "INVALID_DOCUMENT", "message": "..."}}, "parse_error": null}
  ]
}

files — все *.json каталога по возрастанию имени. Файл, который не разобрался как JSON, не роняет задание: response = null, parse_error — текст ошибки. Нормализатору передаётся ровно этот объект.

Формат файла ответа Content AI: {"file_uid", "error": {"code", "message"}, "data_ocr": {...}}. Успех — это error.code == "" (error при этом не null: {"code": "", "message": ""}), поэтому проверять error != null нельзя. Ошибка — непустой error.code (INVALID_DOCUMENT, ...).

Нормализация (statementDocument v2.0)

Нормализация ББ/ОФР — общий код пакета arm_statements (шаги 2–8, статусы, перевод в контракт; 05-domain-rules.md §11.9). Копия пакета лежит в api/arm_statements/ и напрямую не правится: обновление — sync-statements (../docs/scripts/sync-statements.sh), свежесть — make statements-check. Словари (редакции форм, двойники, label → тип) живут в пакете (default_rules()); правка словарей — через репозиторий arm-statements, а не здесь.

Что остаётся в шлюзе (api/src/normalizer/):

  • ocr/adapter.py — адаптер шага 1 (05-domain-rules.md §11.1): таблица data_ocr.table_structure[i] → кандидат пакета (сетка ячеек по возрастанию row_index, ref ячейки — номер строки ИС, шапка data_ocr, заголовок формы). Нарушенная структура таблицы (повтор row_index / column_index, нецелые индексы, rows не список) — исключение адаптера, то есть TABLE_PARSE_ERROR всей таблицы.
  • ocr/answer_set.py — обход набора json склейки: на каждую таблицу успешного файла зовётся normalize_candidate пакета, исключение одной таблицы не роняет остальные; статус набора — envelope_status пакета.
  • ocr/__init__.py — OcrNormalizer: результат пакета переводится в модели контракта функциями to_contract / warning_to_contract (граница контракта; шлюз только валидирует словарь своими pydantic-моделями), версия копии (api/arm_statements/VERSION) идёт в NormalizeOutcome и в service.statements_version файла результата.
  • files.py — ошибки файла набора (error.code, parse_error, корень не объект); outcome.py — NormalizeOutcome.

Ушло в пакет: очистка текста, даты, суммы, раскладка колонок, классы строк, типы форм, редакции, статусы строк и документов, предупреждения документов, checks (§7). Старые модули ocr/text.py … document.py и каталог api/rules/ удалены.

Документ на каждую таблицу успешного файла: locator = "file:<имя json>;table:<i>", header.form_name — заголовок таблицы ИС. Подробные правила шагов 2–8 и статусов — 05-domain-rules.md §11.9, предупреждения документа — там же и в схеме statementDocument.

Статус результата

  • envelope_status пакета: из статусов документов и предупреждений файла. Документы есть и все done, предупреждений файла уровня error нет — done; иначе needs_check; ни одного документа — error.
  • Все json набора ошибочны (Р-33) — error с кодом и сообщением первого ошибочного файла по имени (см. «Коды ошибок»); metrics.warnings при этом перечисляет JSON_FAILED каждого json.
  • Успешные файлы не дали документов (нет таблиц, все таблицы потеряны) — error, RECOGNITION_FAILED, сообщение «ни одной таблицы не разобрано».
  • Ошибочный файл при наличии успешного — JSON_FAILED (error) в metrics.warnings, статус не выше needs_check.
  • Успешный json без data_ocr, без table_structure или с пустым table_structure — тоже JSON_FAILED (Р-32).
  • metrics.rows — число строк lines всех документов (теперь и строки без кода). Файл результата results/{file_id}/{job_id}/result.json пишется для done и needs_check; metrics.warnings в конверте и в файле результата одинаковы.

Предупреждения (metrics.warnings)

metrics.warnings — список структур {code, severity, message, locator} (строк больше нет). Предупреждения файла, которые порождает шлюз и пакет:

Код Уровень Когда locator
JSON_FAILED error файл набора ошибочен (error.code ИС, parse_error, корень не объект), либо успешный json без data_ocr / table_structure / таблиц; message — <код ИС>: <сообщение> или причина file:<имя json>
TABLE_PARSE_ERROR error исключение при разборе таблицы (в том числе нарушенная структура таблицы ИС); message — BACKEND_PARSING_ERROR: <тип>: <текст до 300 символов> file:<имя json>;table:<i>
TABLE_LOST error таблица с данными не распознана как форма: нет столбца кодов и типа формы file:<имя json>;table:<i>
TABLE_SKIPPED info таблица без данных пропущена file:<имя json>;table:<i>

Предупреждения документа (documents[i].warnings, у них нет locator) и нарушения §7 — в самих документах, см. 05-domain-rules.md §11.9. Список metrics.warnings ограничен 100 структурами, message — 1000 символов; при переполнении остаются все error, затем info (сырой ответ — в raw/).

Эталоны и make golden-update

Тест api/tests/unit/normalizer/test_golden.py сравнивает результат нормализатора на реальных ответах ИС (../fixtures/ocr/<имя>.json + <имя>_*.json) с эталонами ../fixtures/ocr/expected_normalize/<имя>.json. Формат v2 (P5-14): {schema_version, status, error, metrics, documents} файла результата без job_id, file_id, service, raw_s3_key (statements_version меняется при каждой раскладке копии); успешный эталон дополнительно проходит схему recognition-result v2.0. Эталоны лежат в репозитории fixtures и обновляются отдельным PR: make golden-update (api/tests/golden_update.py) пересобирает их, diff смотреть через git -C ../fixtures diff и объяснять. Без каталога ../fixtures/ocr golden-тесты не выполняются.

Переменные окружения

Файл окружения — ENV_FILE (по умолчанию .env, путь от текущего каталога, то есть от api/); переменные окружения приоритетнее файла. Шаблон — api/.env.example. Настройки шлюза читаются при импорте src.app: модуль создаёт app (это происходит при запуске run_server.py или uvicorn src.app:app). Импорт пакета src приложение не создаёт, поэтому эмулятор (python -m src.scan_emulator) настройки шлюза не читает: у него свои переменные (SCAN_HOTFOLDER_PATH, SCAN_FIXTURES_DIR, SCAN_EMULATOR_*) из окружения и ENV_FILE.

Переменная По умолчанию Назначение
OPENBAO__APP__<ИМЯ> — контур платформы: туда передаются только секреты OpenBao (OPENBAO__*), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (SCAN_HOTFOLDER_MODE, NATS_ENABLED, DEBUG, SCAN_*, …) может прийти как OPENBAO__APP__<ИМЯ>, например OPENBAO__APP__SCAN_HOTFOLDER_MODE=smb (путь шары — по-прежнему секрет OPENBAO__SMB__SMB_PATH, SCAN_HOTFOLDER_PATH в контуре не нужен). Значения разбираются как из обычного env. Приоритет: обычный env → OPENBAO__APP__* → .env → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте; OPENBAO__APP__OPENBAO__… не допускается. Читается только из окружения процесса (в .env не действует). Реализация — api/src/core/settings_sources.py (тот же файл в arm-core и arm-tabular-parser)
OPENBAO__MINIO__MINIO_URL — (обязательна) адрес MinIO host:port, без схемы
OPENBAO__MINIO__MINIO_ADMIN_LOGIN — (обязательна) логин MinIO
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD — (обязательна) пароль MinIO
OPENBAO__MINIO__MINIO_BUCKET — (обязательна) единственный бакет сервиса; запрос с другим s3.bucket -> FILE_READ_ERROR
OPENBAO__MINIO__MINIO_SECURE true https; для MinIO из compose — false
OPENBAO__NATS__NATS_URL — (обязательна) адрес NATS
OPENBAO__NATS__NATS_SEED, OPENBAO__NATS__NATS_JWT пусто nkeys/JWT платформы (SecretStr, в логи и /healthcheck не попадают); оба пусты — без учётных данных; задан один — ошибка конфигурации
OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT files.img_uploaded subject запроса, на который подписан сервис; у ядра окружения то же значение
OPENBAO__NATS__NATS_RESULT_SUBJECT files.tabular_parsed subject, в который сервис публикует ack и итог
OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT files.tabular_uploaded запрос tabular (сервис его не слушает; значение участвует в проверке: три имени непусты, без пробелов, *, > и попарно различны, иначе сервис не стартует)
OPENBAO__SMB__SMB_HOST, OPENBAO__SMB__SMB_PATH, OPENBAO__SMB__SMB_LOGIN, OPENBAO__SMB__SMB_PASSWORD пусто шара горячих папок; обязательны только при SCAN_HOTFOLDER_MODE=smb; SMB_PATH — share/dir или полный UNC
NATS_ENABLED true false — воркер не запускается, подключения к NATS нет (/healthcheck.nats = disabled)
PUBLISH_RETRIES 3 повторы публикации ack/результата после первой попытки (всего до 4)
DONE_CACHE_SIZE 1000 размер LRU обработанных job_id (дедупликация)
SCAN_HOTFOLDER_MODE local local или smb
SCAN_HOTFOLDER_PATH пусто корень горячей папки в режиме local (обязателен, каталог должен существовать)
SCAN_INPUT_DIR Input «Входящие» относительно корня
SCAN_OUTPUT_DIR Output «Исходящие» относительно корня
SCAN_POLL_SECONDS 5 период опроса «Исходящих», секунды
SCAN_SETTLE_SECONDS 2 набор *.json в Output/<job_id>/ готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; 0 — сразу)
SCAN_TIMEOUT_SECONDS 1200 потолок ожидания ответа ИС, секунды (дополнительно к reply_deadline; 20 мин — в паре со сроком ответа OCR ядра)
SCAN_MAX_INFLIGHT 4 одновременно обрабатываемых заданий
SCAN_CLEANUP false очистка горячей папки после чтения набора ответа: удаляет всё содержимое Output/<job_id>/ и каталог, Input/<job_id>.<ext> (и .done); false — ничего не удаляется; см. «Очистка горячей папки»
SERVICE_NAME scan-gateway service.name в конверте
VERSION 1.0.0 версия сервиса (/healthcheck, service.version)
ROOT_PATH / префикс FastAPI за прокси
SERVER__HOST, SERVER__PORT 0.0.0.0, 8000 адрес HTTP-сервера run_server.py
DEBUG false флаг шаблона платформы; поведение шлюза не меняет
ENV_FILE .env какой файл окружения читать

Эмулятор (python -m src.scan_emulator) читает свои переменные и не использует настройки шлюза (в его контейнере нет MinIO и NATS); те же значения подхватываются из ENV_FILE:

Переменная По умолчанию Назначение
SCAN_HOTFOLDER_PATH — (обязательна) корень горячей папки; должен существовать, иначе код выхода 2
SCAN_INPUT_DIR, SCAN_OUTPUT_DIR Input, Output подкаталоги корня (как у шлюза)
SCAN_TMP_DIR Tmp служебный каталог эмулятора: в нём собирается набор ответа перед rename в Output/<uuid>/ (шлюз эту переменную не читает)
SCAN_FIXTURES_DIR ../../fixtures/ocr каталог эталонных пар: исходник (PDF и др.) и ответы ИС (aep_N.json, aep_N_<суффикс>.json); пара ищется по sha256 содержимого входящего файла; путь от api/
SCAN_EMULATOR_DEFAULT пусто основа имени набора (например, aep_6), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка INVALID_DOCUMENT
SCAN_EMULATOR_DELAY 2 задержка перед выдачей ответа, секунды (>= 0)
SCAN_EMULATOR_POLL_SECONDS 1 период просмотра «Входящих», секунды (> 0)

Коды ошибок

Итоговый конверт error содержит error.code и error.message. Нормализатор возвращает коды ответа ИС и INVALID_FORMAT/RECOGNITION_FAILED (ни одной таблицы) и коды ошибок json, остальные коды ставит воркер (api/src/core/worker.py). Код NOT_IMPLEMENTED (заглушка Среза 0) сервис больше не публикует: есть успешный файл — документы и done / needs_check.

Код Когда Ack
SCHEMA_MISMATCH тело не JSON / не по схеме / MAJOR версии ≠ 1 / pipeline ≠ image. Конверт публикуется, только если в теле есть корректные job_id и file_id, иначе запрос пишется в лог и отбрасывается нет
FILE_READ_ERROR объект S3 не прочитан; s3.bucket не совпадает с бакетом сервиса да
RECOGNITION_FAILED у original_name нет допустимого расширения (файл в ИС не передаётся; <job_id>.<ext>, 1–16 букв/цифр ASCII); не записан файл во «Входящие» (горячая папка недоступна); «Исходящие» не читались ни разу до дедлайна; ни одной таблицы не разобрано (успешные файлы набора не дали ни одного документа; сообщение «ни одной таблицы не разобрано», raw_s3_key заполнен, причины — в metrics.warnings: TABLE_PARSE_ERROR, TABLE_LOST, TABLE_SKIPPED, JSON_FAILED) да
TIMEOUT ИС не ответила до min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS); reply_deadline истёк до отправки файла да
INVALID_FORMAT все файлы ответа ошибочны, и у первого ошибочного файла не разобрался JSON (сырой ответ сохранён, raw_s3_key указан) или корень ответа не объект; склейка нарушена или пуста да
код ошибки ИС все файлы ответа ошибочны (error.code непустой): код первого ошибочного файла по имени пробрасывается, если он входит в ErrorCode (например, INVALID_DOCUMENT); каждый ошибочный файл — предупреждение JSON_FAILED в metrics.warnings да
UNKNOWN_ERROR сырой ответ или результат не записан в S3; нормализатор упал (исключение вне разбора таблицы: исключение внутри таблицы — предупреждение TABLE_PARSE_ERROR); неизвестный код ИС (в том числе error.code не строка: число, список, true); непредвиденная ошибка да

Файл ответа считается ошибочным, если его error.code непустой, файл не разобрался как JSON (parse_error) либо корень ответа не объект (INVALID_FORMAT). metrics.warnings итогового конверта содержит структуру JSON_FAILED (severity = error, locator = file:<имя>, message = <код>: <сообщение>) на каждый ошибочный файл (и когда есть успешный файл, и когда ошибочны все файлы) и предупреждения таблиц (TABLE_PARSE_ERROR, TABLE_LOST, TABLE_SKIPPED) — в порядке файлов набора по имени; при done / needs_check в metrics есть и rows. Если предупреждений нет, warnings опускается.

Контракт NATS (кратко)

Схемы и примеры — ../docs/contracts/; их байт-в-байт копии лежат в api/contracts/ (идентичность проверяет unit-тест api/tests/unit/test_contract.py, входит в make check), pydantic-модели — api/src/contract/.

  • Запрос: subject files.img_uploaded (по умолчанию; OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT), подписка в queue group scan-gateway, Nats-Msg-Id = <job_id>:<attempt>.
  • Ответы — subject files.tabular_parsed (по умолчанию; OPENBAO__NATS__NATS_RESULT_SUBJECT): ack status=processing, Nats-Msg-Id = ack:<job_id>:<attempt>; итог status = done | needs_check | error, Nats-Msg-Id = result:<job_id>:<attempt>. service.name = "scan-gateway".
  • Ключи S3: исходник — из запроса (s3.key), сырой ответ (склейка файлов ИС) — raw/{job_id}.json, результат — results/{file_id}/{job_id}/result.json. Бакет один.
  • NATS без JetStream: переподключение и повторная подписка — на nats-py, ack и повторы публикации — на уровне приложения (api/src/core/nats.py).

Запуск

Требуются Docker с compose v2, uv, Python 3.11. Все команды make — из корня репозитория; backend-команды рецепты выполняют в api/. Рядом в workspace должны лежать ../docs (compose, контракт) и ../fixtures.

Разработка

make up                                  # NATS, MinIO, PostgreSQL: compose-проект arm-ka из ../docs/dev
make venv                                # api/.venv (Python 3.11), зависимости без raisa_*
cp api/.env.example api/.env             # значения совпадают с ../docs/dev (NATS 4222, MinIO 9000, бакет arm-ka)
mkdir -p api/.hotfolder                  # корень горячей папки: сам не создаётся
cd api && .venv/bin/python run_server.py # сервис на :8000
make emulator                            # в другом терминале: эмулятор ИС Сканирование
curl -s localhost:8000/healthcheck

Целей down/stop в Makefile нет намеренно: compose-проект arm-ka общий для сервисов workspace. Контейнера scan-emulator в compose пока нет (его добавит репозиторий docs позже), поэтому эмулятор запускается локально командой make emulator.

Полный круг против compose и эмулятора

Исполняемый пример — api/tests/integration/test_roundtrip.py (поднимает приложение и эмулятор в процессе теста, публикует запрос, проверяет ack, итоговый конверт, объекты S3). Вручную те же шаги:

  1. Выполнить «Разработку» выше: сервис и эмулятор работают, /healthcheck отдаёт nats = ok, hotfolder = ok.
  2. Положить файл в MinIO (консоль http://localhost:9001 или mc) в бакет arm-ka, например uploads/<file_id>/<job_id>/aep_3.pdf. Эмулятор узнаёт файл по содержимому (sha256), а не по имени: нужен PDF из ../fixtures/ocr (aep_3.pdf -> набор aep_3.json + aep_3_ofr.json, aep_6.pdf -> aep_6.json, aep_5.PDF -> aep_5.json); любое другое содержимое даёт INVALID_DOCUMENT, если не задан SCAN_EMULATOR_DEFAULT. original_name в запросе определяет только расширение входящего файла.
  3. Подписаться на files.tabular_parsed любым клиентом NATS.
  4. Опубликовать в files.img_uploaded тело по образцу api/contracts/examples/request-image.json (свои job_id и file_id — UUID, original_name, s3.key, reply_deadline в будущем, attempt = 1) с заголовком Nats-Msg-Id: <job_id>:1.
  5. Ожидать: ack processing, через SCAN_EMULATOR_DELAY (+ до SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS после первого опроса, увидевшего набор) — итоговый конверт:
    • aep_3.pdf — needs_check, result_s3 с двумя документами (BALANCE, INCOME), metrics.warnings с JSON_FAILED для <job_id>_ofr.json (второй файл набора — ответ-ошибка, INVALID_DOCUMENT: ...);
    • aep_6.pdf — needs_check, два документа (BALANCE, INCOME), предупреждения — в самих документах;
    • aep_5.PDF (и aep_8.pdf) — error, INVALID_DOCUMENT с сообщением ИС (ответы-ошибки);
    • неизвестное содержимое — error, INVALID_DOCUMENT эмулятора. В бакете появится raw/<job_id>.json (склейка всех файлов набора), raw_s3_key указан в конверте; для done / needs_check — ещё results/<file_id>/<job_id>/result.json. В горячей папке: при SCAN_CLEANUP=false — Input/<job_id>.pdf.done и Output/<job_id>/, при true — пусто.

Проверки

make check             # lint (ruff check, ruff format --check, mypy src) + test (pytest -m "not integration")
make test-integration  # pytest -m integration: нужны NATS и MinIO (make up)
make docker-build      # docker build -t scan-gateway:dev . (образ без Nexus)
make check-full        # check + statements-check + test-integration + docker-build; перед PR
make statements-check # свежесть копии api/arm_statements (sync-statements --check; код 10 — предупреждение, впишите в PR)
make golden-update     # пересобрать эталоны нормализатора ../fixtures/ocr/expected_normalize/ (см. «Нормализация»)

Тесты запускаются из api/: .env.test подключается через ENV_FILE=.env.test в api/pytest.ini и разрешается относительно текущего каталога (рецепты make это делают сами). Маркеры: integration — нужна инфраструктура, исключается из make test; fixtures — нужны реальные данные из ../fixtures (нет репозитория — тесты пропускаются). Тесты: api/tests/unit/, api/tests/integration/. Интеграционные тесты делят NATS с другими сервисами workspace: если в нём работает другой экземпляр scan-gateway, он может забрать запрос теста из queue group. Шаги make check-full идут последовательно и прерываются на первой ошибке; тесты в образе не запускаются.

/healthcheck

GET /healthcheck всегда отвечает 200; состояние — в полях:

Поле Значения
uptime время старта процесса
version VERSION
nats ok — соединение установлено; down — подключение идёт, переподключение, сбой; disabled — NATS_ENABLED=false
hotfolder ok — HotFolder.healthy() (корень существует и доступен); down — нет, проверка не уложилась в 2 с или упала
subjects {image_request, result} — subject'ы NATS из настроек; отдаётся при любом состоянии NATS; JWT/seed в ответе нет

Подключение к NATS идёт в фоне: HTTP отвечает сразу (nats = down), подписка появляется после подключения. Результат проверки горячей папки кэшируется на 5 с.

Docker-образ

Один образ (Dockerfile, python:3.11-slim, контекст сборки — корень репозитория, docker build -t scan-gateway .) для шлюза и для эмулятора. По умолчанию запускается шлюз (python run_server.py, порт 8000); эмулятор — тем же образом с command: python -m src.scan_emulator. Пакеты raisa_* в образ не ставятся. api/.env в образ не попадает (см. .dockerignore): окружение приходит из compose; шлюзу и эмулятору нужен общий том с горячей папкой по пути SCAN_HOTFOLDER_PATH, эмулятору — каталог фикстур по пути SCAN_FIXTURES_DIR.

Известные ограничения

Нормализатор (правила шагов 2–8 — пакет arm_statements, ограничения — в 05-domain-rules.md §11.9 и спеке statement-normalization; решения шлюза — openspec/changes/archive/2026-10-19-statements-v2/design.md):

  • Сетка OCR строится по возрастанию row_index, пропуски row_index пустыми строками не заполняются (P5-1): шаг 2 не видит «дыр» между строками ИС (в реальных json их нет). Ячейка value не строка приводится через str, confidence вне числа — 0 (P5-3).
  • Успешный json ИС без таблиц — JSON_FAILED (error), рядом с нормальными json конверт будет needs_check (P5-7, Р-32).
  • Нарушенная структура одной таблицы ИС теряет всю таблицу (TABLE_PARSE_ERROR, P5-2); остальные таблицы разбираются.
  • Склейка одной формы из нескольких таблиц (OQ-77) не обрабатывается: каждая таблица — свой документ.

Транспорт и эмулятор:

  • Режим smb покрыт unit-тестами с подменённым smbclient; на реальной шаре ИС Сканирование не проверялся. Фактические имена каталогов и путь шары на стенде неизвестны — они настраиваются, а не зашиты в код. Права на удаление на шаре контура не подтверждены — по умолчанию SCAN_CLEANUP=false.
  • Повторной отправки в ИС нет (OQ-15); падение шлюза после ack теряет задание до reply_deadline ядра.
  • Зависший вызов SMB (poll/put, до ~60 с по умолчанию smbclient) дедлайном воркера не прерывается: вызов идёт в потоке без собственного таймаута, поэтому TIMEOUT может прийти позже дедлайна на это время.
  • Недописанный набор ответа ИС: защита — settle (SCAN_SETTLE_SECONDS), а не разбор содержимого. Если ИС дописывает файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается: response = null, parse_error в raw/, ошибка этого файла (INVALID_FORMAT) — в metrics.warnings.
  • Запись сразу в Input (change hotfolder-direct-write): защита от чтения недописанного файла — только монопольное открытие smbclient на время записи (ASSUMPTION(OQ-79), как в старом АРМ). Если ИС всё же возьмёт файл во время записи, распознавание даст ошибку; откат — вернуть запись через временный файл.
  • Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе он кладёт ответ INVALID_DOCUMENT с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не переиндексируется (решение №9).
  • Реальные фикстуры: aep_3.json (баланс и ОФР), aep_6.json — успешные ответы, aep_3_ofr.json, aep_5.json, aep_8_form4.json — ответы-ошибки (INVALID_DOCUMENT; data_ocr — объект с пустыми полями и table_structure: []).
  • Контейнера scan-emulator в compose пока нет.
Description
No description provided
Readme 2.7 MiB
Languages
Python 99.3%
Makefile 0.6%