Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
87 KiB
План 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.tomlscan-gateway (строгие overrides наsrc.normalizer.*,src.contract.*,src.core.worker); новые модулиsrc/normalizer/**под них попадают. - Копию
api/arm_statements/не править (её раскладываетsync-statements, план 2; прямая правка — код 20statements-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)(у OCRokud_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.mdchange'аstatements-v2(## Decisions made without discussion), контроллер — вdesign.mddocs-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):
- aep_3 (ББ + ОФР, зашумлённый скан без строки заголовка) — колонки сетки 2, 4, 5 (ИС пропускает 3); ББ:
row:0«НИ» (без цифр) с суммой →missing;row:2«изо :» →detailсparent_ref = "row:1";row:3«| 140» →ok1140;row:12«12 /» иrow:31«420» →unreadable;row:17«БАЛАНС» без кода →detailподrow:16; ни одна строка с содержимым не пропала,refуникальны. ОФР:row:0«Выручка» без кода, в колонке 2211(1 |→missing,amount = null,string_value = "211(1 |",is_recognized = false;row:12120(7744536}→amount = "-7744536",is_recognized = false;row:8{38378)→amount = null. Оба документаneeds_check. - 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. - «БО 1 кв 2025» и «Бухгалтерская отчетность 1 кв.25» — склейка
"1500\n1700"(row:73иrow:93) → строкиrow:<i>#11500 иrow:<i>#21700 с суммами 43274 / 198622 и 917775 / 2371333 в колонке 2,ROW_SPLIT(info); повторы 1220 и 1520 — по две строкиok; у «БО»date.form_name— название организации (дата отчёта не разобрана, период — по колонкам); ожидание по Р-23 — все четыре документаdone, конвертdone(P5-17). - Набор с ошибочным 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 с ошибкой) → конвертerrorINVALID_DOCUMENTс сообщением ИС, документов нет. - Форма 4 без таблиц (aep_8_form4) — json с
INVALID_DOCUMENTи пустымtable_structure: конвертerrorINVALID_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, симлинки; changestatements-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_version2.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— ссылка на docsdesign.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 в
mainscan-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 ERRORnormalize_answer_set(raw: object, rules: Rules) -> AnswerSetResult
Порядок normalize_answer_set (спека scan-gateway «Ошибки набора json», statement-normalization «Шаг 8 — статус
документа и конверта»):
rawне{files: list}→ERRORINVALID_FORMATNOT_MERGED_MESSAGE;files == []→ERRORINVALID_FORMATNO_FILES_MESSAGE(как сейчас).- Файлы по порядку склейки.
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:<имя>. - Каждая таблица
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— порядок файлов, внутри файла — порядок таблиц. 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, docsmainпосле #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: RequestSchemaVersionclass 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.jsonv2.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: strOcrNormalizer.normalize(raw: object) -> NormalizeOutcome—normalize_answer_set→ статус (EnvelopeStatus→ResultStatusпо значению), документыdocument_to_contract,warningsfile_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: абзац «Что это» —statementDocumentv2.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) или это дефект правил пакета?