tabular-parser: столбец кодов и колонки сумм — code_column / amount_columns пакета, метки — normalize_label / normalize_caption, маркер — is_continuation, контракт — to_contract / warning_to_contract (statements/contract.py не пишется), XLS_NO_CODEPAGE — предупреждение файла вместо OSV_XLS_NO_CODEPAGE; scan-gateway: contract_map.py не пишется, form_name из пакета, WarningCode в make_warning, FailedRelation.expected/actual — Money | None. Закрыты O-1…O-7, O-9 плана 4 и O-2…O-7 плана 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
1138 lines
89 KiB
Markdown
1138 lines
89 KiB
Markdown
# План 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`, выбор кода ошибки) — всё в типах пакета. Граница с
|
||
контрактом — функции пакета `to_contract` / `warning_to_contract` (документ и предупреждение → `dict` формы v2.0,
|
||
валидируется pydantic-моделями контракта шлюза) в `OcrNormalizer.normalize` → `NormalizeOutcome`, из которого воркер, как
|
||
и прежде, собирает файл результата и конверт. Своего конвертера (`contract_map`) у шлюза нет — он общий с tabular-parser
|
||
(план 1, P1-26).
|
||
|
||
**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 задачи 9 плана 2 (`feat/<issue>-arm-statements-copy`) слит в scan-gateway `main` до задачи 1; задача 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` пакета с членом
|
||
`WarningCode` (`WarningCode.JSON_FAILED`, `WarningCode.TABLE_PARSE_ERROR`), не строковым литералом (уровень — из
|
||
словаря пакета; неизвестный код — `KeyError`).
|
||
- Статус конверта — только `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` — чистые: без I/O, без
|
||
настроек, без логов (как `ocr/*` сейчас). Перевод в контракт — только `to_contract` / `warning_to_contract` пакета;
|
||
своего уплощения шапки и отбора ключей предупреждений шлюз не пишет.
|
||
- Горячие папки, 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)
|
||
│ ├── 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_boundary.py # задача 4: to_contract пакета → модели и схема v2.0 шлюза
|
||
├── 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: Предусловия.** Проверить и при невыполнении остановиться с отчётом контроллеру (не обходить):
|
||
|
||
```bash
|
||
WS0=/Users/andrey.epifancev/projects/awp-ca
|
||
git -C $WS0/docs fetch origin && git -C $WS0/docs show origin/main:contracts/recognition-result.schema.json | grep -q '"arm-ka/recognition-result/v2"' # docs PR #117 слит
|
||
git -C $WS0/scan-gateway fetch origin && git -C $WS0/scan-gateway show origin/main:api/tests/contract_identity.py >/dev/null # план 0 слит
|
||
git -C $WS0/scan-gateway show origin/main:api/arm_statements/VERSION # план 2 слит (задача 1)
|
||
test -d $WS0/arm-statements/.git # источник для statements-check
|
||
```
|
||
|
||
- [ ] **Step 2: Зеркальный workspace и worktree** (порядок `~/.claude/CLAUDE.md`: worktree на временной ветке → issue →
|
||
переименование):
|
||
|
||
```bash
|
||
WS=/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg
|
||
mkdir -p $WS
|
||
git -C $WS0/scan-gateway worktree add -b tmp/statements-v2 $WS/scan-gateway origin/main
|
||
git -C $WS0/docs worktree add --detach $WS/docs origin/main
|
||
git -C $WS0/fixtures fetch origin && git -C $WS0/fixtures worktree add -b tmp/golden-statements-v2 $WS/fixtures origin/main
|
||
for l in reference-old-arm arm-statements CLAUDE.md .claude; do ln -s $WS0/$l $WS/$l; done
|
||
tea issues create --login home --repo RSHB/arm-scan-gateway \
|
||
--title "Единая нормализация ББ/ОФР: адаптер OCR → arm_statements, контракт результата 2.0 (arm-ka-docs#116)" \
|
||
--description "<цель из Goal; ссылки: RSHB/arm-ka-docs#116, план openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md>"
|
||
git -C $WS/scan-gateway branch -m feat/<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**
|
||
|
||
```bash
|
||
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`.
|
||
|
||
> Подключение копии — задача 9 плана 2 (`plans/2026-10-18-plan-2-sync-statements.md`, раздел «Что должны включить
|
||
> планы 3, 4, 5»): `lint` — `mypy src arm_statements`, `testpaths` — `tests` и `arm_statements/tests`, `arm_statements`
|
||
> в строгом блоке mypy и `known-first-party`, `statements-check` в `check-full`. Если копии в `main` нет — **стоп**,
|
||
> задача заблокирована, отчёт контроллеру (первичную раскладку в этой ветке не делать). Расхождение факта с планом 2 —
|
||
> `O-n` в отчёте.
|
||
|
||
- [ ] **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: Новая версия пакета (только если нужна).** Если задачам 2–6 нужна версия `arm-statements` новее,
|
||
чем в `main` (например, исправление по находке Review Focus), — по плану 2: `cd $WT && ../docs/scripts/sync-statements.sh
|
||
--in-place --host-dir .`, копия — отдельным коммитом `chore(arm_statements): копия пакета arm-statements@<v7>
|
||
(#<issue>)`; руками копию не править.
|
||
|
||
- [ ] **Step 5: 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**
|
||
|
||
```python
|
||
import math
|
||
|
||
import pytest
|
||
from arm_statements import Cell, DocumentType, Source, default_rules
|
||
|
||
from src.normalizer.ocr.adapter import TableStructureError, confidence_of, header_cells, table_candidate
|
||
|
||
RULES = default_rules()
|
||
DATA_OCR = {
|
||
"label": {"form_name": "Бухгалтерская отчетность", "confidence": 100.0},
|
||
"organization": {"form_name": "ООО «Пример»", "confidence": 100.0},
|
||
"inn": {"form_name": "3|1 21 2|", "confidence": 61.538464},
|
||
"units": {"form_name": "тыс. руб.", "confidence": 100.0},
|
||
"date": {"form_name": "", "confidence": 0.0},
|
||
}
|
||
|
||
|
||
def row(index: object, *cells: tuple[object, object, object]) -> dict[str, object]:
|
||
return {"row_index": index, "cells": [{"column_index": c, "value": v, "confidence": p} for c, v, p in cells]}
|
||
|
||
|
||
def table(label: str, *rows: dict[str, object]) -> dict[str, object]:
|
||
return {"label": {"form_name": label, "confidence": 100.0}, "rows": list(rows)}
|
||
|
||
|
||
def test_header_is_copied_from_json() -> None:
|
||
h = header_cells(DATA_OCR)
|
||
assert h.organization_name == Cell("ООО «Пример»", None, 100.0, "data_ocr.organization")
|
||
assert h.inn == Cell("3|1 21 2|", None, 61.538464, "data_ocr.inn")
|
||
assert h.units == Cell("тыс. руб.", None, 100.0, "data_ocr.units")
|
||
assert (h.report_date, h.okud, h.okpo, h.okopf, h.okfs, h.okei, h.address) == (None,) * 7
|
||
|
||
|
||
def test_grid_by_row_and_column_index() -> None:
|
||
t = table(
|
||
"Бухгалтерский баланс",
|
||
row(9, (0, "Итого", 100.0)),
|
||
row(8, (1, "[190 :", 25.0), (0, "Прочие", 100.0), (4, "", 0.0), (5, None, 100.0)),
|
||
)
|
||
cand = table_candidate("j.json", 0, t, header_cells(DATA_OCR), RULES)
|
||
assert (cand.source, cand.locator, cand.type_hint) == (Source.OCR, "file:j.json;table:0", DocumentType.BALANCE)
|
||
assert cand.segment_breaks == () and cand.warnings == ()
|
||
assert cand.evidence.okud is None
|
||
assert cand.evidence.title == Cell("Бухгалтерский баланс", None, 100.0, "table:0.label")
|
||
assert cand.rows == (
|
||
(Cell("Прочие", None, 100.0, "row:8"), Cell("[190 :", None, 25.0, "row:8"), None, None,
|
||
Cell("", None, 0.0, "row:8"), Cell("", None, 100.0, "row:8")),
|
||
(Cell("Итого", None, 100.0, "row:9"), None, None, None, None, None),
|
||
)
|
||
assert cand.header == header_cells(DATA_OCR)
|
||
|
||
|
||
def test_multiline_text_is_kept() -> None:
|
||
t = table("Бухгалтерский баланс", row(93, (1, "1500\n1700", 78.0), (2, "917 775\n2 371 333", 100.0)))
|
||
cand = table_candidate("j.json", 0, t, header_cells(DATA_OCR), RULES)
|
||
assert cand.rows[0][1] == Cell("1500\n1700", None, 78.0, "row:93")
|
||
|
||
|
||
def test_income_label_and_unknown_label() -> None:
|
||
income = table_candidate("j.json", 1, table("Отчет о финансовых результатах"), header_cells(DATA_OCR), RULES)
|
||
assert income.type_hint is DocumentType.INCOME and income.rows == ()
|
||
blank = table_candidate("j.json", 2, {"label": {"form_name": " ", "confidence": 0.0}, "rows": []},
|
||
header_cells(DATA_OCR), RULES)
|
||
assert (blank.evidence.title, blank.type_hint) == (None, None)
|
||
|
||
|
||
@pytest.mark.parametrize(("raw", "expected"), [(25, 25.0), (150.0, 100.0), (-3, 0.0), (math.nan, 0.0),
|
||
(math.inf, 0.0), ("100", 0.0), (True, 0.0), (None, 0.0)])
|
||
def test_confidence_of(raw: object, expected: float) -> None:
|
||
assert confidence_of(raw) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"broken",
|
||
[
|
||
"не объект",
|
||
{"label": {}, "rows": "x"},
|
||
table("ББ", {"cells": []}),
|
||
table("ББ", row(True, (0, "a", 100.0))),
|
||
table("ББ", row(1, (0, "a", 100.0)), row(1, (0, "b", 100.0))),
|
||
table("ББ", {"row_index": 0, "cells": "x"}),
|
||
table("ББ", row(0, ("1", "a", 100.0))),
|
||
table("ББ", row(0, (-1, "a", 100.0))),
|
||
table("ББ", row(0, (1, "a", 100.0), (1, "b", 100.0))),
|
||
],
|
||
)
|
||
def test_broken_structure_raises(broken: object) -> None:
|
||
with pytest.raises(TableStructureError):
|
||
table_candidate("j.json", 0, broken, header_cells(DATA_OCR), RULES)
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd $WT/api && .venv/bin/pytest tests/unit/normalizer/test_adapter.py -v`
|
||
Expected: FAIL — `ModuleNotFoundError: No module named 'src.normalizer.ocr.adapter'`
|
||
|
||
- [ ] **Step 3: Implement `adapter.py`** по Interfaces. Docstring модуля — ссылка на спеку `scan-gateway` «Адаптер json ИС
|
||
шага 1» и §11.1. Никакой очистки текста — её делает пакет.
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
Run: `cd $WT && make check`
|
||
Expected: зелёный.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git -C $WT add api/src/normalizer/ocr/adapter.py api/tests/unit/normalizer/test_adapter.py
|
||
git -C $WT commit -m "feat(normalizer): адаптер шага 1 — таблица json ИС → FormCandidate (#<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`, `WarningCode`, `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(WarningCode.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) SetError(code: ErrorCode, message: str)`
|
||
- `@dataclass(frozen=True) AnswerSetResult(status: EnvelopeStatus, documents: tuple[StatementDocument, ...],
|
||
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(WarningCode.TABLE_PARSE_ERROR, f"BACKEND_PARSING_ERROR: {type(exc).__name__}: {exc}", rules,
|
||
locator=f"file:{name};table:{i}")`, текст исключения обрезается до 300 символов (P5-9); иначе документ — в
|
||
`documents` (заголовок таблицы — `document.header.form_name`, его ставит пакет из `evidence.title`), `file_warnings`
|
||
результата — в `file_warnings`. Порядок `file_warnings` — порядок файлов,
|
||
внутри файла — порядок таблиц.
|
||
4. `status = envelope_status([d.status for d in documents], file_warnings)`; `ERROR` → нет успешного json —
|
||
`SetError(<код первого ошибочного>, <его summary>)`, иначе `SetError(RECOGNITION_FAILED, NO_TABLES_MESSAGE)`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
from datetime import date
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from arm_statements import DocStatus, DocumentType, EnvelopeStatus, PeriodKind, Severity, default_rules
|
||
|
||
import src.normalizer.ocr.answer_set as answer_set_module
|
||
from src.contract import ErrorCode
|
||
from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE, normalize_answer_set
|
||
|
||
RULES = default_rules()
|
||
OK = {"code": "", "message": ""}
|
||
BAD = {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."}
|
||
HEAD = {
|
||
"organization": {"form_name": "ООО «Пример»", "confidence": 100.0},
|
||
"inn": {"form_name": "7700000000", "confidence": 100.0},
|
||
"units": {"form_name": "тыс. руб.", "confidence": 100.0},
|
||
"date": {"form_name": "на 31 декабря 2024 г.", "confidence": 100.0},
|
||
}
|
||
BALANCE = [("Наименование показателя", "Код", "На 31 декабря 2024 г."), ("Запасы", "1210", "60"),
|
||
("Дебиторы", "1230", "40"), ("Итого II", "1200", "100"), ("НМА", "1110", "50"), ("Итого I", "1100", "50"),
|
||
("БАЛАНС", "1600", "150"), ("Капитал", "1310", "150"), ("Итого III", "1300", "150"),
|
||
("БАЛАНС", "1700", "150")]
|
||
INCOME = [("Наименование показателя", "Код", "За 2024 г."), ("Выручка", "2110", "100"),
|
||
("Себестоимость", "2120", "(60)"), ("Валовая прибыль", "2100", "40"), ("Прибыль от продаж", "2200", "40")]
|
||
|
||
|
||
def ocr_table(label: str, rows: list[tuple[str, ...]]) -> dict[str, Any]:
|
||
return {"label": {"form_name": label, "confidence": 100.0}, "rows": [
|
||
{"row_index": i, "cells": [{"column_index": c, "value": v, "confidence": 100.0} for c, v in enumerate(r)]}
|
||
for i, r in enumerate(rows)]}
|
||
|
||
|
||
def response(*tables: dict[str, Any], error: dict[str, str] = OK, head: dict[str, Any] = HEAD) -> dict[str, Any]:
|
||
return {"file_uid": "x", "error": error, "data_ocr": {**head, "table_structure": list(tables)}}
|
||
|
||
|
||
def merged(*files: tuple[str, Any]) -> dict[str, Any]:
|
||
return {"job_id": "j", "files": [{"name": n, "response": r, "parse_error": None} for n, r in files]}
|
||
|
||
|
||
def test_one_json_two_forms_copy_header() -> None:
|
||
res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE),
|
||
ocr_table("Отчет о финансовых результатах", INCOME)))), RULES)
|
||
assert [(d.locator, d.document_type, d.header.form_name) for d in res.documents] == [
|
||
("file:j.json;table:0", DocumentType.BALANCE, "Бухгалтерский баланс"),
|
||
("file:j.json;table:1", DocumentType.INCOME, "Отчет о финансовых результатах")]
|
||
assert {d.header.inn.value for d in res.documents} == {"7700000000"}
|
||
assert res.documents[0].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_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]
|
||
assert [(c.period_kind, c.period_date) for c in doc.columns] == [(PeriodKind.ABSOLUTE, expected)]
|
||
assert doc.header.report_date.value == date(2024, 12, 31)
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd $WT/api && .venv/bin/pytest tests/unit/normalizer/test_answer_set.py -v`
|
||
Expected: FAIL — `ModuleNotFoundError: No module named 'src.normalizer.ocr.answer_set'`
|
||
|
||
- [ ] **Step 3: Implement `files.py` и `answer_set.py`** по Interfaces и порядку выше; `src/normalizer/__init__.py`
|
||
импортирует перенесённое из `files.py` (поведение `NullNormalizer` и строки `warnings` пока прежние — меняются в задаче
|
||
4). Старый `OcrNormalizer` не трогать.
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
Run: `cd $WT && make check`
|
||
Expected: зелёный (в том числе `tests/unit/test_normalizer.py` и `test_worker.py` без изменений).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git -C $WT add api/src/normalizer api/tests/unit/normalizer/test_answer_set.py
|
||
git -C $WT commit -m "feat(normalizer): набор json ИС через arm_statements — JSON_FAILED, TABLE_PARSE_ERROR, статус конверта (#<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`
|
||
- 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_boundary.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`, `FileError.to_warning`); из пакета — `to_contract`,
|
||
`warning_to_contract`, `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: int, relation, codes, expected: Money | None, actual: Money |
|
||
None)` (`null` — цель или все члены пусты, план 1 P1-21);
|
||
`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` задан».
|
||
- Перевод в контракт — пакетом (план 1, задача 19): `StatementDocument.model_validate(to_contract(document))` и
|
||
`FileWarning.model_validate(warning_to_contract(warning, file_level=True))`; уплощение шапки (в том числе `form_name`
|
||
из `document.header.form_name`), отбрасывание `column_key` и пустых необязательных ключей предупреждений, `checks`
|
||
нет ⇒ ключа нет — правила `to_contract` (P5-15). Модуля `contract_map.py` нет.
|
||
- 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())` →
|
||
`FileWarning.model_validate(warning_to_contract(..., file_level=True))`); итог прежний (`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` по значению), документы — `StatementDocument.model_validate(to_contract(doc))`, `warnings` —
|
||
`FileWarning.model_validate(warning_to_contract(w, file_level=True))`, `metrics =
|
||
Metrics(rows=<сумма len(lines)>)` при документах (P5-18), `error` — из `SetError`, `statements_version`
|
||
- Produces (`src/core/worker.py`):
|
||
- `build_result(outcome: NormalizeOutcome, *, job_id: uuid.UUID, file_id: uuid.UUID, service: ServiceInfo,
|
||
raw_s3_key: str, metrics: Metrics | None) -> RecognitionResult` — то, что сейчас собирается внутри `_accept_answer`;
|
||
`schema_version = CONTRACT_VERSION`, `service` с `statements_version` исхода (P5-11)
|
||
- `_outcome_metrics` — структуры `FileWarning`: `message` через `clean_text(…, MAX_WARNING_CHARS)`; больше
|
||
`MAX_WARNINGS` → берутся все `error` по порядку, затем `info` по порядку до `MAX_WARNINGS`, итоговый порядок —
|
||
исходный; строки-сводки «ещё предупреждений» нет (P5-10); тот же список — в конверте и в файле результата
|
||
- `src/app.py`: `OcrNormalizer(default_rules())` вместо `OcrNormalizer(load_rules(settings.SCAN_RULES_DIR))`
|
||
|
||
- [ ] **Step 1: Скопировать контракт и написать падающие тесты.** Копирование:
|
||
|
||
```bash
|
||
cp $WS/docs/contracts/recognition-request.schema.json $WS/docs/contracts/recognition-result.schema.json \
|
||
$WS/docs/contracts/recognition-result-message.schema.json $WT/api/contracts/
|
||
cp $WS/docs/contracts/examples/*.json $WT/api/contracts/examples/
|
||
```
|
||
|
||
Тесты (`tests/unit/normalizer/test_contract_boundary.py` — новые: граница «пакет → модели шлюза»; правила перевода
|
||
проверяет пакет, здесь — что его `dict` проходит модели и схему v2.0 шлюза; в `test_contract.py` — добавить к
|
||
существующему набору;
|
||
валидатор схемы — `VALIDATORS` из `test_contract.py` или такой же реестр; `merged`, `response`, `ocr_table`, `BALANCE`,
|
||
`INCOME`, `BAD` — из `ocr_builders.py`; `SERVICE = ServiceInfo(name="scan-gateway", version="test")`; `ocr_module` —
|
||
`import src.normalizer.ocr as ocr_module`):
|
||
|
||
```python
|
||
def test_mapped_document_passes_schema_v2() -> None:
|
||
outcome = OcrNormalizer(default_rules(), version="abc1234").normalize(merged(("j.json", response(
|
||
ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME)))))
|
||
result = build_result(outcome, job_id=uuid.uuid4(), file_id=uuid.uuid4(), service=SERVICE,
|
||
raw_s3_key="raw/x.json", metrics=None)
|
||
payload = result.to_wire()
|
||
VALIDATORS["result"].validate(payload)
|
||
assert payload["schema_version"] == "2.0" and payload["service"]["statements_version"] == "abc1234"
|
||
head = payload["documents"][0]["header"]
|
||
assert (head["form_name"], head["inn"], head["inn_text"], head["inn_confidence"]) == (
|
||
"Бухгалтерский баланс", "7700000000", "7700000000", 100.0)
|
||
assert "column_key" not in json.dumps(payload)
|
||
|
||
|
||
def test_package_warning_fits_file_warning_model() -> None:
|
||
w = make_warning(WarningCode.ROW_SPLIT, "разлеплено", default_rules(), line_refs=("row:3#1", "row:3#2"))
|
||
assert FileWarning.model_validate(warning_to_contract(w, file_level=True)).to_wire() == {
|
||
"code": "ROW_SPLIT", "severity": "info", "message": "разлеплено", "line_refs": ["row:3#1", "row:3#2"]}
|
||
|
||
|
||
def test_empty_target_relation_passes_model() -> None:
|
||
rows = [("Наименование показателя", "Код", "На 31 декабря 2024 г."), ("Запасы", "1210", "60"),
|
||
("Итого II", "1200", "60")]
|
||
outcome = OcrNormalizer(default_rules(), version="abc1234").normalize(
|
||
merged(("j.json", response(ocr_table("Бухгалтерский баланс", rows)))))
|
||
failed = outcome.documents[0].checks.relations_failed
|
||
assert any(f.relation == "1600 = 1100 + 1200" and f.expected is None for f in failed)
|
||
|
||
|
||
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_boundary.py -v`
|
||
Expected: FAIL — примеры v2.0 не проходят модели v1 (`document_type`, `warnings`), `OcrNormalizer` отдаёт документы v1.
|
||
|
||
- [ ] **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: `NormalizeOutcome`, `NullNormalizer`, `OcrNormalizer` (перевод — `to_contract` пакета),
|
||
`worker.build_result`, `_outcome_metrics`, `app.py`** по Interfaces; удалить `ocr/document.py`, старый код `ocr/__init__.py` и
|
||
`test_document.py`; убрать из `src/normalizer/__init__.py` цикл импортов («в конце модуля: …») — `OcrNormalizer`
|
||
больше не берёт помощники из `src.normalizer` (они в `files.py`).
|
||
|
||
- [ ] **Step 5: Run all tests**
|
||
|
||
Run: `cd $WT && make check`
|
||
Expected: зелёный; `grep -rn "duplicate_line_codes\|OTHER\"" api/src` — пусто.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git -C $WT add -A api
|
||
git -C $WT commit -m "feat: контракт результата 2.0 — документы и предупреждения файла общего кода arm_statements (#<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**
|
||
|
||
```bash
|
||
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`):
|
||
|
||
```python
|
||
NORMALIZER = OcrNormalizer(default_rules(), version="0" * 40)
|
||
|
||
|
||
def run(name: str) -> NormalizeOutcome:
|
||
return NORMALIZER.normalize(real_set(name))
|
||
|
||
|
||
def lines(doc: StatementDocument) -> dict[str, StatementLine]:
|
||
refs = [ln.ref for ln in doc.lines]
|
||
assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines)))
|
||
return {ln.ref: ln for ln in doc.lines}
|
||
|
||
|
||
def value(line: StatementLine, column_index: int) -> StatementValue:
|
||
return next(v for v in line.values if v.column_index == column_index)
|
||
|
||
|
||
def test_aep_3_junk_codes_and_dirty_amounts() -> None: # Review Focus 1, 4
|
||
out = run("aep_3")
|
||
assert out.status is ResultStatus.NEEDS_CHECK
|
||
bal, inc = out.documents
|
||
assert [(d.document_type, d.locator, d.status) for d in out.documents] == [
|
||
("BALANCE", f"file:{JOB}.json;table:0", "needs_check"), ("INCOME", f"file:{JOB}.json;table:1", "needs_check")]
|
||
assert [c.column_index for c in bal.columns] == [2, 4, 5]
|
||
b = lines(bal)
|
||
assert (b["row:0"].line_status, b["row:0"].line_code_text) == ("missing", "НИ")
|
||
assert (b["row:2"].line_status, b["row:2"].parent_ref) == ("detail", "row:1")
|
||
assert (b["row:3"].line_status, b["row:3"].line_code) == ("ok", "1140")
|
||
assert {b["row:12"].line_status, b["row:31"].line_status} == {"unreadable"}
|
||
assert (b["row:17"].line_status, b["row:17"].parent_ref) == ("detail", "row:16")
|
||
i = lines(inc)
|
||
revenue = value(i["row:0"], 2)
|
||
assert i["row:0"].line_status == "missing"
|
||
assert (revenue.amount, revenue.string_value, revenue.is_recognized) == (None, "211(1 |", False)
|
||
cost = value(i["row:1"], 2)
|
||
assert (i["row:1"].line_code, cost.amount, cost.is_recognized) == ("2120", Decimal("-7744536"), False)
|
||
assert value(i["row:8"], 2).amount is None
|
||
[failed] = [w for w in out.warnings if w.code == "JSON_FAILED"]
|
||
assert (failed.severity, failed.locator) == ("error", f"file:{JOB}_ofr.json")
|
||
assert failed.message == "INVALID_DOCUMENT: Не удалось определить тип документа."
|
||
|
||
|
||
def test_aep_6_garbage_code_and_repeats() -> None: # Review Focus 2
|
||
bal, inc = run("aep_6").documents
|
||
b = lines(bal)
|
||
assert (b["row:6"].line_status, b["row:6"].line_code, b["row:6"].line_code_text) == ("unreadable", None,
|
||
"44 30\nI |")
|
||
assert "row:6#1" not in b and all(v.amount is None for v in b["row:6"].values)
|
||
assert [ln.line_code for ln in bal.lines].count("1220") == 2 and [ln.line_code for ln in bal.lines].count("1520") == 2
|
||
i = lines(inc)
|
||
assert [ln.line_code for ln in inc.lines].count("2410") == 2 and i["row:4"].line_status == "detail"
|
||
assert bal.status == "needs_check"
|
||
|
||
|
||
@pytest.mark.parametrize(("name", "row", "amounts"), [
|
||
("БО 1 кв 2025", 73, (Decimal("43274"), Decimal("198622"))),
|
||
("Бухгалтерская отчетность 1 кв.25", 93, (Decimal("917775"), Decimal("2371333"))),
|
||
])
|
||
def test_q1_2025_split_repeats_and_done(name: str, row: int, amounts: tuple[Decimal, Decimal]) -> None: # RF 3
|
||
out = run(name)
|
||
bal, _ = out.documents
|
||
b = lines(bal)
|
||
first, second = b[f"row:{row}#1"], b[f"row:{row}#2"]
|
||
assert (first.line_code, second.line_code) == ("1500", "1700")
|
||
assert (value(first, 2).amount, value(second, 2).amount) == amounts
|
||
assert any(w.code == "ROW_SPLIT" and w.severity == "info" for w in bal.warnings)
|
||
codes = [ln.line_code for ln in bal.lines]
|
||
assert codes.count("1220") == 2 and codes.count("1520") == 2
|
||
# Р-23: расшифровки не держат документ; расхождение — находка для пакета (P5-17), не правка шлюза
|
||
assert [d.status for d in out.documents] == ["done", "done"], _holders(out)
|
||
assert out.status is ResultStatus.DONE
|
||
|
||
|
||
@pytest.mark.parametrize("name", ["aep_5", "aep_8_form4"])
|
||
def test_all_json_failed_is_error(name: str) -> None: # Review Focus 4, 5
|
||
out = run(name)
|
||
assert (out.status, out.documents) == (ResultStatus.ERROR, [])
|
||
assert out.error is not None and out.error.code is ErrorCode.INVALID_DOCUMENT
|
||
assert out.error.message == "Не удалось определить тип документа."
|
||
assert [(w.code, w.locator) for w in out.warnings] == [("JSON_FAILED", f"file:{JOB}.json")]
|
||
```
|
||
|
||
`_holders(out)` — вспомогательная функция теста: список `(locator, ref, column_index, text, confidence)` значений и строк с
|
||
`needs_review`, строк не `ok`/`detail` и предупреждений `error` — печатается при падении утверждения `done`.
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cd $WT/api && .venv/bin/pytest tests/unit/normalizer/test_real_answers.py tests/unit/normalizer/test_golden.py -v`
|
||
Expected: `test_real_answers` — PASS или FAIL по существу (правила пакета); `test_golden` — FAIL «эталон разошёлся» (формат v1).
|
||
|
||
- [ ] **Step 3: Golden v2.** Обновить `golden_update.py` по Interfaces; `make golden-update` (пишет в `$FX`, fixtures —
|
||
worktree ветки golden). `git -C $FX diff --stat` и выборочно `git -C $FX diff` — объяснить в отчёте задачи каждое
|
||
крупное изменение (строки без кода больше не пропадают, повторы кода, `needs_review`, статусы документов,
|
||
предупреждения-структуры). Обновить `$FX/ocr/expected_normalize/README.md`: формат `{schema_version, status, error,
|
||
metrics, documents}` контракта v2.0, `metrics.warnings` — структуры, ключи `job_id`/`file_id`/`service` не пишутся.
|
||
|
||
- [ ] **Step 4: Разбор Review Focus.** Если утверждение `test_real_answers.py` падает: проверить кандидата адаптера
|
||
(`table_candidate` по той же таблице — `ref`, тексты, уверенности как в json). Кандидат верен → находка `O-n` для
|
||
пакета с выводом `_holders` и строкой json; утверждение **не** ослаблять без решения контроллера (`R-n`), копию пакета
|
||
не править. Кандидат неверен → исправить адаптер (задача 2) с тестом.
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
Run: `cd $WT && make check`
|
||
Expected: зелёный (или задокументированная находка `O-n` с решением контроллера по шагу 4).
|
||
|
||
- [ ] **Step 6: Доля `done` для PR** (proposal: «фактическая доля `done` фиксируется в PR scan-gateway»):
|
||
|
||
```bash
|
||
python3 -I -c "
|
||
import json, pathlib, collections
|
||
for p in sorted(pathlib.Path('$FX/ocr/expected_normalize').glob('*.json')):
|
||
g = json.loads(p.read_text(encoding='utf-8'))
|
||
print(p.stem, g['status'], collections.Counter(d['status'] for d in g['documents']))
|
||
"
|
||
```
|
||
|
||
Вывод — в отчёт задачи (его переносит задача 7 в описание PR).
|
||
|
||
- [ ] **Step 7: Commit (две ветки)**
|
||
|
||
```bash
|
||
git -C $FX add ocr/expected_normalize
|
||
git -C $FX commit -m "golden: эталоны scan-gateway по контракту 2.0 и общему коду arm_statements (scan-gateway#<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`), граница контракта (`to_contract` пакета в `normalizer/ocr/__init__.py`), копия
|
||
`api/arm_statements/` не правится (`sync-statements`), предупреждения — структуры; «Добавлено» — новые модули, удалённые — убрать.
|
||
|
||
- [ ] **Step 3: Полная проверка**
|
||
|
||
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg/docs/dev && docker compose -f docker-compose.yml --project-name arm-ka ps` (если не поднято — `cd $WT && make up`), затем `cd $WT && make check-full; echo "код $?"`
|
||
Expected: код 0; `statements-check` — `0` или `10` (предупреждение — в описание PR).
|
||
|
||
- [ ] **Step 4: OpenSpec.** Отметить задачи в `tasks.md`; дописать в `design.md` решения `R-n`, принятые по ходу.
|
||
Run: `cd $WT && openspec validate statements-v2 --strict` → valid.
|
||
|
||
- [ ] **Step 5: Commit и push**
|
||
|
||
```bash
|
||
git -C $WT add -A
|
||
git -C $WT commit -m "docs: README и CLAUDE.md — адаптер OCR, arm_statements, контракт 2.0; интеграционные тесты (#<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.<поле ИС>` (пакет `ref` шапки в выход не переносит — у `HeaderField` его нет, план 1, раздел API);
|
||
`data_ocr.label` (заголовок комплекта) не используется. Цена: `<поле>_text` пустого поля — `null`, а не `""`.
|
||
- **P5-6.** `evidence.title = Cell(label.form_name, …, ref="table:<i>.label")`; `header.form_name` контракта —
|
||
`document.header.form_name` пакета (= `evidence.title.text` как в источнике, пустой — `null`, план 1 P1-29); шлюз
|
||
`form_name` сам не ставит и пару «кандидат → документ» не хранит.
|
||
- **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.** Перевод в контракт — `to_contract` / `warning_to_contract` пакета (план 1, P1-26): у предупреждений
|
||
документа нет `locator` и пустых необязательных ключей (схема не допускает `null` у `column_index` и `field`), у
|
||
нарушений §7 нет `column_key`, у предупреждений файла `locator` есть; шлюз только валидирует `dict` своими моделями.
|
||
Цена: правка правил перевода — в `arm-statements`, а не здесь.
|
||
- **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`); своего конвертера в контракт (`contract_map.py`) нет.
|
||
- **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` пакета.
|
||
- **P5-20.** Обход набора зовёт `normalize_candidate` на каждую таблицу, а не `normalize`: исключение одной таблицы —
|
||
`TABLE_PARSE_ERROR` с `locator` таблицы, остальные таблицы разбираются (план 1: `normalize` исключения кандидата не
|
||
перехватывает). `AnswerSetResult.documents` — документы пакета как есть (заголовок — в `header.form_name`).
|
||
|
||
## Чего не хватает в API пакета для адаптера OCR (O-n)
|
||
|
||
Пункты O-2…O-7 закрыты сведением с планом 1 (18.10.2026, его P1-21…P1-31); маркеры сохранены.
|
||
|
||
- **O-1.** План 2 (задача 9) подключает копию в scan-gateway отдельным bounded PR и проверяет `default_rules()` в
|
||
образе; этот план от него зависит и не дублирует. Симлинк `arm-statements` в зеркальном workspace `$WS` нужен
|
||
`statements-check` (`../arm-statements`) — в плане 2 для зеркала хостов он не упомянут, здесь добавлен (задача 0).
|
||
- **O-2.** Закрыт: `StatementHeader.form_name` в пакете (P1-29), конвейер ставит его из `evidence.title`; P5-6.
|
||
- **O-3.** Закрыт: `FailedRelation.expected` / `actual` — `Decimal | None`, при пустой цели `expected = None` (P1-21);
|
||
модель шлюза — `Money | None`, тест `test_empty_target_relation_passes_model` (задача 4).
|
||
- **O-4.** Закрыт: `to_contract` / `warning_to_contract` в пакете (P1-26); `contract_map.py` не пишется.
|
||
- **O-5.** Закрыт: `WarningCode` (`StrEnum`) в API, `make_warning` с неизвестным кодом — `KeyError` (P1-30); шлюз
|
||
передаёт `WarningCode.JSON_FAILED`, `WarningCode.TABLE_PARSE_ERROR`.
|
||
- **O-6.** Закрыт: `ref` ячеек шапки и `evidence` пакет в выход не переносит (раздел API плана 1, «Вход шага 1»).
|
||
- **O-7.** Закрыт: в API плана 1 записано, что `normalize` исключения кандидата не перехватывает; шлюз зовёт
|
||
`normalize_candidate` по таблице (P5-20).
|
||
|
||
## Открытые вопросы владельцу (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) или это дефект правил пакета?
|