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 на каждый
распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.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)`,
старт — запись файла во «Входящие». **Нет доступа к БД.**
@ -16,18 +16,21 @@
- `api/arm_statements/` — копия пакета `arm-statements`, напрямую не править: обновление — `../docs/scripts/sync-statements.sh`
(`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`).
- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14), по `05-domain-rules.md`
§11 и §11.1: правило, которого там нет, не реализуется, а поднимается вопросом. Функции очистки
(`src/normalizer/ocr/`) перенесены из старого ядра **дословно** (единственное исключение для `reference-old-arm`):
при правке сначала разберись, что делает источник; расхождения с ним — только решением, записанным в
`openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md` (таблица «Decisions made without discussion»: №12, №13, №20, №22,
№30 — `Money` не больше двух знаков после запятой, №31 — тексты дат длиннее 200 символов не разбираются).
Функции `ocr/*` — чистые: без I/O, без настроек, без логов.
- Справочники — данные: `api/rules/*.yaml` (редакции форм `66n` / `fsbu4`, двойники, `label` → тип), правила не
зашиваются в код. `fsbu4` пока пуста — данные кладёт владелец, код при этом не меняется.
- Статус: `done` только когда всё распознано (`confidence` 100, даты разобраны, редакция определена по дате и заполнена,
нет предупреждений) и ни один файл набора не ошибочен (№28); иначе `needs_check`; ни одной таблицы → `error RECOGNITION_FAILED`. Предупреждения — строки
`metrics.warnings` (`<имя json>: table:<i>: <текст>`); при изменении их текстов обнови README (раздел «Предупреждения»)
и golden.
§11, §11.1 и §11.9: правило, которого там нет, не реализуется, а поднимается вопросом. Шаги 2–8, словари и статусы —
в пакете `arm_statements`; в шлюзе остаются только:
- адаптер шага 1 — `api/src/normalizer/ocr/adapter.py` (таблица ИС → кандидат, `ref` — номер строки ИС);
- обход набора json — `api/src/normalizer/ocr/answer_set.py` (`normalize_candidate` на каждую таблицу,
`envelope_status`, коды `JSON_FAILED` / `TABLE_PARSE_ERROR`; Р-32/Р-33);
- граница контракта — `to_contract` / `warning_to_contract` пакета, вызываются в `api/src/normalizer/ocr/__init__.py`
(`OcrNormalizer`); своего конвертера и своего уплощения шапки нет, шлюз только валидирует словарь моделями
`src/contract`.
Переносы старого ядра (`ocr/text.py … document.py`) и справочники `api/rules/` удалены: словари меняются через
репозиторий `arm-statements`, не здесь. Решения по адаптеру (P5-n, R-n) — `openspec/changes/statements-v2/design.md`.
- Статус документа и конверта определяет пакет (`envelope_status`); шлюз его не пересчитывает. Все json набора
ошибочны — `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 и объяснять; эталоны меняются отдельным PR fixtures.
- Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, запись сразу в `Input` открытием `wb` с перезаписью, без
@ -41,18 +44,18 @@
каталога по возрастанию имени; нормализатор получает ровно эту структуру.
- Успех файла ответа — `error.code == ""` (`error` при этом не `null`!), ошибка — непустой `error.code`,
`parse_error` или корень ответа не объект (`INVALID_FORMAT`). Проверка `error is None` для реального ответа неверна.
Ошибочные файлы — в `metrics.warnings` (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого; есть
Ошибочные файлы — предупреждения `JSON_FAILED` в `metrics.warnings` (`locator = file:<имя>`, `message = <код>: <сообщение>`); все файлы ошибочны → код первого; есть
успешный — документы из успешных файлов, статус не выше `needs_check` (№28). `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только
для тестов воркера).
- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы; если все коды
строк одной группы (`1xxx`/`2xxx`) и противоречат `label` — тип по кодам с предупреждением (#30); `label` не сопоставлен — коды по большинству. `checks` в документах не передаются — их считает ядро (Срез 3b).
- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа и редакцию определяет пакет
(`05-domain-rules.md` §11.9). `checks` считает пакет и отдаёт в документах.
- 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/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/`.
## Соглашения шаблона
@ -79,22 +82,20 @@ SMB-сессия, S3, NATS, воркер; `GET /healthcheck`; gzip/proxy middlew
- `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`, `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`; идентичность по правилу MAJOR (S-53,
- `api/src/normalizer/` — протокол `Normalizer`, `NullNormalizer` (заглушка Среза 0, остаётся для тестов воркера; ошибки
файлов выдаёт `JSON_FAILED` через `make_warning` пакета), `outcome.py` (`NormalizeOutcome`), `files.py` (ошибки json
набора) и `OcrNormalizer` (подключён в `create_app`).
- `api/src/normalizer/ocr/` — `adapter.py` (адаптер шага 1), `answer_set.py` (обход набора json), `__init__.py`
(`OcrNormalizer`, граница контракта через `to_contract`). Остальной разбор — `api/arm_statements/` (копия пакета).
- `api/src/contract/` + `api/contracts/` — pydantic-модели (контракт 2.0, `CONTRACT_VERSION`) и байт-в-байт копии схем `../docs/contracts`; идентичность по правилу MAJOR (S-53,
`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`,
`SCAN_EMULATOR_POLL_SECONDS`, `SCAN_EMULATOR_DEFAULT`; пара по sha256 содержимого); в compose контейнера пока нет —
`make emulator`.
- `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/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`);
`api/tests/golden_update.py` — сбор склейки реального ответа и запись эталонов в `../fixtures/ocr/expected_normalize/`
(`make golden-update`); перед PR — `make check-full`.

247
README.md
View File

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

View File

@ -1,6 +1,6 @@
"""Сквозной сценарий 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` пуст) работают в процессе
теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог.
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, пишется прямо в `Input`), пару фикстур
@ -14,7 +14,7 @@
`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`;
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → результат с `result_s3` (имя строки из OCR);
5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора;
6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_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` — объект с полями
# `label/organization/inn/units/date` (`form_name` + `confidence`) и `table_structure[{label, rows}]`. Все
# `confidence` = 100, шапка есть, даты колонок и отчёта разобраны, но дата 2025 года попадает в редакцию `fsbu4`,
# справочник которой пуст: нормализатор даёт `needs_check` (предупреждение «редакция fsbu4 не заполнена»)
# `confidence` = 100, шапка есть, даты колонок и отчёта разобраны, но форма — одна строка «БАЛАНС»
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 = "ООО «Русский бисквит»"
@ -227,7 +226,7 @@ class Roundtrip:
continue
MESSAGE_VALIDATOR.validate(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
out.append(Envelope(msg.headers[NATS_MSG_ID_HEADER], body))
return out
@ -472,17 +471,8 @@ async def roundtrip_cleanup(
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`.
"""Эталон v2 `fixtures/ocr/expected_normalize/<name>.json` с подставленным `job_id`; нет эталона — `None`.
Эталон строится на условном `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)
raw = await roundtrip.raw(job_id)
assert [entry["response"]["error"] for entry in raw["files"]] == [expected_error]
# все файлы набора ошибочные: итог — ошибка первого файла, а warnings перечисляют каждый ошибочный файл (D-5)
assert result.body["metrics"]["warnings"] == [f"{job_id}.json: INVALID_DOCUMENT: {expected_error['message']}"]
# все файлы набора ошибочные (Р-32/Р-33): итог — ошибка первого файла, а warnings перечисляют `JSON_FAILED`
# каждого ошибочного файла структурой
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, без второго файла и второго результата
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",
]
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"]
assert warnings[-1] == f"{job_id}_ofr.json: INVALID_DOCUMENT: {ofr_error['message']}"
assert f"{job_id}.json: ИНН распознан неуверенно" in warnings # в aep_3 ИНН извлечён, но confidence < 100
assert f"{job_id}.json: table:0: шапка синтезирована: колонки без дат" in warnings
assert 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)
if expected is not None: # эталон нормализатора (`make golden-update`) совпадает со сквозным результатом
assert _drop_none(payload["documents"]) == expected["documents"]
assert warnings == expected["warnings"]
assert payload["documents"] == expected["documents"]
assert warnings == expected["metrics"]["warnings"]
raw = await roundtrip.raw(job_id)
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"]
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) # ошибочных файлов в наборе нет
# ошибочных файлов в наборе нет: предупреждений файла нет, документы равны эталону
assert result.body["metrics"].get("warnings", []) == []
expected = _golden("aep_6", job_id)
if expected is not None:
assert _drop_none(payload["documents"]) == expected["documents"]
assert warnings == expected["warnings"]
assert payload["documents"] == expected["documents"]
assert result.body["metrics"].get("warnings", []) == expected["metrics"].get("warnings", [])
raw = await roundtrip.raw(job_id)
[entry] = raw["files"]
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 [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 не заполнена"]
assert (line["line_code"], line["line_name"]) == ("1600", "БАЛАНС") # имя из OCR (`line_name`)
assert [v["amount"] for v in line["values"]] == ["1234567.00", "-12345.00"] # деньги — строки numeric(18,2)
# предупреждения «редакция не заполнена» больше нет (редакции — в пакете); предупреждений файла нет
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)
[entry] = raw["files"]
assert entry["response"]["error"] == {"code": "", "message": ""}

View File

@ -69,3 +69,12 @@
`Claude-Session: …` (план) — совпадает с обоими источниками. Цена ошибки: нет.
- **R-2 (Р-36).** Задача 4 копирует из `docs/contracts/examples` только существующие файлы верхнего уровня как есть, новых
примеров контракта верхнего уровня не добавляет (решение владельца Р-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: подготовка хоста по плану 2 (копия `arm_statements`, `statements-check`, линтеры, `testpaths`)
- [x] 1.1 Задача 1: подготовка хоста по плану 2 (копия `arm_statements`, `statements-check`, линтеры, `testpaths`)
## 2. Адаптер
- [ ] 2.1 Задача 2: адаптер — таблица json ИС → `FormCandidate`
- [x] 2.1 Задача 2: адаптер — таблица json ИС → `FormCandidate`
## 3. Обход набора json
- [ ] 3.1 Задача 3: `normalize`, предупреждения файла, статус и код ошибки
- [x] 3.1 Задача 3: `normalize`, предупреждения файла, статус и код ошибки
## 4. Контракт 2.0
- [ ] 4.1 Задача 4: контракт v2.0 и переключение нормализатора
- [x] 4.1 Задача 4: контракт v2.0 и переключение нормализатора
## 5. Удаление старого
- [ ] 5.1 Задача 5: удаление старого нормализатора, справочников и `SCAN_RULES_DIR`
- [x] 5.1 Задача 5: удаление старого нормализатора, справочников и `SCAN_RULES_DIR`
## 6. Golden
- [ ] 6.1 Задача 6: golden v2 и Review Focus на реальных ответах ИС
- [x] 6.1 Задача 6: golden v2 и Review Focus на реальных ответах ИС
## 7. Завершение
- [ ] 7.1 Задача 7: интеграционные тесты, документация, `make check-full`, PR
- [x] 7.1 Задача 7: интеграционные тесты, документация, `make check-full`, PR