docs(scan-gateway): README/CLAUDE.md под Срез 3a, roundtrip с документами, tasks.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 02:42:49 +04:00
parent 13bd2885e5
commit bef8441ca8
4 changed files with 573 additions and 158 deletions

View File

@ -3,31 +3,54 @@
Общий контекст системы — `../CLAUDE.md` и `../docs/`. Этот файл — только про этот сервис.
## Что это
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: переименовать файл `UUID_имя`, положить в «Входящие»
(через `SCAN_TMP_DIR`, по умолчанию `Tmp`: `.part` пишется туда, на той же ФС/шаре, и переименовывается), забрать **все**
`Исходящие/UUID/*.json` (ИС кладёт по одному json на каждый распознанный документ), склеить и сохранить сырой ответ в S3
`raw/{job_id}.json` (в БД не регистрируется),
нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument`), опубликовать
`recognition.result`. Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`, старт —
запись файла во «Входящие». **Нет доступа к БД.**
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: положить файл во «Входящие» как `<job_id>.<ext>`
(расширение `original_name` в нижнем регистре; `.part` пишется в `SCAN_TMP_DIR`, по умолчанию `Tmp`, на той же ФС/шаре и
публикуется **без замены**), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый
распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить
горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json`
(`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `recognition.result`
(`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`,
старт — запись файла во «Входящие». **Нет доступа к БД.**
## Правила
- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14): confidence → 0–100,
поля организации/даты — по тому, что реально приходит.
- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14), по `05-domain-rules.md`
§11 и §11.1: правило, которого там нет, не реализуется, а поднимается вопросом. Функции очистки
(`src/normalizer/ocr/`) перенесены из старого ядра **дословно** (единственное исключение для `reference-old-arm`):
при правке сначала разберись, что делает источник; расхождения с ним — только решением, записанным в
`openspec/changes/slice-3a-ocr-normalizer/design.md` (таблица «Decisions made without discussion»: №12, №13, №20, №22).
Функции `ocr/*` — чистые: без I/O, без настроек, без логов.
- Справочники — данные: `api/rules/*.yaml` (редакции форм `66n` / `fsbu4`, двойники, `label` → тип), правила не
зашиваются в код. `fsbu4` пока пуста — данные кладёт владелец, код при этом не меняется.
- Статус: `done` только когда всё распознано (`confidence` 100, даты разобраны, редакция определена по дате и заполнена,
нет предупреждений); иначе `needs_check`; ни одной таблицы → `error RECOGNITION_FAILED`. Предупреждения — строки
`metrics.warnings` (`<имя json>: table:<i>: <текст>`); при изменении их текстов обнови README (раздел «Предупреждения»)
и golden.
- Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть
в fixtures и объяснять; эталоны меняются отдельным PR fixtures.
- Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, `.part` только в `Tmp`, запись без замены (`os.link` /
`smbclient.rename`; существующее назначение = «доставлено»). `.part`, ставший целью публикации, снимается самой записью
независимо от флага (№26); любое другое удаление (ответ, входящий файл, `.done`, осиротевшие `.part`) — только при
`SCAN_CLEANUP=true` и только после сохранения `raw/` (не при `TIMEOUT`, `FILE_READ_ERROR`, ошибке записи во входящий каталог или `raw/`). Ошибка
удаления — одна строка лога, конверт не меняется. Нет расширения у `original_name` → `RECOGNITION_FAILED`.
- Набор ответа готов по settle: есть `*.json`, а имена и размеры не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2;
отсчёт от первого опроса, увидевшего окончательный набор). Не ждать «один файл `<UUID>.json`».
- Склеенный `raw/{job_id}.json`: `{"job_id", "files": [{"name", "response", "parse_error"}, ...]}`, все `*.json`
каталога по возрастанию имени; нормализатор получает ровно эту структуру.
- Успех файла ответа — `error.code == ""` (`error` при этом не `null`!), ошибка — непустой `error.code`,
`parse_error` или корень ответа не объект (`INVALID_FORMAT`). Проверка `error is None` для реального ответа неверна. Ошибочные файлы — в `metrics.warnings`
(`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого, есть успешный → `NOT_IMPLEMENTED` (Срез 0).
- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы. Таблицы не
интерпретируются в Срезе 0 — разбор в Срезе 3.
- Dev-режим: эмулятор ИС Сканирование: по входящему `<uuid>_<name>.<ext>` кладёт в `Output/<uuid>/` набор фикстур
`<name>.json` + `<name>_*.json` как `<uuid>.json` / `<uuid>_<суффикс>.json` (атомарно, через `Tmp`): `make emulator`
(то же, что `python -m src.scan_emulator` из `api/`); в контейнере — тот же образ с `command: python -m src.scan_emulator`.
- Описание сервиса, протокол горячих папок, env, запуск и коды ошибок — `README.md`; требования и решения Среза 0 —
`openspec/changes/slice-0-skeleton/` (`design.md`).
`parse_error` или корень ответа не объект (`INVALID_FORMAT`). Проверка `error is None` для реального ответа неверна.
Ошибочные файлы — в `metrics.warnings` (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого; есть
успешный — документы из успешных файлов. `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только
для тестов воркера).
- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы (запасной
путь — коды строк). `checks` в документах не передаются — их считает ядро (Срез 3b).
- Dev-режим: эмулятор ИС Сканирование (`make emulator`, то же, что `python -m src.scan_emulator` из `api/`; в контейнере —
тот же образ с `command: python -m src.scan_emulator`): по входящему `<uuid>.<ext>` считает sha256 содержимого, находит
пару в `SCAN_FIXTURES_DIR` (PDF + `<имя>.json` + `<имя>_*.json`) и кладёт в `Output/<uuid>/` набор как `<uuid>.json` /
`<uuid>_<суффикс>.json` (атомарно, через `Tmp`). Промах → набор `SCAN_EMULATOR_DEFAULT`, иначе ответ-ошибка
`INVALID_DOCUMENT`. Имя входящего файла на выбор пары не влияет.
- Описание сервиса, протокол горячих папок, нормализация, env, запуск и коды ошибок — `README.md`; требования и решения
Среза 3a — `openspec/changes/slice-3a-ocr-normalizer/` (`design.md`, `specs/`), Среза 0 —
`openspec/changes/archive/2026-10-04-slice-0-skeleton/`.
## Соглашения шаблона
Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения —
@ -47,18 +70,28 @@ SMB-сессия, S3, NATS, воркер; `GET /healthcheck`; gzip/proxy middlew
`raisa_fastapi_protected_api`; шаблонные `core/nats_callback.py` и `README.md` заменены своими.
**Добавлено:**
- `api/src/gateway/` — горячие папки: протокол `HotFolder` (`hotfolder.py`: `LocalHotFolder`, `SettleTracker`, `inbox_name`),
`smb.py` (`SmbHotFolder`, импортируется лениво), фабрика `create_hotfolder`; `merge.py` — склейка файлов ответа в `raw/`; референс поллинга — `../reference-old-arm/api/src/core/PDFWorker`.
- `api/src/core/` — `worker.py` (задание: проверка → ack → S3 → горячая папка → опрос → `raw/` → нормализатор →
результат; ошибки по таблице D-7), `nats.py` (core NATS без JetStream, queue group `scan-gateway`, публикация с
- `api/src/gateway/` — горячие папки: протокол `HotFolder` (`hotfolder.py`: `LocalHotFolder`, `SettleTracker`, `inbox_name`;
методы `put`, `poll`, `cleanup`, `healthy`), `smb.py` (`SmbHotFolder`, импортируется лениво), фабрика `create_hotfolder`;
`merge.py` — склейка файлов ответа в `raw/`; референс поллинга — `../reference-old-arm/api/src/core/PDFWorker`.
- `api/src/core/` — `worker.py` (задание: проверка → ack → S3 → горячая папка → опрос → `raw/` → очистка (под флагом) →
нормализатор → результат; ошибки по таблице D-7), `nats.py` (core NATS без JetStream, queue group `scan-gateway`, публикация с
повторами), `clients.py` (`S3Storage`), `health.py` (`nats`/`hotfolder` в `/healthcheck`).
- `api/src/normalizer/` — `Normalizer`, `NullNormalizer` (Срез 0: разбор по файлам склейки — все ошибочны → код
первого, есть успешный → `NOT_IMPLEMENTED`, ошибочные файлы → `metrics.warnings`); настоящая нормализация — Срез 3.
- `api/src/normalizer/` — протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (заглушка Среза 0, остаётся для тестов
воркера) и `OcrNormalizer` (Срез 3a, подключён в `create_app`).
- `api/src/normalizer/ocr/` (Срез 3a) — конвейер чистых функций: `text.py` → `dates.py` → `amounts.py` → `layout.py` →
`rows.py` → `header.py` → `document.py`; `__init__.py` — `OcrNormalizer` (обход файлов склейки, статус, `metrics`).
- `api/src/normalizer/rules.py` — `load_rules(SCAN_RULES_DIR)`: pydantic-модели справочников, `OcrRules` (выбор редакции
по дате / по кодам); `api/rules/` — `statement_lines.yaml` (редакции `66n`, `fsbu4`), `ocr_lookalikes.yaml`,
`statement_labels.yaml`.
- `api/src/contract/` + `api/contracts/` — pydantic-модели и байт-в-байт копии схем `../docs/contracts`; идентичность
проверяет `api/tests/unit/test_contract.py` (входит в `make check`).
- `api/src/scan_emulator/` — эмулятор ИС Сканирование (`SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_DELAY`,
`SCAN_EMULATOR_POLL_SECONDS`); в compose контейнера пока нет — `make emulator`.
`SCAN_EMULATOR_POLL_SECONDS`, `SCAN_EMULATOR_DEFAULT`; пара по sha256 содержимого); в compose контейнера пока нет —
`make emulator`.
- `Makefile` (`venv`, `lint`, `test`, `test-integration`, `check`, `docker-build`, `check-full`, `up`,
`emulator`), `api/pyproject.toml` (ruff, mypy), `Dockerfile` + `.dockerignore` (один образ: шлюз и эмулятор),
`emulator`, `golden-update`), `api/pyproject.toml` (ruff, mypy), `Dockerfile` + `.dockerignore` (один образ: шлюз и эмулятор),
`api/.env.example`, `api/.env.test`.
- Тесты: `api/tests/unit/`, `api/tests/integration/` (маркеры `integration`, `fixtures`); перед PR — `make check-full`.
- Тесты: `api/tests/unit/` (в том числе `unit/normalizer/` — юниты по модулям `ocr/` и golden), `api/tests/integration/`
(маркеры `integration`, `fixtures`; `test_roundtrip.py` — сквозной сценарий с эмулятором и `SCAN_CLEANUP`);
`api/tests/golden_update.py` — сбор склейки реального ответа и запись эталонов в `../fixtures/ocr/expected_normalize/`
(`make golden-update`); перед PR — `make check-full`.

