# 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` (`.`). Известные ограничения — в конце файла. Правила для агента и соглашения репозитория — `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. горячая папка: /. (сразу, wb, существующий перезаписывается) 6. опрос // каждые 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/.` → `Output//*.json`. - Входящий файл: `/.`. `` — суффикс после последней точки последнего компонента `original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII (`aep_5.PDF` → `.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку. - Запись — **сразу в `Input`**, как в старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`): файл открывается на запись с усечением (`wb`) и пишется целиком; временного файла, служебного каталога и переименования нет. В режиме `smb` — `smbclient.open_file(\<имя>, "wb")`: файл открыт монопольно, пока идёт запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). Если `Input/.` уже существует (повтор задания ядром после потери ack в другом процессе), он **перезаписывается** содержимым объекта S3, задание продолжается опросом `Output//`. Ошибка посреди записи → `RECOGNITION_FAILED`; недописанный файл снимается только при `SCAN_CLEANUP=true` (иначе остаётся в `Input`). Каталог `Input` создаётся при необходимости (без родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`. - Ответ ИС — каталог `//`: ИС кладёт туда **все** распознанные документы задания одновременно, по одному `*.json` на файл (`Output//*.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//` (не только `*.json`) и сам каталог, `Input/.`, `Input/..done` (след эмулятора). `Tmp` шлюз не трогает. Отсутствующий файл или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а подкаталог внутри `Output//` не даст снять каталог (`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` (включая каталог ``). Пока права на удаление не подтверждены, оставляйте `SCAN_CLEANUP=false`. ### Эмулятор ИС в режиме `local` Для файла `.` эмулятор ждёт `SCAN_EMULATOR_DELAY` и кладёт в `Output//` набор эталонных ответов. Пара ищется **по sha256 содержимого** входящего файла, а не по имени: эмулятор при старте индексирует `SCAN_FIXTURES_DIR` (для каждого файла, не являющегося `*.json`, — sha256 → основа имени без расширения, `aep_6.pdf` → `aep_6`; индекс пересчитывается, когда меняется список файлов каталога). При совпадении хеша в наборе все `<основа>.json` и `<основа>_<суффикс>.json` → `.json` и `_<суффикс>.json` (именно `<основа>_*`, а не префикс `<основа>*`: `aep_3` не забирает `aep_30*`; в каждом файле `file_uid` = ``). **Промах по хешу:** набор `SCAN_EMULATOR_DEFAULT` (основа имени, например `aep_6`), если он задан и такой набор есть, в логе отметка «по умолчанию»; иначе один ответ-ошибка `INVALID_DOCUMENT` с текстом `<первые 12 символов sha256>: нет пары по содержимому в фикстурах`. Набор собирается во временном каталоге `Tmp` и появляется в `Output//` атомарно целиком (rename каталога). Затем входящий файл переименовывается в `….done`. Файлы с точки, `*.part`, `*.tmp`, `*.done` игнорируются. ## Склеенный ответ ИС (`raw/{job_id}.json`) В S3 до нормализации сохраняется **склейка** всех `*.json` каталога ответа (`raw_s3_key` — одна строка `raw/{job_id}.json`): ```json { "job_id": "", "files": [ {"name": ".json", "response": {"file_uid": "...", "error": {"code": "", "message": ""}, "data_ocr": {}}, "parse_error": null}, {"name": "_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:"`, `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:` | | `TABLE_LOST` | `error` | таблица с данными не распознана как форма: нет столбца кодов и типа формы | `file:<имя json>;table:` | | `TABLE_SKIPPED` | `info` | таблица без данных пропущена | `file:<имя json>;table:` | Предупреждения **документа** (`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//` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) | | `SCAN_TIMEOUT_SECONDS` | `1200` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`; 20 мин — в паре со сроком ответа OCR ядра) | | `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий | | `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output//` и каталог, `Input/.` (и `.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//` (шлюз эту переменную не читает) | | `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` нет допустимого расширения (файл в ИС не передаётся; `.`, 1–16 букв/цифр ASCII); не записан файл во «Входящие» (горячая папка недоступна); «Исходящие» не читались ни разу до дедлайна; **ни одной таблицы не разобрано** (успешные файлы набора не дали ни одного документа; сообщение «ни одной таблицы не разобрано», `raw_s3_key` заполнен, причины — в `metrics.warnings`: `TABLE_PARSE_ERROR`, `TABLE_LOST`, `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 = :`. - Ответы — subject `files.tabular_parsed` (по умолчанию; `OPENBAO__NATS__NATS_RESULT_SUBJECT`): ack `status=processing`, `Nats-Msg-Id = ack::`; итог `status = done | needs_check | error`, `Nats-Msg-Id = result::`. `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`. ### Разработка ```bash 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///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: :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` для `_ofr.json` (второй файл набора — ответ-ошибка, `INVALID_DOCUMENT: ...`); - `aep_6.pdf` — `needs_check`, два документа (`BALANCE`, `INCOME`), предупреждения — в самих документах; - `aep_5.PDF` (и `aep_8.pdf`) — `error`, `INVALID_DOCUMENT` с сообщением ИС (ответы-ошибки); - неизвестное содержимое — `error`, `INVALID_DOCUMENT` эмулятора. В бакете появится `raw/.json` (склейка всех файлов набора), `raw_s3_key` указан в конверте; для `done` / `needs_check` — ещё `results///result.json`. В горячей папке: при `SCAN_CLEANUP=false` — `Input/.pdf.done` и `Output//`, при `true` — пусто. ### Проверки ```bash 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/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-36) и склейка одной формы из нескольких таблиц (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 пока нет.