diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md new file mode 100644 index 0000000..8c6a5cd --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md @@ -0,0 +1,1117 @@ +# План 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) или это дефект правил пакета?