293
README.md
View File

@ -2,7 +2,9 @@
Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений
(PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его
и публикует результат в `recognition.result`. Контракт обмена — `../docs/contracts/` (v1.0), архитектура —
и публикует результат в `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):
@ -12,8 +14,11 @@
- сырой ответ ИС лежит в S3 (`raw/{job_id}.json`) и в БД не регистрируется;
- саму ИС Сканирование мы не пишем: для разработки есть эмулятор (`api/src/scan_emulator/`).
Состояние: Срез 0 (скелет). Сквозной путь работает, нормализатор — заглушка (см. «Ограничения Среза 0»).
Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения — `openspec/changes/slice-0-skeleton/`.
Состояние: Срез 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/`.
## Поток обработки
@ -23,12 +28,13 @@ recognition.image.request (NATS, queue group scan-gateway)
2. ack: recognition.result, status=processing (до чтения S3 и до ожидания слота)
3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
4. файл из S3 (s3.bucket/s3.key)
5. горячая папка: <Input>/<job_id>_<original_name> (через <Tmp>/.<имя>.part и rename)
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. нормализатор ──► (done/needs_check) S3 results/{file_id}/{job_id}/result.json
9. recognition.result: итоговый конверт (done | needs_check | error), в нём raw_s3_key
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` с кодом (таблица ниже), а не в
@ -40,26 +46,34 @@ ack (с `attempt` повторного запроса); файл в ИС вто
## Протокол горячих папок
Реализации интерфейса `HotFolder` (`api/src/gateway/hotfolder.py`: `put`, `poll`, `healthy`):
Реализации интерфейса `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__*` | платформа |
- Входящий файл: `<SCAN_INPUT_DIR>/<job_id>_<original_name>`. `original_name` приходит из запроса и не доверяется: берётся
последний компонент пути, символы, недопустимые в Windows/SMB (`: < > " | ? *` и управляющие), заменяются на `_`,
завершающие точки и пробелы отбрасываются; кириллица и пробелы сохраняются; длинное имя укорачивается так, чтобы
временный `.<имя>.part` умещался в 240 байт UTF-8 (расширение сохраняется). Если имени не осталось —
`RECOGNITION_FAILED`.
- Запись атомарна: сначала временный `.<имя>.part` в служебном каталоге `<SCAN_TMP_DIR>` (по умолчанию `Tmp`), затем
переименование в `Input`. `Tmp` лежит рядом с `Input` и `Output`, на той же ФС/шаре, чтобы `rename` оставался
атомарным, а ИС не забрала недописанный файл из `Input`. Каталоги `Input` и `Tmp` создаются при необходимости (без
родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`.
Раскладка совпадает с проверенной в контуре (`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/<имя>` (`FileExistsError`, если назначение есть) и удаление `.part`; на файловой
системе без жёстких ссылок (`EPERM`/`EXDEV`/`ENOTSUP`) — `os.replace`, если назначения ещё нет (решение №17). В
режиме `smb` — `smbclient.rename` (поднимает `FileExistsError`, если назначение есть). Если `Input/<job_id>.<ext>`
уже существует (повторная публикация задания после потери ack в другом процессе), файл считается доставленным:
назначение не перезаписывается, задание продолжается опросом `Output/<job_id>/`. Каталоги `Input` и `Tmp`
создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`.
- Ответ ИС — каталог `<SCAN_OUTPUT_DIR>/<job_id>/`: ИС кладёт туда **все** распознанные документы задания одновременно,
по одному `*.json` на файл (`Output/<job_id>/*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один
ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть
несколько таблиц разных форм (баланс, ОФР); таблицы в Срезе 0 не интерпретируются (Срез 3).
несколько таблиц разных форм (баланс, ОФР); нормализатор делает из каждой таблицы отдельный документ.
- Готовность набора — по стабилизации (settle): в каталоге есть хотя бы один `*.json`, а имена и размеры `*.json` не
менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2 с). Отсчёт идёт от первого опроса, который увидел окончательный
набор (а не от времени изменения файлов), поэтому ответ приходит не раньше чем через `SCAN_SETTLE_SECONDS` после
@ -70,12 +84,44 @@ ack (с `attempt` повторного запроса); файл в ИС вто
Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется.
- Повторной отправки в ИС нет (OQ-15): после `TIMEOUT` входящий файл остаётся в каталоге, поздний ответ ИС теряется
(сырой файл остаётся на шаре).
- Эмулятор ИС в `local`-режиме: для файла `<uuid>_<имя>` ждёт `SCAN_EMULATOR_DELAY` и кладёт в `Output/<uuid>/` весь
набор фикстур: `<имя>.json` -> `<uuid>.json` и `<имя>_<суффикс>.json` -> `<uuid>_<суффикс>.json` (`<имя>` — имя
входящего файла без расширения; именно `<имя>_*`, а не префикс `<имя>*`: `aep_3` не забирает `aep_30*`; в каждом
файле `file_uid` = `<uuid>`). Набор собирается во временном каталоге `Tmp` и появляется в `Output/<uuid>/`
атомарно целиком (rename каталога). Затем входящий файл переименовывается в `….done`. Нет фикстуры — один
ответ-ошибка `INVALID_DOCUMENT`. Dot-файлы и `.done` игнорируются.
### Очистка горячей папки (`SCAN_CLEANUP`)
Очистка включается `SCAN_CLEANUP=true`; **по умолчанию выключена**: на SMB-шаре контура права на удаление не
подтверждены (открытый вопрос Среза 3a). Для dev-стенда (compose, эмулятор) флаг стоит включать.
- **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в
`raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС).
- **Что удаляется** (всё, что есть): `Output/<job_id>/*.json` и сам каталог `Output/<job_id>/`, `Input/<job_id>.<ext>`,
`Input/<job_id>.<ext>.done` (след эмулятора), `Tmp/.<job_id>.<ext>.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`)
@ -99,6 +145,114 @@ ack (с `attempt` повторного запроса); файл в ИС вто
`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 и разбор не
отбрасывал символов`.
- `header.report_date` — после замены двойников; `inn` — только цифры.
### Справочники `api/rules/`
Данные, а не код; загружаются один раз при старте из `SCAN_RULES_DIR` (по умолчанию `rules`, путь от `api/`) с
pydantic-валидацией; битый файл (код не из 4–5 цифр, пустое наименование, дубль `id`) — сервис не стартует, в ошибке
имя файла.
| Файл | Содержимое |
|---|---|
| `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), перезапуск сервиса (справочники читаются при старте).
### Статус результата
- Все файлы набора ошибочны — `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`) статус сам по себе не
понижает: он попадает в `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/`):
| Уровень | Текст |
|---|---|
| файл | `ИНН не распознан` (ИНН нет или `confidence` ≠ 100) |
| файл | `дата отчёта не разобрана` |
| файл | `data_ocr отсутствует или не объект` |
| таблица | `нет шапки и столбца кода` (таблица пропущена, остальные разбираются) |
| таблица | `шапка синтезирована: колонки без дат` (в таблице нет строки-шапки: раскладка выведена по данным) |
| таблица | `колонка <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/`); переменные окружения
@ -124,11 +278,13 @@ ack (с `attempt` повторного запроса); файл в ИС вто
| `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) |
| `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня |
| `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня |
| `SCAN_TMP_DIR` | `Tmp` | служебный каталог относительно корня (на той же ФС/шаре, что `Input`/`Output`): временный `.part` записи входящего файла |
| `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 за прокси |
@ -143,30 +299,34 @@ MinIO и NATS); те же значения подхватываются из `EN
|---|---|---|
| `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` | каталог эталонных ответов ИС (`aep_N.json`, `aep_N_<суффикс>.json`); путь от `api/` |
| `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`. Нормализатор может вернуть любой код `ErrorCode`
контракта, остальные коды ставит воркер (`api/src/core/worker.py`).
Итоговый конверт `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`; «Исходящие» не читались ни разу до дедлайна | да |
| `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` | да |
| `NOT_IMPLEMENTED` | есть хотя бы один успешный файл (`error.code == ""`); нормализатор Среза 0 — заглушка. Ошибочные файлы набора — в `metrics.warnings` | да |
| `UNKNOWN_ERROR` | сырой ответ или результат не записан в S3; нормализатор упал; неизвестный код ИС (в том числе `error.code` не строка: число, список, `true`); непредвиденная ошибка | да |
| `UNKNOWN_ERROR` | сырой ответ или результат не записан в S3; нормализатор упал (исключение вне разбора таблицы: исключение внутри таблицы — предупреждение `BACKEND_PARSING_ERROR`); неизвестный код ИС (в том числе `error.code` не строка: число, список, `true`); непредвиденная ошибка | да |
Файл ответа считается ошибочным, если его `error.code` непустой, файл не разобрался как JSON (`parse_error`) либо корень
ответа не объект (`INVALID_FORMAT`).
`metrics.warnings` итогового конверта содержит по строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл (и при
`NOT_IMPLEMENTED`, и когда ошибочны все файлы); если ошибочных файлов нет, поле `metrics` не передаётся.
`metrics.warnings` итогового конверта содержит по строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл (и когда
есть успешный файл, и когда ошибочны все файлы) вперемешку с предупреждениями нормализации успешных файлов (см.
«Нормализация»), в порядке файлов набора по имени; при `done` / `needs_check` в `metrics` есть и `rows`. Если
предупреждений нет, `warnings` опускается.
## Контракт NATS (кратко)
@ -209,20 +369,25 @@ curl -s localhost:8000/healthcheck
1. Выполнить «Разработку» выше: сервис и эмулятор работают, `/healthcheck` отдаёт `nats = ok`, `hotfolder = ok`.
2. Положить файл в MinIO (консоль http://localhost:9001 или `mc`) в бакет `arm-ka`, например
`uploads/<file_id>/<job_id>/aep_3.pdf` (имя файла должно совпадать с фикстурами эмулятора: `aep_3.*` -> набор
`aep_3.json` + `aep_3_ofr.json`, `aep_6.*` -> `aep_6.json`, `aep_5.*` -> `aep_5.json`).
`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` после первого опроса, увидевшего набор) —
конверт `error`:
- `aep_3` — `NOT_IMPLEMENTED` и `metrics.warnings` с одной строкой `<job_id>_ofr.json: INVALID_DOCUMENT: ...`
(второй файл набора — ответ-ошибка);
- `aep_6` — `NOT_IMPLEMENTED` без `metrics`;
- `aep_5` (и `aep_8`) — `INVALID_DOCUMENT` с сообщением ИС (ответы-ошибки);
- неизвестное имя — `INVALID_DOCUMENT` эмулятора.
В бакете появится `raw/<job_id>.json` (склейка всех файлов набора), `raw_s3_key` указан в конверте.
итоговый конверт:
- `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` — пусто.
### Проверки
@ -231,6 +396,7 @@ make check # lint (ruff check, ruff format --check, mypy src) + test
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` и разрешается
@ -262,22 +428,47 @@ make check-full # check + test-integration + docker-build; перед PR
`.dockerignore`): окружение приходит из compose; шлюзу и эмулятору нужен общий том с горячей папкой по пути
`SCAN_HOTFOLDER_PATH`, эмулятору — каталог фикстур по пути `SCAN_FIXTURES_DIR`.
## Ограничения Среза 0
## Известные ограничения
Нормализатор (Срез 3a; решения — таблица «Decisions made without discussion» в
`openspec/changes/slice-3a-ocr-normalizer/design.md`):
- Редакция `fsbu4` (отчётность с 2025 года) пока пуста: имена строк документов 2025 года берутся из OCR, предупреждение
«редакция fsbu4 не заполнена», статус `needs_check`. Состав редакции — данные владельца (см. «Справочники»).
- Строки, чей код не целое число после обрезки пробелов (например, `1150 :` — перенос `_classify_row` старого ядра:
считается повторной шапкой), и строки без кода **пропускаются молча**, без предупреждения; в документ не попадают,
остаются в `raw/`.
- Скобка, распознанная как `}` или `]` (`(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).
- Ошибочный файл набора при успешных статус не понижает (`done` возможен, предупреждение `<имя>: <код>: ...` остаётся
в `metrics.warnings`).
- Расшифровки без кода (OQ-36) и склейка одной формы из нескольких таблиц (OQ-77) не обрабатываются: каждая таблица —
свой документ.
Транспорт и эмулятор:
- `NullNormalizer` (`api/src/normalizer/`): если ошибочны все файлы ответа, код первого из них пробрасывается; если
есть успешный файл (`error.code == ""`) — `NOT_IMPLEMENTED`; ошибочные файлы — в `metrics.warnings`. Таблицы
`data_ocr` (в одном json их может быть несколько, разных форм: баланс, ОФР) не интерпретируются; настоящая
нормализация в `statementDocument` — Срез 3.
- Реальные фикстуры: `aep_3.json` (баланс и ОФР), `aep_6.json` — успешные ответы, `aep_3_ofr.json`, `aep_5.json`,
`aep_8_form4.json` — ответы-ошибки (`INVALID_DOCUMENT`; `data_ocr` — объект с пустыми полями и `table_structure: []`).
- Режим `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` с сообщением о повреждённой фикстуре и пишет ошибку в лог.
- Контейнер `scan-emulator` в compose отсутствует.
он кладёт ответ `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 пока нет.

View File

@ -1,23 +1,30 @@
"""Сквозной сценарий scan-gateway против compose-инфраструктуры (задача 6.1, R-7, R-10).
"""Сквозной сценарий scan-gateway против compose-инфраструктуры (задача 6.1, R-7, R-10; Срез 3a).
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `NullNormalizer`) и эмулятор ИС Сканирование
(`src.scan_emulator.run` в потоке) работают в процессе теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из
`api/.env.test` (`make up`), горячая папка — tmp-каталог.
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и
эмулятор ИС Сканирование (`src.scan_emulator.run` в потоке, `SCAN_EMULATOR_DEFAULT` пуст) работают в процессе
теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог.
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, `.part` живёт в `Tmp`), пару фикстур
эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из
`fixtures/ocr` и синтетический `ok_doc`).
Сценарии (реальные фикстуры `fixtures/ocr`: OQ-76/77/78; эмулятор кладёт в `Output/<job_id>/` весь набор ответа):
1. `aep_5.PDF` (ответ-ошибка `INVALID_DOCUMENT`) → ack `processing`, затем `INVALID_DOCUMENT` с сообщением ИС и
`raw_s3_key`; повтор запроса во время ожидания ответа ИС и после результата — ещё один ack, второго файла нет;
2. `aep_3.pdf` (набор из двух файлов: `<job_id>.json` — успех, `<job_id>_ofr.json` — `INVALID_DOCUMENT`) → ack, затем
`NOT_IMPLEMENTED` с `metrics.warnings` про `_ofr.json`; `raw/{job_id}.json` — оба файла (склейка, D-4);
3. `aep_6.pdf` (один успешный файл) → `NOT_IMPLEMENTED` без `metrics`;
4. `ok_doc.pdf` (синтетический успешный ответ) → ack, затем `NOT_IMPLEMENTED` с `raw_s3_key`;
5. `unknown.pdf` (нет фикстуры) → ack, затем `INVALID_DOCUMENT` эмулятора.
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; склеенный сырой ответ в
MinIO содержит ровно те файлы, что лежат в каталоге ответа, в порядке возрастания имени, с телами байт в байт;
входящий файл совпадает с объектом S3. Готовность набора — по settle (`SETTLE_SECONDS`, 0.2 с).
`needs_check` с документами (баланс и ОФР) в файле результата и `metrics.warnings`, в том числе про `_ofr.json`;
`raw/{job_id}.json` — оба файла (склейка, D-4);
3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`);
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`;
5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора;
6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты, `Tmp/` без `.part`.
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата
из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге
ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора
— по settle (`SETTLE_SECONDS`, 0.2 с).
Общая инфраструктура: NATS и MinIO используют и другие сервисы workspace. Чужие конверты на `recognition.result`
отсекаются по `job_id` теста; ключи MinIO уникальны (`uploads/itest-<uuid>/…`, `raw/<job_id>.json`) и удаляются.
отсекаются по `job_id` теста; ключи MinIO уникальны (`uploads/itest-<uuid>/…`, `raw/<job_id>.json`, `results/…`) и
удаляются.
Подписка шлюза идёт в queue group `scan-gateway`: если в этом NATS живёт другой экземпляр scan-gateway, он может
забрать запрос теста (тест тогда упадёт по таймауту ожидания ack). Перед запуском занятость группы можно проверить
мониторингом NATS: `curl -s 'http://127.0.0.1:8222/subsz?subs=1'` — подписчиков `recognition.image.request` быть
@ -26,6 +33,7 @@ MinIO содержит ровно те файлы, что лежат в ката
"""
import asyncio
import hashlib
import json
import shutil
import socket
@ -48,10 +56,9 @@ from nats.aio.client import Client as NATS
from nats.aio.msg import Msg
from src.app import create_app
from src.contract import IMAGE_REQUEST_SUBJECT, NATS_MSG_ID_HEADER, RESULT_SUBJECT, request_msg_id
from src.contract import CONTRACT_VERSION, IMAGE_REQUEST_SUBJECT, NATS_MSG_ID_HEADER, RESULT_SUBJECT, request_msg_id
from src.core.clients import JSON_CONTENT_TYPE, S3Storage
from src.core.config import Settings, get_settings
from src.normalizer import NOT_IMPLEMENTED_MESSAGE
from src.scan_emulator import EmulatorSettings, run
pytestmark = [
@ -68,6 +75,7 @@ CONTRACTS_DIR = API_DIR / "contracts"
# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как в unit-тестах
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
GOLDEN_JOB_ID = "3f1c2b7a-0000-4000-8000-000000000001" # условный job_id эталонов (`tests/golden_update.py`)
CONNECT_WAIT = 10.0 # секунд на подключение к NATS (тест и фоновая задача приложения)
SCENARIO_WAIT = 20.0 # секунд на ожидание конвертов одного сценария
QUIET_PERIOD = 1.0 # секунд тишины после повторного запроса: второго результата быть не должно
@ -80,44 +88,53 @@ MESSAGE_VALIDATOR = Draft202012Validator(
json.loads((CONTRACTS_DIR / "recognition-result-message.schema.json").read_text(encoding="utf-8")),
format_checker=FormatChecker(),
)
RESULT_VALIDATOR = Draft202012Validator(
json.loads((CONTRACTS_DIR / "recognition-result.schema.json").read_text(encoding="utf-8")),
format_checker=FormatChecker(),
)
REQUEST_EXAMPLE: dict[str, Any] = json.loads(
(CONTRACTS_DIR / "examples" / "request-image.json").read_text(encoding="utf-8")
)
# Синтетический успешный ответ ИС (R-10): копия `aep_5.json` с `error = null` и непустым `data_ocr` по форме
# docs/context/05-domain-rules.md §11 (документы с label/organization/inn/units/date/table_structure.rows)
SUCCESS_DATA_OCR: list[dict[str, Any]] = [
# Синтетический успешный ответ ИС (R-10) в форме реальных ответов (`aep_6.json`): `data_ocr` — объект с полями
# `label/organization/inn/units/date` (`form_name` + `confidence`) и `table_structure[{label, rows}]`. Все
# `confidence` = 100, шапка есть, даты колонок и отчёта разобраны, но дата 2025 года попадает в редакцию `fsbu4`,
# справочник которой пуст: нормализатор даёт `needs_check` (предупреждение «редакция fsbu4 не заполнена»)
OK_DOC_PDF = b"%PDF-1.4\n% scan-gateway itest ok_doc.pdf\n\x00\x01\x02"
UNKNOWN_PDF = b"%PDF-1.4\n% scan-gateway itest unknown.pdf\n\x00\x01\x02"
OK_DOC_ORGANIZATION = "ООО «Русский бисквит»"
SUCCESS_DATA_OCR: dict[str, Any] = {
"label": {"form_name": "Бухгалтерская отчетность", "confidence": 100.0},
"organization": {"form_name": OK_DOC_ORGANIZATION, "confidence": 100.0},
"inn": {"form_name": "7701234567", "confidence": 100.0},
"units": {"form_name": "тыс. руб.", "confidence": 100.0},
"date": {"form_name": "30.09.2025", "confidence": 100.0},
"table_structure": [
{
"pages": [1],
"label": {"form_name": "Бухгалтерский баланс", "confidence": 98.4},
"organization": {"full_name": "ООО «Русский бисквит»", "confidence": 97.1},
"inn": {"inn": "7701234567", "confidence": 99.2},
"units": {"measurement": "тыс. руб.", "confidence": 96.0},
"date": {"date": "30.09.2025", "confidence": 95.5},
"table_structure": {
"label": {"form_name": "Бухгалтерский баланс", "confidence": 100.0},
"rows": [
{
"row_index": 0,
"cells": [
{"column_index": 0, "value": "Наименование показателя", "confidence": 99.0},
{"column_index": 1, "value": "Код", "confidence": 99.0},
{"column_index": 2, "value": "На 30 сентября 2025 г.", "confidence": 97.0},
{"column_index": 3, "value": "На 31 декабря 2024 г.", "confidence": 97.0},
{"column_index": 0, "value": "Наименование показателя", "confidence": 100.0},
{"column_index": 1, "value": "Код", "confidence": 100.0},
{"column_index": 2, "value": "На 30 сентября 2025 г.", "confidence": 100.0},
{"column_index": 3, "value": "На 31 декабря 2024 г.", "confidence": 100.0},
],
},
{
"row_index": 1,
"cells": [
{"column_index": 0, "value": "БАЛАНС", "confidence": 98.0},
{"column_index": 1, "value": "1600", "confidence": 99.0},
{"column_index": 2, "value": "1 234 567", "confidence": 96.5},
{"column_index": 3, "value": "(12 345)", "confidence": 95.0},
{"column_index": 0, "value": "БАЛАНС", "confidence": 100.0},
{"column_index": 1, "value": "1600", "confidence": 100.0},
{"column_index": 2, "value": "1 234 567", "confidence": 100.0},
{"column_index": 3, "value": "(12 345)", "confidence": 100.0},
],
},
]
},
],
}
]
],
}
def _require_port(host: str, port: int, what: str) -> None:
@ -161,6 +178,10 @@ class Roundtrip:
def input_dir(self) -> Path:
return self.root / self.settings.SCAN_INPUT_DIR
@property
def tmp_dir(self) -> Path:
return self.root / self.settings.SCAN_TMP_DIR
def answer_dir(self, job_id: str) -> Path:
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id
@ -174,7 +195,7 @@ class Roundtrip:
job_id, file_id = str(uuid.uuid4()), str(uuid.uuid4())
key = f"uploads/itest-{uuid.uuid4()}/{file_id}/{job_id}/{original_name}"
self.jobs.add(job_id)
self.keys += [key, f"raw/{job_id}.json"]
self.keys += [key, f"raw/{job_id}.json", f"results/{file_id}/{job_id}/result.json"]
await self.s3.put_bytes(key, data, content_type="application/pdf")
deadline = datetime.now(UTC) + timedelta(seconds=60)
return {
@ -239,7 +260,13 @@ class Roundtrip:
def inbox(self, job_id: str) -> list[str]:
return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id))
async def run(self, original_name: str, data: bytes) -> tuple[dict[str, Any], Envelope]:
def stray_parts(self) -> list[str]:
"""Временные `.part` записи во входящий каталог: после успешной публикации их в `Tmp` нет (решение №26)."""
return (
sorted(p.name for p in self.tmp_dir.iterdir() if p.name.endswith(".part")) if self.tmp_dir.is_dir() else []
)
async def run(self, original_name: str, data: bytes, *, cleanup: bool = False) -> tuple[dict[str, Any], Envelope]:
"""Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог."""
body = await self.request(original_name, data)
job_id = body["job_id"]
@ -249,56 +276,98 @@ class Roundtrip:
assert [e.msg_id for e in envelopes] == [f"ack:{job_id}:1", f"result:{job_id}:1"] # ack раньше итога
assert envelopes[0].body["status"] == "processing"
assert envelopes[0].body["file_id"] == body["file_id"]
await self.check_files(body, envelopes[1], data)
await self.check_files(body, envelopes[1], data, cleanup=cleanup)
return body, envelopes[1]
async def check_files(self, body: dict[str, Any], result: Envelope, data: bytes) -> None:
"""Итог `error` с `raw_s3_key`; сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий = объект S3."""
async def check_files(self, body: dict[str, Any], result: Envelope, data: bytes, *, cleanup: bool = False) -> None:
"""Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `<job_id>.pdf` = объект S3.
Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при
очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет. `.part` в `Tmp` нет
в обоих режимах.
"""
job_id = body["job_id"]
assert result.body["status"] == "error"
assert result.body["file_id"] == body["file_id"]
assert result.body["raw_s3_key"] == f"raw/{job_id}.json"
raw = await self.raw(job_id)
assert raw["job_id"] == job_id
assert raw["files"], "в склейке нет ни одного файла"
for entry in raw["files"]:
assert entry["parse_error"] is None
assert entry["response"]["file_uid"] == job_id
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
assert stat.content_type == JSON_CONTENT_TYPE
assert self.stray_parts() == []
if cleanup:
assert list(self.input_dir.iterdir()) == []
assert not self.answer_dir(job_id).exists()
return
names = self.answer_names(job_id)
assert names, "эмулятор не положил ни одного json"
# склейка (D-4): все json каталога ответа, по возрастанию имени, тела совпадают с файлами
assert [entry["name"] for entry in raw["files"]] == names
for entry in raw["files"]:
assert entry["parse_error"] is None
assert entry["response"] == json.loads((self.answer_dir(job_id) / entry["name"]).read_bytes())
assert entry["response"]["file_uid"] == job_id
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
assert stat.content_type == JSON_CONTENT_TYPE
# результат-файл пишется только для done/needs_check
with pytest.raises(S3Error):
await self.s3.client.stat_object(self.s3.bucket, f"results/{body['file_id']}/{job_id}/result.json")
name = f"{job_id}_{body['original_name']}.done"
# входящий файл — `<job_id>.<ext>` в нижнем регистре; эмулятор переименовал его в `.done`
name = f"{job_id}.pdf.done"
assert self.inbox(job_id) == [name]
assert (self.input_dir / name).read_bytes() == await self.s3.get_bytes(body["s3"]["key"]) == data
async def check_error(self, body: dict[str, Any], result: Envelope) -> None:
"""Итог `error`: файла результата в MinIO нет (он пишется только для done/needs_check)."""
assert result.body["status"] == "error"
assert "result_s3" not in result.body
with pytest.raises(S3Error):
await self.s3.client.stat_object(self.s3.bucket, f"results/{body['file_id']}/{body['job_id']}/result.json")
async def check_needs_check(self, body: dict[str, Any], result: Envelope) -> dict[str, Any]:
"""Итог `needs_check` с `result_s3`; файл результата из MinIO проходит схему `recognition-result`.
Возвращает файл результата (документы лежат только в нём: конверт несёт ссылку `result_s3`).
"""
job_id, file_id = body["job_id"], body["file_id"]
assert result.body["status"] == "needs_check"
assert "error" not in result.body
key = f"results/{file_id}/{job_id}/result.json"
assert result.body["result_s3"] == {"bucket": self.s3.bucket, "key": key}
stat = await self.s3.client.stat_object(self.s3.bucket, key)
assert stat.content_type == JSON_CONTENT_TYPE
payload: dict[str, Any] = json.loads(await self.s3.get_bytes(key))
RESULT_VALIDATOR.validate(payload)
assert payload["schema_version"] == CONTRACT_VERSION
assert (payload["job_id"], payload["file_id"], payload["status"]) == (job_id, file_id, "needs_check")
assert payload["service"]["name"] == "scan-gateway"
assert payload["raw_s3_key"] == f"raw/{job_id}.json"
# итог в конверте и в файле результата — одни и те же метрики
assert payload["metrics"] == result.body["metrics"]
return payload
def _write_fixtures(fixtures: Path) -> None:
"""Каталог фикстур эмулятора: копии реальных ответов (`aep_3.json` + `aep_3_ofr.json`, `aep_5.json`, `aep_6.json`)
и синтетическая успешная `ok_doc.json` (R-10). Без репозитория fixtures — только `ok_doc.json`."""
"""Каталог фикстур эмулятора: пары PDF+JSON по содержимому (sha256). Реальные — копии `aep_3` (`aep_3.json` +
`aep_3_ofr.json`), `aep_5`, `aep_6` из `fixtures/ocr`; синтетическая — `ok_doc.pdf` + `ok_doc.json` (R-10), её байты
сценарий 4 отправляет в S3. Без репозитория fixtures — только синтетическая пара."""
fixtures.mkdir()
template: dict[str, Any] = {"file_uid": "aep_5"}
if REAL_FIXTURES.is_dir():
for name in ("aep_3.json", "aep_3_ofr.json", "aep_5.json", "aep_6.json"):
shutil.copy(REAL_FIXTURES / name, fixtures / name)
for path in sorted(REAL_FIXTURES.iterdir()):
if path.stem in ("aep_3", "aep_3_ofr", "aep_5", "aep_6") and path.suffix.lower() in (".json", ".pdf"):
shutil.copy(path, fixtures / path.name)
template = json.loads((fixtures / "aep_5.json").read_text(encoding="utf-8"))
# успех — в форме реальных ответов ИС: `error = {"code": "", "message": ""}`, а не `null` (OQ-77)
success = {**template, "file_uid": "ok_doc", "error": {"code": "", "message": ""}, "data_ocr": SUCCESS_DATA_OCR}
(fixtures / "ok_doc.pdf").write_bytes(OK_DOC_PDF)
(fixtures / "ok_doc.json").write_text(json.dumps(success, ensure_ascii=False, indent=2), encoding="utf-8")
def _settings(monkeypatch: pytest.MonkeyPatch, root: Path) -> Settings:
def _settings(monkeypatch: pytest.MonkeyPatch, root: Path, *, cleanup: bool) -> Settings:
"""Настройки шлюза из `.env.test` и окружения (как в проде) с tmp-корнем; инфраструктура недоступна — fail."""
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local")
monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(root))
monkeypatch.setenv("SCAN_POLL_SECONDS", str(POLL_SECONDS))
monkeypatch.setenv("SCAN_SETTLE_SECONDS", str(SETTLE_SECONDS))
monkeypatch.setenv("SCAN_TIMEOUT_SECONDS", str(TIMEOUT_SECONDS))
monkeypatch.setenv("SCAN_CLEANUP", "true" if cleanup else "false")
monkeypatch.setenv("NATS_ENABLED", "true")
settings = get_settings()
parsed = urlparse(settings.OPENBAO.NATS.NATS_URL)
@ -328,6 +397,7 @@ def _start_emulator(stack: AsyncExitStack, settings: Settings, root: Path, fixtu
fixtures_dir=fixtures,
delay=EMULATOR_DELAY,
poll_interval=0.05,
default="", # `SCAN_EMULATOR_DEFAULT` пуст: промах по хешу даёт ответ-ошибку, а не набор по умолчанию
)
emulator = asyncio.create_task(asyncio.to_thread(run, emulator_settings, stop), name="scan-emulator")
@ -338,12 +408,15 @@ def _start_emulator(stack: AsyncExitStack, settings: Settings, root: Path, fixtu
stack.push_async_callback(stop_emulator)
@pytest.fixture
async def roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str]) -> AsyncIterator[Roundtrip]:
async def _roundtrip(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str], *, cleanup: bool
) -> AsyncIterator[Roundtrip]:
"""Запущенные шлюз и эмулятор; `cleanup` — значение `SCAN_CLEANUP` шлюза."""
root = tmp_path / "hotfolder"
root.mkdir()
_write_fixtures(tmp_path / "fixtures")
settings = _settings(monkeypatch, root)
settings = _settings(monkeypatch, root, cleanup=cleanup)
assert settings.SCAN_CLEANUP is cleanup
nats_url = settings.OPENBAO.NATS.NATS_URL
async with AsyncExitStack() as stack:
@ -385,6 +458,43 @@ async def roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines:
assert errors == [], errors
@pytest.fixture
async def roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str]) -> AsyncIterator[Roundtrip]:
"""Шлюз с `SCAN_CLEANUP=false` (по умолчанию)."""
async for rt in _roundtrip(tmp_path, monkeypatch, log_lines, cleanup=False):
yield rt
@pytest.fixture
async def roundtrip_cleanup(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str]
) -> AsyncIterator[Roundtrip]:
"""Шлюз с `SCAN_CLEANUP=true`."""
async for rt in _roundtrip(tmp_path, monkeypatch, log_lines, cleanup=True):
yield rt
def _drop_none(value: Any) -> Any:
"""Без полей `null`: эталон записан с `exclude_none`, файл результата хранит явные `null` (`exclude_unset`)."""
if isinstance(value, dict):
return {key: _drop_none(item) for key, item in value.items() if item is not None}
if isinstance(value, list):
return [_drop_none(item) for item in value]
return value
def _golden(name: str, job_id: str) -> dict[str, Any] | None:
"""Эталон `fixtures/ocr/expected_normalize/<name>.json` с подставленным `job_id`; нет эталона — `None`.
Эталон строится на условном `job_id` (`tests/golden_update.py`): он встречается в `locator` и в предупреждениях.
"""
path = REAL_FIXTURES / "expected_normalize" / f"{name}.json"
if not path.is_file():
return None
expected: dict[str, Any] = json.loads(path.read_text(encoding="utf-8").replace(GOLDEN_JOB_ID, job_id))
return expected
@pytest.mark.fixtures
async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(roundtrip: Roundtrip) -> None:
pdf = _real_fixture("aep_5", ".pdf") # реальное имя `aep_5.PDF`: расширение в верхнем регистре
@ -396,7 +506,8 @@ async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(ro
body = await roundtrip.request(pdf.name, data)
job_id = body["job_id"]
await roundtrip.publish(body)
inbox_file = roundtrip.input_dir / f"{job_id}_{pdf.name}"
# расширение входящего файла — в нижнем регистре, имя файла = job_id (раскладка FileWorker)
inbox_file = roundtrip.input_dir / f"{job_id}.pdf"
await roundtrip.wait_for(f"файл {inbox_file.name} во входящем каталоге", inbox_file.exists)
# повтор того же запроса во время ожидания ответа ИС (эмулятор «распознаёт» EMULATOR_DELAY с)
await roundtrip.publish(body)
@ -409,6 +520,7 @@ async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(ro
assert result.body["error"] == {"code": "INVALID_DOCUMENT", "message": expected_error["message"]}
assert result.body["error"]["message"] == "Не удалось определить тип документа."
await roundtrip.check_files(body, result, data)
await roundtrip.check_error(body, result)
raw = await roundtrip.raw(job_id)
assert [entry["response"]["error"] for entry in raw["files"]] == [expected_error]
# все файлы набора ошибочные: итог — ошибка первого файла, а warnings перечисляют каждый ошибочный файл (D-5)
@ -420,11 +532,11 @@ async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(ro
await asyncio.sleep(QUIET_PERIOD)
assert roundtrip.acks(job_id) == [f"ack:{job_id}:1", f"ack:{job_id}:1", f"ack:{job_id}:2"]
assert [e.msg_id for e in roundtrip.results(job_id)] == [f"result:{job_id}:1"]
assert roundtrip.inbox(job_id) == [f"{job_id}_{pdf.name}.done"]
assert roundtrip.inbox(job_id) == [f"{job_id}.pdf.done"]
@pytest.mark.fixtures
async def test_real_fixture_aep_3_two_files_merged_with_warning(roundtrip: Roundtrip) -> None:
async def test_real_fixture_aep_3_two_files_merged_needs_check_with_documents(roundtrip: Roundtrip) -> None:
"""Набор из двух файлов: успешный `<job_id>.json` и `<job_id>_ofr.json` с `INVALID_DOCUMENT` (OQ-76, OQ-78)."""
data = _real_fixture("aep_3", ".pdf").read_bytes()
ofr_error = json.loads(_real_fixture("aep_3_ofr", ".json").read_text(encoding="utf-8"))["error"]
@ -432,9 +544,24 @@ async def test_real_fixture_aep_3_two_files_merged_with_warning(roundtrip: Round
body, result = await roundtrip.run("aep_3.pdf", data)
job_id = body["job_id"]
assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE}
# ошибочный файл набора — в warnings одной строкой `<имя>: <код>: <сообщение>`; успешный файл в них не попадает
assert result.body["metrics"]["warnings"] == [f"{job_id}_ofr.json: INVALID_DOCUMENT: {ofr_error['message']}"]
payload = await roundtrip.check_needs_check(body, result)
# документы собраны из успешного файла: баланс и ОФР, по одному на таблицу `table_structure`
assert [d["document_type"] for d in payload["documents"]] == ["BALANCE", "INCOME"]
assert [d["locator"] for d in payload["documents"]] == [
f"file:{job_id}.json;table:0",
f"file:{job_id}.json;table:1",
]
assert payload["metrics"]["rows"] == sum(len(d["lines"]) for d in payload["documents"])
# ошибочный файл набора — в warnings одной строкой `<имя>: <код>: <сообщение>` (последней: файлы по имени);
# успешный файл даёт предупреждения нормализации
warnings = result.body["metrics"]["warnings"]
assert warnings[-1] == f"{job_id}_ofr.json: INVALID_DOCUMENT: {ofr_error['message']}"
assert f"{job_id}.json: ИНН не распознан" in warnings
assert f"{job_id}.json: table:0: шапка синтезирована: колонки без дат" in warnings
expected = _golden("aep_3", job_id)
if expected is not None: # эталон нормализатора (`make golden-update`) совпадает со сквозным результатом
assert _drop_none(payload["documents"]) == expected["documents"]
assert warnings == expected["warnings"]
raw = await roundtrip.raw(job_id)
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json", f"{job_id}_ofr.json"]
@ -449,36 +576,57 @@ async def test_real_fixture_aep_3_two_files_merged_with_warning(roundtrip: Round
@pytest.mark.fixtures
async def test_real_fixture_aep_6_success_without_warnings(roundtrip: Roundtrip) -> None:
async def test_real_fixture_aep_6_needs_check_with_two_documents(roundtrip: Roundtrip) -> None:
data = _real_fixture("aep_6", ".pdf").read_bytes()
body, result = await roundtrip.run("aep_6.pdf", data)
job_id = body["job_id"]
assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE}
assert "metrics" not in result.body # ошибочных файлов нет — warnings опущено
payload = await roundtrip.check_needs_check(body, result)
assert [d["document_type"] for d in payload["documents"]] == ["BALANCE", "INCOME"]
# отчётность за 2025 год попадает в редакцию `fsbu4`, справочник которой пуст (по одному предупреждению на таблицу)
warnings = result.body["metrics"]["warnings"]
assert f"{job_id}.json: table:0: редакция fsbu4 не заполнена" in warnings
assert f"{job_id}.json: table:1: редакция fsbu4 не заполнена" in warnings
assert not any("INVALID_DOCUMENT" in warning for warning in warnings) # ошибочных файлов в наборе нет
expected = _golden("aep_6", job_id)
if expected is not None:
assert _drop_none(payload["documents"]) == expected["documents"]
assert warnings == expected["warnings"]
raw = await roundtrip.raw(job_id)
[entry] = raw["files"]
assert entry["name"] == f"{job_id}.json"
assert entry["response"]["error"] == {"code": "", "message": ""}
@pytest.mark.fixtures
async def test_success_answer_gives_not_implemented(roundtrip: Roundtrip) -> None:
data = _real_fixture("aep_5", ".pdf").read_bytes() # байты реального скана под именем ok_doc.pdf
body, result = await roundtrip.run("ok_doc.pdf", data)
async def test_synthetic_success_answer_gives_needs_check(roundtrip: Roundtrip) -> None:
"""`ok_doc.pdf` лежит в фикстурах эмулятора теми же байтами, что отправляются в S3 (пара по sha256, R-10)."""
body, result = await roundtrip.run("ok_doc.pdf", OK_DOC_PDF)
job_id = body["job_id"]
assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE}
assert "metrics" not in result.body # ошибочных файлов нет — warnings опущено
payload = await roundtrip.check_needs_check(body, result)
[document] = payload["documents"]
assert document["document_type"] == "BALANCE"
assert document["locator"] == f"file:{job_id}.json;table:0"
assert document["header"]["organization_name"] == OK_DOC_ORGANIZATION
assert document["header"]["inn"] == "7701234567"
assert document["header"]["report_date"] == "2025-09-30"
assert [c["period_date"] for c in document["columns"]] == ["2025-09-30", "2024-12-31"]
[line] = document["lines"]
assert (line["line_code"], line["line_name"]) == ("1600", "БАЛАНС") # имя из OCR: редакция fsbu4 пуста
assert [v["amount"] for v in line["values"]] == ["1234567", "-12345"] # деньги в файле результата — строки
# всё распознано, кроме пустой редакции: единственное предупреждение
assert result.body["metrics"]["warnings"] == [f"{job_id}.json: table:0: редакция fsbu4 не заполнена"]
raw = await roundtrip.raw(job_id)
[entry] = raw["files"]
assert entry["response"]["error"] == {"code": "", "message": ""}
assert entry["response"]["data_ocr"] == SUCCESS_DATA_OCR
async def test_unknown_name_gets_emulator_invalid_document(roundtrip: Roundtrip) -> None:
data = b"%PDF-1.4\n% scan-gateway itest unknown.pdf\n\x00\x01\x02"
body, result = await roundtrip.run("unknown.pdf", data)
async def test_unknown_content_gets_emulator_invalid_document(roundtrip: Roundtrip) -> None:
"""Содержимого нет среди фикстур, `SCAN_EMULATOR_DEFAULT` пуст: ответ-ошибка эмулятора (D-3)."""
body, result = await roundtrip.run("unknown.pdf", UNKNOWN_PDF)
job_id = body["job_id"]
error = {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"}
digest = hashlib.sha256(UNKNOWN_PDF).hexdigest()
error = {"code": "INVALID_DOCUMENT", "message": f"{digest[:12]}: нет пары по содержимому в фикстурах"}
await roundtrip.check_error(body, result)
assert result.body["error"] == error
raw = await roundtrip.raw(job_id)
[entry] = raw["files"]
@ -488,3 +636,18 @@ async def test_unknown_name_gets_emulator_invalid_document(roundtrip: Roundtrip)
assert entry["response"]["file_uid"] == job_id
assert entry["response"]["error"] == error
assert entry["response"]["data_ocr"]["table_structure"] == []
@pytest.mark.fixtures
async def test_cleanup_enabled_leaves_input_and_answer_empty(roundtrip_cleanup: Roundtrip) -> None:
"""`SCAN_CLEANUP=true`: после результата во входящем каталоге пусто, каталога `Output/<job_id>/` нет (D-2)."""
data = _real_fixture("aep_6", ".pdf").read_bytes()
body, result = await roundtrip_cleanup.run("aep_6.pdf", data, cleanup=True)
job_id = body["job_id"]
await roundtrip_cleanup.check_needs_check(body, result)
# очистка идёт после сохранения сырого ответа: он и файл результата на месте, горячая папка чиста
raw = await roundtrip_cleanup.raw(job_id)
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json"]
assert list(roundtrip_cleanup.input_dir.iterdir()) == []
assert not roundtrip_cleanup.answer_dir(job_id).exists()
assert roundtrip_cleanup.stray_parts() == []

View File

@ -1,5 +1,33 @@
# Tasks: Срез 3a — горячие папки и нормализатор OCR
Заполняется планом (`writing-plans`) после одобрения спеки владельцем. Репозиторий — scan-gateway; команды — из `api/`.
Репозиторий — scan-gateway; команды `make` — из корня, `pytest` — из `api/`. Нумерация и состав задач — как в плане
`docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md` (Task 0–17); пошаговый код и проверки — там. Исполнение —
subagent-driven-development: один коммит на задачу плюс правки ревью. CI нет — проверки локальные (`make check`,
`make test-integration`, `make check-full`).
- [ ] 0.1 План реализации написан и одобрен; задачи перенесены сюда из плана
- [x] 0 Контракт v1.2 в ветке и окружение: `origin/main` влит, схемы байт-в-байт равны `../docs/contracts`, `make venv`,
`make check` зелёный; `CONTRACT_VERSION` остаётся `1.0` (решение №14)
- [x] 1 Настройки и справочники: `SCAN_CLEANUP`, `SCAN_RULES_DIR`; `api/rules/` (`statement_lines.yaml` с редакциями `66n`
и пустой `fsbu4`, `ocr_lookalikes.yaml`, `statement_labels.yaml`); `src/normalizer/rules.py` (`load_rules`, `OcrRules`)
- [x] 2 Очистка текста (`src/normalizer/ocr/text.py`): двойники вплотную к цифре, `|` как разделитель клеток, `clean_value`
- [x] 3 Разбор дат (`ocr/dates.py`): `parse_date`, дословный перенос `date_utils` старого ядра
- [x] 4 Суммы (`ocr/amounts.py`): `parse_amount`, `is_blank` (решения №12, №13)
- [x] 5 Раскладка колонок (`ocr/layout.py`): `build_layout` по шапке «Код», `infer_layout` по данным
- [x] 6 Строки таблицы (`ocr/rows.py`): шапка настоящая или синтетическая, классы строк, тип по кодам
- [x] 7 Шапка файла (`ocr/header.py`): организация, ИНН, единицы, дата отчёта
- [x] 8 Сборка документа (`ocr/document.py`): тип, колонки, строки, значения с `is_recognized`, редакции
(решения №15, №16, №19, №21–25)
- [x] 9 `OcrNormalizer`: статусы `done` / `needs_check` / `error RECOGNITION_FAILED`, предупреждения, `metrics.rows`,
подключение в `create_app`
- [x] 10 Golden на реальных ответах `../fixtures/ocr/*.json` (эталоны `expected_normalize/` — в fixtures), `make golden-update`
- [x] 11 Горячая папка `local`: имя `<job_id>.<ext>`, `.part` в `Tmp`, запись без замены (`os.link`), `cleanup` под
`SCAN_CLEANUP` (решения №17, №26, №27)
- [x] 12 Горячая папка `smb`: `smbclient.rename` без замены, `cleanup` под флагом
- [x] 13 Воркер и приложение: очистка после сохранения `raw/` (под флагом), `RECOGNITION_FAILED` без расширения,
`OcrNormalizer` в lifespan
- [x] 14 Эмулятор: пара по sha256 содержимого, `SCAN_EMULATOR_DEFAULT`, входящий `<uuid>.<ext>`, игнор `.part`/`.tmp`/`.done`
- [x] 15 Сквозной сценарий, README и CLAUDE.md сервиса: `test_roundtrip.py` (документы, `ok_doc`, неизвестное содержимое,
`SCAN_CLEANUP=true`, файл результата по схеме), README, CLAUDE.md, этот файл
- [ ] 16 Документы workspace (репозиторий docs, bounded-PR): `03-architecture.md`, `05-domain-rules.md` §11.1, OQ,
`09-delivery-plan.md`, dev-compose и `qa-guide.md`
- [ ] 17 Завершение: синхронизация с `main`, `make check-full`, `openspec validate --strict`, PR fixtures с эталонами, PR scan-gateway