arm-scan-gateway/README.md
Andrey Epifancev 3a89a82dc1 docs: согласование числа в README (#46)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 20:31:47 +04:00

425 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# scan-gateway
Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений
(PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его
и публикует результат в `files.tabular_parsed`. Контракт обмена — `../docs/contracts/` (v2.0: `statementDocument` с
`status`, `line_status`, `needs_review`, структурными предупреждениями; `schema_version = "2.0"` в конвертах, включая
ack, и в файле результата), архитектура — `../docs/context/03-architecture.md`.
Границы сервиса (DECISION-4):
- нет базы данных: единственный писатель в БД — `arm-core`; шлюз видит только NATS, S3 и горячие папки;
- нет фронта; HTTP — только `GET /healthcheck`;
- сырой ответ ИС лежит в S3 (`raw/{job_id}.json`) и в БД не регистрируется;
- саму ИС Сканирование мы не пишем: для разработки есть эмулятор (`api/src/scan_emulator/`).
Состояние: сквозной путь работает, ответ ИС нормализуется общим кодом `arm_statements` в `statementDocument` v2.0 (раздел «Нормализация»);
раскладка горячих папок — как в проверенном в контуре `armka_file_worker` (`<job_id>.<ext>`). Известные ограничения —
в конце файла. Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения —
`openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/` (`design.md`: таблица «Decisions made without discussion») и архив
Среза 0 `openspec/changes/archive/2026-10-04-slice-0-skeleton/`.
## Поток обработки
```
files.img_uploaded (NATS, queue group scan-gateway)
1. разбор и проверка запроса ──нет/не по схеме──► error SCHEMA_MISMATCH (без ack)
2. ack: files.tabular_parsed, status=processing (до чтения S3 и до ожидания слота)
3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
4. файл из S3 (s3.bucket/s3.key)
5. горячая папка: <Input>/<job_id>.<ext> (сразу, wb, существующий перезаписывается)
6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда
не менялся SCAN_SETTLE_SECONDS
7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
8. очистка горячей папки (только при SCAN_CLEANUP=true)
9. нормализатор (OcrNormalizer) ──► (done/needs_check) S3 results/{file_id}/{job_id}/result.json
10. files.tabular_parsed: итоговый конверт (done | needs_check | error), в нём raw_s3_key
```
Каждое задание заканчивается опубликованным конвертом: сбой превращается в `error` с кодом (таблица ниже), а не в
молчание. Если не удалось даже опубликовать (после повторов) — запись в лог уровня error, ядро закроет задание по
своему `reply_deadline`. Падение шлюза после ack теряет задание до `reply_deadline` (без JetStream иначе нельзя).
Повторный запрос с `job_id`, который уже в работе или в LRU обработанных (`DONE_CACHE_SIZE`), получает повторный
ack (с `attempt` повторного запроса); файл в ИС второй раз не отправляется.
## Протокол горячих папок
Реализации интерфейса `HotFolder` (`api/src/gateway/hotfolder.py`: `put`, `poll`, `cleanup`, `healthy`):
| Режим | Реализация | Для чего |
|---|---|---|
| `local` (по умолчанию) | `LocalHotFolder`, обычный каталог `SCAN_HOTFOLDER_PATH` | разработка, тесты, эмулятор |
| `smb` | `SmbHotFolder` (`api/src/gateway/smb.py`, `smbprotocol`), шара из `OPENBAO__SMB__*` | платформа |
Раскладка совпадает с проверенной в контуре (`RSHB/armka_file_worker`, OQ-137): `Input/<job_id>.<ext>` →
`Output/<job_id>/*.json`.
- Входящий файл: `<SCAN_INPUT_DIR>/<job_id>.<ext>`. `<ext>` — суффикс после последней точки последнего компонента
`original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII
(`aep_5.PDF` → `<job_id>.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не
попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку.
- Запись — **сразу в `Input`**, как в старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`):
файл открывается на запись с усечением (`wb`) и пишется целиком; временного файла, служебного каталога и
переименования нет. В режиме `smb` — `smbclient.open_file(<Input>\<имя>, "wb")`: файл открыт монопольно, пока идёт
запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). Если `Input/<job_id>.<ext>` уже существует
(повтор задания ядром после потери ack в другом процессе), он **перезаписывается** содержимым объекта S3, задание
продолжается опросом `Output/<job_id>/`. Ошибка посреди записи → `RECOGNITION_FAILED`; недописанный файл снимается
только при `SCAN_CLEANUP=true` (иначе остаётся в `Input`). Каталог `Input` создаётся при необходимости (без
родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня —
`RECOGNITION_FAILED`.
- Ответ ИС — каталог `<SCAN_OUTPUT_DIR>/<job_id>/`: ИС кладёт туда **все** распознанные документы задания одновременно,
по одному `*.json` на файл (`Output/<job_id>/*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один
ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть
несколько таблиц разных форм (баланс, ОФР); нормализатор делает из каждой таблицы отдельный документ.
- Готовность набора — по стабилизации (settle): в каталоге есть хотя бы один `*.json`, а имена и размеры `*.json` не
менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2 с). Отсчёт идёт от первого опроса, который увидел окончательный
набор (а не от времени изменения файлов), поэтому ответ приходит не раньше чем через `SCAN_SETTLE_SECONDS` после
такого опроса и не позднее `SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS` после первого опроса, увидевшего набор; `0` — готов любой непустой набор. Появление или изменение файла сбрасывает отсчёт; набор, который так
и не стабилизировался до дедлайна, даёт `TIMEOUT`.
- Таймаут ожидания: `min(reply_deadline, момент записи файла + SCAN_TIMEOUT_SECONDS)`; опрос каждые `SCAN_POLL_SECONDS`
(ожидание settle входит в этот срок).
Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется.
- Повторной отправки в ИС нет (OQ-15): после `TIMEOUT` входящий файл остаётся в каталоге, поздний ответ ИС теряется
(сырой файл остаётся на шаре).
### Очистка горячей папки (`SCAN_CLEANUP`)
Очистка включается `SCAN_CLEANUP=true`; **по умолчанию выключена**: на SMB-шаре контура права на удаление не
подтверждены (открытый вопрос Среза 3a). Для dev-стенда (compose, эмулятор) флаг стоит включать.
- **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в
`raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС).
- **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output/<job_id>/` (не только `*.json`) и сам каталог,
`Input/<job_id>.<ext>`, `Input/<job_id>.<ext>.done` (след эмулятора). `Tmp` шлюз не трогает. Отсутствующий файл
или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а
подкаталог внутри `Output/<job_id>/` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки
поднимается и логируется, остальные пункты (входящий файл, `.done`) в этом задании не удаляются.
- **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во
входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются.
- **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт,
`metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа.
- **Флаг управляет всем удалением:** при `SCAN_CLEANUP=false` сервис не удаляет ничего — ни каталог ответа, ни входящий
файл и `.done`, ни недописанный при ошибке записи входящий файл — и не пишет об этом в лог (решение №27). Остатки
`Tmp/.*.part` от версий шлюза до change `hotfolder-direct-write` сервис не трогает — убрать вручную.
- Права на SMB: для записи учётной записи шары нужно создание и перезапись файлов в `Input`, для чтения ответа —
листинг и чтение `Output`; для очистки — удаление в `Input` и `Output` (включая каталог `<job_id>`). Пока права на удаление не
подтверждены, оставляйте `SCAN_CLEANUP=false`.
### Эмулятор ИС в режиме `local`
Для файла `<uuid>.<ext>` эмулятор ждёт `SCAN_EMULATOR_DELAY` и кладёт в `Output/<uuid>/` набор эталонных ответов. Пара
ищется **по sha256 содержимого** входящего файла, а не по имени: эмулятор при старте индексирует `SCAN_FIXTURES_DIR`
(для каждого файла, не являющегося `*.json`, — sha256 → основа имени без расширения, `aep_6.pdf` → `aep_6`; индекс
пересчитывается, когда меняется список файлов каталога). При совпадении хеша в наборе все `<основа>.json` и
`<основа>_<суффикс>.json` → `<uuid>.json` и `<uuid>_<суффикс>.json` (именно `<основа>_*`, а не префикс `<основа>*`:
`aep_3` не забирает `aep_30*`; в каждом файле `file_uid` = `<uuid>`). **Промах по хешу:** набор `SCAN_EMULATOR_DEFAULT`
(основа имени, например `aep_6`), если он задан и такой набор есть, в логе отметка «по умолчанию»; иначе один
ответ-ошибка `INVALID_DOCUMENT` с текстом `<первые 12 символов sha256>: нет пары по содержимому в фикстурах`. Набор
собирается во временном каталоге `Tmp` и появляется в `Output/<uuid>/` атомарно целиком (rename каталога). Затем
входящий файл переименовывается в `….done`. Файлы с точки, `*.part`, `*.tmp`, `*.done` игнорируются.
## Склеенный ответ ИС (`raw/{job_id}.json`)
В S3 до нормализации сохраняется **склейка** всех `*.json` каталога ответа (`raw_s3_key` — одна строка
`raw/{job_id}.json`):
```json
{
"job_id": "<job_id>",
"files": [
{"name": "<job_id>.json", "response": {"file_uid": "...", "error": {"code": "", "message": ""}, "data_ocr": {}}, "parse_error": null},
{"name": "<job_id>_ofr.json", "response": {"file_uid": "...", "error": {"code": "INVALID_DOCUMENT", "message": "..."}}, "parse_error": null}
]
}
```
`files` — все `*.json` каталога по возрастанию имени. Файл, который не разобрался как JSON, не роняет задание:
`response = null`, `parse_error` — текст ошибки. Нормализатору передаётся ровно этот объект.
Формат файла ответа Content AI: `{"file_uid", "error": {"code", "message"}, "data_ocr": {...}}`. **Успех — это
`error.code == ""`** (`error` при этом не `null`: `{"code": "", "message": ""}`), поэтому проверять `error != null`
нельзя. Ошибка — непустой `error.code` (`INVALID_DOCUMENT`, ...).
## Нормализация (`statementDocument` v2.0)
Нормализация ББ/ОФР — общий код пакета `arm_statements` (шаги 2–8, статусы, перевод в контракт; `05-domain-rules.md`
§11.9). Копия пакета лежит в `api/arm_statements/` и напрямую не правится: обновление — `sync-statements`
(`../docs/scripts/sync-statements.sh`), свежесть — `make statements-check`. Словари (редакции форм, двойники, `label` →
тип) живут в пакете (`default_rules()`); правка словарей — через репозиторий `arm-statements`, а не здесь.
Что остаётся в шлюзе (`api/src/normalizer/`):
- `ocr/adapter.py` — адаптер **шага 1** (`05-domain-rules.md` §11.1): таблица `data_ocr.table_structure[i]` → кандидат
пакета (сетка ячеек по возрастанию `row_index`, `ref` ячейки — номер строки ИС, шапка `data_ocr`, заголовок формы).
Нарушенная структура таблицы (повтор `row_index` / `column_index`, нецелые индексы, `rows` не список) — исключение
адаптера, то есть `TABLE_PARSE_ERROR` всей таблицы.
- `ocr/answer_set.py` — обход набора json склейки: на каждую таблицу успешного файла зовётся `normalize_candidate`
пакета, исключение одной таблицы не роняет остальные; статус набора — `envelope_status` пакета.
- `ocr/__init__.py` — `OcrNormalizer`: результат пакета переводится в модели контракта функциями `to_contract` /
`warning_to_contract` (граница контракта; шлюз только валидирует словарь своими pydantic-моделями), версия копии
(`api/arm_statements/VERSION`) идёт в `NormalizeOutcome` и в `service.statements_version` файла результата.
- `files.py` — ошибки файла набора (`error.code`, `parse_error`, корень не объект); `outcome.py` — `NormalizeOutcome`.
Ушло в пакет: очистка текста, даты, суммы, раскладка колонок, классы строк, типы форм, редакции, статусы строк и
документов, предупреждения документов, `checks` (§7). Старые модули `ocr/text.py … document.py` и каталог `api/rules/`
удалены.
Документ на каждую таблицу успешного файла: `locator = "file:<имя json>;table:<i>"`, `header.form_name` — заголовок
таблицы ИС. Подробные правила шагов 2–8 и статусов — `05-domain-rules.md` §11.9, предупреждения документа — там же и
в схеме `statementDocument`.
### Статус результата
- `envelope_status` пакета: из статусов документов и предупреждений файла. Документы есть и все `done`, предупреждений
файла уровня `error` нет — `done`; иначе `needs_check`; ни одного документа — `error`.
- Все json набора ошибочны (Р-33) — `error` с кодом и сообщением первого ошибочного файла по имени (см. «Коды ошибок»);
`metrics.warnings` при этом перечисляет `JSON_FAILED` каждого json.
- Успешные файлы не дали документов (нет таблиц, все таблицы потеряны) — `error`, `RECOGNITION_FAILED`, сообщение
«ни одной таблицы не разобрано».
- Ошибочный файл при наличии успешного — `JSON_FAILED` (`error`) в `metrics.warnings`, статус не выше `needs_check`.
- Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` — тоже `JSON_FAILED` (Р-32).
- `metrics.rows` — число строк `lines` всех документов (теперь и строки без кода). Файл результата
`results/{file_id}/{job_id}/result.json` пишется для `done` и `needs_check`; `metrics.warnings` в конверте и в файле
результата одинаковы.
### Предупреждения (`metrics.warnings`)
`metrics.warnings` — список структур `{code, severity, message, locator}` (строк больше нет). Предупреждения **файла**,
которые порождает шлюз и пакет:
| Код | Уровень | Когда | `locator` |
|---|---|---|---|
| `JSON_FAILED` | `error` | файл набора ошибочен (`error.code` ИС, `parse_error`, корень не объект), либо успешный json без `data_ocr` / `table_structure` / таблиц; `message` — `<код ИС>: <сообщение>` или причина | `file:<имя json>` |
| `TABLE_PARSE_ERROR` | `error` | исключение при разборе таблицы (в том числе нарушенная структура таблицы ИС); `message` — `BACKEND_PARSING_ERROR: <тип>: <текст до 300 символов>` | `file:<имя json>;table:<i>` |
| `TABLE_LOST` | `error` | таблица с данными не распознана как форма: нет столбца кодов и типа формы | `file:<имя json>;table:<i>` |
| `TABLE_SKIPPED` | `info` | таблица без данных пропущена | `file:<имя json>;table:<i>` |
Предупреждения **документа** (`documents[i].warnings`, у них нет `locator`) и нарушения §7 — в самих документах, см.
`05-domain-rules.md` §11.9. Список `metrics.warnings` ограничен 100 структурами, `message` — 1000 символов; при
переполнении остаются все `error`, затем `info` (сырой ответ — в `raw/`).
### Эталоны и `make golden-update`
Тест `api/tests/unit/normalizer/test_golden.py` сравнивает результат нормализатора на реальных ответах ИС
(`../fixtures/ocr/<имя>.json` + `<имя>_*.json`) с эталонами `../fixtures/ocr/expected_normalize/<имя>.json`. Формат v2
(P5-14): `{schema_version, status, error, metrics, documents}` файла результата без `job_id`, `file_id`, `service`,
`raw_s3_key` (`statements_version` меняется при каждой раскладке копии); успешный эталон дополнительно проходит схему
`recognition-result` v2.0. Эталоны лежат в репозитории **fixtures** и обновляются отдельным PR: `make golden-update`
(`api/tests/golden_update.py`) пересобирает их, diff смотреть через `git -C ../fixtures diff` и объяснять. Без каталога
`../fixtures/ocr` golden-тесты не выполняются.
## Переменные окружения
Файл окружения — `ENV_FILE` (по умолчанию `.env`, путь от текущего каталога, то есть от `api/`); переменные окружения
приоритетнее файла. Шаблон — `api/.env.example`. Настройки шлюза читаются при импорте `src.app`: модуль создаёт `app`
(это происходит при запуске `run_server.py` или `uvicorn src.app:app`). Импорт пакета `src` приложение не создаёт, поэтому
эмулятор (`python -m src.scan_emulator`) настройки шлюза не читает: у него свои переменные (`SCAN_HOTFOLDER_PATH`,
`SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_*`) из окружения и `ENV_FILE`.
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`SCAN_HOTFOLDER_MODE`, `NATS_ENABLED`, `DEBUG`, `SCAN_*`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__SCAN_HOTFOLDER_MODE=smb` (путь шары — по-прежнему секрет `OPENBAO__SMB__SMB_PATH`, `SCAN_HOTFOLDER_PATH` в контуре не нужен). Значения разбираются как из обычного env. Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте; `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` (тот же файл в arm-core и arm-tabular-parser) |
| `OPENBAO__MINIO__MINIO_URL` | — (обязательна) | адрес MinIO `host:port`, без схемы |
| `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | — (обязательна) | логин MinIO |
| `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | — (обязательна) | пароль MinIO |
| `OPENBAO__MINIO__MINIO_BUCKET` | — (обязательна) | единственный бакет сервиса; запрос с другим `s3.bucket` -> `FILE_READ_ERROR` |
| `OPENBAO__MINIO__MINIO_SECURE` | `true` | https; для MinIO из compose — `false` |
| `OPENBAO__NATS__NATS_URL` | — (обязательна) | адрес NATS |
| `OPENBAO__NATS__NATS_SEED`, `OPENBAO__NATS__NATS_JWT` | пусто | nkeys/JWT платформы (`SecretStr`, в логи и `/healthcheck` не попадают); оба пусты — без учётных данных; задан один — ошибка конфигурации |
| `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` | `files.img_uploaded` | subject запроса, на который подписан сервис; у ядра окружения то же значение |
| `OPENBAO__NATS__NATS_RESULT_SUBJECT` | `files.tabular_parsed` | subject, в который сервис публикует ack и итог |
| `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` | `files.tabular_uploaded` | запрос `tabular` (сервис его не слушает; значение участвует в проверке: три имени непусты, без пробелов, `*`, `>` и попарно различны, иначе сервис не стартует) |
| `OPENBAO__SMB__SMB_HOST`, `OPENBAO__SMB__SMB_PATH`, `OPENBAO__SMB__SMB_LOGIN`, `OPENBAO__SMB__SMB_PASSWORD` | пусто | шара горячих папок; обязательны только при `SCAN_HOTFOLDER_MODE=smb`; `SMB_PATH` — `share/dir` или полный UNC |
| `NATS_ENABLED` | `true` | `false` — воркер не запускается, подключения к NATS нет (`/healthcheck.nats = disabled`) |
| `PUBLISH_RETRIES` | `3` | повторы публикации ack/результата после первой попытки (всего до 4) |
| `DONE_CACHE_SIZE` | `1000` | размер LRU обработанных `job_id` (дедупликация) |
| `SCAN_HOTFOLDER_MODE` | `local` | `local` или `smb` |
| `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) |
| `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня |
| `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня |
| `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды |
| `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output/<job_id>/` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) |
| `SCAN_TIMEOUT_SECONDS` | `1200` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`; 20 мин — в паре со сроком ответа OCR ядра) |
| `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий |
| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output/<job_id>/` и каталог, `Input/<job_id>.<ext>` (и `.done`); `false` — ничего не удаляется; см. «Очистка горячей папки» |
| `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте |
| `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) |
| `ROOT_PATH` | `/` | префикс FastAPI за прокси |
| `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес HTTP-сервера `run_server.py` |
| `DEBUG` | `false` | флаг шаблона платформы; поведение шлюза не меняет |
| `ENV_FILE` | `.env` | какой файл окружения читать |
Эмулятор (`python -m src.scan_emulator`) читает свои переменные и не использует настройки шлюза (в его контейнере нет
MinIO и NATS); те же значения подхватываются из `ENV_FILE`:
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `SCAN_HOTFOLDER_PATH` | — (обязательна) | корень горячей папки; должен существовать, иначе код выхода 2 |
| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR` | `Input`, `Output` | подкаталоги корня (как у шлюза) |
| `SCAN_TMP_DIR` | `Tmp` | служебный каталог эмулятора: в нём собирается набор ответа перед rename в `Output/<uuid>/` (шлюз эту переменную не читает) |
| `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных пар: исходник (PDF и др.) и ответы ИС (`aep_N.json`, `aep_N_<суффикс>.json`); пара ищется по sha256 содержимого входящего файла; путь от `api/` |
| `SCAN_EMULATOR_DEFAULT` | пусто | основа имени набора (например, `aep_6`), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка `INVALID_DOCUMENT` |
| `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) |
| `SCAN_EMULATOR_POLL_SECONDS` | `1` | период просмотра «Входящих», секунды (> 0) |
## Коды ошибок
Итоговый конверт `error` содержит `error.code` и `error.message`. Нормализатор возвращает коды ответа ИС и
`INVALID_FORMAT`/`RECOGNITION_FAILED` (ни одной таблицы) и коды ошибок json, остальные коды ставит воркер (`api/src/core/worker.py`).
Код `NOT_IMPLEMENTED` (заглушка Среза 0) сервис больше не публикует: есть успешный файл — документы и `done` /
`needs_check`.
| Код | Когда | Ack |
|---|---|---|
| `SCHEMA_MISMATCH` | тело не JSON / не по схеме / MAJOR версии ≠ 1 / `pipeline` ≠ `image`. Конверт публикуется, только если в теле есть корректные `job_id` и `file_id`, иначе запрос пишется в лог и отбрасывается | нет |
| `FILE_READ_ERROR` | объект S3 не прочитан; `s3.bucket` не совпадает с бакетом сервиса | да |
| `RECOGNITION_FAILED` | у `original_name` нет допустимого расширения (файл в ИС не передаётся; `<job_id>.<ext>`, 1–16 букв/цифр ASCII); не записан файл во «Входящие» (горячая папка недоступна); «Исходящие» не читались ни разу до дедлайна; **ни одной таблицы не разобрано** (успешные файлы набора не дали ни одного документа; сообщение «ни одной таблицы не разобрано», `raw_s3_key` заполнен, причины — в `metrics.warnings`: `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`, `JSON_FAILED`) | да |
| `TIMEOUT` | ИС не ответила до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`; `reply_deadline` истёк до отправки файла | да |
| `INVALID_FORMAT` | все файлы ответа ошибочны, и у первого ошибочного файла не разобрался JSON (сырой ответ сохранён, `raw_s3_key` указан) или корень ответа не объект; склейка нарушена или пуста | да |
| код ошибки ИС | **все** файлы ответа ошибочны (`error.code` непустой): код первого ошибочного файла по имени пробрасывается, если он входит в `ErrorCode` (например, `INVALID_DOCUMENT`); каждый ошибочный файл — предупреждение `JSON_FAILED` в `metrics.warnings` | да |
| `UNKNOWN_ERROR` | сырой ответ или результат не записан в S3; нормализатор упал (исключение вне разбора таблицы: исключение внутри таблицы — предупреждение `TABLE_PARSE_ERROR`); неизвестный код ИС (в том числе `error.code` не строка: число, список, `true`); непредвиденная ошибка | да |
Файл ответа считается ошибочным, если его `error.code` непустой, файл не разобрался как JSON (`parse_error`) либо корень
ответа не объект (`INVALID_FORMAT`).
`metrics.warnings` итогового конверта содержит структуру `JSON_FAILED` (`severity = error`, `locator = file:<имя>`,
`message = <код>: <сообщение>`) на каждый ошибочный файл (и когда есть успешный файл, и когда ошибочны все файлы) и
предупреждения таблиц (`TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`) — в порядке файлов набора по имени; при
`done` / `needs_check` в `metrics` есть и `rows`. Если предупреждений нет, `warnings` опускается.
## Контракт NATS (кратко)
Схемы и примеры — `../docs/contracts/`; их байт-в-байт копии лежат в `api/contracts/` (идентичность проверяет unit-тест
`api/tests/unit/test_contract.py`, входит в `make check`), pydantic-модели — `api/src/contract/`.
- Запрос: subject `files.img_uploaded` (по умолчанию; `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT`), подписка в queue group `scan-gateway`, `Nats-Msg-Id = <job_id>:<attempt>`.
- Ответы — subject `files.tabular_parsed` (по умолчанию; `OPENBAO__NATS__NATS_RESULT_SUBJECT`): ack `status=processing`, `Nats-Msg-Id = ack:<job_id>:<attempt>`; итог
`status = done | needs_check | error`, `Nats-Msg-Id = result:<job_id>:<attempt>`. `service.name = "scan-gateway"`.
- Ключи S3: исходник — из запроса (`s3.key`), сырой ответ (склейка файлов ИС) — `raw/{job_id}.json`, результат —
`results/{file_id}/{job_id}/result.json`. Бакет один.
- NATS без JetStream: переподключение и повторная подписка — на `nats-py`, ack и повторы публикации — на уровне
приложения (`api/src/core/nats.py`).
## Запуск
Требуются Docker с compose v2, `uv`, Python 3.11. Все команды `make` — из корня репозитория; backend-команды
рецепты выполняют в `api/`. Рядом в workspace должны лежать `../docs` (compose, контракт) и `../fixtures`.
### Разработка
```bash
make up # NATS, MinIO, PostgreSQL: compose-проект arm-ka из ../docs/dev
make venv # api/.venv (Python 3.11), зависимости без raisa_*
cp api/.env.example api/.env # значения совпадают с ../docs/dev (NATS 4222, MinIO 9000, бакет arm-ka)
mkdir -p api/.hotfolder # корень горячей папки: сам не создаётся
cd api && .venv/bin/python run_server.py # сервис на :8000
make emulator # в другом терминале: эмулятор ИС Сканирование
curl -s localhost:8000/healthcheck
```
Целей `down`/`stop` в `Makefile` нет намеренно: compose-проект `arm-ka` общий для сервисов workspace.
Контейнера `scan-emulator` в compose пока нет (его добавит репозиторий `docs` позже), поэтому эмулятор запускается
локально командой `make emulator`.
### Полный круг против compose и эмулятора
Исполняемый пример — `api/tests/integration/test_roundtrip.py` (поднимает приложение и эмулятор в процессе теста,
публикует запрос, проверяет ack, итоговый конверт, объекты S3). Вручную те же шаги:
1. Выполнить «Разработку» выше: сервис и эмулятор работают, `/healthcheck` отдаёт `nats = ok`, `hotfolder = ok`.
2. Положить файл в MinIO (консоль http://localhost:9001 или `mc`) в бакет `arm-ka`, например
`uploads/<file_id>/<job_id>/aep_3.pdf`. Эмулятор узнаёт файл **по содержимому** (sha256), а не по имени: нужен
PDF из `../fixtures/ocr` (`aep_3.pdf` -> набор `aep_3.json` + `aep_3_ofr.json`, `aep_6.pdf` -> `aep_6.json`,
`aep_5.PDF` -> `aep_5.json`); любое другое содержимое даёт `INVALID_DOCUMENT`, если не задан `SCAN_EMULATOR_DEFAULT`.
`original_name` в запросе определяет только расширение входящего файла.
3. Подписаться на `files.tabular_parsed` любым клиентом NATS.
4. Опубликовать в `files.img_uploaded` тело по образцу `api/contracts/examples/request-image.json`
(свои `job_id` и `file_id` — UUID, `original_name`, `s3.key`, `reply_deadline` в будущем, `attempt` = 1) с заголовком
`Nats-Msg-Id: <job_id>:1`.
5. Ожидать: ack `processing`, через `SCAN_EMULATOR_DELAY` (+ до `SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS` после первого опроса, увидевшего набор) —
итоговый конверт:
- `aep_3.pdf` — `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`), `metrics.warnings` с
`JSON_FAILED` для `<job_id>_ofr.json` (второй файл набора — ответ-ошибка, `INVALID_DOCUMENT: ...`);
- `aep_6.pdf` — `needs_check`, два документа (`BALANCE`, `INCOME`), предупреждения — в самих документах;
- `aep_5.PDF` (и `aep_8.pdf`) — `error`, `INVALID_DOCUMENT` с сообщением ИС (ответы-ошибки);
- неизвестное содержимое — `error`, `INVALID_DOCUMENT` эмулятора.
В бакете появится `raw/<job_id>.json` (склейка всех файлов набора), `raw_s3_key` указан в конверте; для
`done` / `needs_check` — ещё `results/<file_id>/<job_id>/result.json`. В горячей папке: при `SCAN_CLEANUP=false` —
`Input/<job_id>.pdf.done` и `Output/<job_id>/`, при `true` — пусто.
### Проверки
```bash
make check # lint (ruff check, ruff format --check, mypy src) + test (pytest -m "not integration")
make test-integration # pytest -m integration: нужны NATS и MinIO (make up)
make docker-build # docker build -t scan-gateway:dev . (образ без Nexus)
make check-full # check + statements-check + test-integration + docker-build; перед PR
make statements-check # свежесть копии api/arm_statements (sync-statements --check; код 10 — предупреждение, впишите в PR)
make golden-update # пересобрать эталоны нормализатора ../fixtures/ocr/expected_normalize/ (см. «Нормализация»)
```
Тесты запускаются из `api/`: `.env.test` подключается через `ENV_FILE=.env.test` в `api/pytest.ini` и разрешается
относительно текущего каталога (рецепты `make` это делают сами). Маркеры: `integration` — нужна инфраструктура,
исключается из `make test`; `fixtures` — нужны реальные данные из `../fixtures` (нет репозитория — тесты пропускаются).
Тесты: `api/tests/unit/`, `api/tests/integration/`. Интеграционные тесты делят NATS с другими сервисами workspace:
если в нём работает другой экземпляр `scan-gateway`, он может забрать запрос теста из queue group.
Шаги `make check-full` идут последовательно и прерываются на первой ошибке; тесты в образе не запускаются.
## `/healthcheck`
`GET /healthcheck` всегда отвечает 200; состояние — в полях:
| Поле | Значения |
|---|---|
| `uptime` | время старта процесса |
| `version` | `VERSION` |
| `nats` | `ok` — соединение установлено; `down` — подключение идёт, переподключение, сбой; `disabled` — `NATS_ENABLED=false` |
| `hotfolder` | `ok` — `HotFolder.healthy()` (корень существует и доступен); `down` — нет, проверка не уложилась в 2 с или упала |
| `subjects` | `{image_request, result}` — subject'ы NATS из настроек; отдаётся при любом состоянии NATS; JWT/seed в ответе нет |
Подключение к NATS идёт в фоне: HTTP отвечает сразу (`nats = down`), подписка появляется после подключения.
Результат проверки горячей папки кэшируется на 5 с.
## Docker-образ
Один образ (`Dockerfile`, `python:3.11-slim`, контекст сборки — корень репозитория, `docker build -t scan-gateway .`)
для шлюза и для эмулятора. По умолчанию запускается шлюз (`python run_server.py`, порт 8000); эмулятор — тем же образом
с `command: python -m src.scan_emulator`. Пакеты `raisa_*` в образ не ставятся. `api/.env` в образ не попадает (см.
`.dockerignore`): окружение приходит из compose; шлюзу и эмулятору нужен общий том с горячей папкой по пути
`SCAN_HOTFOLDER_PATH`, эмулятору — каталог фикстур по пути `SCAN_FIXTURES_DIR`.
## Известные ограничения
Нормализатор (правила шагов 2–8 — пакет `arm_statements`, ограничения — в `05-domain-rules.md` §11.9 и спеке
`statement-normalization`; решения шлюза — `openspec/changes/statements-v2/design.md`):
- Сетка OCR строится по возрастанию `row_index`, пропуски `row_index` пустыми строками не заполняются (P5-1): шаг 2 не
видит «дыр» между строками ИС (в реальных json их нет). Ячейка `value` не строка приводится через `str`, `confidence`
вне числа — 0 (P5-3).
- Успешный json ИС без таблиц — `JSON_FAILED` (`error`), рядом с нормальными json конверт будет `needs_check` (P5-7, Р-32).
- Нарушенная структура одной таблицы ИС теряет всю таблицу (`TABLE_PARSE_ERROR`, P5-2); остальные таблицы разбираются.
- Склейка одной формы из нескольких таблиц (OQ-77) не обрабатывается: каждая таблица —
свой документ.
Транспорт и эмулятор:
- Режим `smb` покрыт unit-тестами с подменённым `smbclient`; на реальной шаре ИС Сканирование не проверялся. Фактические
имена каталогов и путь шары на стенде неизвестны — они настраиваются, а не зашиты в код. Права на удаление на шаре
контура не подтверждены — по умолчанию `SCAN_CLEANUP=false`.
- Повторной отправки в ИС нет (OQ-15); падение шлюза после ack теряет задание до `reply_deadline` ядра.
- Зависший вызов SMB (`poll`/`put`, до ~60 с по умолчанию `smbclient`) дедлайном воркера не прерывается: вызов идёт
в потоке без собственного таймаута, поэтому `TIMEOUT` может прийти позже дедлайна на это время.
- Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает
файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается:
`response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`.
- Запись сразу в `Input` (change `hotfolder-direct-write`): защита от чтения недописанного файла — только монопольное
открытие `smbclient` на время записи (ASSUMPTION(OQ-79), как в старом АРМ). Если ИС всё же возьмёт файл во время
записи, распознавание даст ошибку; откат — вернуть запись через временный файл.
- Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе
он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей
пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не
переиндексируется (решение №9).
- Реальные фикстуры: `aep_3.json` (баланс и ОФР), `aep_6.json` — успешные ответы, `aep_3_ofr.json`, `aep_5.json`,
`aep_8_form4.json` — ответы-ошибки (`INVALID_DOCUMENT`; `data_ocr` — объект с пустыми полями и `table_structure: []`).
- Контейнера `scan-emulator` в compose пока нет.