# План 5 — scan-gateway: адаптер OCR → `arm_statements`, контракт результата 2.0. План реализации > **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 8 задач: по > `~/.claude/CLAUDE.md` — SDD, контроллер — фоновый сабагент на модели sonnet). Модели исполнителей: задачи 0, 2, 3, 4, > 6, 7 — sonnet (готового кода в плане нет, есть сигнатуры, тесты и правила спеки); задачи 1 и 5 — haiku (проверки и > удаление); финальное ревью ветки — opus. Шаги размечены чекбоксами (`- [ ]`). **Goal:** scan-gateway разбирает набор json ИС Сканирование адаптером шага 1 (одна таблица ИС — один `FormCandidate`) и копией общего пакета `arm_statements` (шаги 2–8), пишет файл результата и конверт по контракту v2.0 со структурными предупреждениями файла (`JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`); собственный нормализатор шагов 2–8 (`normalizer/ocr/text|dates|amounts|layout|rows|header|document`), `normalizer/rules.py` и `api/rules/*.yaml` удалены; golden `fixtures/ocr/expected_normalize/*` пересобраны. **Architecture:** обмен с ИС (горячие папки, опрос, settle, склейка `raw/{job_id}.json`, очистка) не меняется. Новый нормализатор — три слоя чистых функций: `normalizer/files.py` (ошибка json набора), `normalizer/ocr/adapter.py` (таблица json → `FormCandidate`), `normalizer/ocr/answer_set.py` (обход склейки: `JSON_FAILED`, `TABLE_PARSE_ERROR`, `normalize_candidate` на каждую таблицу, `envelope_status`, выбор кода ошибки) — всё в типах пакета. Граница с контрактом — `normalizer/contract_map.py` (документ и предупреждение пакета → pydantic-модели контракта v2.0) и `OcrNormalizer.normalize` → `NormalizeOutcome`, из которого воркер, как и прежде, собирает файл результата и конверт. **Tech Stack:** Python 3.11, FastAPI, pydantic v2, PyYAML (через пакет), pytest 9.1.1, ruff 0.16.10, mypy 2.4.0, jsonschema 4.26.0; `tea` (Gitea), OpenSpec, codegraph, git worktree. **Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/` (ветка `feat/116-unified-normalization` репозитория arm-ka-docs): главное — `specs/scan-gateway/spec.md`; ещё `specs/statement-normalization/spec.md` («Шаг 1 — кандидат в форму», «Шаг 1 — шапка кандидата», «Очистка OCR-текста», «Шаг 8 — предупреждения», «Шаг 8 — статус документа и конверта»), `specs/recognition-contract/spec.md` («Сообщение результата распознавания», «Файл результата», «Версионирование контракта», «Копия контракта…», «Переход на контракт v2.0 — «сначала ядро»»), `specs/quality-checks/spec.md`, `proposal.md` («Порядок выкладки», шаг 5; «Чего ждать на реальных сканах»); контракт — `docs/contracts/*.schema.json` v2.0 и `docs/contracts/examples/`; правила — `docs/context/05-domain-rules.md` §11.1 (адаптер, ошибки набора), §11.9 (таблица предупреждений, статус). Публичный API пакета — `plans/2026-10-18-plan-1-arm-statements.md`, раздел «Публичный API пакета» (только эти имена). --- ## Место в работе и порядок | План | Что | Связь с этим планом | |---|---|---| | 0 | тест идентичности копий контракта по MAJOR (в том числе scan-gateway) | слит в scan-gateway `main` до задачи 4; тест идентичности здесь не меняется | | 1 | пакет `arm_statements` (репозиторий `arm-statements`) | API — раздел «Публичный API пакета»; правила разбора OCR (`test_parses_real_forms`, двойники, правки sg#40) — в пакете | | 2 | `sync-statements`: копия пакета в `api/arm_statements/`, `statements-check` в `make check-full` | PR плана 2 в scan-gateway слит до задачи 1 (файла плана 2 на 18.10.2026 нет — задача 1 сверяет факт, см. O-1) | | docs PR #117 | контракт v2.0, правила §11 | слит в docs `main` до задачи 4 | | 3 | arm-core: приём `2.0` (части А и Б) | **выложено на контур до слияния PR этого плана** («сначала ядро», `proposal.md` шаг 3) | | 4 | tabular-parser на 2.0 | независим (шаг 5 — в любом порядке) | PR этого плана можно открыть и пройти ревью раньше, но **слияние** — только по команде человека и только после выкладки ядра, принимающего `2.0`: первый же `line_code = null` ядру, знающему только `1.x`, даёт файлу `ERROR`. Golden в fixtures — отдельная ветка и PR; он сливается **первым**, PR scan-gateway — сразу за ним (между слияниями тест golden в `main` scan-gateway красный на машине с fixtures — окно держать минимальным). ## Разбивка | | scan-gateway | fixtures | |---|---|---| | Issue | RSHB/arm-scan-gateway: «Единая нормализация ББ/ОФР: адаптер OCR → arm_statements, контракт результата 2.0 (arm-ka-docs#116)» | своей issue нет — PR ссылается на issue scan-gateway (как fixtures PR #23 → scan-gateway#39) | | Ветка | `feat/-statements-v2` | `feat/-golden-statements-v2` | | Worktree | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg/scan-gateway` | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg/fixtures` | | Change OpenSpec | `openspec/changes/statements-v2/` (capability `image-recognition-gateway`) | — | | Слияние | вторым, после выкладки ядра | первым | В тексте плана `$WS` = `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg`, `$WT` = `$WS/scan-gateway`, `$FX` = `$WS/fixtures`. Команды `make` — из `$WT`, `pytest` — из `$WT/api`, git — `git -C <абсолютный путь>`. ## Global Constraints - Python 3.11; конфиг ruff и mypy — существующий `api/pyproject.toml` scan-gateway (строгие overrides на `src.normalizer.*`, `src.contract.*`, `src.core.worker`); новые модули `src/normalizer/**` под них попадают. - Копию `api/arm_statements/` **не править** (её раскладывает `sync-statements`, план 2; прямая правка — код 20 `statements-check`). Импорт пакета — только `from arm_statements import …` и только имена раздела «Публичный API пакета» плана 1; `import arm_statements` — только в `src/normalizer/**` и `tests/**`. - Адаптер (спека `scan-gateway`, «Адаптер json ИС шага 1», дословно): кандидат на **каждую** таблицу каждого успешного json; шапка json (`organization`, `inn`, `units`, `date`) копируется в шапку **каждой** своей таблицы как `Cell` (`text = form_name`, `confidence` ИС); `label.form_name` таблицы — заголовок в `evidence`; сетка — плотная по `row_index` и `column_index` ИС, пустая позиция — `None`; `Cell.ref = row:`; `locator = file:<имя json>;table:`; `value` у OCR-ячеек всегда `None`; `source = Source.OCR`; `segment_breaks = ()`. - `type_hint` — только функцией пакета `type_hint(okud_text, title_text, rules)` (у OCR `okud_text = None`). - Предупреждения файла: `JSON_FAILED` (`error`, `locator = file:<имя json>`, `message = "<код ИС>: <сообщение ИС>"`), `TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:`, документа у таблицы нет); `TABLE_LOST` / `TABLE_SKIPPED` выдаёт пакет. Хост создаёт предупреждения **только** через `make_warning` пакета (уровень — из словаря пакета). - Статус конверта — только `envelope_status` пакета; документов нет → `error`: все json ошибочны — код и сообщение первого ошибочного json; есть успешный json — `RECOGNITION_FAILED` с сообщением `ни одной таблицы не разобрано`. - Контракт: файл результата и конверт (включая ack `processing`) — `schema_version = "2.0"`, `result_schema_version = "2.0"`; запрос `recognition-request` остаётся `1.x` (запрос `2.0` → `SCHEMA_MISMATCH`, как сейчас). `service.statements_version` — содержимое `api/arm_statements/VERSION` — в файле результата; в конверте `service` — `{name, version}`. `metrics.warnings` конверта — те же структуры, что `metrics.warnings` файла результата. - Функции `normalizer/files.py`, `normalizer/ocr/adapter.py`, `normalizer/ocr/answer_set.py`, `normalizer/contract_map.py` — чистые: без I/O, без настроек, без логов (как `ocr/*` сейчас). - Горячие папки, settle, склейка (`gateway/merge.py`), очистка, таймауты, эмулятор — **не меняются**. - Тест идентичности контракта (`api/tests/unit/test_contract.py::test_vendored_contracts_are_identical_to_docs` и `tests/contract_identity.py`) — правило плана 0; в этом плане не меняется. - `make check` (без Docker) зелёный после каждой задачи; перед PR — `make check-full` (`make up` из `../docs/dev/`) с `statements-check`; код `10` («копия отстала») — зелёный, вписывается в описание PR. - Язык комментариев, docstring, сообщений, коммитов, issue, PR — русский; идентификаторы — английские. Docstring нового модуля называет требование спеки или § правил. - Коммиты заканчиваются строками `Co-Authored-By: Claude Opus 5.5 ` и `Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; описание PR — строкой `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. Слияние — только по команде человека. - Находки и решения — с маркерами (`T.`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`); решения этого плана — `P5-n` (раздел в конце); исполнитель задачи 0 переносит их в `design.md` change'а `statements-v2` (`## Decisions made without discussion`), контроллер — в `design.md` docs-change'а. - Если утверждение теста этого плана противоречит спеке — источник истины спека; если реальный json ведёт себя не так, как ждёт Review Focus, а адаптер построил кандидата верно, — это находка для пакета (план 1), а не повод править копию или подгонять адаптер. ## Review Focus Реальные ответы ИС из `fixtures/ocr/`, на которых сойдутся все правила сразу. Каждый пункт закреплён тестом задачи 6 (`api/tests/unit/normalizer/test_real_answers.py`, маркер `fixtures`); набор json собирается как в golden (`tests.golden_update.real_set`, имена `.json`, `_<суффикс>.json`): 1. **aep_3 (ББ + ОФР, зашумлённый скан без строки заголовка)** — колонки сетки 2, 4, 5 (ИС пропускает 3); ББ: `row:0` «НИ» (без цифр) с суммой → `missing`; `row:2` «изо :» → `detail` с `parent_ref = "row:1"`; `row:3` «| 140» → `ok` 1140; `row:12` «12 /» и `row:31` «420» → `unreadable`; `row:17` «БАЛАНС» без кода → `detail` под `row:16`; ни одна строка с содержимым не пропала, `ref` уникальны. ОФР: `row:0` «Выручка» без кода, в колонке 2 `211(1 |` → `missing`, `amount = null`, `string_value = "211(1 |"`, `is_recognized = false`; `row:1` 2120 `(7744536}` → `amount = "-7744536"`, `is_recognized = false`; `row:8` `{38378)` → `amount = null`. Оба документа `needs_check`. 2. **aep_6 (чистый скан, повтор шапки)** — строка `row:6` с кодом `"44 30\nI |"` → одна строка `unreadable` (`line_code = null`, `line_code_text` сырой, без `#1`/`#2`, значения пустые); повторы 1220 и 1520 в ББ и 2410 в ОФР — все строки `ok`; ячейка кода `}` (ОФР `row:4`) — пустая, строка `detail`; ББ `needs_check`. 3. **«БО 1 кв 2025» и «Бухгалтерская отчетность 1 кв.25»** — склейка `"1500\n1700"` (`row:73` и `row:93`) → строки `row:#1` 1500 и `row:#2` 1700 с суммами 43274 / 198622 и 917775 / 2371333 в колонке 2, `ROW_SPLIT` (`info`); повторы 1220 и 1520 — по две строки `ok`; у «БО» `date.form_name` — название организации (дата отчёта не разобрана, период — по колонкам); ожидание по Р-23 — все четыре документа `done`, конверт `done` (P5-17). 4. **Набор с ошибочным json** — aep_3 + `aep_3_ofr` (`INVALID_DOCUMENT`): документы из `aep_3.json`, `JSON_FAILED` (`error`, `locator = file:_ofr.json`, `message = "INVALID_DOCUMENT: Не удалось определить тип документа."`), конверт `needs_check`; aep_5 (единственный json с ошибкой) → конверт `error` `INVALID_DOCUMENT` с сообщением ИС, документов нет. 5. **Форма 4 без таблиц (aep_8_form4)** — json с `INVALID_DOCUMENT` и пустым `table_structure`: конверт `error` `INVALID_DOCUMENT`, документов нет, исключений нет. --- ## Структура файлов ``` scan-gateway/ ├── openspec/changes/statements-v2/ # proposal.md, tasks.md, design.md, specs/image-recognition-gateway/spec.md └── api/ ├── arm_statements/ # копия пакета (план 2) — НЕ ПРАВИТЬ ├── contracts/ # копии схем и примеров v2.0 (запрос — v1.0) ├── rules/ # УДАЛЯЕТСЯ целиком (задача 5) ├── src/ │ ├── contract/{common,request,message,result}.py # модели v2.0 (задача 4) │ ├── core/worker.py # build_result(), метрики-структуры (задача 4) │ ├── app.py # OcrNormalizer(default_rules()) (задача 4) │ └── normalizer/ │ ├── __init__.py # Normalizer, NormalizeOutcome v2, NullNormalizer │ ├── files.py # FileError, file_error() — ошибки json набора (задача 3) │ ├── contract_map.py # пакет → модели контракта (задача 4) │ ├── rules.py # УДАЛЯЕТСЯ (задача 5) │ └── ocr/ │ ├── __init__.py # OcrNormalizer (задача 4) │ ├── adapter.py # header_cells(), table_candidate() (задача 2) │ ├── answer_set.py # normalize_answer_set() (задача 3) │ └── text|dates|amounts|layout|rows|header|document.py # УДАЛЯЮТСЯ (задачи 4–5) └── tests/ ├── golden_update.py # формат golden v2 (задача 6) └── unit/normalizer/ ├── test_adapter.py # задача 2 ├── test_answer_set.py # задача 3 (в том числе сквозной тест дат) ├── test_contract_map.py # задача 4 ├── test_ocr_normalizer.py # переписан (задача 4) ├── test_golden.py # задача 6 ├── test_real_answers.py # Review Focus (задача 6) └── test_text|dates|amounts|layout|rows|header|document|rules.py # УДАЛЯЮТСЯ (задачи 4–5) ``` --- ### Task 0: Workspace, issue, ветки, change OpenSpec **Files:** - Create: `$WT/openspec/changes/statements-v2/{proposal.md,tasks.md,design.md,specs/image-recognition-gateway/spec.md}` **Interfaces:** - Consumes: — - Produces: worktree `$WT` на `feat/-statements-v2`, worktree `$FX` на `feat/-golden-statements-v2`, отсоединённый `$WS/docs`, симлинки; change `statements-v2`, который проходит `openspec validate --strict`. - [ ] **Step 1: Предусловия.** Проверить и при невыполнении остановиться с отчётом контроллеру (не обходить): ```bash WS0=/Users/andrey.epifancev/projects/awp-ca git -C $WS0/docs fetch origin && git -C $WS0/docs show origin/main:contracts/recognition-result.schema.json | grep -q '"arm-ka/recognition-result/v2"' # docs PR #117 слит git -C $WS0/scan-gateway fetch origin && git -C $WS0/scan-gateway show origin/main:api/tests/contract_identity.py >/dev/null # план 0 слит git -C $WS0/scan-gateway show origin/main:api/arm_statements/VERSION # план 2 слит (задача 1) test -d $WS0/arm-statements/.git # источник для statements-check ``` - [ ] **Step 2: Зеркальный workspace и worktree** (порядок `~/.claude/CLAUDE.md`: worktree на временной ветке → issue → переименование): ```bash WS=/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg mkdir -p $WS git -C $WS0/scan-gateway worktree add -b tmp/statements-v2 $WS/scan-gateway origin/main git -C $WS0/docs worktree add --detach $WS/docs origin/main git -C $WS0/fixtures fetch origin && git -C $WS0/fixtures worktree add -b tmp/golden-statements-v2 $WS/fixtures origin/main for l in reference-old-arm arm-statements CLAUDE.md .claude; do ln -s $WS0/$l $WS/$l; done tea issues create --login home --repo RSHB/arm-scan-gateway \ --title "Единая нормализация ББ/ОФР: адаптер OCR → arm_statements, контракт результата 2.0 (arm-ka-docs#116)" \ --description "<цель из Goal; ссылки: RSHB/arm-ka-docs#116, план openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md>" git -C $WS/scan-gateway branch -m feat/-statements-v2 git -C $WS/fixtures branch -m feat/-golden-statements-v2 cd $WS/scan-gateway && codegraph init && make venv ``` Все вызовы `codegraph_explore` дальше — с `projectPath` = `$WT`. - [ ] **Step 3: Change OpenSpec `statements-v2`.** `proposal.md` — зачем (ссылка на docs-change и спеку `scan-gateway`), что меняется (по Goal), порядок выкладки (шаг 5, слияние после ядра; fixtures первым). `specs/image-recognition-gateway/spec.md` — дельта к `openspec/specs/image-recognition-gateway/spec.md`: - `MODIFIED` «Результат по контракту» — v2.0: `schema_version` `2.0` у конверта и файла, структурные `metrics.warnings`, `JSON_FAILED`, `service.statements_version`; сценарии «Ошибка одного из документов» и «Все документы ошибочны» — по спеке `scan-gateway` «Ошибки набора json»; - `MODIFIED` «Нормализация ответа ИС в `statementDocument`» — заменяется требованием адаптера (текст требования «Адаптер json ИС шага 1» docs-спеки `scan-gateway` и сценарий «Один json — ББ и ОФР»), шаги 2–8 — копия `arm_statements`; - `MODIFIED` «Статус результата нормализации» — `envelope_status`, `TABLE_PARSE_ERROR`, `RECOGNITION_FAILED`; - `REMOVED` «Справочники нормализации — данные с редакциями форм» (Reason: справочник строк удалён, двойники и заголовки — словари пакета; Migration: `api/rules/` и `SCAN_RULES_DIR` удалены). `tasks.md` — задачи 1–7 этого плана чекбоксами. `design.md` — ссылка на docs `design.md` и раздел `## Decisions made without discussion` с P5-1…P5-19 (дата 18.10.2026, решение, обоснование, цена — из раздела плана). - [ ] **Step 4: Validate** Run: `cd $WT && openspec validate statements-v2 --strict` Expected: `Change 'statements-v2' is valid` - [ ] **Step 5: Commit** ```bash git -C $WT add openspec/changes/statements-v2 git -C $WT commit -m "openspec: change statements-v2 — адаптер OCR → arm_statements, контракт 2.0 (#, arm-ka-docs#116)" -m "Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" ``` --- ### Task 1: Подготовка хоста по плану 2 (сверить с планом 2) **Files:** — (только проверка; правок нет, если план 2 выполнен) **Interfaces:** - Consumes: результат плана 2 в `main` scan-gateway. - Produces: подтверждённые факты для задач 2–7: `import arm_statements` работает из `api/`, `make check` гоняет тесты и `mypy` копии, `make statements-check` есть и входит в `make check-full`. > Файла плана 2 на момент написания нет. Ниже — то, что спека требует от хоста (`statement-normalization`, > «`sync-statements` — копии пакета в потребителях»; `quality-checks`). Если план 2 сделал иначе (имена целей, пути) — > исполнитель сверяет с планом 2 и отмечает расхождение `O-n` в отчёте; если копии в `main` нет — **стоп**, задача > заблокирована, отчёт контроллеру (копию в этой ветке не раскладывать). - [ ] **Step 1: Копия и маркер версии** Run: `cd $WT/api && test -f arm_statements/VERSION && test -d arm_statements/tests && test -f arm_statements/rules/ocr_lookalikes.yaml && .venv/bin/python -c "import arm_statements as a; print(a.statements_version(), a.default_rules() is not None)"` Expected: полный хэш коммита и `True`. - [ ] **Step 2: `make check` охватывает копию** Run: `cd $WT && grep -n "mypy src arm_statements" Makefile && grep -n "arm_statements" api/pytest.ini && make check` Expected: обе строки найдены; `make check` зелёный, в выводе pytest есть `arm_statements/tests/`. - [ ] **Step 3: `statements-check` и `check-full`** Run: `cd $WT && grep -n "statements-check" Makefile && make statements-check; echo "код $?"` Expected: цель есть и вызывается из `check-full`; код `0` (или `10` — запомнить для описания PR). - [ ] **Step 4: conftest хоста.** `grep -n "autouse" $WT/api/tests/conftest.py $WT/api/tests/unit/conftest.py` — каждая `autouse`-фикстура ограничена каталогом `tests/` хоста (спека: иначе она задевает тесты копии). Нарушение — находка `O-n` плану 2, не правка в этой ветке. --- ### Task 2: Адаптер — таблица json ИС → `FormCandidate` **Files:** - Create: `$WT/api/src/normalizer/ocr/adapter.py` - Test: `$WT/api/tests/unit/normalizer/test_adapter.py` **Interfaces:** - Consumes: из пакета — `Cell`, `HeaderCells`, `Evidence`, `FormCandidate`, `Source`, `Rules`, `type_hint`. - Produces (`src/normalizer/ocr/adapter.py`): - `class TableStructureError(ValueError)` — нарушена структура таблицы ИС (P5-2) - `confidence_of(raw: object) -> float` — число (не `bool`) → в `[0, 100]`; NaN, бесконечность, не число, отсутствие → `0.0` (P5-3) - `header_cells(data_ocr: Mapping[str, object]) -> HeaderCells` — `organization` → `organization_name`, `inn` → `inn`, `units` → `units`, `date` → `report_date`; каждое — `Cell(form_name, None, confidence_of(confidence), "data_ocr.<поле ИС>")`; поля нет, не объект, `form_name` не строка или пустой после `strip()` → `None`; остальные шесть полей — `None` (P5-5) - `table_candidate(file_name: str, index: int, table: object, header: HeaderCells, rules: Rules) -> FormCandidate` — по Global Constraints; строки сетки — строки ИС по возрастанию `row_index`, по одной строке сетки на строку ИС, пропуски `row_index` не заполняются; ширина каждой строки сетки — `max(column_index) + 1` по таблице; ячейка — `Cell(text, None, confidence_of(...), f"row:{row_index}")`, `text` — `value` как есть (`None` → `""`, не строка → `str(value)`), без `strip`; позиция без ячейки ИС — `None`; `evidence = Evidence(okud=None, title=)`, `title.ref = f"table:{index}.label"`; `type_hint = type_hint(None, , rules)`. Бросает `TableStructureError`, если: `table` не объект; `rows` не список; строка не объект или без целого `row_index` (`bool` — не целое); `row_index` повторяется в таблице; `cells` не список; ячейка не объект, `column_index` не целое или `< 0`; два `column_index` в одной строке. - [ ] **Step 1: Write the failing test** ```python import math import pytest from arm_statements import Cell, DocumentType, Source, default_rules from src.normalizer.ocr.adapter import TableStructureError, confidence_of, header_cells, table_candidate RULES = default_rules() DATA_OCR = { "label": {"form_name": "Бухгалтерская отчетность", "confidence": 100.0}, "organization": {"form_name": "ООО «Пример»", "confidence": 100.0}, "inn": {"form_name": "3|1 21 2|", "confidence": 61.538464}, "units": {"form_name": "тыс. руб.", "confidence": 100.0}, "date": {"form_name": "", "confidence": 0.0}, } def row(index: object, *cells: tuple[object, object, object]) -> dict[str, object]: return {"row_index": index, "cells": [{"column_index": c, "value": v, "confidence": p} for c, v, p in cells]} def table(label: str, *rows: dict[str, object]) -> dict[str, object]: return {"label": {"form_name": label, "confidence": 100.0}, "rows": list(rows)} def test_header_is_copied_from_json() -> None: h = header_cells(DATA_OCR) assert h.organization_name == Cell("ООО «Пример»", None, 100.0, "data_ocr.organization") assert h.inn == Cell("3|1 21 2|", None, 61.538464, "data_ocr.inn") assert h.units == Cell("тыс. руб.", None, 100.0, "data_ocr.units") assert (h.report_date, h.okud, h.okpo, h.okopf, h.okfs, h.okei, h.address) == (None,) * 7 def test_grid_by_row_and_column_index() -> None: t = table( "Бухгалтерский баланс", row(9, (0, "Итого", 100.0)), row(8, (1, "[190 :", 25.0), (0, "Прочие", 100.0), (4, "", 0.0), (5, None, 100.0)), ) cand = table_candidate("j.json", 0, t, header_cells(DATA_OCR), RULES) assert (cand.source, cand.locator, cand.type_hint) == (Source.OCR, "file:j.json;table:0", DocumentType.BALANCE) assert cand.segment_breaks == () and cand.warnings == () assert cand.evidence.okud is None assert cand.evidence.title == Cell("Бухгалтерский баланс", None, 100.0, "table:0.label") assert cand.rows == ( (Cell("Прочие", None, 100.0, "row:8"), Cell("[190 :", None, 25.0, "row:8"), None, None, Cell("", None, 0.0, "row:8"), Cell("", None, 100.0, "row:8")), (Cell("Итого", None, 100.0, "row:9"), None, None, None, None, None), ) assert cand.header == header_cells(DATA_OCR) def test_multiline_text_is_kept() -> None: t = table("Бухгалтерский баланс", row(93, (1, "1500\n1700", 78.0), (2, "917 775\n2 371 333", 100.0))) cand = table_candidate("j.json", 0, t, header_cells(DATA_OCR), RULES) assert cand.rows[0][1] == Cell("1500\n1700", None, 78.0, "row:93") def test_income_label_and_unknown_label() -> None: income = table_candidate("j.json", 1, table("Отчет о финансовых результатах"), header_cells(DATA_OCR), RULES) assert income.type_hint is DocumentType.INCOME and income.rows == () blank = table_candidate("j.json", 2, {"label": {"form_name": " ", "confidence": 0.0}, "rows": []}, header_cells(DATA_OCR), RULES) assert (blank.evidence.title, blank.type_hint) == (None, None) @pytest.mark.parametrize(("raw", "expected"), [(25, 25.0), (150.0, 100.0), (-3, 0.0), (math.nan, 0.0), (math.inf, 0.0), ("100", 0.0), (True, 0.0), (None, 0.0)]) def test_confidence_of(raw: object, expected: float) -> None: assert confidence_of(raw) == expected @pytest.mark.parametrize( "broken", [ "не объект", {"label": {}, "rows": "x"}, table("ББ", {"cells": []}), table("ББ", row(True, (0, "a", 100.0))), table("ББ", row(1, (0, "a", 100.0)), row(1, (0, "b", 100.0))), table("ББ", {"row_index": 0, "cells": "x"}), table("ББ", row(0, ("1", "a", 100.0))), table("ББ", row(0, (-1, "a", 100.0))), table("ББ", row(0, (1, "a", 100.0), (1, "b", 100.0))), ], ) def test_broken_structure_raises(broken: object) -> None: with pytest.raises(TableStructureError): table_candidate("j.json", 0, broken, header_cells(DATA_OCR), RULES) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd $WT/api && .venv/bin/pytest tests/unit/normalizer/test_adapter.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'src.normalizer.ocr.adapter'` - [ ] **Step 3: Implement `adapter.py`** по Interfaces. Docstring модуля — ссылка на спеку `scan-gateway` «Адаптер json ИС шага 1» и §11.1. Никакой очистки текста — её делает пакет. - [ ] **Step 4: Run tests** Run: `cd $WT && make check` Expected: зелёный. - [ ] **Step 5: Commit** ```bash git -C $WT add api/src/normalizer/ocr/adapter.py api/tests/unit/normalizer/test_adapter.py git -C $WT commit -m "feat(normalizer): адаптер шага 1 — таблица json ИС → FormCandidate (#)" -m "Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" ``` --- ### Task 3: Обход набора json — `normalize`, предупреждения файла, статус и код ошибки **Files:** - Create: `$WT/api/src/normalizer/files.py`, `$WT/api/src/normalizer/ocr/answer_set.py` - Modify: `$WT/api/src/normalizer/__init__.py` (перенос `_FileError`, `_file_error`, `_response_error`, `_invalid_file` и констант сообщений в `files.py`; `NullNormalizer` пока берёт их оттуда, строковые предупреждения — как были) - Test: `$WT/api/tests/unit/normalizer/test_answer_set.py`; вспомогательные `ocr_table`, `response`, `merged` и константы `OK`, `BAD`, `HEAD`, `BALANCE`, `INCOME` из теста ниже вынести в `$WT/api/tests/unit/normalizer/ocr_builders.py` (их импортируют тесты задач 4 и 6) **Interfaces:** - Consumes: задача 2 (`header_cells`, `table_candidate`, `TableStructureError`); из пакета — `normalize_candidate`, `envelope_status`, `make_warning`, `EnvelopeStatus`, `StatementDocument`, `StatementWarning`, `Rules`; из `src.contract` — `ErrorCode`. - Produces (`src/normalizer/files.py`): - константы `NOT_OBJECT_MESSAGE`, `NOT_JSON_MESSAGE`, `NOT_MERGED_MESSAGE`, `NO_FILES_MESSAGE`, `BAD_ENTRY_MESSAGE` — тексты как сейчас в `src/normalizer/__init__.py` - `@dataclass(frozen=True) FileError(name: str, code: ErrorCode, source_code: str, message: str, summary: str)` с методом `to_warning(rules: Rules) -> StatementWarning` = `make_warning("JSON_FAILED", f"{source_code}: {message}", rules, locator=f"file:{name}")`; элемент без имени — `name = f"files[{index}]"` - `file_error(index: int, entry: object) -> FileError | None` — правило успеха без изменений (`error.code == ""`, №57) - Produces (`src/normalizer/ocr/answer_set.py`): - `NO_TABLES_MESSAGE = "ни одной таблицы не разобрано"` - `@dataclass(frozen=True) TableDocument(document: StatementDocument, form_name: str | None)` — `form_name` — `label.form_name` таблицы как в источнике, пустой → `None` - `@dataclass(frozen=True) SetError(code: ErrorCode, message: str)` - `@dataclass(frozen=True) AnswerSetResult(status: EnvelopeStatus, documents: tuple[TableDocument, ...], file_warnings: tuple[StatementWarning, ...], error: SetError | None)` — `error is not None ⇔ status is ERROR` - `normalize_answer_set(raw: object, rules: Rules) -> AnswerSetResult` Порядок `normalize_answer_set` (спека `scan-gateway` «Ошибки набора json», `statement-normalization` «Шаг 8 — статус документа и конверта»): 1. `raw` не `{files: list}` → `ERROR` `INVALID_FORMAT` `NOT_MERGED_MESSAGE`; `files == []` → `ERROR` `INVALID_FORMAT` `NO_FILES_MESSAGE` (как сейчас). 2. Файлы по порядку склейки. `file_error(...)` не `None` → `to_warning(rules)` в `file_warnings` (P5-8: и когда все json ошибочны). Успешный: `data_ocr` не объект → `JSON_FAILED` «ответ ИС без data_ocr»; `table_structure` не список → `JSON_FAILED` «ответ ИС без table_structure»; пустой список → `JSON_FAILED` «в ответе ИС нет таблиц» (P5-7); `locator` у всех трёх — `file:<имя>`. 3. Каждая таблица `i`: `table_candidate` и `normalize_candidate` в одном `try`; любое `Exception` → `make_warning("TABLE_PARSE_ERROR", f"BACKEND_PARSING_ERROR: {type(exc).__name__}: {exc}", rules, locator=f"file:{name};table:{i}")`, текст исключения обрезается до 300 символов (P5-9); иначе документ — в `documents` (с `form_name`), `file_warnings` результата — в `file_warnings`. Порядок `file_warnings` — порядок файлов, внутри файла — порядок таблиц. 4. `status = envelope_status([d.document.status for d in documents], file_warnings)`; `ERROR` → нет успешного json — `SetError(<код первого ошибочного>, <его summary>)`, иначе `SetError(RECOGNITION_FAILED, NO_TABLES_MESSAGE)`. - [ ] **Step 1: Write the failing test** ```python from datetime import date from typing import Any import pytest from arm_statements import DocStatus, DocumentType, EnvelopeStatus, PeriodKind, Severity, default_rules import src.normalizer.ocr.answer_set as answer_set_module from src.contract import ErrorCode from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE, normalize_answer_set RULES = default_rules() OK = {"code": "", "message": ""} BAD = {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."} HEAD = { "organization": {"form_name": "ООО «Пример»", "confidence": 100.0}, "inn": {"form_name": "7700000000", "confidence": 100.0}, "units": {"form_name": "тыс. руб.", "confidence": 100.0}, "date": {"form_name": "на 31 декабря 2024 г.", "confidence": 100.0}, } BALANCE = [("Наименование показателя", "Код", "На 31 декабря 2024 г."), ("Запасы", "1210", "60"), ("Дебиторы", "1230", "40"), ("Итого II", "1200", "100"), ("НМА", "1110", "50"), ("Итого I", "1100", "50"), ("БАЛАНС", "1600", "150"), ("Капитал", "1310", "150"), ("Итого III", "1300", "150"), ("БАЛАНС", "1700", "150")] INCOME = [("Наименование показателя", "Код", "За 2024 г."), ("Выручка", "2110", "100"), ("Себестоимость", "2120", "(60)"), ("Валовая прибыль", "2100", "40"), ("Прибыль от продаж", "2200", "40")] def ocr_table(label: str, rows: list[tuple[str, ...]]) -> dict[str, Any]: return {"label": {"form_name": label, "confidence": 100.0}, "rows": [ {"row_index": i, "cells": [{"column_index": c, "value": v, "confidence": 100.0} for c, v in enumerate(r)]} for i, r in enumerate(rows)]} def response(*tables: dict[str, Any], error: dict[str, str] = OK, head: dict[str, Any] = HEAD) -> dict[str, Any]: return {"file_uid": "x", "error": error, "data_ocr": {**head, "table_structure": list(tables)}} def merged(*files: tuple[str, Any]) -> dict[str, Any]: return {"job_id": "j", "files": [{"name": n, "response": r, "parse_error": None} for n, r in files]} def test_one_json_two_forms_copy_header() -> None: res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME)))), RULES) assert [(d.document.locator, d.document.document_type, d.form_name) for d in res.documents] == [ ("file:j.json;table:0", DocumentType.BALANCE, "Бухгалтерский баланс"), ("file:j.json;table:1", DocumentType.INCOME, "Отчет о финансовых результатах")] assert {d.document.header.inn.value for d in res.documents} == {"7700000000"} assert res.documents[0].document.status is DocStatus.DONE and res.error is None def test_lost_ofr_json_is_file_error() -> None: res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE))), ("j_ofr.json", response(error=BAD))), RULES) assert res.status is EnvelopeStatus.NEEDS_CHECK and len(res.documents) == 1 assert [(w.code, w.severity, w.locator, w.message) for w in res.file_warnings] == [ ("JSON_FAILED", Severity.ERROR, "file:j_ofr.json", "INVALID_DOCUMENT: Не удалось определить тип документа.")] def test_table_exception_is_file_warning(monkeypatch: pytest.MonkeyPatch) -> None: real = answer_set_module.normalize_candidate def flaky(candidate: Any, rules: Any = None) -> Any: if candidate.locator.endswith("table:1"): raise RuntimeError("сломалось") return real(candidate, rules) monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky) res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME)))), RULES) assert [d.document.document_type for d in res.documents] == [DocumentType.BALANCE] [w] = res.file_warnings assert (w.code, w.severity, w.locator) == ("TABLE_PARSE_ERROR", Severity.ERROR, "file:j.json;table:1") assert w.message == "BACKEND_PARSING_ERROR: RuntimeError: сломалось" assert res.status is EnvelopeStatus.NEEDS_CHECK def test_broken_table_structure_is_table_parse_error() -> None: res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), {"label": {}, "rows": "x"}))), RULES) assert [w.code for w in res.file_warnings] == ["TABLE_PARSE_ERROR"] and len(res.documents) == 1 def test_text_only_table_skipped_and_no_documents() -> None: res = normalize_answer_set(merged(("j.json", response(ocr_table("", [("Руководитель", "Иванов")])))), RULES) assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [ ("TABLE_SKIPPED", Severity.INFO, "file:j.json;table:0")] assert res.status is EnvelopeStatus.ERROR assert (res.error.code, res.error.message) == (ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE) def test_lost_table_with_numbers() -> None: res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("", [("Итого", "", "15 000")])))), RULES) assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [ ("TABLE_LOST", Severity.ERROR, "file:j.json;table:1")] assert res.status is EnvelopeStatus.NEEDS_CHECK def test_all_json_failed_first_code_and_all_listed() -> None: res = normalize_answer_set(merged(("j.json", response(error=BAD)), ("j_ofr.json", "не объект")), RULES) assert res.status is EnvelopeStatus.ERROR and res.documents == () assert (res.error.code, res.error.message) == (ErrorCode.INVALID_DOCUMENT, BAD["message"]) assert [(w.code, w.locator) for w in res.file_warnings] == [("JSON_FAILED", "file:j.json"), ("JSON_FAILED", "file:j_ofr.json")] @pytest.mark.parametrize(("data_ocr", "message"), [(None, "ответ ИС без data_ocr"), ({**HEAD, "table_structure": None}, "ответ ИС без table_structure"), ({**HEAD, "table_structure": []}, "в ответе ИС нет таблиц")]) def test_successful_json_without_tables(data_ocr: object, message: str) -> None: res = normalize_answer_set(merged(("j.json", {"error": OK, "data_ocr": data_ocr})), RULES) assert [(w.code, w.severity, w.message) for w in res.file_warnings] == [("JSON_FAILED", Severity.ERROR, message)] assert res.error is not None and res.error.code is ErrorCode.RECOGNITION_FAILED @pytest.mark.parametrize("raw", [None, {"files": "x"}, {"job_id": "j"}]) def test_not_merged(raw: object) -> None: res = normalize_answer_set(raw, RULES) assert res.status is EnvelopeStatus.ERROR and res.error is not None assert res.error.code is ErrorCode.INVALID_FORMAT # Сквозной тест дат шлюза: строки OCR из test_parses_real_forms (правило — в пакете, план 1, задача 5) @pytest.mark.parametrize(("label", "expected"), [ ("На 30 июня\n2025 г", date(2025, 6, 30)), ("За январь - июнь\n2025 г.", date(2025, 6, 30)), ("30 сентября 2025 r.", date(2025, 9, 30)), ("3l.12.2О24", date(2024, 12, 31)), ("9 мес. 2025", date(2025, 9, 30)), ("30.09.2025 0:00:00", date(2025, 9, 30)), ]) def test_ocr_column_dates_end_to_end(label: str, expected: date) -> None: rows = [("Наименование показателя", "Код", label), *BALANCE[1:]] head = {**HEAD, "date": {"form_name": "2|0|2|4|", "confidence": 75.0}} res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", rows), head=head))), RULES) doc = res.documents[0].document assert [(c.period_kind, c.period_date) for c in doc.columns] == [(PeriodKind.ABSOLUTE, expected)] assert doc.header.report_date.value == date(2024, 12, 31) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd $WT/api && .venv/bin/pytest tests/unit/normalizer/test_answer_set.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'src.normalizer.ocr.answer_set'` - [ ] **Step 3: Implement `files.py` и `answer_set.py`** по Interfaces и порядку выше; `src/normalizer/__init__.py` импортирует перенесённое из `files.py` (поведение `NullNormalizer` и строки `warnings` пока прежние — меняются в задаче 4). Старый `OcrNormalizer` не трогать. - [ ] **Step 4: Run tests** Run: `cd $WT && make check` Expected: зелёный (в том числе `tests/unit/test_normalizer.py` и `test_worker.py` без изменений). - [ ] **Step 5: Commit** ```bash git -C $WT add api/src/normalizer api/tests/unit/normalizer/test_answer_set.py git -C $WT commit -m "feat(normalizer): набор json ИС через arm_statements — JSON_FAILED, TABLE_PARSE_ERROR, статус конверта (#)" -m "Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" ``` --- ### Task 4: Контракт v2.0 и переключение нормализатора Одна задача: модели контракта v1 и старый `ocr/document.py` не компилируются вместе с моделями v2, а `make check` должен оставаться зелёным. **Files:** - Copy: `docs/contracts/*.schema.json` без `*.v.schema.json` → `$WT/api/contracts/`; `docs/contracts/examples/*.json` (только верхний уровень) → `$WT/api/contracts/examples/` (источник — `$WS/docs`, docs `main` после #117) - Modify: `$WT/api/src/contract/{common,request,message,result,__init__}.py` - Create: `$WT/api/src/normalizer/contract_map.py` - Modify: `$WT/api/src/normalizer/__init__.py`, `$WT/api/src/normalizer/ocr/__init__.py`, `$WT/api/src/core/worker.py`, `$WT/api/src/app.py` - Delete: `$WT/api/src/normalizer/ocr/document.py`, `$WT/api/tests/unit/normalizer/test_document.py` - Test: `$WT/api/tests/unit/test_contract.py`, `$WT/api/tests/unit/normalizer/test_contract_map.py`, `$WT/api/tests/unit/normalizer/test_ocr_normalizer.py` (переписать), `$WT/api/tests/unit/test_normalizer.py`, `$WT/api/tests/unit/test_worker.py`, `$WT/api/tests/worker_helpers.py`, `$WT/api/tests/unit/test_app.py` - Не трогать: `test_contract.py::test_vendored_contracts_are_identical_to_docs`, `tests/contract_identity.py`, `tests/unit/test_contract_identity.py` (план 0) **Interfaces:** - Consumes: задача 3 (`normalize_answer_set`, `AnswerSetResult`, `TableDocument`, `FileError.to_warning`); из пакета — `to_jsonable`, `statements_version`, `default_rules`, `Rules`, `StatementDocument`, `StatementWarning`. - Produces (`src/contract/common.py`): - `CONTRACT_VERSION = "2.0"` (файл результата и конверт, включая ack); `SCHEMA_VERSION_PATTERN = r"^2\.\d+$"`; `REQUEST_SCHEMA_VERSION_PATTERN = r"^1\.\d+$"`, `RequestSchemaVersion`; `SUPPORTED_MAJOR = 1` — MAJOR **запроса** (`is_supported_major` — по шаблону запроса); `request.py` — `schema_version: RequestSchemaVersion` - `class Severity(StrEnum)`: `ERROR`, `INFO`; `HeaderFieldName = Literal["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"]` - `DocumentWarning(ContractModel)`: `code` (шаблон `^[A-Z][A-Z0-9_]*$`), `severity: Severity`, `message: str`, `line_refs: OptionalNotNull[list[str]]`, `column_index: OptionalNotNull[IntLike]`, `field: OptionalNotNull[HeaderFieldName]`; `FileWarning(DocumentWarning)` + `locator: OptionalNotNull[str]` - `Metrics.warnings: OptionalNotNull[list[FileWarning]]`; `ServiceInfo.statements_version: OptionalNotNull[NonEmptyStr]` - Produces (`src/contract/result.py`) — модели v2.0 буквально по `recognition-result.schema.json` v2.0: `OsvDocument` + `status`, `warnings` (и прочие поля v2.0 ОСВ — по схеме); `StatementHeader` (все поля схемы, включая `form_name`, `<поле>_text`, `<поле>_confidence`, `needs_review_fields`); `StatementColumn(column_index, label, label_confidence, period_date, period_kind)`; `StatementValue(column_index, amount, string_value, text, confidence, is_recognized, needs_review)`; `StatementLine` (все поля схемы; валидатор: `line_status == "ok"` ⇔ `line_code` не `None`, `detail` ⇒ `parent_ref` строка); `FailedRelation(column_index, relation, codes, expected: Money, actual: Money)`; `StatementChecks(relations_failed, soft_relations_failed)`; `StatementDocument(document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"], form_variant: Literal["full", "simplified"] | None` (обязательно), `locator, header, columns, lines, status, warnings, checks: OptionalNotNull[StatementChecks])`; `RecognitionResult` — валидатор «есть `StatementDocument` ⇒ `service.statements_version` задан». - Produces (`src/normalizer/contract_map.py`): - `document_to_contract(document: arm_statements.StatementDocument, *, form_name: str | None) -> StatementDocument` — через `to_jsonable`: шапка уплощается (`<поле>` = `value`, `<поле>_text` = `text`, `<поле>_confidence` = `confidence`, `needs_review_fields`, `form_name`); у `FailedRelation` отбрасывается `column_key`; у предупреждений документа отбрасываются `locator` и ключи со значением `None` или `[]` (P5-15); `checks` нет ⇒ ключа нет - `file_warning_to_contract(warning: arm_statements.StatementWarning) -> FileWarning` — то же, `locator` остаётся - Produces (`src/normalizer/__init__.py`): - `NormalizeOutcome`: `warnings: list[FileWarning]`, `documents: list[StatementDocument]` (v2.0), новое поле `statements_version: str | None = None`; инвариант дополнен: `documents` непусты ⇒ `statements_version` задан - `NullNormalizer` — `warnings` = `JSON_FAILED` (`file_error(...).to_warning(default_rules())` → `file_warning_to_contract`); итог прежний (`NOT_IMPLEMENTED` / код первого) - Produces (`src/normalizer/ocr/__init__.py`): - `OcrNormalizer(rules: Rules | None = None, *, version: str | None = None)` — `version is None` → `statements_version()`; всё ещё `None` → `ValueError("копия arm_statements без VERSION: разложите её sync-statements")` (P5-11); свойство `statements_version: str` - `OcrNormalizer.normalize(raw: object) -> NormalizeOutcome` — `normalize_answer_set` → статус (`EnvelopeStatus` → `ResultStatus` по значению), документы `document_to_contract`, `warnings` `file_warning_to_contract`, `metrics = Metrics(rows=<сумма len(lines)>)` при документах (P5-18), `error` — из `SetError`, `statements_version` - Produces (`src/core/worker.py`): - `build_result(outcome: NormalizeOutcome, *, job_id: uuid.UUID, file_id: uuid.UUID, service: ServiceInfo, raw_s3_key: str, metrics: Metrics | None) -> RecognitionResult` — то, что сейчас собирается внутри `_accept_answer`; `schema_version = CONTRACT_VERSION`, `service` с `statements_version` исхода (P5-11) - `_outcome_metrics` — структуры `FileWarning`: `message` через `clean_text(…, MAX_WARNING_CHARS)`; больше `MAX_WARNINGS` → берутся все `error` по порядку, затем `info` по порядку до `MAX_WARNINGS`, итоговый порядок — исходный; строки-сводки «ещё предупреждений» нет (P5-10); тот же список — в конверте и в файле результата - `src/app.py`: `OcrNormalizer(default_rules())` вместо `OcrNormalizer(load_rules(settings.SCAN_RULES_DIR))` - [ ] **Step 1: Скопировать контракт и написать падающие тесты.** Копирование: ```bash cp $WS/docs/contracts/recognition-request.schema.json $WS/docs/contracts/recognition-result.schema.json \ $WS/docs/contracts/recognition-result-message.schema.json $WT/api/contracts/ cp $WS/docs/contracts/examples/*.json $WT/api/contracts/examples/ ``` Тесты (`tests/unit/normalizer/test_contract_map.py` — новые; в `test_contract.py` — добавить к существующему набору; валидатор схемы — `VALIDATORS` из `test_contract.py` или такой же реестр; `merged`, `response`, `ocr_table`, `BALANCE`, `INCOME`, `BAD` — из `ocr_builders.py`; `SERVICE = ServiceInfo(name="scan-gateway", version="test")`; `ocr_module` — `import src.normalizer.ocr as ocr_module`): ```python def test_mapped_document_passes_schema_v2() -> None: outcome = OcrNormalizer(default_rules(), version="abc1234").normalize(merged(("j.json", response( ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME))))) result = build_result(outcome, job_id=uuid.uuid4(), file_id=uuid.uuid4(), service=SERVICE, raw_s3_key="raw/x.json", metrics=None) payload = result.to_wire() VALIDATORS["result"].validate(payload) assert payload["schema_version"] == "2.0" and payload["service"]["statements_version"] == "abc1234" head = payload["documents"][0]["header"] assert (head["form_name"], head["inn"], head["inn_text"], head["inn_confidence"]) == ( "Бухгалтерский баланс", "7700000000", "7700000000", 100.0) assert "column_key" not in json.dumps(payload) def test_document_warning_drops_empty_keys() -> None: w = make_warning("ROW_SPLIT", "разлеплено", default_rules(), line_refs=("row:3#1", "row:3#2")) assert file_warning_to_contract(w).to_wire() == {"code": "ROW_SPLIT", "severity": "info", "message": "разлеплено", "line_refs": ["row:3#1", "row:3#2"]} def test_null_normalizer_structured_json_failed() -> None: outcome = NullNormalizer().normalize(merged(("j.json", response()), ("j_ofr.json", response(error=BAD)))) assert outcome.error is not None and outcome.error.code is ErrorCode.NOT_IMPLEMENTED assert [w.to_wire() for w in outcome.warnings] == [{"code": "JSON_FAILED", "severity": "error", "message": "INVALID_DOCUMENT: Не удалось определить тип документа.", "locator": "file:j_ofr.json"}] def test_normalizer_requires_statements_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(ocr_module, "statements_version", lambda path=None: None) with pytest.raises(ValueError, match="без VERSION"): OcrNormalizer(default_rules()) ``` В `test_contract.py` — добавить: ack, done и error из `build_*_message` имеют `schema_version == "2.0"` и проходят схему конверта; `metrics.warnings` из строки (`["a"]`) отвергают и схема, и модель; документ `document_type = "OTHER"`, документ без `form_variant`, строка `unreadable` с `line_code = "1100"`, файл со `statementDocument` без `service.statements_version` — отвергают и схема, и модель; запрос `schema_version = "2.0"` по-прежнему `is_supported_major(...) is False`. В `test_worker.py` — сценарий R-30 переписать под P5-10: 300 предупреждений (100 `error` и 200 `info` вперемешку) → в конверте ровно 100 и все `error` среди них, `message` не длиннее `MAX_WARNING_CHARS`, файл результата несёт тот же список. В `worker_helpers.py` и `test_worker.py` — ожидаемые `schema_version`/`result_schema_version` конвертов `"2.0"`, строки `metrics.warnings` — структуры `JSON_FAILED`. `test_ocr_normalizer.py` переписать: синтетические сценарии задачи 3 через `OcrNormalizer.normalize` проверяют `NormalizeOutcome` (статус, коды ошибок, `warnings` — `FileWarning`, `metrics.rows`), реальные json отсюда уходят в задачу 6. `test_app.py::test_create_app_fails_on_broken_rules` заменить на `test_create_app_uses_package_rules` (`app.state.normalizer.statements_version` равен содержимому `api/arm_statements/VERSION`). - [ ] **Step 2: Run tests to verify they fail** Run: `cd $WT/api && .venv/bin/pytest tests/unit/test_contract.py tests/unit/normalizer/test_contract_map.py -v` Expected: FAIL — примеры v2.0 не проходят модели v1 (`document_type`, `warnings`), `ImportError` для `contract_map`. - [ ] **Step 3: Модели контракта v2.0** (`src/contract/*`) по Interfaces; модуль-docstring — «контракт v2.0, запрос — v1.0». Run: `cd $WT/api && .venv/bin/pytest tests/unit/test_contract.py -v` → PASS. - [ ] **Step 4: `contract_map.py`, `NormalizeOutcome`, `NullNormalizer`, `OcrNormalizer`, `worker.build_result`, `_outcome_metrics`, `app.py`** по Interfaces; удалить `ocr/document.py`, старый код `ocr/__init__.py` и `test_document.py`; убрать из `src/normalizer/__init__.py` цикл импортов («в конце модуля: …») — `OcrNormalizer` больше не берёт помощники из `src.normalizer` (они в `files.py`). - [ ] **Step 5: Run all tests** Run: `cd $WT && make check` Expected: зелёный; `grep -rn "duplicate_line_codes\|OTHER\"" api/src` — пусто. - [ ] **Step 6: Commit** ```bash git -C $WT add -A api git -C $WT commit -m "feat: контракт результата 2.0 — документы и предупреждения файла общего кода arm_statements (#)" -m "Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" ``` --- ### Task 5: Удаление старого нормализатора, справочников и `SCAN_RULES_DIR` **Files:** - Delete: `$WT/api/src/normalizer/ocr/{text,dates,amounts,layout,rows,header}.py`, `$WT/api/src/normalizer/rules.py`, `$WT/api/rules/` (`statement_lines.yaml`, `statement_labels.yaml`, `ocr_lookalikes.yaml`), `$WT/api/tests/unit/normalizer/test_{text,dates,amounts,layout,rows,header,rules}.py` - Modify: `$WT/api/src/core/config.py` (поле `SCAN_RULES_DIR`), `$WT/api/.env.example`, `$WT/api/.env.test` (если есть строка), `$WT/api/src/app.py` (docstring про справочники) **Interfaces:** - Consumes: задача 4 (ничто в `src/` старые модули не импортирует). - Produces: в `api/src` и `api/tests` нет ссылок на удалённое; `Settings` без `SCAN_RULES_DIR` (`extra="ignore"` — оставшаяся в окружении контура переменная безвредна, P5-12). - [ ] **Step 1: Удалить файлы и поле настроек** (по Files). - [ ] **Step 2: Проверка отсутствия ссылок** Run: `cd $WT && grep -rn "statement_lines\|statement_labels\|ocr_lookalikes\|SCAN_RULES_DIR\|normalizer\.rules\|normalizer\.ocr\.\(text\|dates\|amounts\|layout\|rows\|header\|document\)" api/src api/tests api/.env.example Dockerfile` Expected: пусто (README и CLAUDE.md — задача 7). - [ ] **Step 3: Run tests** Run: `cd $WT && make check` Expected: зелёный. - [ ] **Step 4: Commit** ```bash git -C $WT add -A api git -C $WT commit -m "refactor: удалён собственный нормализатор шагов 2–8, справочник строк и SCAN_RULES_DIR (#)" -m "Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" ``` --- ### Task 6: Golden v2 и Review Focus на реальных ответах ИС **Files:** - Modify: `$WT/api/tests/golden_update.py`, `$WT/api/tests/unit/normalizer/test_golden.py` - Create: `$WT/api/tests/unit/normalizer/test_real_answers.py` - Modify (fixtures): `$FX/ocr/expected_normalize/*.json` (пересборка), `$FX/ocr/expected_normalize/README.md` **Interfaces:** - Consumes: задача 4 (`OcrNormalizer`, `build_result`, `_outcome_metrics` — или публичная обёртка, если исполнитель задачи 4 её выделил; `NormalizeOutcome`). - Produces (`tests/golden_update.py`): - `outcome_to_json(outcome: NormalizeOutcome) -> dict[str, Any]` — формат golden v2 (P5-14): успех — из `build_result(...).to_wire()` с фиктивными `JOB`/`FILE` ключи `schema_version`, `status`, `metrics`, `documents` (`job_id`, `file_id`, `service`, `raw_s3_key` не пишутся — `statements_version` меняется при каждой раскладке); ошибка — `{schema_version: "2.0", status: "error", error, metrics, documents: []}` - `full_payload(outcome) -> dict[str, Any]` — полный файл результата для проверки схемой (только успех) - `real_set(name)`, `golden_cases()` — без изменений - [ ] **Step 1: Write the failing tests.** `test_golden.py`: сравнение с эталоном (как сейчас) **плюс** для `status != error` — `VALIDATORS["result"].validate(full_payload(outcome))`. `test_real_answers.py` (маркер `fixtures`, пропуск без fixtures — как в `test_golden.py`; `JOB`, `real_set` — из `tests.golden_update`; модели — из `src.contract.result`): ```python NORMALIZER = OcrNormalizer(default_rules(), version="0" * 40) def run(name: str) -> NormalizeOutcome: return NORMALIZER.normalize(real_set(name)) def lines(doc: StatementDocument) -> dict[str, StatementLine]: refs = [ln.ref for ln in doc.lines] assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines))) return {ln.ref: ln for ln in doc.lines} def value(line: StatementLine, column_index: int) -> StatementValue: return next(v for v in line.values if v.column_index == column_index) def test_aep_3_junk_codes_and_dirty_amounts() -> None: # Review Focus 1, 4 out = run("aep_3") assert out.status is ResultStatus.NEEDS_CHECK bal, inc = out.documents assert [(d.document_type, d.locator, d.status) for d in out.documents] == [ ("BALANCE", f"file:{JOB}.json;table:0", "needs_check"), ("INCOME", f"file:{JOB}.json;table:1", "needs_check")] assert [c.column_index for c in bal.columns] == [2, 4, 5] b = lines(bal) assert (b["row:0"].line_status, b["row:0"].line_code_text) == ("missing", "НИ") assert (b["row:2"].line_status, b["row:2"].parent_ref) == ("detail", "row:1") assert (b["row:3"].line_status, b["row:3"].line_code) == ("ok", "1140") assert {b["row:12"].line_status, b["row:31"].line_status} == {"unreadable"} assert (b["row:17"].line_status, b["row:17"].parent_ref) == ("detail", "row:16") i = lines(inc) revenue = value(i["row:0"], 2) assert i["row:0"].line_status == "missing" assert (revenue.amount, revenue.string_value, revenue.is_recognized) == (None, "211(1 |", False) cost = value(i["row:1"], 2) assert (i["row:1"].line_code, cost.amount, cost.is_recognized) == ("2120", Decimal("-7744536"), False) assert value(i["row:8"], 2).amount is None [failed] = [w for w in out.warnings if w.code == "JSON_FAILED"] assert (failed.severity, failed.locator) == ("error", f"file:{JOB}_ofr.json") assert failed.message == "INVALID_DOCUMENT: Не удалось определить тип документа." def test_aep_6_garbage_code_and_repeats() -> None: # Review Focus 2 bal, inc = run("aep_6").documents b = lines(bal) assert (b["row:6"].line_status, b["row:6"].line_code, b["row:6"].line_code_text) == ("unreadable", None, "44 30\nI |") assert "row:6#1" not in b and all(v.amount is None for v in b["row:6"].values) assert [ln.line_code for ln in bal.lines].count("1220") == 2 and [ln.line_code for ln in bal.lines].count("1520") == 2 i = lines(inc) assert [ln.line_code for ln in inc.lines].count("2410") == 2 and i["row:4"].line_status == "detail" assert bal.status == "needs_check" @pytest.mark.parametrize(("name", "row", "amounts"), [ ("БО 1 кв 2025", 73, (Decimal("43274"), Decimal("198622"))), ("Бухгалтерская отчетность 1 кв.25", 93, (Decimal("917775"), Decimal("2371333"))), ]) def test_q1_2025_split_repeats_and_done(name: str, row: int, amounts: tuple[Decimal, Decimal]) -> None: # RF 3 out = run(name) bal, _ = out.documents b = lines(bal) first, second = b[f"row:{row}#1"], b[f"row:{row}#2"] assert (first.line_code, second.line_code) == ("1500", "1700") assert (value(first, 2).amount, value(second, 2).amount) == amounts assert any(w.code == "ROW_SPLIT" and w.severity == "info" for w in bal.warnings) codes = [ln.line_code for ln in bal.lines] assert codes.count("1220") == 2 and codes.count("1520") == 2 # Р-23: расшифровки не держат документ; расхождение — находка для пакета (P5-17), не правка шлюза assert [d.status for d in out.documents] == ["done", "done"], _holders(out) assert out.status is ResultStatus.DONE @pytest.mark.parametrize("name", ["aep_5", "aep_8_form4"]) def test_all_json_failed_is_error(name: str) -> None: # Review Focus 4, 5 out = run(name) assert (out.status, out.documents) == (ResultStatus.ERROR, []) assert out.error is not None and out.error.code is ErrorCode.INVALID_DOCUMENT assert out.error.message == "Не удалось определить тип документа." assert [(w.code, w.locator) for w in out.warnings] == [("JSON_FAILED", f"file:{JOB}.json")] ``` `_holders(out)` — вспомогательная функция теста: список `(locator, ref, column_index, text, confidence)` значений и строк с `needs_review`, строк не `ok`/`detail` и предупреждений `error` — печатается при падении утверждения `done`. - [ ] **Step 2: Run tests to verify they fail** Run: `cd $WT/api && .venv/bin/pytest tests/unit/normalizer/test_real_answers.py tests/unit/normalizer/test_golden.py -v` Expected: `test_real_answers` — PASS или FAIL по существу (правила пакета); `test_golden` — FAIL «эталон разошёлся» (формат v1). - [ ] **Step 3: Golden v2.** Обновить `golden_update.py` по Interfaces; `make golden-update` (пишет в `$FX`, fixtures — worktree ветки golden). `git -C $FX diff --stat` и выборочно `git -C $FX diff` — объяснить в отчёте задачи каждое крупное изменение (строки без кода больше не пропадают, повторы кода, `needs_review`, статусы документов, предупреждения-структуры). Обновить `$FX/ocr/expected_normalize/README.md`: формат `{schema_version, status, error, metrics, documents}` контракта v2.0, `metrics.warnings` — структуры, ключи `job_id`/`file_id`/`service` не пишутся. - [ ] **Step 4: Разбор Review Focus.** Если утверждение `test_real_answers.py` падает: проверить кандидата адаптера (`table_candidate` по той же таблице — `ref`, тексты, уверенности как в json). Кандидат верен → находка `O-n` для пакета с выводом `_holders` и строкой json; утверждение **не** ослаблять без решения контроллера (`R-n`), копию пакета не править. Кандидат неверен → исправить адаптер (задача 2) с тестом. - [ ] **Step 5: Run tests** Run: `cd $WT && make check` Expected: зелёный (или задокументированная находка `O-n` с решением контроллера по шагу 4). - [ ] **Step 6: Доля `done` для PR** (proposal: «фактическая доля `done` фиксируется в PR scan-gateway»): ```bash python3 -I -c " import json, pathlib, collections for p in sorted(pathlib.Path('$FX/ocr/expected_normalize').glob('*.json')): g = json.loads(p.read_text(encoding='utf-8')) print(p.stem, g['status'], collections.Counter(d['status'] for d in g['documents'])) " ``` Вывод — в отчёт задачи (его переносит задача 7 в описание PR). - [ ] **Step 7: Commit (две ветки)** ```bash git -C $FX add ocr/expected_normalize git -C $FX commit -m "golden: эталоны scan-gateway по контракту 2.0 и общему коду arm_statements (scan-gateway#)" -m "Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" git -C $WT add api/tests git -C $WT commit -m "test: golden v2 и Review Focus на реальных ответах ИС (#)" -m "Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" ``` --- ### Task 7: Интеграционные тесты, документация, `check-full`, PR **Files:** - Modify: `$WT/api/tests/integration/test_roundtrip.py`, `$WT/README.md`, `$WT/CLAUDE.md`, `$WT/openspec/changes/statements-v2/tasks.md`, `$WT/openspec/changes/statements-v2/design.md` **Interfaces:** - Consumes: задачи 1–6. - Produces: PR fixtures и PR scan-gateway. - [ ] **Step 1: `test_roundtrip.py`.** Ожидания конвертов — `schema_version == "2.0"` (ack тоже); файл результата проверяется схемой v2.0; aep_3: документы `BALANCE`, `INCOME`, `JSON_FAILED` про `_ofr.json` структурой, документы и `metrics.warnings` равны golden (с заменой `JOB` на `job_id` задания в `locator`, как сейчас для строк); aep_6 — равенство golden; `ok_doc.pdf` — `line_name` из OCR, без предупреждения «редакция не заполнена»; все файлы набора ошибочны — `metrics.warnings == [{"code": "JSON_FAILED", "severity": "error", "message": "INVALID_DOCUMENT: …", "locator": f"file:{job_id}.json"}]`. - [ ] **Step 2: README и CLAUDE.md.** README: «Нормализация» — адаптер шага 1 и копия `arm_statements` (что остаётся в шлюзе, что ушло; ссылка на §11.1, §11.9); раздел «Справочники `api/rules/`» удалить (словари — в пакете, правка — через `arm-statements`); «Статус результата» — `envelope_status`; «Предупреждения» — таблица предупреждений файла (`JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`) с уровнем и `locator`, предупреждения документа — ссылка на §11.9; «Эталоны» — формат v2; «Переменные окружения» — без `SCAN_RULES_DIR`; «Проверки» — `statements-check`. CLAUDE.md: абзац «Что это» — `statementDocument` v2.0 общим кодом; «Правила» — пункты про перенос `ocr/*`, `api/rules`, статус и строки предупреждений заменить на: адаптер (`normalizer/ocr/adapter.py`), обход набора (`normalizer/ocr/answer_set.py`), граница контракта (`normalizer/contract_map.py`), копия `api/arm_statements/` не правится (`sync-statements`), предупреждения — структуры; «Добавлено» — новые модули, удалённые — убрать. - [ ] **Step 3: Полная проверка** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg/docs/dev && docker compose -f docker-compose.yml --project-name arm-ka ps` (если не поднято — `cd $WT && make up`), затем `cd $WT && make check-full; echo "код $?"` Expected: код 0; `statements-check` — `0` или `10` (предупреждение — в описание PR). - [ ] **Step 4: OpenSpec.** Отметить задачи в `tasks.md`; дописать в `design.md` решения `R-n`, принятые по ходу. Run: `cd $WT && openspec validate statements-v2 --strict` → valid. - [ ] **Step 5: Commit и push** ```bash git -C $WT add -A git -C $WT commit -m "docs: README и CLAUDE.md — адаптер OCR, arm_statements, контракт 2.0; интеграционные тесты (#)" -m "Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" git -C $FX push -u origin feat/-golden-statements-v2 git -C $WT push -u origin feat/-statements-v2 ``` - [ ] **Step 6: PR.** Сначала fixtures (`tea pulls create --login home --repo RSHB/arm-ka-fixtures`): что пересобрано, почему (ссылка на PR scan-gateway), «сливать первым, PR scan-gateway — сразу за ним». Затем scan-gateway (`tea pulls create --login home --repo RSHB/arm-scan-gateway`, `Closes #`, ссылки на arm-ka-docs#116 и PR fixtures): итог `make check-full` и `statements-check`, доля `done` по golden (задача 6, шаг 6), таблица находок (`| Маркер | Суть | Статус |`), «Rulings I made» (P5-n и R-n), блок **«Слияние — после выкладки ядра, принимающего 2.0 (план 3, части А и Б); fixtures PR — первым»**. Слияние — только по команде человека. --- ## Решения автора плана без обсуждения (P5-n) Исполнитель задачи 0 переносит их в `design.md` change'а `statements-v2`, контроллер — в `design.md` docs-change'а (`## Decisions made without discussion`, 18.10.2026). - **P5-1.** Сетка OCR — одна строка сетки на строку ИС по возрастанию `row_index`, пропуски `row_index` пустыми строками не заполняются; ширина строк таблицы — `max(column_index) + 1`. «Плотная по `row_index`» прочитано как «по порядку `row_index`», `ref` всё равно несёт номер ИС. Цена: шаг 2 не видит «дыры» между строками (в реальных json их нет). - **P5-2.** Нарушенная структура таблицы ИС (повтор `row_index`, повтор `column_index` в строке, нецелые индексы, `rows` не список) — исключение адаптера → `TABLE_PARSE_ERROR` всей таблицы. Сегодня такие ячейки молча пропускались или перезаписывались; повтор `row_index` сломал бы уникальность `ref`. Цена: одна битая ячейка теряет таблицу (видно — `error`). - **P5-3.** `value` ячейки: `None` → `""`, не строка → `str(value)`, без `strip`; `confidence`: число (не `bool`) → `[0, 100]`, иначе `0` (как `clamp_confidence`, §11.1 «NaN → 0»). Цена: числовой `value` ИС (не встречался) даст текст `"1234.0"`. - **P5-4.** Ячейка ИС с пустым текстом — `Cell("", …)`, а не `None`: ИС отдаёт ячейку на каждую позицию строки, `None` — только там, где ячейки нет. Цена: нет. - **P5-5.** Шапка: поле ИС отсутствует, не объект или `form_name` пустой → `None` (а не `Cell("", 0)`); `ref` полей шапки — `data_ocr.<поле ИС>`; `data_ocr.label` (заголовок комплекта) не используется. Цена: `<поле>_text` пустого поля — `null`, а не `""`. - **P5-6.** `evidence.title.ref = table:.label`; `header.form_name` контракта — `label.form_name` таблицы как в источнике (пустой — `null`); пакет `form_name` не несёт — его ставит хост (O-2). - **P5-7.** Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` — `JSON_FAILED` (`error`): «ничего не выбрасывать молча» (§11, принцип 2). Цена: если ИС вернёт успешный пустой json рядом с нормальными, конверт будет `needs_check` (вопрос владельцу — Q-1). - **P5-8.** Все json набора ошибочны — конверт `error` с кодом первого, а `metrics.warnings` всё равно перечисляют `JSON_FAILED` каждого json (как строки Среза 0): иначе коды остальных json теряются. Цена: спека называет `JSON_FAILED` «при успешном соседе»; на `error`-конверт ядро `RECOGNIZED` не считает, вреда нет (Q-2). - **P5-9.** `TABLE_PARSE_ERROR.message` = `BACKEND_PARSING_ERROR: <тип>: <текст>` (текст до 300 символов) — код старого §11.1 п. 9 сохранён в тексте для поиска по логам и golden. Цена: нет. - **P5-10.** Предел `metrics.warnings` (100 структур, `message` до 1000 символов) сохранён; при переполнении остаются все `error`, затем `info`; строки-сводки нет — новый код предупреждения вводится только словарём пакета. Список в конверте и в файле результата одинаков (спека). Цена: при > 100 предупреждениях часть `info` не видна ни там, ни там (сырой ответ — в `raw/`). - **P5-11.** `statements_version` читается при создании `OcrNormalizer`; нет `VERSION` — сервис не стартует. Версия едет в `NormalizeOutcome.statements_version` и в `service.statements_version` файла результата; в конверт не пишется (в схеме конверта поля нет). Цена: dev-checkout без `VERSION` не стартует (копию всегда раскладывает скрипт). - **P5-12.** `api/rules/` удалён целиком, `SCAN_RULES_DIR` убран из настроек; словари — `default_rules()` пакета. Цена: переопределить словарь без релиза нельзя (и не нужно — словари меняются через `arm-statements`). - **P5-13.** `CONTRACT_VERSION = "2.0"` — для файла результата и всех конвертов, включая ack; MAJOR запроса — отдельная константа (`SUPPORTED_MAJOR = 1`). - **P5-14.** Golden v2 — `{schema_version, status, error, metrics, documents}` файла результата без `job_id`, `file_id`, `service`, `raw_s3_key` (`statements_version` меняется при каждой раскладке копии); успешный golden дополнительно проходит схему v2.0. Цена: golden не ловит регресс поля `service`. - **P5-15.** При переводе в контракт у предупреждений документа отбрасываются `locator` и пустые необязательные ключи (схема не допускает `null` у `column_index` и `field`), у нарушений §7 — `column_key`. Цена: нет. - **P5-16.** Модули: `OcrNormalizer` остаётся в `src/normalizer/ocr/__init__.py` (меньше правок `app.py` и тестов), адаптер — `ocr/adapter.py`, обход набора — `ocr/answer_set.py`, ошибки json — `normalizer/files.py` (снимает цикл импортов `src.normalizer` ↔ `src.normalizer.ocr`). - **P5-17.** Review Focus 3 утверждает `done` для обоих документов «БО 1 кв 2025» и «Бухгалтерская отчетность 1 кв.25» (proposal, Р-23). Падение при верном кандидате — находка для пакета, а не правка шлюза; ослабить утверждение может только контроллер рулингом. - **P5-18.** `metrics.rows` — число строк `lines` всех документов (как в v1.2), теперь включая строки без кода. - **P5-19.** `NullNormalizer` сохраняется для тестов воркера и выдаёт `JSON_FAILED` через `make_warning` пакета. ## Чего не хватает в API пакета для адаптера OCR (O-n; план 1 не правится) - **O-1.** Файла плана 2 нет: имена целей (`statements-check`), правки `pytest.ini`/`Makefile` и симлинк источника `arm-statements` в зеркальном workspace взяты из спеки; задача 1 сверяет их с фактом. - **O-2.** `StatementDocument`/`StatementHeader` пакета не несут заголовок формы, а у `statementDocument.header` контракта есть `form_name`. Хост берёт его из `candidate.evidence.title` — это работает только при вызове `normalize_candidate` на кандидата (через `normalize(...)` связь документа с кандидатом теряется: документов меньше, чем кандидатов). То же понадобится tabular-parser. Предложение плану 1: `StatementHeader.form_name` или `StatementDocument.title`. - **O-3.** `FailedRelation.expected: Decimal` в пакете, а в контракте `expected` допускает `null` («цели нет» — «посчитанное» соотношение с пустой целью, например `1600 = 1700` на листе 2 баланса). Нужно сверить, что пакет отдаёт при пустой цели; если `0` — смысл «цели нет» теряется. - **O-4.** Нет публичного перевода выхода пакета в форму контракта (уплощение шапки, отбрасывание `column_key`, пустых ключей предупреждений). По спеке это делает хост — значит, код `contract_map.py` повторится в tabular-parser; кандидат в общий код, если контракт станет общей зависимостью. - **O-5.** Коды предупреждений хоста (`JSON_FAILED`, `TABLE_PARSE_ERROR`) передаются в `make_warning` строками; констант кодов в публичном API нет — опечатка ловится только тестом уровня (`make_warning` неизвестного кода, по плану 1, — ошибка словаря?). Сверить поведение `make_warning` на неизвестном коде. - **O-6.** Формат `Cell.ref` для полей шапки OCR спека не задаёт (только `row:` у таблицы); выбран `data_ocr.<поле>` (P5-5). Нужно подтверждение, что пакет `ref` шапки нигде в выход не переносит. - **O-7.** `normalize(...)` не изолирует исключения кандидата — хост обязан звать `normalize_candidate` в цикле (план так и делает); публичная `normalize` шлюзу не нужна. ## Открытые вопросы владельцу (Q-n) - **Q-1.** Успешный json ИС без таблиц (`error.code == ""`, `table_structure == []`) — `JSON_FAILED` (`error`) или молча пропустить? Образца нет; план выбрал `JSON_FAILED` (P5-7). - **Q-2.** Все json набора ошибочны — перечислять `JSON_FAILED` каждого json в `metrics.warnings` конверта `error` (P5-8) или только код первого, как буквально в спеке? - **Q-3.** Если Review Focus 3 покажет `needs_check` у «БО 1 кв 2025» / «Бухгалтерская отчетность 1 кв.25» по причине, которую правила пакета считают законной, — принимаем как факт (фиксируется в PR) или это дефект правил пакета?