87 KiB
Raw Blame History

План 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/<issue>-statements-v2 feat/<issue sg>-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:<row_index>; locator = file:<имя json>;table:<i>; 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:<i>, документа у таблицы нет); 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 <noreply@anthropic.com> и Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W; описание PR — строкой 🤖 Generated with [Claude Code](https://claude.com/claude-code). Слияние — только по команде человека.
  • Находки и решения — с маркерами (T<n>.<k>, 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, имена <JOB>.json, <JOB>_<суффикс>.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:<i>#1 1500 и row:<i>#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:<JOB>_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/<issue>-statements-v2, worktree $FX на feat/<issue>-golden-statements-v2, отсоединённый $WS/docs, симлинки; change statements-v2, который проходит openspec validate --strict.

  • Step 1: Предусловия. Проверить и при невыполнении остановиться с отчётом контроллеру (не обходить):

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 → переименование):
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/<issue>-statements-v2
git -C $WS/fixtures branch -m feat/<issue>-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
git -C $WT add openspec/changes/statements-v2
git -C $WT commit -m "openspec: change statements-v2 — адаптер OCR → arm_statements, контракт 2.0 (#<issue>, arm-ka-docs#116)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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=<Cell label или None>), title.ref = f"table:{index}.label"; type_hint = type_hint(None, <title.text или None>, rules). Бросает TableStructureError, если: table не объект; rows не список; строка не объект или без целого row_index (bool — не целое); row_index повторяется в таблице; cells не список; ячейка не объект, column_index не целое или < 0; два column_index в одной строке.
  • Step 1: Write the failing test

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
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 (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
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
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, статус конверта (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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<N>.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: Скопировать контракт и написать падающие тесты. Копирование:

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):

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
git -C $WT add -A api
git -C $WT commit -m "feat: контракт результата 2.0 — документы и предупреждения файла общего кода arm_statements (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
git -C $WT add -A api
git -C $WT commit -m "refactor: удалён собственный нормализатор шагов 2–8, справочник строк и SCAN_RULES_DIR (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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):

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»):
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 (две ветки)
git -C $FX add ocr/expected_normalize
git -C $FX commit -m "golden: эталоны scan-gateway по контракту 2.0 и общему коду arm_statements (scan-gateway#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W"
git -C $WT add api/tests
git -C $WT commit -m "test: golden v2 и Review Focus на реальных ответах ИС (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 про <job_id>_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

git -C $WT add -A
git -C $WT commit -m "docs: README и CLAUDE.md — адаптер OCR, arm_statements, контракт 2.0; интеграционные тесты (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W"
git -C $FX push -u origin feat/<issue>-golden-statements-v2
git -C $WT push -u origin feat/<issue>-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 #<issue>, ссылки на 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:<i>.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:<row_index> у таблицы); выбран 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) или это дефект правил пакета?