arm-scan-gateway/README.md
2026-10-07 04:05:35 +04:00

60 KiB
Raw Blame History

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 (<job_id>.<ext>). Известные ограничения — в конце файла. Правила для агента и соглашения репозитория — CLAUDE.md; требования и решения — openspec/changes/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. горячая папка: <Input>/<job_id>.<ext>                  (через <Tmp>/.<job_id>.<ext>.part, без замены)
  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. 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/<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 без записи в горячую папку.
  • Запись атомарна и идёт без замены: сначала временный файл .<job_id>.<ext>.part в служебном каталоге <SCAN_TMP_DIR> (по умолчанию 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/<job_id>.<ext> уже существует (повторная публикация задания после потери ack в другом процессе), файл считается доставленным: ничего не пишется, назначение не перезаписывается, .part не трогается (при SCAN_CLEANUP=true существующий .part снимается), задание продолжается опросом Output/<job_id>/ (решение №35). Каталоги Input и Tmp создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — 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/.<job_id>.<ext>.part. Отсутствующий файл или каталог не ошибка. В режиме local каталог ответа удаляется рекурсивно; в режиме smb удаляются файлы каталога, а подкаталог внутри Output/<job_id>/ не даст снять каталог (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 (включая каталог <job_id>) и Tmp; для записи — создание файлов в Tmp и Input и переименование. Пока права на удаление не подтверждены, оставляйте 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 каталога ответа (контракт v1.0 не меняется: 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 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:<i>"). Проверенные на стенде функции очистки старого ядра перенесены дословно (единственное исключение для 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/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 означает лишь «нечего подсвечивать аналитику».
  • Ошибочный файл набора при наличии успешного (например, <job_id>_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:<i>: <текст>, уровня файла — <имя json>: <текст>, ошибочного файла — <имя json>: <код ИС>: <сообщение> (код INVALID_FORMAT для файла, не разобранного как JSON, и для корня-не-объекта). Строка обрезается до 1000 символов, строк не больше 100: лишние сводятся в последнюю (ещё предупреждений: <N>; все ошибки файлов — в raw/<job_id>.json). Тексты (из src/normalizer/ocr/):

Уровень Текст
файл ИНН не распознан (в поле inn нет цифр, ИНН не извлечён)
файл ИНН распознан неуверенно (ИНН извлечён, но confidence ≠ 100 или из текста убран мусор)
файл дата отчёта не разобрана
файл data_ocr отсутствует или не объект
таблица нет шапки и столбца кода (таблица пропущена, остальные разбираются; также когда rows не список или у таблицы есть шапка, но нет ни одной строки данных)
таблица строк с нечисловым кодом пропущено: <N> (непустой код, не целое число: 1150 :, изо :; одно на таблицу)
таблица строк без кода с суммами пропущено: <N> (пустой код, но в колонках значений есть разобранная сумма: «БАЛАНС», «Выручка»; как в Excel-пути, OQ-141; строки без кода и без сумм — заголовки групп, «в том числе:» — не считаются; одно на таблицу)
таблица шапка синтезирована: колонки без дат (в таблице нет строки-шапки: раскладка выведена по данным)
таблица колонка <i> без даты: <подпись колонки>
таблица нет колонок значений
таблица повторная шапка изменила число колонок значений (одно на таблицу; значения лишних колонок отброшены, недостающие — «ячейки нет»)
таблица тип по кодам (<тип>) не совпал с label (<label>) (берётся тип по label)
таблица тип определён по кодам (label не сопоставлен)
таблица тип не определён: OTHER
таблица редакция определена по кодам: <id> (нет ни даты отчёта, ни дат колонок)
таблица редакция не определена
таблица редакция <id> не заполнена (сейчас — fsbu4)
таблица код <код> из редакции <id> (код есть в другой редакции, но не в выбранной; только если выбранная редакция не пуста)
таблица код <код> повторяется: оставлена первая строка
таблица BACKEND_PARSING_ERROR: <класс исключения>: <сообщение> (исключение при разборе таблицы: остальные таблицы разбираются)

Любое предупреждение уровня таблицы или файла нормализации означает needs_check.

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

Тест api/tests/unit/normalizer/test_golden.py сравнивает результат нормализатора на реальных ответах ИС (../fixtures/ocr/<имя>.json + <имя>_*.json) с эталонами ../fixtures/ocr/expected_normalize/<имя>.json (status, warnings, metrics, error, documents). Эталоны лежат в репозитории 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__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 платформы; оба пусты — без учётных данных; задан один — ошибка конфигурации
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_TMP_DIR Tmp служебный каталог относительно корня (на той же ФС/шаре, что Input/Output): временный .<job_id>.<ext>.part записи входящего файла
SCAN_POLL_SECONDS 5 период опроса «Исходящих», секунды
SCAN_SETTLE_SECONDS 2 набор *.json в Output/<job_id>/ готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; 0 — сразу)
SCAN_TIMEOUT_SECONDS 300 потолок ожидания ответа ИС, секунды (дополнительно к reply_deadline)
SCAN_MAX_INFLIGHT 4 одновременно обрабатываемых заданий
SCAN_CLEANUP false очистка горячей папки после чтения набора ответа: удаляет всё содержимое Output/<job_id>/ и каталог, Input/<job_id>.<ext> (и .done), Tmp/.<job_id>.<ext>.part; false — ничего не удаляется (кроме .part, снятого самой записью); см. «Очистка горячей папки»
SCAN_RULES_DIR rules каталог справочников нормализатора (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml), путь от текущего каталога (api/); битый справочник — сервис не стартует
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, SCAN_TMP_DIR Input, Output, Tmp подкаталоги корня (как у шлюза); в 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 (ни одной таблицы), остальные коды ставит воркер (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) да
TIMEOUT ИС не ответила до min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS); reply_deadline истёк до отправки файла да
INVALID_FORMAT все файлы ответа ошибочны, и у первого ошибочного файла не разобрался JSON (сырой ответ сохранён, raw_s3_key указан) или корень ответа не объект; склейка нарушена или пуста да
код ошибки ИС все файлы ответа ошибочны (error.code непустой): код первого ошибочного файла по имени пробрасывается, если он входит в ErrorCode (например, INVALID_DOCUMENT); каждый ошибочный файл — строка metrics.warnings да
UNKNOWN_ERROR сырой ответ или результат не записан в S3; нормализатор упал (исключение вне разбора таблицы: исключение внутри таблицы — предупреждение BACKEND_PARSING_ERROR); неизвестный код ИС (в том числе error.code не строка: число, список, true); непредвиденная ошибка да

Файл ответа считается ошибочным, если его error.code непустой, файл не разобрался как JSON (parse_error) либо корень ответа не объект (INVALID_FORMAT). metrics.warnings итогового конверта содержит по строке <имя файла>: <код>: <сообщение> на каждый ошибочный файл (и когда есть успешный файл, и когда ошибочны все файлы) вперемешку с предупреждениями нормализации успешных файлов (см. «Нормализация»), в порядке файлов набора по имени; при 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 recognition.image.request, подписка в queue group scan-gateway, Nats-Msg-Id = <job_id>:<attempt>.
  • Ответы — subject recognition.result: 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. Подписаться на recognition.result любым клиентом NATS.
  4. Опубликовать в recognition.image.request тело по образцу 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 28 строк и INCOME 13 строк после удаления повторов кодов), metrics.warnings с предупреждениями нормализации и строкой <job_id>_ofr.json: INVALID_DOCUMENT: ... (второй файл набора — ответ-ошибка);
    • aep_6.pdf — needs_check, два документа (BALANCE, INCOME), в предупреждениях «редакция fsbu4 не заполнена»;
    • 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 + test-integration + docker-build; перед 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 с или упала

Подключение к 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.

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

Нормализатор (Срез 3a; решения — таблица «Decisions made without discussion» в openspec/changes/slice-3a-ocr-normalizer/design.md):

  • Редакция fsbu4 (отчётность с 2025 года) пока пуста: имена строк документов 2025 года берутся из OCR, предупреждение «редакция fsbu4 не заполнена», статус needs_check. Состав редакции — данные владельца (см. «Справочники»).
  • Строки, чей код не целое число после обрезки пробелов (например, 1150 :), разбор относит к классу HEADER и пропускает (перенос _classify_row; раскладка колонок при этом не переписывается — её меняет только строка с ячейкой «Код…»). Строки с пустым кодом тоже пропускаются. В документ они не попадают и остаются в raw/, но таблица получает предупреждения строк с нечисловым кодом пропущено: <N> и строк без кода с суммами пропущено: <N> (последнее — только если в строке есть сумма), то есть needs_check (решение №29). Сами строки не восстанавливаются (OQ-149): в реальных сканах так теряются, например, «БАЛАНС» и «Выручка» с пустым кодом.
  • Скобка, распознанная как } или ] ((7744536}), знак минус не даёт: сумма остаётся положительной с is_recognized = false (решение №12, дословный перенос _parse_amount; расширение набора закрывающих скобок — после проверки на стенде, вынесено в открытые вопросы docs).
  • Длинное тире «—» / «–» в ячейке значения (прочерк) при confidence = 100 даёт is_recognized = false (дословный parse_amount вырезает тире, решение №20); дефис «-» — true. Лишний needs_check на формах с длинным тире.
  • Замена двойников (fix_lookalikes) применяется только к датам (дата отчёта, подписи колонок); код строки с латинской O вместо нуля отбрасывается как не-целое (решение №22). Суммы разбираются parse_amount без замены символов.
  • Повтор кода строки в таблице: остаётся первая строка, вторая теряется в документе (решение №15).
  • checks в документах нет: соотношения §7 считает ядро (Срез 3b).
  • Ошибочный файл набора при успешных даёт needs_check (done невозможен, решение №28); предупреждение <имя>: <код>: ... остаётся в metrics.warnings.
  • Расшифровки без кода (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.
  • Запись без замены на файловой системе без жёстких ссылок (EPERM/EXDEV/ENOTSUP): между проверкой назначения и os.replace есть крошечное окно гонки (повтор задания с тем же job_id редок, решение №17).
  • Эмулятор: повреждённая фикстура (не 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 пока нет.