# scan-gateway Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений (PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его и публикует результат в `recognition.result`. Контракт обмена — `../docs/contracts/` (v1.2: `statementDocument`; `schema_version` в конвертах и файле результата сервис по-прежнему пишет `1.0` — `CONTRACT_VERSION`, решение №14 change'а `slice-3a-ocr-normalizer`: поля 1.1–1.2 документы PDF-пути не используют), архитектура — `../docs/context/03-architecture.md`. Границы сервиса (DECISION-4): - нет базы данных: единственный писатель в БД — `arm-core`; шлюз видит только NATS, S3 и горячие папки; - нет фронта; HTTP — только `GET /healthcheck`; - сырой ответ ИС лежит в S3 (`raw/{job_id}.json`) и в БД не регистрируется; - саму ИС Сканирование мы не пишем: для разработки есть эмулятор (`api/src/scan_emulator/`). Состояние: Срез 3a. Сквозной путь работает, ответ ИС нормализуется в `statementDocument` v1.2 (раздел «Нормализация»); раскладка горячих папок — как в проверенном в контуре `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/`. ## Поток обработки ``` recognition.image.request (NATS, queue group scan-gateway) 1. разбор и проверка запроса ──нет/не по схеме──► error SCHEMA_MISMATCH (без ack) 2. ack: recognition.result, status=processing (до чтения S3 и до ожидания слота) 3. слот (SCAN_MAX_INFLIGHT одновременных заданий) 4. файл из S3 (s3.bucket/s3.key) 5. горячая папка: /. (через /...part, без замены) 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. recognition.result: итоговый конверт (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` без записи в горячую папку. - Запись атомарна и идёт **без замены**: сначала временный файл `...part` в служебном каталоге `` (по умолчанию `Tmp`), затем он публикуется в `Input` под настоящим именем. `Tmp` лежит рядом с `Input` и `Output`, на той же ФС/шаре: публикация атомарна, а ИС не может забрать недописанный файл (временных файлов в `Input` нет — решение владельца 06.10.2026, у FileWorker `.tmp` лежит рядом с назначением). В режиме `local` публикация — жёсткая ссылка `.part` → `Input/<имя>` и удаление `.part`; на файловой системе без жёстких ссылок (`EPERM`/`EXDEV`/`ENOTSUP`/`EOPNOTSUPP` от самой ссылки) — `os.replace`, если назначения ещё нет (решение №17). `.part` создаётся заново: остаток прежней записи удаляется (ошибка удаления подавляется), файл открывается с `O_EXCL`; поверх оставшегося `.part` пишем только если это не жёсткая ссылка (`st_nlink == 1`) — иначе исключение (решение №32), чтобы не испортить уже опубликованный файл. В режиме `smb` — `smbclient.rename` (поднимает `FileExistsError`, если назначение есть). Если `Input/.` уже существует (повторная публикация задания после потери ack в другом процессе), файл считается доставленным: ничего не пишется, назначение не перезаписывается, `.part` не трогается (при `SCAN_CLEANUP=true` существующий `.part` снимается), задание продолжается опросом `Output//` (решение №35). Каталоги `Input` и `Tmp` создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — `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/...part`. Отсутствующий файл или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а подкаталог внутри `Output//` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки поднимается и логируется, остальные пункты (входящий файл, `.done`, `.part`) в этом задании не удаляются. - **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются. - **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт, `metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа. - **`.part` и флаг (решение №26).** `.part`, ставший целью успешной публикации, снимается **самой записью**, независимо от `SCAN_CLEANUP` (в `local` — удаление после жёсткой ссылки, ошибка удаления подавляется; в `smb` `.part` переименовывается во входящий и отдельного удаления не требует): иначе при выключенной очистке на каждое задание оставалась бы лишняя ссылка, а ошибка удаления на шаре с неподтверждёнными правами превращала бы доставленный файл в `RECOGNITION_FAILED`. **Флаг управляет остатками:** осиротевшие `.part` (оборванная запись, `.part` при уже существующем назначении), каталог ответа, входящий файл и `.done`. При `SCAN_CLEANUP=false` сервис не удаляет ничего из этого и не пишет в лог об осиротевших `.part` (решение №27): они накапливаются в `Tmp` и убираются вручную или включением флага. - Права на SMB: для очистки учётной записи шары нужны права на удаление в `Input`, `Output` (включая каталог ``) и `Tmp`; для записи — создание файлов в `Tmp` и `Input` и переименование. Пока права на удаление не подтверждены, оставляйте `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` каталога ответа (контракт v1.0 не меняется: `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` v1.2) `OcrNormalizer` (`api/src/normalizer/ocr/`) превращает склеенный ответ ИС в документы контракта v1.2 по `§11` и `§11.1` `../docs/context/05-domain-rules.md`: из каждой таблицы `data_ocr.table_structure[i]` каждого успешного файла получается один `statementDocument` (`locator = "file:<имя json>;table:"`). Проверенные на стенде функции очистки старого ядра перенесены дословно (единственное исключение для `reference-old-arm`, решение владельца 06.10.2026), DB-обвязка не переносится. Нормализатор — чистые функции без I/O и настроек; `checks` в документах не передаются (их считает ядро). Конвейер (`src/normalizer/ocr/`): `text.py` (двойники букв и цифр, `|` как разделитель клеток, `clean_value`) → `dates.py` (`parse_date`: даты и периоды вида «на 30 июня 2025 г», «за январь - июнь», «1 квартал», «9 месяцев» → последний день периода) → `amounts.py` (`parse_amount`: запятая → точка, скобки → минус, прочерк → `null`) → `layout.py` и `rows.py` (раскладка колонок по шапке с ячейкой «Код» или, если шапки нет, по данным; классы строк; повторная шапка перезаписывает раскладку) → `header.py` (организация, ИНН, единицы, дата отчёта из `data_ocr`) → `document.py` (тип документа, колонки, строки, значения с `is_recognized`) → `__init__.py` (`OcrNormalizer.normalize`: обход файлов склейки, статус, `metrics`). Что получается в документе (подробно — требование «Нормализация ответа ИС» спеки `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/`): - `document_type` — по `label` таблицы (`statement_labels.yaml`), если не сопоставился — по первой цифре кодов строк (`1…` → `BALANCE`, `2…` → `INCOME`), иначе `OTHER` (документ всё равно отдаётся, статус `needs_check`). - `columns` — колонки значений первой шапки, перенумерованные с 0; `period_date` — из подписи колонки. - `lines` — только строки данных с целым кодом. Повтор кода: остаётся первая строка (в БД `line_code` уникален в документе), остальные теряются в документе, но остаются в `raw/`. - `values` — по одному на колонку документа; `amount` — число (в файле результата деньги строкой) или `null`; `string_value` — текст ячейки, только когда сумма не разобрана; `is_recognized = confidence == 100 и разбор не отбрасывал символов`. - **`Money` (решение №30).** Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками (`1,234`, `0,125`: пробел тысяч, распознанный как запятая) не принимается: `amount = null`, `string_value` — текст ячейки, `is_recognized = false` (таблица получает `needs_check`, остальные ячейки и документ целы). Сумма вида `1.234,56` (несколько точек) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) — с `is_recognized = false`. - `confidence` ячеек и полей шапки приводится к диапазону 0–100, `NaN` и бесконечность дают 0 (`json.loads` принимает `NaN`/`Infinity`, а контракт требует 0–100). - `header.report_date` — после замены двойников; `inn` — только цифры. Тексты дат (дата отчёта, подпись колонки) длиннее 200 символов не разбираются и не проходят замену двойников (решение №31: регулярки дат на длинном тексте работают неприемлемо долго). ### Справочники `api/rules/` Данные, а не код; загружаются один раз при старте из `SCAN_RULES_DIR` (по умолчанию `rules`, путь от `api/`) с pydantic-валидацией; битый файл (код не из 4–5 цифр, пустое наименование, дубль `id`, **повторяющийся ключ yaml** — например, один код строки дважды в `lines`; решение №33) — сервис не стартует, в ошибке имя файла (и ключ с номером строки). | Файл | Содержимое | |---|---| | `statement_lines.yaml` | `editions`: список редакций форм `{id, valid_from?, valid_until?, lines: {код: наименование}}` | | `ocr_lookalikes.yaml` | `digits`: символ, который OCR ставит вместо цифры → цифра (применяется только к датам, вплотную к цифрам) | | `statement_labels.yaml` | `labels`: нормализованная (нижний регистр, не-буквенно-цифровое → пробел) подстрока `label` таблицы → `BALANCE` / `INCOME` | Редакции форм: `66n` (Приказ Минфина № 66н, отчётность по 2024 год включительно, `valid_until: 2024-12-31`; заполнена из `codes_mapping` старого ядра) и `fsbu4` (ФСБУ 4/2023, отчётность с 2025 года, `valid_from: 2025-01-01`; **пока пуста** — данные положит владелец). Редакция документа выбирается по `header.report_date`, если его нет — по самой поздней `period_date` колонок, если нет и её — по числу совпадений кодов (с предупреждением). Имя строки берётся из словаря выбранной редакции по коду; если кода в нём нет — из ячейки наименования OCR. Пустая выбранная редакция — предупреждение «редакция fsbu4 не заполнена», имена из OCR, `needs_check`. Как заполнить `fsbu4`: в `api/rules/statement_lines.yaml` заменить `lines: {}` у редакции `id: fsbu4` на таблицу `"<код>": "<наименование>"` (коды — строки из 4–5 цифр в кавычках, без дублей; наименования непустые; источник — Приложения 3–4 ФСБУ 4/2023, черновой перечень кодов — шаблоны `fixtures/statements-xlsx/templates/`). Код нормализатора не меняется. Затем: `cd api && .venv/bin/pytest tests/unit/normalizer/test_rules.py` (валидность данных), `make golden-update` и `git -C ../fixtures diff` (эталоны документов 2025 года изменятся: имена строк и предупреждения — объяснить в PR fixtures), перезапуск сервиса (справочники читаются при старте). Тесты, которые закрепляют **пустую** `fsbu4` и после заполнения упадут — их нужно обновить (список получен поиском по `fsbu4` / `не заполнена`): - `api/tests/unit/normalizer/test_document.py`: `TestTypeAndEdition::test_edition_2025_is_empty` (имя строки из OCR, предупреждение «редакция fsbu4 не заполнена») и `TestEditionWarnings::test_empty_edition_gives_single_warning` (`warnings == ["редакция fsbu4 не заполнена"]`); - `api/tests/integration/test_roundtrip.py`, сценарий 3 (`aep_6.pdf`, тест `test_real_fixture_aep_6_needs_check_with_two_documents`: строки «редакция fsbu4 не заполнена» для `table:0` и `table:1`) и сценарий 4 (`ok_doc.pdf`, тест `test_synthetic_success_answer_gives_needs_check`: имя строки `1600` «БАЛАНС» из OCR, единственное предупреждение про пустую редакцию, статус `needs_check`; при заполненной редакции с кодом `1600` этот документ станет `done`), а также комментарии к ним в начале файла; - эталоны `../fixtures/ocr/expected_normalize/aep_6.json` (предупреждения «редакция fsbu4 не заполнена», имена строк 2025 года из OCR) — их проверяет `api/tests/unit/normalizer/test_golden.py` (обновляются `make golden-update`); - `api/tests/unit/normalizer/test_ocr_normalizer.py` и `api/tests/unit/test_worker.py` (`aep_6`, `SUCCESS_ANSWER` с датой 2025 года) пустую `fsbu4` напрямую не закрепляют: проверяют `needs_check`, который остаётся из-за `confidence` < 100. ### Статус результата - Все файлы набора ошибочны — `error` с кодом первого ошибочного файла по имени (см. «Коды ошибок»). - Из успешных файлов не собран ни один документ (нет ни одной таблицы с шапкой или столбцом кода, `table_structure` пуст или это не объект `data_ocr`) — `error`, `RECOGNITION_FAILED`, сообщение «ни одной таблицы не разобрано»; причины по таблицам — в `metrics.warnings`; `raw_s3_key` заполнен. - Документы есть — `done`, если всё распознано и ни один файл набора не ошибочен: у всех документов `inn_confidence` и `organization_name_confidence` равны 100; дата отчёта разобрана и распознана; все подписи колонок разобраны в даты; все `line_code_confidence` и `line_name_confidence` равны 100; все значения `is_recognized`; тип по `label` совпал с типом по кодам; редакция определена по дате отчёта или дате колонки и заполнена; нет предупреждений уровня таблицы и файла нормализации. Иначе `needs_check`. Единицы (`units`) в критерий не входят (в контракте у поля нет `confidence`). Реальные сканы почти всегда дают `needs_check`: `done` означает лишь «нечего подсвечивать аналитику». - Ошибочный файл набора при наличии успешного (например, `_ofr.json` с `INVALID_DOCUMENT`) **понижает статус до `needs_check`** (решение №28: потерянный ОФР не должен остаться незамеченным под `done`); документы собираются из успешных файлов, а ошибочный файл попадает в `metrics.warnings` строкой `<имя>: <код ИС>: <сообщение>`. - `metrics.rows` — число строк во всех документах. Файл результата `results/{file_id}/{job_id}/result.json` пишется для `done` и `needs_check`; предупреждения (`metrics.warnings`) лежат и в конверте, и в файле результата. ### Предупреждения (`metrics.warnings`) Одна строка на предупреждение; уровня таблицы — `<имя json>: table:: <текст>`, уровня файла — `<имя json>: <текст>`, ошибочного файла — `<имя json>: <код ИС>: <сообщение>` (код `INVALID_FORMAT` для файла, не разобранного как JSON, и для корня-не-объекта). Строка обрезается до 1000 символов, строк не больше 100: лишние сводятся в последнюю (`ещё предупреждений: ; все ошибки файлов — в raw/.json`). Тексты (из `src/normalizer/ocr/`): | Уровень | Текст | |---|---| | файл | `ИНН не распознан` (в поле `inn` нет цифр, ИНН не извлечён) | | файл | `ИНН распознан неуверенно` (ИНН извлечён, но `confidence` ≠ 100 или из текста убран мусор) | | файл | `дата отчёта не разобрана` | | файл | `data_ocr отсутствует или не объект` | | таблица | `нет шапки и столбца кода` (таблица пропущена, остальные разбираются; также когда `rows` не список или у таблицы есть шапка, но нет ни одной строки данных) | | таблица | `строк с нечисловым кодом пропущено: ` (непустой код, не целое число: `1150 :`, `изо :`; одно на таблицу) | | таблица | `строк без кода с суммами пропущено: ` (пустой код, но в колонках значений есть разобранная сумма: «БАЛАНС», «Выручка»; как в Excel-пути, OQ-141; строки без кода и без сумм — заголовки групп, «в том числе:» — не считаются; одно на таблицу) | | таблица | `шапка синтезирована: колонки без дат` (в таблице нет строки-шапки: раскладка выведена по данным) | | таблица | `колонка без даты: <подпись колонки>` | | таблица | `нет колонок значений` | | таблица | `повторная шапка изменила число колонок значений` (одно на таблицу; значения лишних колонок отброшены, недостающие — «ячейки нет») | | таблица | `тип по кодам (<тип>) не совпал с label (