Решение владельца 16.10.2026 (R-3): шаблон точки [+-]?[1-9]\d*(\.\d{3})+ —
«1 234.567», «1234.567», «12345.678», «(1 234.567)» -> тысячи. Дробная часть
не из трёх цифр, ведущий ноль, смешанные записи — как раньше. Правило для
запятой не расширено.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
63 KiB
scan-gateway
Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений
(PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его
и публикует результат в files.tabular_parsed. Контракт обмена — ../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/archive/2026-10-07-slice-3a-ocr-normalizer/ (design.md: таблица «Decisions made without discussion») и архив
Среза 0 openspec/changes/archive/2026-10-04-slice-0-skeleton/.
Поток обработки
files.img_uploaded (NATS, queue group scan-gateway)
1. разбор и проверка запроса ──нет/не по схеме──► error SCHEMA_MISMATCH (без ack)
2. ack: files.tabular_parsed, status=processing (до чтения S3 и до ожидания слота)
3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
4. файл из S3 (s3.bucket/s3.key)
5. горячая папка: <Input>/<job_id>.<ext> (сразу, wb, существующий перезаписывается)
6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда
не менялся SCAN_SETTLE_SECONDS
7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
8. очистка горячей папки (только при SCAN_CLEANUP=true)
9. нормализатор (OcrNormalizer) ──► (done/needs_check) S3 results/{file_id}/{job_id}/result.json
10. files.tabular_parsed: итоговый конверт (done | needs_check | error), в нём raw_s3_key
Каждое задание заканчивается опубликованным конвертом: сбой превращается в error с кодом (таблица ниже), а не в
молчание. Если не удалось даже опубликовать (после повторов) — запись в лог уровня error, ядро закроет задание по
своему reply_deadline. Падение шлюза после ack теряет задание до reply_deadline (без JetStream иначе нельзя).
Повторный запрос с job_id, который уже в работе или в LRU обработанных (DONE_CACHE_SIZE), получает повторный
ack (с attempt повторного запроса); файл в ИС второй раз не отправляется.
Протокол горячих папок
Реализации интерфейса HotFolder (api/src/gateway/hotfolder.py: put, poll, cleanup, healthy):
| Режим | Реализация | Для чего |
|---|---|---|
local (по умолчанию) |
LocalHotFolder, обычный каталог SCAN_HOTFOLDER_PATH |
разработка, тесты, эмулятор |
smb |
SmbHotFolder (api/src/gateway/smb.py, smbprotocol), шара из OPENBAO__SMB__* |
платформа |
Раскладка совпадает с проверенной в контуре (RSHB/armka_file_worker, OQ-137): Input/<job_id>.<ext> →
Output/<job_id>/*.json.
- Входящий файл:
<SCAN_INPUT_DIR>/<job_id>.<ext>.<ext>— суффикс после последней точки последнего компонентаoriginal_nameзапроса (разделители пути/и\учитываются), в нижнем регистре, 1–16 букв и цифр ASCII (aep_5.PDF→<job_id>.pdf): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не попадает. Нет допустимого расширения —RECOGNITION_FAILEDбез записи в горячую папку. - Запись — сразу в
Input, как в старом АРМ (решение владельца 09.10.2026, changehotfolder-direct-write): файл открывается на запись с усечением (wb) и пишется целиком; временного файла, служебного каталога и переименования нет. В режимеsmb—smbclient.open_file(<Input>\<имя>, "wb"): файл открыт монопольно, пока идёт запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). ЕслиInput/<job_id>.<ext>уже существует (повтор задания ядром после потери ack в другом процессе), он перезаписывается содержимым объекта S3, задание продолжается опросомOutput/<job_id>/. Ошибка посреди записи →RECOGNITION_FAILED; недописанный файл снимается только приSCAN_CLEANUP=true(иначе остаётся вInput). КаталогInputсоздаётся при необходимости (без родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня —RECOGNITION_FAILED. - Ответ ИС — каталог
<SCAN_OUTPUT_DIR>/<job_id>/: ИС кладёт туда все распознанные документы задания одновременно, по одному*.jsonна файл (Output/<job_id>/*.json). Шлюз читает все*.jsonкаталога и склеивает их в один ответ; прочие файлы каталога игнорируются.job_idпроверяется на безопасность пути. В одном json может быть несколько таблиц разных форм (баланс, ОФР); нормализатор делает из каждой таблицы отдельный документ. - Готовность набора — по стабилизации (settle): в каталоге есть хотя бы один
*.json, а имена и размеры*.jsonне менялисьSCAN_SETTLE_SECONDS(по умолчанию 2 с). Отсчёт идёт от первого опроса, который увидел окончательный набор (а не от времени изменения файлов), поэтому ответ приходит не раньше чем черезSCAN_SETTLE_SECONDSпосле такого опроса и не позднееSCAN_SETTLE_SECONDS + SCAN_POLL_SECONDSпосле первого опроса, увидевшего набор;0— готов любой непустой набор. Появление или изменение файла сбрасывает отсчёт; набор, который так и не стабилизировался до дедлайна, даётTIMEOUT. - Таймаут ожидания:
min(reply_deadline, момент записи файла + SCAN_TIMEOUT_SECONDS); опрос каждыеSCAN_POLL_SECONDS(ожидание settle входит в этот срок). Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется. - Повторной отправки в ИС нет (OQ-15): после
TIMEOUTвходящий файл остаётся в каталоге, поздний ответ ИС теряется (сырой файл остаётся на шаре).
Очистка горячей папки (SCAN_CLEANUP)
Очистка включается SCAN_CLEANUP=true; по умолчанию выключена: на SMB-шаре контура права на удаление не
подтверждены (открытый вопрос Среза 3a). Для dev-стенда (compose, эмулятор) флаг стоит включать.
- Когда. Воркер вызывает
HotFolder.cleanupсразу после того, как набор ответа прочитан, склеен и сохранён вraw/{job_id}.json— при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС). - Что удаляется (всё, что есть): всё содержимое каталога
Output/<job_id>/(не только*.json) и сам каталог,Input/<job_id>.<ext>,Input/<job_id>.<ext>.done(след эмулятора).Tmpшлюз не трогает. Отсутствующий файл или каталог не ошибка. В режимеlocalкаталог ответа удаляется рекурсивно; в режимеsmbудаляются файлы каталога, а подкаталог внутриOutput/<job_id>/не даст снять каталог (rmdir→STATUS_DIRECTORY_NOT_EMPTY): ошибка очистки поднимается и логируется, остальные пункты (входящий файл,.done) в этом задании не удаляются. - Когда не вызывается:
TIMEOUT(ИС может ещё писать ответ),FILE_READ_ERROR,RECOGNITION_FAILEDзаписи во входящий каталог, ошибка записиraw/в S3. Входящий файл и каталог ответа тогда остаются. - Ошибки удаления (например,
PermissionErrorна шаре): одна строка в логе (warning) на задание; конверт,metrics.warningsи/healthcheckне меняются — исход задания определён разбором ответа. - Флаг управляет всем удалением: при
SCAN_CLEANUP=falseсервис не удаляет ничего — ни каталог ответа, ни входящий файл и.done, ни недописанный при ошибке записи входящий файл — и не пишет об этом в лог (решение №27). ОстаткиTmp/.*.partот версий шлюза до changehotfolder-direct-writeсервис не трогает — убрать вручную. - Права на SMB: для записи учётной записи шары нужно создание и перезапись файлов в
Input, для чтения ответа — листинг и чтениеOutput; для очистки — удаление вInputиOutput(включая каталог<job_id>). Пока права на удаление не подтверждены, оставляйтеSCAN_CLEANUP=false.
Эмулятор ИС в режиме local
Для файла <uuid>.<ext> эмулятор ждёт SCAN_EMULATOR_DELAY и кладёт в Output/<uuid>/ набор эталонных ответов. Пара
ищется по sha256 содержимого входящего файла, а не по имени: эмулятор при старте индексирует SCAN_FIXTURES_DIR
(для каждого файла, не являющегося *.json, — sha256 → основа имени без расширения, aep_6.pdf → aep_6; индекс
пересчитывается, когда меняется список файлов каталога). При совпадении хеша в наборе все <основа>.json и
<основа>_<суффикс>.json → <uuid>.json и <uuid>_<суффикс>.json (именно <основа>_*, а не префикс <основа>*:
aep_3 не забирает aep_30*; в каждом файле file_uid = <uuid>). Промах по хешу: набор SCAN_EMULATOR_DEFAULT
(основа имени, например aep_6), если он задан и такой набор есть, в логе отметка «по умолчанию»; иначе один
ответ-ошибка INVALID_DOCUMENT с текстом <первые 12 символов sha256>: нет пары по содержимому в фикстурах. Набор
собирается во временном каталоге Tmp и появляется в Output/<uuid>/ атомарно целиком (rename каталога). Затем
входящий файл переименовывается в ….done. Файлы с точки, *.part, *.tmp, *.done игнорируются.
Склеенный ответ ИС (raw/{job_id}.json)
В S3 до нормализации сохраняется склейка всех *.json каталога ответа (контракт 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/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). Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками (0,125,12,3456; запятая-разделитель тысяч4,200/1,234— это 4200/1234, AURORA-1405, #35; точка так же:4.200/837.994— 4200/837994, #37) не принимается:amount = null,string_value— текст ячейки,is_recognized = false(таблица получаетneeds_check, остальные ячейки и документ целы). Точка-разделитель тысяч — ровно три цифры после каждой точки, цифр до первой точки сколько угодно ([+-]?[1-9]\d*(\.\d{3})+, скобки на всю строку;4.200,(4.200),1.234.567,1234.567,1 234.567), без потериis_recognized;4.2,4.20,1234.56— дроби,1.2345— текст (№30); для запятой правило прежнее (\d{0,2}до первой запятой,1234,567— не тысячи). Смешанная сумма вида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; строки без кода и без сумм — заголовки групп, «в том числе:» — не считаются; одно на таблицу) |
| таблица | строк с несколькими кодами разлеплено: <N> (ИС склеила соседние строки: код 1500\n1700, суммы через перевод строки; строка разлеплена на N строк, OQ-156; одно на таблицу) |
| таблица | шапка синтезирована: колонки без дат (в таблице нет строки-шапки: раскладка выведена по данным) |
| таблица | колонка <i> без даты: <подпись колонки> |
| таблица | нет колонок значений |
| таблица | повторная шапка изменила число колонок значений (одно на таблицу; значения лишних колонок отброшены, недостающие — «ячейки нет») |
| таблица | тип по label (<label>) не совпал с кодами: взят <тип> (все коды строк данных одной группы — 1xxx или 2xxx, а label говорит иное: берётся тип по кодам, header.form_name — как распознан; #30) |
| таблица | тип по кодам (<тип>) не совпал с label (<label>) (коды обеих групп — шум OCR: берётся тип по 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__APP__<ИМЯ> |
— | контур платформы: туда передаются только секреты OpenBao (OPENBAO__*), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (SCAN_HOTFOLDER_MODE, NATS_ENABLED, DEBUG, SCAN_*, …) может прийти как OPENBAO__APP__<ИМЯ>, например OPENBAO__APP__SCAN_HOTFOLDER_MODE=smb (путь шары — по-прежнему секрет OPENBAO__SMB__SMB_PATH, SCAN_HOTFOLDER_PATH в контуре не нужен). Значения разбираются как из обычного env. Приоритет: обычный env → OPENBAO__APP__* → .env → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте; OPENBAO__APP__OPENBAO__… не допускается. Читается только из окружения процесса (в .env не действует). Реализация — api/src/core/settings_sources.py (тот же файл в arm-core и arm-tabular-parser) |
OPENBAO__MINIO__MINIO_URL |
— (обязательна) | адрес MinIO host:port, без схемы |
OPENBAO__MINIO__MINIO_ADMIN_LOGIN |
— (обязательна) | логин MinIO |
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD |
— (обязательна) | пароль MinIO |
OPENBAO__MINIO__MINIO_BUCKET |
— (обязательна) | единственный бакет сервиса; запрос с другим s3.bucket -> FILE_READ_ERROR |
OPENBAO__MINIO__MINIO_SECURE |
true |
https; для MinIO из compose — false |
OPENBAO__NATS__NATS_URL |
— (обязательна) | адрес NATS |
OPENBAO__NATS__NATS_SEED, OPENBAO__NATS__NATS_JWT |
пусто | nkeys/JWT платформы (SecretStr, в логи и /healthcheck не попадают); оба пусты — без учётных данных; задан один — ошибка конфигурации |
OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT |
files.img_uploaded |
subject запроса, на который подписан сервис; у ядра окружения то же значение |
OPENBAO__NATS__NATS_RESULT_SUBJECT |
files.tabular_parsed |
subject, в который сервис публикует ack и итог |
OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT |
files.tabular_uploaded |
запрос tabular (сервис его не слушает; значение участвует в проверке: три имени непусты, без пробелов, *, > и попарно различны, иначе сервис не стартует) |
OPENBAO__SMB__SMB_HOST, OPENBAO__SMB__SMB_PATH, OPENBAO__SMB__SMB_LOGIN, OPENBAO__SMB__SMB_PASSWORD |
пусто | шара горячих папок; обязательны только при SCAN_HOTFOLDER_MODE=smb; SMB_PATH — share/dir или полный UNC |
NATS_ENABLED |
true |
false — воркер не запускается, подключения к NATS нет (/healthcheck.nats = disabled) |
PUBLISH_RETRIES |
3 |
повторы публикации ack/результата после первой попытки (всего до 4) |
DONE_CACHE_SIZE |
1000 |
размер LRU обработанных job_id (дедупликация) |
SCAN_HOTFOLDER_MODE |
local |
local или smb |
SCAN_HOTFOLDER_PATH |
пусто | корень горячей папки в режиме local (обязателен, каталог должен существовать) |
SCAN_INPUT_DIR |
Input |
«Входящие» относительно корня |
SCAN_OUTPUT_DIR |
Output |
«Исходящие» относительно корня |
SCAN_POLL_SECONDS |
5 |
период опроса «Исходящих», секунды |
SCAN_SETTLE_SECONDS |
2 |
набор *.json в Output/<job_id>/ готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; 0 — сразу) |
SCAN_TIMEOUT_SECONDS |
1200 |
потолок ожидания ответа ИС, секунды (дополнительно к reply_deadline; 20 мин — в паре со сроком ответа OCR ядра) |
SCAN_MAX_INFLIGHT |
4 |
одновременно обрабатываемых заданий |
SCAN_CLEANUP |
false |
очистка горячей папки после чтения набора ответа: удаляет всё содержимое Output/<job_id>/ и каталог, Input/<job_id>.<ext> (и .done); false — ничего не удаляется; см. «Очистка горячей папки» |
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 |
Input, Output |
подкаталоги корня (как у шлюза) |
SCAN_TMP_DIR |
Tmp |
служебный каталог эмулятора: в нём собирается набор ответа перед rename в Output/<uuid>/ (шлюз эту переменную не читает) |
SCAN_FIXTURES_DIR |
../../fixtures/ocr |
каталог эталонных пар: исходник (PDF и др.) и ответы ИС (aep_N.json, aep_N_<суффикс>.json); пара ищется по sha256 содержимого входящего файла; путь от api/ |
SCAN_EMULATOR_DEFAULT |
пусто | основа имени набора (например, aep_6), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка INVALID_DOCUMENT |
SCAN_EMULATOR_DELAY |
2 |
задержка перед выдачей ответа, секунды (>= 0) |
SCAN_EMULATOR_POLL_SECONDS |
1 |
период просмотра «Входящих», секунды (> 0) |
Коды ошибок
Итоговый конверт error содержит error.code и error.message. Нормализатор возвращает коды ответа ИС и
INVALID_FORMAT/RECOGNITION_FAILED (ни одной таблицы), остальные коды ставит воркер (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
files.img_uploaded(по умолчанию;OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT), подписка в queue groupscan-gateway,Nats-Msg-Id = <job_id>:<attempt>. - Ответы — subject
files.tabular_parsed(по умолчанию;OPENBAO__NATS__NATS_RESULT_SUBJECT): ackstatus=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). Вручную те же шаги:
- Выполнить «Разработку» выше: сервис и эмулятор работают,
/healthcheckотдаётnats = ok,hotfolder = ok. - Положить файл в 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в запросе определяет только расширение входящего файла. - Подписаться на
files.tabular_parsedлюбым клиентом NATS. - Опубликовать в
files.img_uploadedтело по образцуapi/contracts/examples/request-image.json(своиjob_idиfile_id— UUID,original_name,s3.key,reply_deadlineв будущем,attempt= 1) с заголовкомNats-Msg-Id: <job_id>:1. - Ожидать: ack
processing, черезSCAN_EMULATOR_DELAY(+ доSCAN_SETTLE_SECONDS + SCAN_POLL_SECONDSпосле первого опроса, увидевшего набор) — итоговый конверт:aep_3.pdf—needs_check,result_s3с двумя документами (BALANCE28 строк иINCOME13 строк после удаления повторов кодов),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 с или упала |
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.
Известные ограничения
Нормализатор (Срез 3a; решения — таблица «Decisions made without discussion» в
openspec/changes/archive/2026-10-07-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): в реальных сканах так теряются, например, «БАЛАНС» и «Выручка» с пустым кодом. - Склеенная строка ИС — несколько целых кодов в ячейке через перевод строки (
1500\n1700, ББ РБ и Счастливочной за 1 кв. 2025) — до классификации разлепляется на столько строк, сколько кодов, если в каждой непустой ячейке значений столько же частей (наименование делится так же, иначе остаётся целым); иначе строка пропускается как нечисловой код. Предупреждениестрок с несколькими кодами разлеплено: <N>, статусneeds_check(OQ-156, решение владельца 08.10.2026; расхождение с источником_classify_row). - Скобка, распознанная как
}или]((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. - Запись сразу в
Input(changehotfolder-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 пока нет.