docs: README и CLAUDE.md — адаптер OCR, arm_statements, контракт 2.0; интеграционные тесты (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 20:23:30 +04:00
parent 22ca529bc6
commit 64dab79290
5 changed files with 157 additions and 240 deletions

View File

@ -8,7 +8,7 @@
старом АРМ), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый старом АРМ), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый
распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить
горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json` горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json`
(`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed` (`statementDocument` v2.0 общим кодом `arm_statements`: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed`
(по умолчанию; задаётся `OPENBAO__NATS__NATS_*_SUBJECT`) (`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`, (по умолчанию; задаётся `OPENBAO__NATS__NATS_*_SUBJECT`) (`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`,
старт — запись файла во «Входящие». **Нет доступа к БД.** старт — запись файла во «Входящие». **Нет доступа к БД.**
@ -16,18 +16,21 @@
- `api/arm_statements/` — копия пакета `arm-statements`, напрямую не править: обновление — `../docs/scripts/sync-statements.sh` - `api/arm_statements/` — копия пакета `arm-statements`, напрямую не править: обновление — `../docs/scripts/sync-statements.sh`
(`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`). (`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`).
- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14), по `05-domain-rules.md` - Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14), по `05-domain-rules.md`
§11 и §11.1: правило, которого там нет, не реализуется, а поднимается вопросом. Функции очистки §11, §11.1 и §11.9: правило, которого там нет, не реализуется, а поднимается вопросом. Шаги 2–8, словари и статусы —
(`src/normalizer/ocr/`) перенесены из старого ядра **дословно** (единственное исключение для `reference-old-arm`): в пакете `arm_statements`; в шлюзе остаются только:
при правке сначала разберись, что делает источник; расхождения с ним — только решением, записанным в - адаптер шага 1 — `api/src/normalizer/ocr/adapter.py` (таблица ИС → кандидат, `ref` — номер строки ИС);
`openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md` (таблица «Decisions made without discussion»: №12, №13, №20, №22, - обход набора json — `api/src/normalizer/ocr/answer_set.py` (`normalize_candidate` на каждую таблицу,
№30 — `Money` не больше двух знаков после запятой, №31 — тексты дат длиннее 200 символов не разбираются). `envelope_status`, коды `JSON_FAILED` / `TABLE_PARSE_ERROR`; Р-32/Р-33);
Функции `ocr/*` — чистые: без I/O, без настроек, без логов. - граница контракта — `to_contract` / `warning_to_contract` пакета, вызываются в `api/src/normalizer/ocr/__init__.py`
- Справочники — данные: `api/rules/*.yaml` (редакции форм `66n` / `fsbu4`, двойники, `label` → тип), правила не (`OcrNormalizer`); своего конвертера и своего уплощения шапки нет, шлюз только валидирует словарь моделями
зашиваются в код. `fsbu4` пока пуста — данные кладёт владелец, код при этом не меняется. `src/contract`.
- Статус: `done` только когда всё распознано (`confidence` 100, даты разобраны, редакция определена по дате и заполнена, Переносы старого ядра (`ocr/text.py … document.py`) и справочники `api/rules/` удалены: словари меняются через
нет предупреждений) и ни один файл набора не ошибочен (№28); иначе `needs_check`; ни одной таблицы → `error RECOGNITION_FAILED`. Предупреждения — строки репозиторий `arm-statements`, не здесь. Решения по адаптеру (P5-n, R-n) — `openspec/changes/statements-v2/design.md`.
`metrics.warnings` (`<имя json>: table:<i>: <текст>`); при изменении их текстов обнови README (раздел «Предупреждения») - Статус документа и конверта определяет пакет (`envelope_status`); шлюз его не пересчитывает. Все json набора
и golden. ошибочны — `error` с кодом первого ошибочного файла, а `metrics.warnings` всё равно перечисляют `JSON_FAILED` каждого
json. Успешный json без таблиц — `JSON_FAILED`. Ни одной таблицы — `error RECOGNITION_FAILED`.
- Предупреждения — структуры `{code, severity, message, locator}`, не строки; новый код предупреждения вводится только
словарём пакета (`WarningCode`, `make_warning`). При изменении правил обнови README (раздел «Предупреждения») и golden.
- Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть - Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть
в fixtures и объяснять; эталоны меняются отдельным PR fixtures. в fixtures и объяснять; эталоны меняются отдельным PR fixtures.
- Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, запись сразу в `Input` открытием `wb` с перезаписью, без - Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, запись сразу в `Input` открытием `wb` с перезаписью, без
@ -41,18 +44,18 @@
каталога по возрастанию имени; нормализатор получает ровно эту структуру. каталога по возрастанию имени; нормализатор получает ровно эту структуру.
- Успех файла ответа — `error.code == ""` (`error` при этом не `null`!), ошибка — непустой `error.code`, - Успех файла ответа — `error.code == ""` (`error` при этом не `null`!), ошибка — непустой `error.code`,
`parse_error` или корень ответа не объект (`INVALID_FORMAT`). Проверка `error is None` для реального ответа неверна. `parse_error` или корень ответа не объект (`INVALID_FORMAT`). Проверка `error is None` для реального ответа неверна.
Ошибочные файлы — в `metrics.warnings` (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого; есть Ошибочные файлы — предупреждения `JSON_FAILED` в `metrics.warnings` (`locator = file:<имя>`, `message = <код>: <сообщение>`); все файлы ошибочны → код первого; есть
успешный — документы из успешных файлов, статус не выше `needs_check` (№28). `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только успешный — документы из успешных файлов, статус не выше `needs_check` (№28). `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только
для тестов воркера). для тестов воркера).
- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы; если все коды - В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа и редакцию определяет пакет
строк одной группы (`1xxx`/`2xxx`) и противоречат `label` — тип по кодам с предупреждением (#30); `label` не сопоставлен — коды по большинству. `checks` в документах не передаются — их считает ядро (Срез 3b). (`05-domain-rules.md` §11.9). `checks` считает пакет и отдаёт в документах.
- Dev-режим: эмулятор ИС Сканирование (`make emulator`, то же, что `python -m src.scan_emulator` из `api/`; в контейнере — - Dev-режим: эмулятор ИС Сканирование (`make emulator`, то же, что `python -m src.scan_emulator` из `api/`; в контейнере —
тот же образ с `command: python -m src.scan_emulator`): по входящему `<uuid>.<ext>` считает sha256 содержимого, находит тот же образ с `command: python -m src.scan_emulator`): по входящему `<uuid>.<ext>` считает sha256 содержимого, находит
пару в `SCAN_FIXTURES_DIR` (PDF + `<имя>.json` + `<имя>_*.json`) и кладёт в `Output/<uuid>/` набор как `<uuid>.json` / пару в `SCAN_FIXTURES_DIR` (PDF + `<имя>.json` + `<имя>_*.json`) и кладёт в `Output/<uuid>/` набор как `<uuid>.json` /
`<uuid>_<суффикс>.json` (атомарно, через `Tmp`). Промах → набор `SCAN_EMULATOR_DEFAULT`, иначе ответ-ошибка `<uuid>_<суффикс>.json` (атомарно, через `Tmp`). Промах → набор `SCAN_EMULATOR_DEFAULT`, иначе ответ-ошибка
`INVALID_DOCUMENT`. Имя входящего файла на выбор пары не влияет. `INVALID_DOCUMENT`. Имя входящего файла на выбор пары не влияет.
- Описание сервиса, протокол горячих папок, нормализация, env, запуск и коды ошибок — `README.md`; требования и решения - Описание сервиса, протокол горячих папок, нормализация, env, запуск и коды ошибок — `README.md`; требования и решения
Среза 3a — `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/` (`design.md`, `specs/`), Среза 0 — текущей фичи — `openspec/changes/statements-v2/`, Среза 3a — `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/`, Среза 0 —
`openspec/changes/archive/2026-10-04-slice-0-skeleton/`. `openspec/changes/archive/2026-10-04-slice-0-skeleton/`.
## Соглашения шаблона ## Соглашения шаблона
@ -79,22 +82,20 @@ SMB-сессия, S3, NATS, воркер; `GET /healthcheck`; gzip/proxy middlew
- `api/src/core/` — `worker.py` (задание: проверка → ack → S3 → горячая папка → опрос → `raw/` → очистка (под флагом) → - `api/src/core/` — `worker.py` (задание: проверка → ack → S3 → горячая папка → опрос → `raw/` → очистка (под флагом) →
нормализатор → результат; ошибки по таблице D-7), `nats.py` (core NATS без JetStream, queue group `scan-gateway`, публикация с нормализатор → результат; ошибки по таблице D-7), `nats.py` (core NATS без JetStream, queue group `scan-gateway`, публикация с
повторами), `clients.py` (`S3Storage`), `health.py` (`nats`/`hotfolder` в `/healthcheck`). повторами), `clients.py` (`S3Storage`), `health.py` (`nats`/`hotfolder` в `/healthcheck`).
- `api/src/normalizer/` — протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (заглушка Среза 0, остаётся для тестов - `api/src/normalizer/` — протокол `Normalizer`, `NullNormalizer` (заглушка Среза 0, остаётся для тестов воркера; ошибки
воркера) и `OcrNormalizer` (Срез 3a, подключён в `create_app`). файлов выдаёт `JSON_FAILED` через `make_warning` пакета), `outcome.py` (`NormalizeOutcome`), `files.py` (ошибки json
- `api/src/normalizer/ocr/` (Срез 3a) — конвейер чистых функций: `text.py` → `dates.py` → `amounts.py` → `layout.py` → набора) и `OcrNormalizer` (подключён в `create_app`).
`rows.py` → `header.py` → `document.py`; `__init__.py` — `OcrNormalizer` (обход файлов склейки, статус, `metrics`). - `api/src/normalizer/ocr/` — `adapter.py` (адаптер шага 1), `answer_set.py` (обход набора json), `__init__.py`
- `api/src/normalizer/rules.py` — `load_rules(SCAN_RULES_DIR)`: pydantic-модели справочников, `OcrRules` (выбор редакции (`OcrNormalizer`, граница контракта через `to_contract`). Остальной разбор — `api/arm_statements/` (копия пакета).
по дате / по кодам); `api/rules/` — `statement_lines.yaml` (редакции `66n`, `fsbu4`), `ocr_lookalikes.yaml`, - `api/src/contract/` + `api/contracts/` — pydantic-модели (контракт 2.0, `CONTRACT_VERSION`) и байт-в-байт копии схем `../docs/contracts`; идентичность по правилу MAJOR (S-53,
`statement_labels.yaml`.
- `api/src/contract/` + `api/contracts/` — pydantic-модели и байт-в-байт копии схем `../docs/contracts`; идентичность по правилу MAJOR (S-53,
`tests/contract_identity.py`; docs — `../docs` или `ARM_KA_DOCS`) проверяет `api/tests/unit/test_contract.py` (входит в `make check`). `tests/contract_identity.py`; docs — `../docs` или `ARM_KA_DOCS`) проверяет `api/tests/unit/test_contract.py` (входит в `make check`).
- `api/src/scan_emulator/` — эмулятор ИС Сканирование (`SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_DELAY`, - `api/src/scan_emulator/` — эмулятор ИС Сканирование (`SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_DELAY`,
`SCAN_EMULATOR_POLL_SECONDS`, `SCAN_EMULATOR_DEFAULT`; пара по sha256 содержимого); в compose контейнера пока нет — `SCAN_EMULATOR_POLL_SECONDS`, `SCAN_EMULATOR_DEFAULT`; пара по sha256 содержимого); в compose контейнера пока нет —
`make emulator`. `make emulator`.
- `Makefile` (`venv`, `lint`, `test`, `test-integration`, `check`, `docker-build`, `check-full`, `up`, - `Makefile` (`venv`, `lint`, `test`, `test-integration`, `check`, `docker-build`, `check-full`, `up`,
`emulator`, `golden-update`), `api/pyproject.toml` (ruff, mypy), `Dockerfile` + `.dockerignore` (один образ: шлюз и эмулятор), `emulator`, `golden-update`, `statements-check`), `api/pyproject.toml` (ruff, mypy), `Dockerfile` + `.dockerignore` (один образ: шлюз и эмулятор),
`api/.env.example`, `api/.env.test`. `api/.env.example`, `api/.env.test`.
- Тесты: `api/tests/unit/` (в том числе `unit/normalizer/` — юниты по модулям `ocr/` и golden), `api/tests/integration/` - Тесты: `api/tests/unit/` (в том числе `unit/normalizer/` — адаптер, обход набора, граница контракта, реальные ответы и golden), `api/tests/integration/`
(маркеры `integration`, `fixtures`; `test_roundtrip.py` — сквозной сценарий с эмулятором и `SCAN_CLEANUP`); (маркеры `integration`, `fixtures`; `test_roundtrip.py` — сквозной сценарий с эмулятором и `SCAN_CLEANUP`);
`api/tests/golden_update.py` — сбор склейки реального ответа и запись эталонов в `../fixtures/ocr/expected_normalize/` `api/tests/golden_update.py` — сбор склейки реального ответа и запись эталонов в `../fixtures/ocr/expected_normalize/`
(`make golden-update`); перед PR — `make check-full`. (`make golden-update`); перед PR — `make check-full`.

247
README.md
View File

@ -2,10 +2,9 @@
Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений
(PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его (PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его
и публикует результат в `files.tabular_parsed`. Контракт обмена — `../docs/contracts/` (v1.2: `statementDocument`; и публикует результат в `files.tabular_parsed`. Контракт обмена — `../docs/contracts/` (v2.0: `statementDocument` с
`schema_version` в конвертах и файле результата сервис по-прежнему пишет `1.0` — `CONTRACT_VERSION`, решение №14 `status`, `line_status`, `needs_review`, структурными предупреждениями; `schema_version = "2.0"` в конвертах, включая
change'а `slice-3a-ocr-normalizer`: поля 1.1–1.2 документы PDF-пути не используют), архитектура — ack, и в файле результата), архитектура — `../docs/context/03-architecture.md`.
`../docs/context/03-architecture.md`.
Границы сервиса (DECISION-4): Границы сервиса (DECISION-4):
@ -14,7 +13,7 @@ change'а `slice-3a-ocr-normalizer`: поля 1.1–1.2 документы PDF-
- сырой ответ ИС лежит в S3 (`raw/{job_id}.json`) и в БД не регистрируется; - сырой ответ ИС лежит в S3 (`raw/{job_id}.json`) и в БД не регистрируется;
- саму ИС Сканирование мы не пишем: для разработки есть эмулятор (`api/src/scan_emulator/`). - саму ИС Сканирование мы не пишем: для разработки есть эмулятор (`api/src/scan_emulator/`).
Состояние: Срез 3a. Сквозной путь работает, ответ ИС нормализуется в `statementDocument` v1.2 (раздел «Нормализация»); Состояние: сквозной путь работает, ответ ИС нормализуется общим кодом `arm_statements` в `statementDocument` v2.0 (раздел «Нормализация»);
раскладка горячих папок — как в проверенном в контуре `armka_file_worker` (`<job_id>.<ext>`). Известные ограничения — раскладка горячих папок — как в проверенном в контуре `armka_file_worker` (`<job_id>.<ext>`). Известные ограничения —
в конце файла. Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения — в конце файла. Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения —
`openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/` (`design.md`: таблица «Decisions made without discussion») и архив `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/` (`design.md`: таблица «Decisions made without discussion») и архив
@ -122,8 +121,8 @@ ack (с `attempt` повторного запроса); файл в ИС вто
## Склеенный ответ ИС (`raw/{job_id}.json`) ## Склеенный ответ ИС (`raw/{job_id}.json`)
В S3 до нормализации сохраняется **склейка** всех `*.json` каталога ответа (контракт v1.0 не меняется: `raw_s3_key` — В S3 до нормализации сохраняется **склейка** всех `*.json` каталога ответа (`raw_s3_key` — одна строка
по-прежнему одна строка `raw/{job_id}.json`): `raw/{job_id}.json`):
```json ```json
{ {
@ -142,147 +141,73 @@ ack (с `attempt` повторного запроса); файл в ИС вто
`error.code == ""`** (`error` при этом не `null`: `{"code": "", "message": ""}`), поэтому проверять `error != null` `error.code == ""`** (`error` при этом не `null`: `{"code": "", "message": ""}`), поэтому проверять `error != null`
нельзя. Ошибка — непустой `error.code` (`INVALID_DOCUMENT`, ...). нельзя. Ошибка — непустой `error.code` (`INVALID_DOCUMENT`, ...).
## Нормализация (`statementDocument` v1.2) ## Нормализация (`statementDocument` v2.0)
`OcrNormalizer` (`api/src/normalizer/ocr/`) превращает склеенный ответ ИС в документы контракта v1.2 по `§11` и `§11.1` Нормализация ББ/ОФР — общий код пакета `arm_statements` (шаги 2–8, статусы, перевод в контракт; `05-domain-rules.md`
`../docs/context/05-domain-rules.md`: из каждой таблицы `data_ocr.table_structure[i]` каждого успешного файла получается §11.9). Копия пакета лежит в `api/arm_statements/` и напрямую не правится: обновление — `sync-statements`
один `statementDocument` (`locator = "file:<имя json>;table:<i>"`). Проверенные на стенде функции очистки старого ядра (`../docs/scripts/sync-statements.sh`), свежесть — `make statements-check`. Словари (редакции форм, двойники, `label` →
перенесены дословно (единственное исключение для `reference-old-arm`, решение владельца 06.10.2026), DB-обвязка не тип) живут в пакете (`default_rules()`); правка словарей — через репозиторий `arm-statements`, а не здесь.
переносится. Нормализатор — чистые функции без I/O и настроек; `checks` в документах не передаются (их считает ядро).
Конвейер (`src/normalizer/ocr/`): `text.py` (двойники букв и цифр, `|` как разделитель клеток, `clean_value`) → Что остаётся в шлюзе (`api/src/normalizer/`):
`dates.py` (`parse_date`: даты и периоды вида «на 30 июня 2025 г», «за январь - июнь», «1 квартал», «9 месяцев» → последний день периода) →
`amounts.py` (`parse_amount`: запятая → точка, скобки → минус, прочерк → `null`) → `layout.py` и `rows.py` (раскладка
колонок по шапке с ячейкой «Код» или, если шапки нет, по данным; классы строк; повторная шапка перезаписывает
раскладку) → `header.py` (организация, ИНН, единицы, дата отчёта из `data_ocr`) → `document.py` (тип документа,
колонки, строки, значения с `is_recognized`) → `__init__.py` (`OcrNormalizer.normalize`: обход файлов склейки, статус,
`metrics`).
Что получается в документе (подробно — требование «Нормализация ответа ИС» спеки `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/`): - `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`.
- `document_type` — по `label` таблицы (`statement_labels.yaml`), если не сопоставился — по первой цифре кодов строк Ушло в пакет: очистка текста, даты, суммы, раскладка колонок, классы строк, типы форм, редакции, статусы строк и
(`1…` → `BALANCE`, `2…` → `INCOME`), иначе `OTHER` (документ всё равно отдаётся, статус `needs_check`). документов, предупреждения документов, `checks` (§7). Старые модули `ocr/text.py … document.py` и каталог `api/rules/`
- `columns` — колонки значений первой шапки, перенумерованные с 0; `period_date` — из подписи колонки. удалены.
- `lines` — только строки данных с целым кодом. Повтор кода: остаётся первая строка (в БД `line_code` уникален в
документе), остальные теряются в документе, но остаются в `raw/`.
- `values` — по одному на колонку документа; `amount` — число (в файле результата деньги строкой) или `null`;
`string_value` — текст ячейки, только когда сумма не разобрана; `is_recognized = confidence == 100 и разбор не
отбрасывал символов`.
- **`Money` (решение №30).** Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками
(`0,125`, `12,3456`; запятая-разделитель тысяч `4,200`/`1,234` — это 4200/1234, AURORA-1405, #35; точка так же: `4.200`/`837.994` — 4200/837994, #37) не принимается: `amount = null`, `string_value` — текст
ячейки, `is_recognized = false` (таблица получает `needs_check`, остальные ячейки и документ целы). Точка-разделитель тысяч — ровно три цифры после каждой точки, цифр до первой точки сколько угодно
(`[+-]?[1-9]\d*(\.\d{3})+`, скобки на всю строку; `4.200`, `(4.200)`, `1.234.567`, `1234.567`, `1 234.567`), без
потери `is_recognized`; `4.2`, `4.20`, `1234.56` — дроби, `1.2345` — текст (№30); для запятой правило прежнее
(`\d{0,2}` до первой запятой, `1234,567` — не тысячи).
Смешанная сумма вида `1.234,56` (разделители обоих видов) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) —
с `is_recognized = false`.
- `confidence` ячеек и полей шапки приводится к диапазону 0–100, `NaN` и бесконечность дают 0 (`json.loads` принимает
`NaN`/`Infinity`, а контракт требует 0–100).
- `header.report_date` — после замены двойников; `inn` — только цифры. Тексты дат (дата отчёта, подпись колонки) длиннее
200 символов не разбираются и не проходят замену двойников (решение №31: регулярки дат на длинном тексте работают
неприемлемо долго).
### Справочники `api/rules/` Документ на каждую таблицу успешного файла: `locator = "file:<имя json>;table:<i>"`, `header.form_name` — заголовок
таблицы ИС. Подробные правила шагов 2–8 и статусов — `05-domain-rules.md` §11.9, предупреждения документа — там же и
Данные, а не код; загружаются один раз при старте из `SCAN_RULES_DIR` (по умолчанию `rules`, путь от `api/`) с в схеме `statementDocument`.
pydantic-валидацией; битый файл (код не из 4–5 цифр, пустое наименование, дубль `id`, **повторяющийся ключ yaml** — например,
один код строки дважды в `lines`; решение №33) — сервис не стартует, в ошибке имя файла (и ключ с номером строки).
| Файл | Содержимое |
|---|---|
| `statement_lines.yaml` | `editions`: список редакций форм `{id, valid_from?, valid_until?, lines: {код: наименование}}` |
| `ocr_lookalikes.yaml` | `digits`: символ, который OCR ставит вместо цифры → цифра (применяется только к датам, вплотную к цифрам) |
| `statement_labels.yaml` | `labels`: нормализованная (нижний регистр, не-буквенно-цифровое → пробел) подстрока `label` таблицы → `BALANCE` / `INCOME` |
Редакции форм: `66n` (Приказ Минфина № 66н, отчётность по 2024 год включительно, `valid_until: 2024-12-31`; заполнена из
`codes_mapping` старого ядра) и `fsbu4` (ФСБУ 4/2023, отчётность с 2025 года, `valid_from: 2025-01-01`; **пока
пуста** — данные положит владелец). Редакция документа выбирается по `header.report_date`, если его нет — по самой
поздней `period_date` колонок, если нет и её — по числу совпадений кодов (с предупреждением). Имя строки берётся из
словаря выбранной редакции по коду; если кода в нём нет — из ячейки наименования OCR. Пустая выбранная редакция —
предупреждение «редакция fsbu4 не заполнена», имена из OCR, `needs_check`.
Как заполнить `fsbu4`: в `api/rules/statement_lines.yaml` заменить `lines: {}` у редакции `id: fsbu4` на таблицу
`"<код>": "<наименование>"` (коды — строки из 4–5 цифр в кавычках, без дублей; наименования непустые; источник —
Приложения 3–4 ФСБУ 4/2023, черновой перечень кодов — шаблоны `fixtures/statements-xlsx/templates/`). Код нормализатора не
меняется. Затем: `cd api && .venv/bin/pytest tests/unit/normalizer/test_rules.py` (валидность данных),
`make golden-update` и `git -C ../fixtures diff` (эталоны документов 2025 года изменятся: имена строк и предупреждения —
объяснить в PR fixtures), перезапуск сервиса (справочники читаются при старте).
Тесты, которые закрепляют **пустую** `fsbu4` и после заполнения упадут — их нужно обновить (список получен поиском по
`fsbu4` / `не заполнена`):
- `api/tests/unit/normalizer/test_document.py`: `TestTypeAndEdition::test_edition_2025_is_empty` (имя строки из OCR,
предупреждение «редакция fsbu4 не заполнена») и `TestEditionWarnings::test_empty_edition_gives_single_warning`
(`warnings == ["редакция fsbu4 не заполнена"]`);
- `api/tests/integration/test_roundtrip.py`, сценарий 3 (`aep_6.pdf`, тест `test_real_fixture_aep_6_needs_check_with_two_documents`:
строки «редакция fsbu4 не заполнена» для `table:0` и `table:1`) и сценарий 4 (`ok_doc.pdf`, тест
`test_synthetic_success_answer_gives_needs_check`: имя строки `1600` «БАЛАНС» из OCR, единственное предупреждение про
пустую редакцию, статус `needs_check`; при заполненной редакции с кодом `1600` этот документ станет `done`), а также
комментарии к ним в начале файла;
- эталоны `../fixtures/ocr/expected_normalize/aep_6.json` (предупреждения «редакция fsbu4 не заполнена», имена строк
2025 года из OCR) — их проверяет `api/tests/unit/normalizer/test_golden.py` (обновляются `make golden-update`);
- `api/tests/unit/normalizer/test_ocr_normalizer.py` и `api/tests/unit/test_worker.py` (`aep_6`, `SUCCESS_ANSWER` с датой
2025 года) пустую `fsbu4` напрямую не закрепляют: проверяют `needs_check`, который остаётся из-за `confidence` < 100.
### Статус результата ### Статус результата
- Все файлы набора ошибочны — `error` с кодом первого ошибочного файла по имени (см. «Коды ошибок»). - `envelope_status` пакета: из статусов документов и предупреждений файла. Документы есть и все `done`, предупреждений
- Из успешных файлов не собран ни один документ (нет ни одной таблицы с шапкой или столбцом кода, `table_structure` файла уровня `error` нет — `done`; иначе `needs_check`; ни одного документа — `error`.
пуст или это не объект `data_ocr`) — `error`, `RECOGNITION_FAILED`, сообщение «ни одной таблицы не разобрано»; - Все json набора ошибочны (Р-33) — `error` с кодом и сообщением первого ошибочного файла по имени (см. «Коды ошибок»);
причины по таблицам — в `metrics.warnings`; `raw_s3_key` заполнен. `metrics.warnings` при этом перечисляет `JSON_FAILED` каждого json.
- Документы есть — `done`, если всё распознано и ни один файл набора не ошибочен: у всех документов `inn_confidence` и `organization_name_confidence` - Успешные файлы не дали документов (нет таблиц, все таблицы потеряны) — `error`, `RECOGNITION_FAILED`, сообщение
равны 100; дата отчёта разобрана и распознана; все подписи колонок разобраны в даты; все `line_code_confidence` и «ни одной таблицы не разобрано».
`line_name_confidence` равны 100; все значения `is_recognized`; тип по `label` совпал с типом по кодам; редакция - Ошибочный файл при наличии успешного — `JSON_FAILED` (`error`) в `metrics.warnings`, статус не выше `needs_check`.
определена по дате отчёта или дате колонки и заполнена; нет предупреждений уровня таблицы и файла нормализации. Иначе `needs_check`. - Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` — тоже `JSON_FAILED` (Р-32).
Единицы (`units`) в критерий не входят (в контракте у поля нет `confidence`). Реальные сканы почти всегда дают - `metrics.rows` — число строк `lines` всех документов (теперь и строки без кода). Файл результата
`needs_check`: `done` означает лишь «нечего подсвечивать аналитику». `results/{file_id}/{job_id}/result.json` пишется для `done` и `needs_check`; `metrics.warnings` в конверте и в файле
- Ошибочный файл набора при наличии успешного (например, `<job_id>_ofr.json` с `INVALID_DOCUMENT`) **понижает статус до результата одинаковы.
`needs_check`** (решение №28: потерянный ОФР не должен остаться незамеченным под `done`); документы собираются из
успешных файлов, а ошибочный файл попадает в `metrics.warnings` строкой `<имя>: <код ИС>: <сообщение>`.
- `metrics.rows` — число строк во всех документах. Файл результата `results/{file_id}/{job_id}/result.json` пишется для
`done` и `needs_check`; предупреждения (`metrics.warnings`) лежат и в конверте, и в файле результата.
### Предупреждения (`metrics.warnings`) ### Предупреждения (`metrics.warnings`)
Одна строка на предупреждение; уровня таблицы — `<имя json>: table:<i>: <текст>`, уровня файла — `<имя json>: <текст>`, `metrics.warnings` — список структур `{code, severity, message, locator}` (строк больше нет). Предупреждения **файла**,
ошибочного файла — `<имя json>: <код ИС>: <сообщение>` (код `INVALID_FORMAT` для файла, не разобранного как JSON, и которые порождает шлюз и пакет:
для корня-не-объекта). Строка обрезается до 1000 символов, строк не больше 100: лишние сводятся в последнюю
(`ещё предупреждений: <N>; все ошибки файлов — в raw/<job_id>.json`). Тексты (из `src/normalizer/ocr/`):
| Уровень | Текст | | Код | Уровень | Когда | `locator` |
|---|---| |---|---|---|---|
| файл | `ИНН не распознан` (в поле `inn` нет цифр, ИНН не извлечён) | | `JSON_FAILED` | `error` | файл набора ошибочен (`error.code` ИС, `parse_error`, корень не объект), либо успешный json без `data_ocr` / `table_structure` / таблиц; `message` — `<код ИС>: <сообщение>` или причина | `file:<имя json>` |
| файл | `ИНН распознан неуверенно` (ИНН извлечён, но `confidence` ≠ 100 или из текста убран мусор) | | `TABLE_PARSE_ERROR` | `error` | исключение при разборе таблицы (в том числе нарушенная структура таблицы ИС); `message` — `BACKEND_PARSING_ERROR: <тип>: <текст до 300 символов>` | `file:<имя json>;table:<i>` |
| файл | `дата отчёта не разобрана` | | `TABLE_LOST` | `error` | таблица с данными не распознана как форма: нет столбца кодов и типа формы | `file:<имя json>;table:<i>` |
| файл | `data_ocr отсутствует или не объект` | | `TABLE_SKIPPED` | `info` | таблица без данных пропущена | `file:<имя json>;table:<i>` |
| таблица | `нет шапки и столбца кода` (таблица пропущена, остальные разбираются; также когда `rows` не список или у таблицы есть шапка, но нет ни одной строки данных) |
| таблица | `строк с нечисловым кодом пропущено: <N>` (непустой код, не целое число: `1150 :`, `изо :`; одно на таблицу) |
| таблица | `строк без кода с суммами пропущено: <N>` (пустой код, но в колонках значений есть разобранная сумма: «БАЛАНС», «Выручка»; как в Excel-пути, OQ-141; строки без кода и без сумм — заголовки групп, «в том числе:» — не считаются; одно на таблицу) |
| таблица | `строк с несколькими кодами разлеплено: <N>` (ИС склеила соседние строки: код `1500\n1700`, суммы через перевод строки; строка разлеплена на N строк, OQ-156; одно на таблицу) |
| таблица | `шапка синтезирована: колонки без дат` (в таблице нет строки-шапки: раскладка выведена по данным) |
| таблица | `колонка <i> без даты: <подпись колонки>` |
| таблица | `нет колонок значений` |
| таблица | `повторная шапка изменила число колонок значений` (одно на таблицу; значения лишних колонок отброшены, недостающие — «ячейки нет») |
| таблица | `тип по label (<label>) не совпал с кодами: взят <тип>` (все коды строк данных одной группы — `1xxx` или `2xxx`, а `label` говорит иное: берётся тип по кодам, `header.form_name` — как распознан; #30) |
| таблица | `тип по кодам (<тип>) не совпал с label (<label>)` (коды обеих групп — шум OCR: берётся тип по `label`) |
| таблица | `тип определён по кодам` (`label` не сопоставлен) |
| таблица | `тип не определён: OTHER` |
| таблица | `редакция определена по кодам: <id>` (нет ни даты отчёта, ни дат колонок) |
| таблица | `редакция не определена` |
| таблица | `редакция <id> не заполнена` (сейчас — `fsbu4`) |
| таблица | `код <код> из редакции <id>` (код есть в другой редакции, но не в выбранной; **только если выбранная редакция не пуста**) |
| таблица | `код <код> повторяется: оставлена первая строка` |
| таблица | `BACKEND_PARSING_ERROR: <класс исключения>: <сообщение>` (исключение при разборе таблицы: остальные таблицы разбираются) |
Любое предупреждение уровня таблицы или файла нормализации означает `needs_check`. Предупреждения **документа** (`documents[i].warnings`, у них нет `locator`) и нарушения §7 — в самих документах, см.
`05-domain-rules.md` §11.9. Список `metrics.warnings` ограничен 100 структурами, `message` — 1000 символов; при
переполнении остаются все `error`, затем `info` (сырой ответ — в `raw/`).
### Эталоны и `make golden-update` ### Эталоны и `make golden-update`
Тест `api/tests/unit/normalizer/test_golden.py` сравнивает результат нормализатора на реальных ответах ИС Тест `api/tests/unit/normalizer/test_golden.py` сравнивает результат нормализатора на реальных ответах ИС
(`../fixtures/ocr/<имя>.json` + `<имя>_*.json`) с эталонами `../fixtures/ocr/expected_normalize/<имя>.json` (`status`, (`../fixtures/ocr/<имя>.json` + `<имя>_*.json`) с эталонами `../fixtures/ocr/expected_normalize/<имя>.json`. Формат v2
`warnings`, `metrics`, `error`, `documents`). Эталоны лежат в репозитории **fixtures** и обновляются отдельным PR: (P5-14): `{schema_version, status, error, metrics, documents}` файла результата без `job_id`, `file_id`, `service`,
`make golden-update` (`api/tests/golden_update.py`) пересобирает их, diff смотреть через `git -C ../fixtures diff` и `raw_s3_key` (`statements_version` меняется при каждой раскладке копии); успешный эталон дополнительно проходит схему
объяснять. Без каталога `../fixtures/ocr` golden-тесты не выполняются. `recognition-result` v2.0. Эталоны лежат в репозитории **fixtures** и обновляются отдельным PR: `make golden-update`
(`api/tests/golden_update.py`) пересобирает их, diff смотреть через `git -C ../fixtures diff` и объяснять. Без каталога
`../fixtures/ocr` golden-тесты не выполняются.
## Переменные окружения ## Переменные окружения
@ -318,7 +243,6 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
| `SCAN_TIMEOUT_SECONDS` | `1200` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`; 20 мин — в паре со сроком ответа OCR ядра) | | `SCAN_TIMEOUT_SECONDS` | `1200` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`; 20 мин — в паре со сроком ответа OCR ядра) |
| `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий | | `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий |
| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output/<job_id>/` и каталог, `Input/<job_id>.<ext>` (и `.done`); `false` — ничего не удаляется; см. «Очистка горячей папки» | | `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output/<job_id>/` и каталог, `Input/<job_id>.<ext>` (и `.done`); `false` — ничего не удаляется; см. «Очистка горячей папки» |
| `SCAN_RULES_DIR` | `rules` | каталог справочников нормализатора (`statement_lines.yaml`, `ocr_lookalikes.yaml`, `statement_labels.yaml`), путь от текущего каталога (`api/`); битый справочник — сервис не стартует |
| `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте | | `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте |
| `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) | | `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) |
| `ROOT_PATH` | `/` | префикс FastAPI за прокси | | `ROOT_PATH` | `/` | префикс FastAPI за прокси |
@ -342,7 +266,7 @@ MinIO и NATS); те же значения подхватываются из `EN
## Коды ошибок ## Коды ошибок
Итоговый конверт `error` содержит `error.code` и `error.message`. Нормализатор возвращает коды ответа ИС и Итоговый конверт `error` содержит `error.code` и `error.message`. Нормализатор возвращает коды ответа ИС и
`INVALID_FORMAT`/`RECOGNITION_FAILED` (ни одной таблицы), остальные коды ставит воркер (`api/src/core/worker.py`). `INVALID_FORMAT`/`RECOGNITION_FAILED` (ни одной таблицы) и коды ошибок json, остальные коды ставит воркер (`api/src/core/worker.py`).
Код `NOT_IMPLEMENTED` (заглушка Среза 0) сервис больше не публикует: есть успешный файл — документы и `done` / Код `NOT_IMPLEMENTED` (заглушка Среза 0) сервис больше не публикует: есть успешный файл — документы и `done` /
`needs_check`. `needs_check`.
@ -350,18 +274,18 @@ MinIO и NATS); те же значения подхватываются из `EN
|---|---|---| |---|---|---|
| `SCHEMA_MISMATCH` | тело не JSON / не по схеме / MAJOR версии ≠ 1 / `pipeline` ≠ `image`. Конверт публикуется, только если в теле есть корректные `job_id` и `file_id`, иначе запрос пишется в лог и отбрасывается | нет | | `SCHEMA_MISMATCH` | тело не JSON / не по схеме / MAJOR версии ≠ 1 / `pipeline` ≠ `image`. Конверт публикуется, только если в теле есть корректные `job_id` и `file_id`, иначе запрос пишется в лог и отбрасывается | нет |
| `FILE_READ_ERROR` | объект S3 не прочитан; `s3.bucket` не совпадает с бакетом сервиса | да | | `FILE_READ_ERROR` | объект S3 не прочитан; `s3.bucket` не совпадает с бакетом сервиса | да |
| `RECOGNITION_FAILED` | у `original_name` нет допустимого расширения (файл в ИС не передаётся; `<job_id>.<ext>`, 1–16 букв/цифр ASCII); не записан файл во «Входящие» (горячая папка недоступна); «Исходящие» не читались ни разу до дедлайна; **ни одной таблицы не разобрано** (успешные файлы набора не дали ни одного документа; сообщение «ни одной таблицы не разобрано», `raw_s3_key` заполнен, причины — в `metrics.warnings`) | да | | `RECOGNITION_FAILED` | у `original_name` нет допустимого расширения (файл в ИС не передаётся; `<job_id>.<ext>`, 1–16 букв/цифр ASCII); не записан файл во «Входящие» (горячая папка недоступна); «Исходящие» не читались ни разу до дедлайна; **ни одной таблицы не разобрано** (успешные файлы набора не дали ни одного документа; сообщение «ни одной таблицы не разобрано», `raw_s3_key` заполнен, причины — в `metrics.warnings`: `TABLE_PARSE_ERROR`, `TABLE_LOST`, `JSON_FAILED`) | да |
| `TIMEOUT` | ИС не ответила до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`; `reply_deadline` истёк до отправки файла | да | | `TIMEOUT` | ИС не ответила до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`; `reply_deadline` истёк до отправки файла | да |
| `INVALID_FORMAT` | все файлы ответа ошибочны, и у первого ошибочного файла не разобрался JSON (сырой ответ сохранён, `raw_s3_key` указан) или корень ответа не объект; склейка нарушена или пуста | да | | `INVALID_FORMAT` | все файлы ответа ошибочны, и у первого ошибочного файла не разобрался JSON (сырой ответ сохранён, `raw_s3_key` указан) или корень ответа не объект; склейка нарушена или пуста | да |
| код ошибки ИС | **все** файлы ответа ошибочны (`error.code` непустой): код первого ошибочного файла по имени пробрасывается, если он входит в `ErrorCode` (например, `INVALID_DOCUMENT`); каждый ошибочный файл — строка `metrics.warnings` | да | | код ошибки ИС | **все** файлы ответа ошибочны (`error.code` непустой): код первого ошибочного файла по имени пробрасывается, если он входит в `ErrorCode` (например, `INVALID_DOCUMENT`); каждый ошибочный файл — предупреждение `JSON_FAILED` в `metrics.warnings` | да |
| `UNKNOWN_ERROR` | сырой ответ или результат не записан в S3; нормализатор упал (исключение вне разбора таблицы: исключение внутри таблицы — предупреждение `BACKEND_PARSING_ERROR`); неизвестный код ИС (в том числе `error.code` не строка: число, список, `true`); непредвиденная ошибка | да | | `UNKNOWN_ERROR` | сырой ответ или результат не записан в S3; нормализатор упал (исключение вне разбора таблицы: исключение внутри таблицы — предупреждение `TABLE_PARSE_ERROR`); неизвестный код ИС (в том числе `error.code` не строка: число, список, `true`); непредвиденная ошибка | да |
Файл ответа считается ошибочным, если его `error.code` непустой, файл не разобрался как JSON (`parse_error`) либо корень Файл ответа считается ошибочным, если его `error.code` непустой, файл не разобрался как JSON (`parse_error`) либо корень
ответа не объект (`INVALID_FORMAT`). ответа не объект (`INVALID_FORMAT`).
`metrics.warnings` итогового конверта содержит по строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл (и когда `metrics.warnings` итогового конверта содержит структуру `JSON_FAILED` (`severity = error`, `locator = file:<имя>`,
есть успешный файл, и когда ошибочны все файлы) вперемешку с предупреждениями нормализации успешных файлов (см. `message = <код>: <сообщение>`) на каждый ошибочный файл (и когда есть успешный файл, и когда ошибочны все файлы) и
«Нормализация»), в порядке файлов набора по имени; при `done` / `needs_check` в `metrics` есть и `rows`. Если предупреждения таблиц (`TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`) — в порядке файлов набора по имени; при
предупреждений нет, `warnings` опускается. `done` / `needs_check` в `metrics` есть и `rows`. Если предупреждений нет, `warnings` опускается.
## Контракт NATS (кратко) ## Контракт NATS (кратко)
@ -414,10 +338,9 @@ curl -s localhost:8000/healthcheck
`Nats-Msg-Id: <job_id>:1`. `Nats-Msg-Id: <job_id>:1`.
5. Ожидать: ack `processing`, через `SCAN_EMULATOR_DELAY` (+ до `SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS` после первого опроса, увидевшего набор) — 5. Ожидать: ack `processing`, через `SCAN_EMULATOR_DELAY` (+ до `SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS` после первого опроса, увидевшего набор) —
итоговый конверт: итоговый конверт:
- `aep_3.pdf` — `needs_check`, `result_s3` с двумя документами (`BALANCE` 28 строк и `INCOME` 13 строк после - `aep_3.pdf` — `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`), `metrics.warnings` с
удаления повторов кодов), `metrics.warnings` с предупреждениями нормализации и строкой `JSON_FAILED` для `<job_id>_ofr.json` (второй файл набора — ответ-ошибка, `INVALID_DOCUMENT: ...`);
`<job_id>_ofr.json: INVALID_DOCUMENT: ...` (второй файл набора — ответ-ошибка); - `aep_6.pdf` — `needs_check`, два документа (`BALANCE`, `INCOME`), предупреждения — в самих документах;
- `aep_6.pdf` — `needs_check`, два документа (`BALANCE`, `INCOME`), в предупреждениях «редакция fsbu4 не заполнена»;
- `aep_5.PDF` (и `aep_8.pdf`) — `error`, `INVALID_DOCUMENT` с сообщением ИС (ответы-ошибки); - `aep_5.PDF` (и `aep_8.pdf`) — `error`, `INVALID_DOCUMENT` с сообщением ИС (ответы-ошибки);
- неизвестное содержимое — `error`, `INVALID_DOCUMENT` эмулятора. - неизвестное содержимое — `error`, `INVALID_DOCUMENT` эмулятора.
В бакете появится `raw/<job_id>.json` (склейка всех файлов набора), `raw_s3_key` указан в конверте; для В бакете появится `raw/<job_id>.json` (склейка всех файлов набора), `raw_s3_key` указан в конверте; для
@ -430,7 +353,8 @@ curl -s localhost:8000/healthcheck
make check # lint (ruff check, ruff format --check, mypy src) + test (pytest -m "not integration") 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 test-integration # pytest -m integration: нужны NATS и MinIO (make up)
make docker-build # docker build -t scan-gateway:dev . (образ без Nexus) make docker-build # docker build -t scan-gateway:dev . (образ без Nexus)
make check-full # check + test-integration + docker-build; перед PR 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/ (см. «Нормализация») make golden-update # пересобрать эталоны нормализатора ../fixtures/ocr/expected_normalize/ (см. «Нормализация»)
``` ```
@ -466,33 +390,14 @@ make golden-update # пересобрать эталоны нормализ
## Известные ограничения ## Известные ограничения
Нормализатор (Срез 3a; решения — таблица «Decisions made without discussion» в Нормализатор (правила шагов 2–8 — пакет `arm_statements`, ограничения — в `05-domain-rules.md` §11.9 и спеке
`openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md`): `statement-normalization`; решения шлюза — `openspec/changes/statements-v2/design.md`):
- Редакция `fsbu4` (отчётность с 2025 года) пока пуста: имена строк документов 2025 года берутся из OCR, предупреждение - Сетка OCR строится по возрастанию `row_index`, пропуски `row_index` пустыми строками не заполняются (P5-1): шаг 2 не
«редакция fsbu4 не заполнена», статус `needs_check`. Состав редакции — данные владельца (см. «Справочники»). видит «дыр» между строками ИС (в реальных json их нет). Ячейка `value` не строка приводится через `str`, `confidence`
- Строки, чей код не целое число после обрезки пробелов (например, `1150 :`), разбор относит к классу `HEADER` и вне числа — 0 (P5-3).
пропускает (перенос `_classify_row`; раскладка колонок при этом **не** переписывается — её меняет только строка с - Успешный json ИС без таблиц — `JSON_FAILED` (`error`), рядом с нормальными json конверт будет `needs_check` (P5-7, Р-32).
ячейкой «Код…»). Строки с пустым кодом тоже пропускаются. В документ они не попадают и остаются в `raw/`, но - Нарушенная структура одной таблицы ИС теряет всю таблицу (`TABLE_PARSE_ERROR`, P5-2); остальные таблицы разбираются.
таблица получает предупреждения `строк с нечисловым кодом пропущено: <N>` и `строк без кода с суммами пропущено: <N>`
(последнее — только если в строке есть сумма), то есть `needs_check` (решение №29). Сами строки не восстанавливаются
(OQ-149): в реальных сканах так теряются, например, «БАЛАНС» и «Выручка» с пустым кодом.
- Склеенная строка ИС — несколько целых кодов в ячейке через перевод строки (`1500\n1700`, ББ РБ и Счастливочной за
1 кв. 2025) — до классификации разлепляется на столько строк, сколько кодов, если в каждой непустой ячейке значений
столько же частей (наименование делится так же, иначе остаётся целым); иначе строка пропускается как нечисловой код.
Предупреждение `строк с несколькими кодами разлеплено: <N>`, статус `needs_check` (OQ-156, решение владельца 08.10.2026;
расхождение с источником `_classify_row`).
- Скобка, распознанная как `}` или `]` (`(7744536}`), знак минус не даёт: сумма остаётся положительной с
`is_recognized = false` (решение №12, дословный перенос `_parse_amount`; расширение набора закрывающих скобок — после
проверки на стенде, вынесено в открытые вопросы docs).
- Длинное тире «—» / «–» в ячейке значения (прочерк) при `confidence = 100` даёт `is_recognized = false` (дословный
`parse_amount` вырезает тире, решение №20); дефис «-» — `true`. Лишний `needs_check` на формах с длинным тире.
- Замена двойников (`fix_lookalikes`) применяется только к датам (дата отчёта, подписи колонок); код строки с латинской
`O` вместо нуля отбрасывается как не-целое (решение №22). Суммы разбираются `parse_amount` без замены символов.
- Повтор кода строки в таблице: остаётся первая строка, вторая теряется в документе (решение №15).
- `checks` в документах нет: соотношения §7 считает ядро (Срез 3b).
- Ошибочный файл набора при успешных даёт `needs_check` (`done` невозможен, решение №28); предупреждение
`<имя>: <код>: ...` остаётся в `metrics.warnings`.
- Расшифровки без кода (OQ-36) и склейка одной формы из нескольких таблиц (OQ-77) не обрабатываются: каждая таблица — - Расшифровки без кода (OQ-36) и склейка одной формы из нескольких таблиц (OQ-77) не обрабатываются: каждая таблица —
свой документ. свой документ.

View File

@ -1,6 +1,6 @@
"""Сквозной сценарий scan-gateway против compose-инфраструктуры (задача 6.1, R-7, R-10; Срез 3a). """Сквозной сценарий scan-gateway против compose-инфраструктуры (задача 6.1, R-7, R-10; Срез 3a).
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` на копии `arm_statements`) и
эмулятор ИС Сканирование (`src.scan_emulator.run` в потоке, `SCAN_EMULATOR_DEFAULT` пуст) работают в процессе эмулятор ИС Сканирование (`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-каталог. теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог.
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, пишется прямо в `Input`), пару фикстур Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, пишется прямо в `Input`), пару фикстур
@ -14,7 +14,7 @@
`needs_check` с документами (баланс и ОФР) в файле результата и `metrics.warnings`, в том числе про `_ofr.json`; `needs_check` с документами (баланс и ОФР) в файле результата и `metrics.warnings`, в том числе про `_ofr.json`;
`raw/{job_id}.json` — оба файла (склейка, D-4); `raw/{job_id}.json` — оба файла (склейка, D-4);
3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`); 3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`);
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`; 4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → результат с `result_s3` (имя строки из OCR);
5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора; 5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора;
6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты. 6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты.
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата
@ -104,8 +104,7 @@ REQUEST_EXAMPLE: dict[str, Any] = json.loads(
# Синтетический успешный ответ ИС (R-10) в форме реальных ответов (`aep_6.json`): `data_ocr` — объект с полями # Синтетический успешный ответ ИС (R-10) в форме реальных ответов (`aep_6.json`): `data_ocr` — объект с полями
# `label/organization/inn/units/date` (`form_name` + `confidence`) и `table_structure[{label, rows}]`. Все # `label/organization/inn/units/date` (`form_name` + `confidence`) и `table_structure[{label, rows}]`. Все
# `confidence` = 100, шапка есть, даты колонок и отчёта разобраны, но дата 2025 года попадает в редакцию `fsbu4`, # `confidence` = 100, шапка есть, даты колонок и отчёта разобраны, но форма — одна строка «БАЛАНС»
# справочник которой пуст: нормализатор даёт `needs_check` (предупреждение «редакция fsbu4 не заполнена»)
OK_DOC_PDF = b"%PDF-1.4\n% scan-gateway itest ok_doc.pdf\n\x00\x01\x02" 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" UNKNOWN_PDF = b"%PDF-1.4\n% scan-gateway itest unknown.pdf\n\x00\x01\x02"
OK_DOC_ORGANIZATION = "ООО «Русский бисквит»" OK_DOC_ORGANIZATION = "ООО «Русский бисквит»"
@ -227,7 +226,7 @@ class Roundtrip:
continue continue
MESSAGE_VALIDATOR.validate(body) MESSAGE_VALIDATOR.validate(body)
assert body["service"]["name"] == "scan-gateway", body assert body["service"]["name"] == "scan-gateway", body
assert body["schema_version"] == "1.0", body assert body["schema_version"] == CONTRACT_VERSION == "2.0", body # ack и итог — 2.0 (P5-13)
assert msg.headers is not None, body assert msg.headers is not None, body
out.append(Envelope(msg.headers[NATS_MSG_ID_HEADER], body)) out.append(Envelope(msg.headers[NATS_MSG_ID_HEADER], body))
return out return out
@ -472,17 +471,8 @@ async def roundtrip_cleanup(
yield rt 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: def _golden(name: str, job_id: str) -> dict[str, Any] | None:
"""Эталон `fixtures/ocr/expected_normalize/<name>.json` с подставленным `job_id`; нет эталона — `None`. """Эталон v2 `fixtures/ocr/expected_normalize/<name>.json` с подставленным `job_id`; нет эталона — `None`.
Эталон строится на условном `job_id` (`tests/golden_update.py`): он встречается в `locator` и в предупреждениях. Эталон строится на условном `job_id` (`tests/golden_update.py`): он встречается в `locator` и в предупреждениях.
""" """
@ -521,8 +511,16 @@ async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(ro
await roundtrip.check_error(body, result) await roundtrip.check_error(body, result)
raw = await roundtrip.raw(job_id) raw = await roundtrip.raw(job_id)
assert [entry["response"]["error"] for entry in raw["files"]] == [expected_error] assert [entry["response"]["error"] for entry in raw["files"]] == [expected_error]
# все файлы набора ошибочные: итог — ошибка первого файла, а warnings перечисляют каждый ошибочный файл (D-5) # все файлы набора ошибочные (Р-32/Р-33): итог — ошибка первого файла, а warnings перечисляют `JSON_FAILED`
assert result.body["metrics"]["warnings"] == [f"{job_id}.json: INVALID_DOCUMENT: {expected_error['message']}"] # каждого ошибочного файла структурой
assert result.body["metrics"]["warnings"] == [
{
"code": "JSON_FAILED",
"severity": "error",
"message": f"INVALID_DOCUMENT: {expected_error['message']}",
"locator": f"file:{job_id}.json",
}
]
# повтор ядра после результата (attempt 2): ещё один ack, без второго файла и второго результата # повтор ядра после результата (attempt 2): ещё один ack, без второго файла и второго результата
await roundtrip.publish({**body, "attempt": 2}) await roundtrip.publish({**body, "attempt": 2})
@ -550,16 +548,22 @@ async def test_real_fixture_aep_3_two_files_merged_needs_check_with_documents(ro
f"file:{job_id}.json;table:1", f"file:{job_id}.json;table:1",
] ]
assert payload["metrics"]["rows"] == sum(len(d["lines"]) for d in payload["documents"]) assert payload["metrics"]["rows"] == sum(len(d["lines"]) for d in payload["documents"])
# ошибочный файл набора — в warnings одной строкой `<имя>: <код>: <сообщение>` (последней: файлы по имени); # ошибочный файл набора — `JSON_FAILED` структурой (`error`, `locator` файла); предупреждения документов лежат
# успешный файл даёт предупреждения нормализации # в самих документах (§11.9), а не в `metrics.warnings`
warnings = result.body["metrics"]["warnings"] warnings = result.body["metrics"]["warnings"]
assert warnings[-1] == f"{job_id}_ofr.json: INVALID_DOCUMENT: {ofr_error['message']}" assert warnings == [
assert f"{job_id}.json: ИНН распознан неуверенно" in warnings # в aep_3 ИНН извлечён, но confidence < 100 {
assert f"{job_id}.json: table:0: шапка синтезирована: колонки без дат" in warnings "code": "JSON_FAILED",
"severity": "error",
"message": f"INVALID_DOCUMENT: {ofr_error['message']}",
"locator": f"file:{job_id}_ofr.json",
}
]
assert payload["metrics"]["warnings"] == warnings
expected = _golden("aep_3", job_id) expected = _golden("aep_3", job_id)
if expected is not None: # эталон нормализатора (`make golden-update`) совпадает со сквозным результатом if expected is not None: # эталон нормализатора (`make golden-update`) совпадает со сквозным результатом
assert _drop_none(payload["documents"]) == expected["documents"] assert payload["documents"] == expected["documents"]
assert warnings == expected["warnings"] assert warnings == expected["metrics"]["warnings"]
raw = await roundtrip.raw(job_id) raw = await roundtrip.raw(job_id)
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json", f"{job_id}_ofr.json"] assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json", f"{job_id}_ofr.json"]
@ -580,15 +584,12 @@ async def test_real_fixture_aep_6_needs_check_with_two_documents(roundtrip: Roun
job_id = body["job_id"] job_id = body["job_id"]
payload = await roundtrip.check_needs_check(body, result) payload = await roundtrip.check_needs_check(body, result)
assert [d["document_type"] for d in payload["documents"]] == ["BALANCE", "INCOME"] assert [d["document_type"] for d in payload["documents"]] == ["BALANCE", "INCOME"]
# отчётность за 2025 год попадает в редакцию `fsbu4`, справочник которой пуст (по одному предупреждению на таблицу) # ошибочных файлов в наборе нет: предупреждений файла нет, документы равны эталону
warnings = result.body["metrics"]["warnings"] assert result.body["metrics"].get("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) expected = _golden("aep_6", job_id)
if expected is not None: if expected is not None:
assert _drop_none(payload["documents"]) == expected["documents"] assert payload["documents"] == expected["documents"]
assert warnings == expected["warnings"] assert result.body["metrics"].get("warnings", []) == expected["metrics"].get("warnings", [])
raw = await roundtrip.raw(job_id) raw = await roundtrip.raw(job_id)
[entry] = raw["files"] [entry] = raw["files"]
assert entry["name"] == f"{job_id}.json" assert entry["name"] == f"{job_id}.json"
@ -608,10 +609,11 @@ async def test_synthetic_success_answer_gives_needs_check(roundtrip: Roundtrip)
assert document["header"]["report_date"] == "2025-09-30" assert document["header"]["report_date"] == "2025-09-30"
assert [c["period_date"] for c in document["columns"]] == ["2025-09-30", "2024-12-31"] assert [c["period_date"] for c in document["columns"]] == ["2025-09-30", "2024-12-31"]
[line] = document["lines"] [line] = document["lines"]
assert (line["line_code"], line["line_name"]) == ("1600", "БАЛАНС") # имя из OCR: редакция fsbu4 пуста assert (line["line_code"], line["line_name"]) == ("1600", "БАЛАНС") # имя из OCR (`line_name`)
assert [v["amount"] for v in line["values"]] == ["1234567", "-12345"] # деньги в файле результата — строки assert [v["amount"] for v in line["values"]] == ["1234567.00", "-12345.00"] # деньги — строки numeric(18,2)
# всё распознано, кроме пустой редакции: единственное предупреждение # предупреждения «редакция не заполнена» больше нет (редакции — в пакете); предупреждений файла нет
assert result.body["metrics"]["warnings"] == [f"{job_id}.json: table:0: редакция fsbu4 не заполнена"] assert result.body["metrics"].get("warnings", []) == []
assert not any("редакция" in json.dumps(w, ensure_ascii=False) for w in document.get("warnings", []))
raw = await roundtrip.raw(job_id) raw = await roundtrip.raw(job_id)
[entry] = raw["files"] [entry] = raw["files"]
assert entry["response"]["error"] == {"code": "", "message": ""} assert entry["response"]["error"] == {"code": "", "message": ""}

View File

@ -69,3 +69,12 @@
`Claude-Session: …` (план) — совпадает с обоими источниками. Цена ошибки: нет. `Claude-Session: …` (план) — совпадает с обоими источниками. Цена ошибки: нет.
- **R-2 (Р-36).** Задача 4 копирует из `docs/contracts/examples` только существующие файлы верхнего уровня как есть, новых - **R-2 (Р-36).** Задача 4 копирует из `docs/contracts/examples` только существующие файлы верхнего уровня как есть, новых
примеров контракта верхнего уровня не добавляет (решение владельца Р-36). Цена ошибки: нет. примеров контракта верхнего уровня не добавляет (решение владельца Р-36). Цена ошибки: нет.
- **R-3.** Усечение до 300 символов (P5-9) применяется к тексту исключения, а не ко всей строке `Тип: текст`. Цена ошибки:
сообщение чуть длиннее 300 символов при длинном имени типа.
- **R-4 (T4).** `NormalizeOutcome` вынесен в `src/normalizer/outcome.py` с реэкспортом из `src.normalizer` — иначе цикл
импортов. Цена ошибки: лишний модуль; импорт из `src.normalizer` работает как раньше.
- **R-5 (T4).** `app.py` вызывает `OcrNormalizer()` (правила `default_rules()` внутри), а не
`OcrNormalizer(default_rules())`: импорт `arm_statements` разрешён только в `src/normalizer/**` и `tests/**`. Цена
ошибки: нет, эквивалентно.
- **R-6 (T4).** Сравнение с эталонами в `test_golden` временно стояло под `pytest.skip` до задачи 6 (эталоны fixtures
были старого формата); в задаче 6 skip снят. Цена ошибки: окно без golden-проверки на ветке между задачами 4 и 6.

View File

@ -1,27 +1,27 @@
## 1. Подготовка хоста ## 1. Подготовка хоста
- [ ] 1.1 Задача 1: подготовка хоста по плану 2 (копия `arm_statements`, `statements-check`, линтеры, `testpaths`) - [x] 1.1 Задача 1: подготовка хоста по плану 2 (копия `arm_statements`, `statements-check`, линтеры, `testpaths`)
## 2. Адаптер ## 2. Адаптер
- [ ] 2.1 Задача 2: адаптер — таблица json ИС → `FormCandidate` - [x] 2.1 Задача 2: адаптер — таблица json ИС → `FormCandidate`
## 3. Обход набора json ## 3. Обход набора json
- [ ] 3.1 Задача 3: `normalize`, предупреждения файла, статус и код ошибки - [x] 3.1 Задача 3: `normalize`, предупреждения файла, статус и код ошибки
## 4. Контракт 2.0 ## 4. Контракт 2.0
- [ ] 4.1 Задача 4: контракт v2.0 и переключение нормализатора - [x] 4.1 Задача 4: контракт v2.0 и переключение нормализатора
## 5. Удаление старого ## 5. Удаление старого
- [ ] 5.1 Задача 5: удаление старого нормализатора, справочников и `SCAN_RULES_DIR` - [x] 5.1 Задача 5: удаление старого нормализатора, справочников и `SCAN_RULES_DIR`
## 6. Golden ## 6. Golden
- [ ] 6.1 Задача 6: golden v2 и Review Focus на реальных ответах ИС - [x] 6.1 Задача 6: golden v2 и Review Focus на реальных ответах ИС
## 7. Завершение ## 7. Завершение
- [ ] 7.1 Задача 7: интеграционные тесты, документация, `make check-full`, PR - [x] 7.1 Задача 7: интеграционные тесты, документация, `make check-full`, PR