plan(#116): планы 4 и 5 — потребители дополненного API пакета

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
This commit is contained in:
Andrey Epifancev 2026-10-18 12:56:04 +04:00
parent 042fc927dc
commit 37d58a4869
2 changed files with 203 additions and 166 deletions

View File

@ -10,7 +10,7 @@
код шагов 2–8 форм и словари форм удалены; манифест реальных форм и golden обновлены.
**Architecture:** новые модули адаптера пишутся рядом со старыми (`statements/cells.py`, `forms.py`, `header_cells.py`,
`adapter.py`, `contract.py`, `pipeline.py`); переключение книги на пакет и на контракт v2.0 — одной задачей (модели,
`adapter.py`, `pipeline.py`); переключение книги на пакет и на контракт v2.0 — одной задачей (модели,
копия схем, сборка книги, путь ОСВ, воркер), сразу за ней — golden и манифест, затем удаление старого кода форм. Пакет
вызывается только через публичный API (`from arm_statements import …`); ячейка Excel → `Cell` (текст по числовому формату,
`value`, `ref = <лист>!<A1>`) — в адаптере; разбор ОСВ не меняется, меняется только вид его предупреждений и статус листа.
@ -81,10 +81,13 @@ cd $M/tabular-parser && codegraph init && make venv
- Пакет — только через публичный API `from arm_statements import …` (имена раздела «Публичный API пакета» плана 1; в т. ч.
`Rules`, `default_rules`, `Cell`, `Evidence`, `HeaderCells`, `FormCandidate`, `Source`, `DocumentType`,
`StatementWarning`, `normalize_candidate`, `envelope_status`, `make_warning`, `type_hint`, `continuation_type`,
`sheet_joins`, `parse_code`, `parse_date`, `parse_header`, `to_jsonable`, `statements_version`). Импорт внутренних модулей
пакета (`arm_statements.codes` и т. п.) в `src/` запрещён. Файлы `api/arm_statements/**` не правятся (прямая правка —
код 20 `statements-check`); нехватка в API — находка `O-n`, не правка копии.
`StatementWarning`, `WarningCode`, `normalize_candidate`, `envelope_status`, `make_warning`, `type_hint`,
`is_continuation`, `continuation_type`, `sheet_joins`, `code_column`, `amount_columns`, `normalize_label`,
`normalize_caption`, `parse_code`, `parse_date`, `parse_header`, `to_contract`, `warning_to_contract`, `to_jsonable`,
`statements_version`). Импорт внутренних модулей пакета (`arm_statements.codes` и т. п.) в `src/` запрещён. Файлы
`api/arm_statements/**` не правятся (прямая правка — код 20 `statements-check`); нехватка в API — находка `O-n`, не
правка копии. Правило шага 2 (столбец кодов, колонки сумм) и нормализация меток в адаптере не повторяются — только
функции пакета (P4-6, P4-7).
- Шаг 1 для Excel (спека): `source = excel`; `locator = sheet:<A>[,<B>…]`; у ячейки `confidence = 100`, `text` — как
показывает числовой формат, `value` — число по формату (`Decimal`), дата или `bool`; `ref = <лист>!<A1>`, имя листа в
одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф внутри имени
@ -94,8 +97,9 @@ cd $M/tabular-parser && codegraph init && make venv
- Контракт: файл результата и конверт (включая ack `processing`) — `schema_version = "2.0"`; запрос остаётся `1.0`
(поддерживаемый MAJOR запроса — `"1"`, `SCHEMA_MISMATCH` по-прежнему). `service.statements_version` — содержимое
`api/arm_statements/VERSION`, обязателен при `statementDocument`.
- Предупреждения форм и файла — только через `make_warning` (код и уровень — словарь пакета); у ОСВ — код с префиксом
`OSV_`, уровень `info`. Строк вида `sheet=<имя> <текст>` в `metrics.warnings` нет.
- Предупреждения форм и файла — только через `make_warning` с членом `WarningCode` (код и уровень — словарь пакета); у
ОСВ — код с префиксом `OSV_`, уровень `info`. Строк вида `sheet=<имя> <текст>` в `metrics.warnings` нет. Перевод
документов форм и предупреждений в контракт — только `to_contract` / `warning_to_contract` пакета.
- Статус конверта — `envelope_status` пакета по статусам всех документов (ОСВ — `done`/`needs_check` листа) и
предупреждениям файла; документов нет — `error INVALID_DOCUMENT`. Порядок документов: ОСВ в порядке листов, затем формы.
- Разбор ОСВ не меняется (`classify.py`, `to_money`, `hierarchy.py`, `checks.py` по существу, словари §1): golden ОСВ
@ -160,7 +164,6 @@ tabular-parser/
│ ├── forms.py # задача 4: блоки форм, вид листа, столбец кодов, колонки сумм
│ ├── header_cells.py # задача 4: шапка блока → HeaderCells, Evidence
│ ├── adapter.py # задача 5: план листов книги, склейка → FormCandidate
│ ├── contract.py # задача 7: документ пакета → модель контракта v2.0
│ ├── pipeline.py # задача 7: StatementPipeline (адаптер → normalize_candidate → контракт)
│ └── {checks,columns,detect,header,lines,merge,statement}.py # задача 9: удаляются
├── api/rules/statement_{headers,periods,checks}.yaml # задача 9: удаляются (словари в пакете)
@ -240,7 +243,7 @@ git -C $T commit -m "openspec: change unified-normalization-tp — адапте
`api/pyproject.toml`, `api/requirements-dev.txt`); `api/arm_statements/VERSION` — 40 hex-символов.
- [ ] **Step 2: Свежесть и API копии**
Run: `cd $T && make statements-check; cd api && .venv/bin/python -c "import arm_statements as a; print(a.statements_version()); [getattr(a, n) for n in ('Rules','default_rules','Cell','Evidence','HeaderCells','FormCandidate','Source','DocumentType','StatementWarning','normalize_candidate','envelope_status','make_warning','type_hint','continuation_type','sheet_joins','parse_code','parse_date','parse_amount','parse_header','to_jsonable','statements_version')]"`
Run: `cd $T && make statements-check; cd api && .venv/bin/python -c "import arm_statements as a; print(a.statements_version()); [getattr(a, n) for n in ('Rules','default_rules','Cell','Evidence','HeaderCells','FormCandidate','Source','DocumentType','StatementWarning','WarningCode','normalize_candidate','envelope_status','make_warning','type_hint','is_continuation','continuation_type','sheet_joins','code_column','amount_columns','normalize_label','normalize_caption','parse_code','parse_date','parse_amount','parse_header','to_contract','warning_to_contract','to_jsonable','statements_version')]"`
Expected: `statements-check` — `0` или `10`; хэш печатается, `AttributeError` нет.
- [ ] **Step 3 (только при `10` «копия отстала» или нехватке имени):** обновить копию в worktree фичи
@ -375,34 +378,44 @@ git -C $T commit -m "feat(statements): ячейка Excel → Cell: текст
- Test: `$T/api/tests/unit/parser/statements/test_forms.py`, `$T/api/tests/unit/parser/statements/test_header_cells.py`
**Interfaces:**
- Consumes: `arm_statements.Rules` (`headers.form_titles`, `headers.okud`, `headers.okud_code`, `headers.continuation`,
`headers.code_caption`, `headers.labels`, `headers.auditor`, `headers.legal_form`, `periods.relative`,
`code_ranges.min_share`, `code_ranges.min_codes`), `type_hint`, `continuation_type`, `parse_code`, `parse_date`,
`parse_amount`, `Source`, `excel_cell` (задача 3),
`normalize_text` из `src.parser.rules` (P4-7).
- Consumes: `arm_statements.Rules` (`headers.form_titles`, `headers.okud`, `headers.okud_code`, `headers.code_caption`,
`headers.labels`, `headers.auditor`, `headers.legal_form`, `periods.relative`), `type_hint`, `is_continuation`,
`continuation_type`, `code_column`, `amount_columns`, `normalize_label`, `normalize_caption`, `parse_code`,
`parse_date`, `DocumentType`, `grid_rows` (задача 3). Нормализация текста под шаблон — таблица «Словари» раздела API
плана 1 (P4-7): метки шапки, заголовки, ОКУД, `auditor`, `legal_form` — `normalize_label`; подпись «Код» и
относительные периоды — `normalize_caption`. `normalize_text` из `src.parser.rules` здесь не используется.
- Produces (`forms.py`; `StatementRules` = `arm_statements.Rules`):
- `matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool` — `search` по `normalize_text(text)`
- `matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool` — `search` по `normalize_label(text)`
- `caption_matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool` — `search` по `normalize_caption(text)`
- `sheet_grid(sheet: Sheet, first_row: int, last_row: int) -> list[tuple[Cell | None, ...]]` — `grid_rows(sheet,
first_row, last_row, <ширина листа>, set())` (неразобранные форматы здесь не собираются — их собирает
`build_candidates`)
- `sheet_code_column(sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, *, relaxed: DocumentType |
None = None) -> int | None` — столбец листа (1-based) = `code_column(sheet_grid(...), rules, relaxed=relaxed) + 1` (`None` → `None`):
правило (а)/(б) шага 2 пакета (с защитой от блока «Коды»); `relaxed` — ослабленный поиск в отрезке формы, минимум
`Rules.code_ranges.min_codes_continuation`
- `FormBlock(type_hint: DocumentType, title: tuple[int, int] | None, okud: tuple[int, int] | None, first_row: int,
last_row: int)` — `title`/`okud` — `(row, col)` ячеек-признаков
- `find_blocks(sheet: Sheet, rules: StatementRules) -> list[FormBlock]` — строки с заголовком (`type_hint(None, text)`
не `None`) или ОКУД (`type_hint(text, None)`; ячейка-маркер продолжения ОКУДом блока не считается) — кандидаты; между
соседними без строки с кодом (`code_rows` столбца кодов листа) — один блок; первый блок начинается с первой строки
листа, каждый — до строки перед следующим; `type_hint` блока — `type_hint(okud_text, title_text, rules)`
- `code_column(sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, *, min_codes: int) -> int | None` —
столбец с наибольшим числом ячеек, где `parse_code(text).code` не `None`, при кодах ≥ `min_codes` и доле среди
непустых ≥ `code_ranges.min_share`
- `code_rows(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[int]`
не `None`) или ОКУД (`type_hint(text, None)`; ячейка, для которой `is_continuation(text)`, ОКУДом блока не
считается) — кандидаты; между соседними без строки с кодом (`code_rows` по `sheet_code_column` листа) — один блок;
первый блок начинается с первой строки листа, каждый — до строки перед следующим; `type_hint` блока —
`type_hint(okud_text, title_text, rules)`
- `code_rows(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[int]` — строки, где
`parse_code(text).code` ячейки столбца `col` не `None`
- `codes_of(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[str]`
- `amount_columns(sheet: Sheet, rules: StatementRules, code_col: int, first_row: int, last_row: int) -> int` — число
столбцов правее `code_col`, где хотя бы в одной строке с кодом есть число (числовая ячейка или текст, у которого
`parse_amount(text, Source.EXCEL).amount` не `None`)
- `continuation_marker(sheet: Sheet, rules: StatementRules) -> Cell | None` — первая ячейка, совпавшая с
`headers.continuation` (тип — `continuation_type(cell.text, rules)`)
- `sheet_amount_columns(sheet: Sheet, rules: StatementRules, code_col: int, first_row: int, last_row: int) -> int` —
`len(amount_columns(sheet_grid(...), code_col - 1, rules))`: столбцы правее столбца кодов, где в строке с кодом есть
число (то же правило, что колонки сегмента без заголовка в шаге 2 пакета)
- `continuation_marker(sheet: Sheet, rules: StatementRules) -> Cell | None` — первая ячейка, для которой
`is_continuation(text, rules)` (маркер с ОКУД вне словаря — тоже маркер; тип — `continuation_type(cell.text, rules)`,
может быть `None`)
- `StatementSheetKind = Literal["statement", "continuation", "other"]`;
`classify_statement_sheet(sheet: Sheet, rules: StatementRules) -> StatementSheetKind` — есть блок → `statement`;
маркер, или подпись `headers.code_caption` с подписью-периодом правее (`parse_date` не `None` или совпадение
`periods.relative`), или `code_column(min_codes=code_ranges.min_codes)` → `continuation`; иначе `other`
- `is_weak_continuation(sheet: Sheet, rules: StatementRules) -> bool` — `code_column(min_codes=1)` не `None`
маркер, или ячейка с `caption_matches(headers.code_caption)` и подписью-периодом правее (`parse_date` не `None` или
`caption_matches(periods.relative)`), или `sheet_code_column(...)` не `None` → `continuation`; иначе `other`
- `is_weak_continuation(sheet: Sheet, rules: StatementRules, form_type: DocumentType) -> bool` —
`sheet_code_column(…, relaxed=form_type)` не `None`; `form_type` не `BALANCE`/`INCOME` → `False`
- Produces (`header_cells.py`):
- `header_cells(sheet: Sheet, rules: StatementRules, block: FormBlock, last_row: int, unparsed_formats: set[str]) ->
tuple[HeaderCells, Evidence]` — поиск в строках `block.first_row..last_row`; `last_row` задаёт адаптер (P4-6)
@ -441,14 +454,24 @@ def test_continuation_marker_is_not_a_block(rules: StatementRules) -> None:
def test_code_column_ignores_title_codes_block(rules: StatementRules) -> None: # Review Focus 1
# столбец F: «Коды», 0710001, 30, 09, 2025, 12345678, 7700000000, 12267, 16, 384 (один код из девяти);
# столбец C: 1110, 1150, 1170, 1100, 1210, 1230 → code_column(...) == 3
# столбец C: 1110, 1150, 1170, 1100, 1210, 1230 → sheet_code_column(...) == 3
def test_amount_columns_counts_numeric_columns_right_of_code(rules: StatementRules) -> None: ...
# код в C, числа в D, E, F в строках с кодами, текст «Изменение» в G без чисел → 3
# код в C, числа в D, E, F в строках с кодами, текст «Изменение» в G без чисел → sheet_amount_columns(...) == 3
def test_weak_and_other_sheets(rules: StatementRules) -> None: ...
# лист с единственной строкой «Чистая прибыль | 2400 | 10 | 8» → classify «other», is_weak_continuation True;
# лист «Примечания» с текстом → «other», is_weak_continuation False
# лист с единственной строкой «Чистая прибыль | 2400 | 10 | 8» → classify «other»,
# is_weak_continuation(…, DocumentType.INCOME) True, (…, DocumentType.BALANCE) False;
# лист «Примечания» с текстом → «other», is_weak_continuation(…, INCOME) False
def test_marker_with_unknown_okud_is_continuation(rules: StatementRules) -> None: ...
# лист «Форма 0710000 с.2» + таблица с кодами → find_blocks == [], classify «continuation»,
# continuation_marker(...).text == "Форма 0710000 с.2", continuation_type(...) is None
def test_labels_with_punctuation_match(rules: StatementRules) -> None:
assert matches(rules.headers.labels["address"], "Местонахождение (адрес)")
assert matches(rules.headers.labels["okopf_okfs"], "по ОКОПФ / ОКФС")
assert matches(rules.headers.legal_form, "АО «Пример»")
def test_header_cells_of_1c_block(rules: StatementRules) -> None: ...
# «Организация | АО «Пример»», «ИНН | 7700000000», «по ОКПО | 12345678», «по ОКОПФ / ОКФС | 12267 | 16» строкой выше
@ -501,30 +524,34 @@ git -C $T commit -m "feat(statements): блоки форм, вид листа и
**Interfaces:**
- Consumes: задачи 3–4; `FormCandidate`, `Evidence`, `HeaderCells`, `Source`, `DocumentType`, `make_warning`,
`sheet_joins`, `continuation_type`, `Deadline`, `SheetKind`.
`WarningCode`, `sheet_joins`, `continuation_type`, `Deadline`, `SheetKind`.
- Produces:
- `detect.is_osv_sheet(sheet: Sheet, rules: Rules) -> bool` — признак ведомости в первых `SHEET_MARKER_ROWS` строках
- `adapter.SheetPlan(osv: tuple[Sheet, ...], statements: tuple[tuple[Sheet, SheetKind], ...], other: tuple[Sheet, ...])`
- `adapter.plan_sheets(sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules) -> SheetPlan` —
по порядку: ОСВ; `statement` → `SheetKind.STATEMENT`; `continuation` → `STATEMENT_CONTINUATION`; `other` со слабым
продолжением сразу после листа формы или продолжения → `STATEMENT_CONTINUATION`; иначе `other`. Лист ОСВ или «прочее»
прерывает «сразу после».
по порядку: ОСВ; `statement` → `SheetKind.STATEMENT`; `continuation` → `STATEMENT_CONTINUATION`; `other` сразу после
листа формы или продолжения и `is_weak_continuation(лист, rules, <type_hint последнего блока последнего листа
формы>)` → `STATEMENT_CONTINUATION`; иначе `other`. Лист ОСВ или «прочее» прерывает «сразу после».
- `adapter.build_candidates(sheets: Sequence[tuple[Sheet, SheetKind]], rules: StatementRules, deadline: Deadline, *,
xlsb: bool) -> list[FormCandidate]`
Алгоритм `build_candidates` (спека «Шаг 1 — многостраничная форма», §11.2 п. 2–4; P4-6, P4-8, P4-10):
1. Лист `STATEMENT`: для каждого блока — новый кандидат: `source=EXCEL`, `locator="sheet:<имя>"`, `type_hint` блока,
`evidence` и `header` — `header_cells(…, last_row=<строка перед первой строкой с кодом столбца кодов блока>)` (кодов
нет — `block.last_row`), `rows = grid_rows(sheet, block.first_row, block.last_row, …)`.
`evidence` и `header` — `header_cells(…, last_row=<строка перед первой строкой с кодом столбца кодов блока>)`
(столбец — `sheet_code_column` блока, строки — `code_rows`; кодов нет — `block.last_row`), `rows = grid_rows(sheet,
block.first_row, block.last_row, …)`.
2. Лист `STATEMENT_CONTINUATION`: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`. Маркер есть и его
тип не `None` и не равен типу формы → отдельный кандидат (`type_hint` = тип маркера) + `CONTINUATION_TYPE_CONFLICT`.
Маркера нет → `sheet_joins(type, codes, amount_columns(лист), amount_columns(первый лист формы в её блоке), rules)`;
Маркера нет → `sheet_joins(type, codes, sheet_amount_columns(лист), sheet_amount_columns(первый лист формы в её
блоке), rules)`, где столбец кодов листа — `sheet_code_column(лист, …)`, а если его нет —
`sheet_code_column(лист, …, relaxed=type)` (как сегмент шага 2 пакета), `codes` — `codes_of` по нему;
`False` → отдельный кандидат (`type_hint=None`) + `SHEET_NOT_JOINED`. Открытой формы нет → отдельный кандидат
(`type_hint` = тип маркера или `None`) без предупреждения. У отдельного кандидата `header = HeaderCells()`,
`evidence = Evidence()`, `rows` — все строки листа. Иначе — присоединение: `segment_breaks` += текущее число строк
сетки, строки листа (все строки листа) дописываются, `locator` += `,<имя>`.
3. Ширина: строки всех листов кандидата выравниваются по наибольшей ширине (дописываются `None`).
4. Предупреждения кандидата (`make_warning`, документа): `xlsb` → `XLSB_NO_FORMATS` у каждого кандидата; непустой набор
4. Предупреждения кандидата (`make_warning` с `WarningCode`, документа): `xlsb` → `XLSB_NO_FORMATS` у каждого
кандидата; непустой набор
неразобранных форматов всех листов кандидата → одно `NUMBER_FORMAT_UNPARSED` с перечнем (`«fmt»` через запятую,
длинные — до 30 символов с «…»).
5. `deadline.check(f"лист «{name}»: форма")` перед каждым листом.
@ -609,7 +636,8 @@ git -C $T commit -m "feat(statements): адаптер шага 1 — книга
- коды (P4-12): `OSV_HIERARCHY_BY_OUTLINE|INDENT|ACCOUNT_PATTERN|SUMS|TRIVIAL` (`hierarchy_note(strategy: str) ->
OsvNote`), `OSV_HEADER` (предупреждения шапки `doc.warnings`), `OSV_ROWS` (предупреждения строк `collect_body_rows`),
`OSV_INN_ROWS` (`glue.warnings`), `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL` (итог не
найден или не равен корню), `OSV_XLSB_NO_OUTLINE`, `OSV_XLS_NO_CODEPAGE` (заметки читателя, задача 7)
найден или не равен корню), `OSV_XLSB_NO_OUTLINE` (заметка читателя, задача 7; «xls без кодовой страницы» — не
код ОСВ, а предупреждение файла `XLS_NO_CODEPAGE` пакета, P4-13)
- `legacy_line(sheet_name: str, note: OsvNote) -> str` — `f"sheet={sheet_name} {note.message}"` (вывод 1.x до задачи 7)
- Produces (`checks.py`): `run_checks(rows, exempt=None) -> tuple[OsvChecks, list[OsvNote]]` — тексты прежние, коды по
виду проверки.
@ -663,7 +691,7 @@ git -C $T commit -m "refactor(osv): предупреждения листа ОС
`*.v1.schema.json` и `examples/v1/` не копируются), `$T/api/src/contract/{common,request,result,message,__init__}.py`,
`$T/api/src/parser/{__init__,book,detect,osv,cli}.py`, `$T/api/src/parser/readers/__init__.py`,
`$T/api/src/core/worker.py`, `$T/api/src/app.py`
- Create: `$T/api/src/parser/statements/contract.py`, `$T/api/src/parser/statements/pipeline.py`
- Create: `$T/api/src/parser/statements/pipeline.py`
- Test: `$T/api/tests/result_contract.py`, `$T/api/tests/golden_helpers.py` (`VOLATILE`),
`$T/api/tests/unit/test_contract.py`, `$T/api/tests/unit/test_contract_v12.py` → `test_contract_v20.py`,
`$T/api/tests/unit/parser/test_book_parser.py`, `$T/api/tests/unit/parser/statements/test_pipeline.py` (новый),
@ -672,8 +700,8 @@ git -C $T commit -m "refactor(osv): предупреждения листа ОС
`$T/api/tests/integration/test_roundtrip.py`
**Interfaces:**
- Consumes: задачи 3–6; `normalize_candidate`, `envelope_status`, `make_warning`, `to_jsonable`, `default_rules`,
`statements_version`, `DocStatus`, `RulesError`.
- Consumes: задачи 3–6; `normalize_candidate`, `envelope_status`, `make_warning`, `WarningCode`, `to_contract`,
`warning_to_contract`, `default_rules`, `statements_version`, `DocStatus`, `RulesError`.
- Produces (контракт, `src/contract/`):
- `CONTRACT_VERSION = "2.0"` (результат и конверт); `SCHEMA_VERSION_PATTERN = r"^2\.\d+$"`;
`REQUEST_SCHEMA_VERSION_PATTERN = r"^1\.\d+$"`, `RequestSchemaVersion` — у `RecognitionRequest`
@ -688,19 +716,20 @@ git -C $T commit -m "refactor(osv): предупреждения листа ОС
`needs_review`); `StatementLine` (`row_index`, `ref`, `line_status`, `line_code: str | None`, `line_code_text`,
`line_code_confidence`, `line_name: str | None`, `line_name_confidence`, `parent_ref`, `needs_review`, `values`;
валидатор: `line_code` не `None` ⇔ `line_status == "ok"`); `FailedRelation` (+ `codes`, `expected`/`actual` —
`Money`); `StatementChecks` без `duplicate_line_codes`; `StatementDocument` (`document_type: Literal["BALANCE",
`Money | None`: `null` — цель или все члены пусты, план 1 P1-21); `StatementChecks` без `duplicate_line_codes`; `StatementDocument` (`document_type: Literal["BALANCE",
"INCOME", "DISCLOSURE"]`, `form_variant: Literal["full", "simplified"] | None` обязателен, `status`, `warnings`;
валидатор `checks` по `allOf` схемы)
- Produces (`statements/contract.py`):
- `to_contract_warning(warning: StatementWarning) -> ResultWarning`; `to_file_warning(warning: StatementWarning) -> FileWarning`
- `to_contract_document(document: arm_statements.StatementDocument, *, form_name: str | None) -> StatementDocument` —
из `to_jsonable(document)`: шапка `HeaderField {text, confidence, value}` → плоские `<поле>`, `<поле>_text`,
`<поле>_confidence`; у `FailedRelation` отбрасывается `column_key`; `checks = None` — ключа нет
- Перевод в контракт — пакетом (план 1, задача 19), своего `statements/contract.py` нет: документ формы —
`StatementDocument.model_validate(to_contract(document))` (шапка плоская, `form_name` из `document.header.form_name`,
без `column_key`, `checks` нет ⇒ ключа нет); предупреждение файла — `FileWarning.model_validate(warning_to_contract(w,
file_level=True))`; предупреждение листа ОСВ (`OsvNote` → `ResultWarning(code, severity="info", message)`) строится
моделью напрямую (коды `OSV_*` не в словаре пакета)
- Produces (`statements/pipeline.py`):
- `StatementsOutcome(documents: tuple[StatementDocument, ...], file_warnings: tuple[FileWarning, ...])`
- `StatementPipeline(rules: StatementRules)` с методом `parse(sheets: Sequence[tuple[Sheet, SheetKind]], deadline:
Deadline, *, xlsb: bool) -> StatementsOutcome` — `build_candidates` → для каждого кандидата `deadline.check` и
`normalize_candidate` (P4-11; `form_name` = `candidate.evidence.title.text`, если есть) → конвертация
`normalize_candidate` (P4-11) → `to_contract` / `warning_to_contract` (заголовок формы — уже в
`document.header.form_name`)
- Produces (книга): `BookParser(rules, *, osv: OsvParser, statements: StatementPipeline, statement_rules:
StatementRules, max_bytes, timeout_seconds, clock)`; `ParseOutcome` прежний (документы — модели v2.0,
`metrics.warnings` — `FileWarning`); `readers.BookRead(sheets, warnings, detected, mismatch: str | None)` — строка
@ -717,8 +746,10 @@ git -C $T commit -m "refactor(osv): предупреждения листа ОС
1. лимиты и чтение — как сейчас; `read.mismatch` → `FILE_TYPE_MISMATCH` (файл, `info`, сообщение — прежний текст
«file_type=…, но содержимое …: прочитано как …»);
2. `plan_sheets(read.sheets, is_osv_sheet, statement_rules)`; листы ОСВ — по порядку `parse_sheet`: документ получает
`status = done if ok else needs_check` и `warnings` из заметок (`severity = info`), плюс заметки читателя
`OSV_XLSB_NO_OUTLINE` / `OSV_XLS_NO_CODEPAGE` в каждый документ ОСВ книги (P4-13); сообщение иерархии — «иерархия строк
`status = done if ok else needs_check` и `warnings` из заметок (`severity = info`), плюс заметка читателя
`OSV_XLSB_NO_OUTLINE` в каждый документ ОСВ книги (P4-13); заметка «xls без кодовой страницы» — предупреждение
файла `make_warning(WarningCode.XLS_NO_CODEPAGE, …)` (`info`, без `locator`) в `metrics.warnings`, в любой книге xls
(с ОСВ, формами или и тем, и другим); сообщение иерархии — «иерархия строк
— по группировке листа (outline)» / «по отступам (indent)» / «по кодам счетов (account_pattern)» / «по суммам (sums)» /
«плоский список (trivial)»;
3. листы `other` → `SHEET_OTHER` (файл, `info`, `locator = sheet:<имя>`, сообщение «лист без признаков ОСВ и формы»);
@ -775,6 +806,9 @@ def test_other_sheet_is_file_warning() -> None: # спека: «Лист «п
# книга [ББ, «Примечания»] → документов 1, metrics.warnings == [SHEET_OTHER info, locator "sheet:Примечания"]
def test_file_type_mismatch_is_info_file_warning() -> None: ...
def test_xls_without_codepage_is_file_warning() -> None: ...
# книга xls (ОСВ + ББ) с заметкой читателя «нет кодовой страницы» → metrics.warnings содержит
# {code: "XLS_NO_CODEPAGE", severity: "info"}; у документов ОСВ нет OSV_XLS_NO_CODEPAGE
def test_disclosures_only_book_is_done() -> None: # спека: «Книга только из пояснений»; Review Focus 5
# листы «0710003» (ОКУД, без кодов) и «Пояснения к бухгалтерскому балансу» (без кодов) →
# [(d.document_type, d.status) for d in docs] == [("DISCLOSURE", "done")] * 2, outcome.status is DONE
@ -1013,33 +1047,36 @@ tea pulls create --login home --repo RSHB/arm-tabular-parser --head feat/<issue>
- **P4-5.** Сетка кандидата: строки блока целиком, включая пустые; объединённая ячейка — только в позиции левой верхней,
прочие позиции `None`; ширина — наибольшая среди листов кандидата. Цена: подпись, объединённая на два столбца, видна
пакету только в левом.
- **P4-6.** Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (правило (б) по `parse_code`), без
кодов — во всём блоке: так титульный блок «Коды» (ОКОПФ 12267) не мешает, а подписи под формой (ИНН и ФИО в блоке
- **P4-6.** Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (столбец — `code_column` пакета:
то же правило (а)/(б) шага 2 с защитой от блока «Коды», своего правила у адаптера нет), без кодов — во всём блоке: так титульный блок «Коды» (ОКОПФ 12267) не мешает, а подписи под формой (ИНН и ФИО в блоке
подписей) не попадают в шапку. Цена: метка шапки ниже начала таблицы не найдётся.
- **P4-7.** Регулярные выражения словаря пакета (`Rules.headers.*`) адаптер применяет к `normalize_text` tabular-parser
(нижний регистр, «ё» → «е», дефисы/тире/переносы → пробел) — так записаны и перенесены словари (P1-13), так же
нормализует `type_hint`. Цена: если пакет сменит нормализацию своих меток, адаптер разойдётся молча (O-2).
- **P4-7.** Шаблоны словаря пакета адаптер применяет к тексту, нормализованному функцией пакета по таблице «Словари»
раздела API плана 1: метки шапки, заголовки, ОКУД, `auditor`, `legal_form` — `normalize_label` (= прежний
`normalize_text` tabular-parser, P1-22); подпись «Код» и относительные периоды — `normalize_caption`; маркер
продолжения — `is_continuation`. Цена: нет — смена нормализации в пакете меняет и `type_hint`, и адаптер.
- **P4-8.** Склейка: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`; лист с маркером сверяется только
по типу; лист без маркера (сильное продолжение — колонка кодов или своя строка заголовка; слабое — 1–2 кода сразу после
листа формы) — `sheet_joins` с числом колонок сумм, посчитанным адаптером («столбцы правее столбца кодов, где в строках с
кодами есть число»); открытой формы нет — отдельный кандидат без предупреждения (пакет даст `NO_ORGANIZATION_NO_INN`).
по типу; лист без маркера (сильное продолжение — столбец кодов по правилу шага 2 или своя строка заголовка; слабое —
столбец кодов ослабленным поиском `code_column(…, relaxed=<тип формы>)`, минимум `min_codes_continuation` кодов,
сразу после листа формы) — `sheet_joins` с числом колонок сумм `len(amount_columns(...))` пакета («столбцы правее
столбца кодов, где в строках с кодами есть число»); открытой формы нет — отдельный кандидат без предупреждения (пакет даст `NO_ORGANIZATION_NO_INN`).
Цена: «Изменение, %» с числами считается колонкой сумм с обеих сторон сравнения; лист с иной шапкой, но тем же числом
колонок, присоединится и будет сопоставлен пакетом по датам.
- **P4-9.** `SHEET_OTHER` для листов «прочее» выдаёт книга сама (документа и кандидата нет), а не пакет по кандидату без
`type_hint`. Цена: два места порождения одного кода (у пакета — для кандидатов, не нашедших ни типа, ни кодов).
- **P4-10.** `XLSB_NO_FORMATS` — у каждого кандидата книги xlsb (P1-5); `NUMBER_FORMAT_UNPARSED` — одно на кандидата с
перечнем форматов всех его листов. Цена: у пояснений xlsb тоже есть `XLSB_NO_FORMATS` (`info`).
- **P4-11.** Книга вызывает `normalize_candidate` по одному кандидату, а не `normalize`: так сохраняется пара «кандидат →
документ» для `header.form_name` (текст заголовка из `evidence.title`) и проверяется дедлайн между кандидатами.
- **P4-11.** Книга вызывает `normalize_candidate` по одному кандидату, а не `normalize`: так проверяется дедлайн между
кандидатами (`header.form_name` пакет ставит сам из `evidence.title`, пара «кандидат → документ» не нужна).
Исключение пакета на кандидате — `RECOGNITION_FAILED` всей книги, как сейчас (`TABLE_PARSE_ERROR` — код таблицы ИС).
Цена: одна сломанная форма роняет книгу с ОСВ.
- **P4-12.** Коды ОСВ — по источнику предупреждения, не по тексту: `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`, `OSV_HEADER`,
`OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`,
`OSV_XLSB_NO_OUTLINE`, `OSV_XLS_NO_CODEPAGE`; тексты сообщений прежние, кроме иерархии. Цена: внутри `OSV_HEADER` и
`OSV_XLSB_NO_OUTLINE`; тексты сообщений прежние, кроме иерархии. Цена: внутри `OSV_HEADER` и
`OSV_ROWS` разные случаи различаются только текстом.
- **P4-13.** Заметки читателя: несовпадение формата — `FILE_TYPE_MISMATCH` файла; «xlsb без outline» и «xls без кодовой
страницы» — в каждый документ ОСВ книги (`OSV_XLSB_NO_OUTLINE`, `OSV_XLS_NO_CODEPAGE`); в книге без ОСВ они только
логируются. Цена: у форм из xls без кодовой страницы предупреждения нет (O-5).
- **P4-13.** Заметки читателя: несовпадение формата — `FILE_TYPE_MISMATCH` файла; «xls без кодовой страницы» —
`XLS_NO_CODEPAGE` файла (`info`, код пакета, P1-23) в любой книге xls — касается и форм, и ОСВ; «xlsb без outline» — в
каждый документ ОСВ книги (`OSV_XLSB_NO_OUTLINE`), в книге без ОСВ только логируется (иерархия строк — только у ОСВ).
Цена: предупреждение о кодировке видно в карточке файла, а не у листа ОСВ.
- **P4-14.** Предупреждение «форма не разобрана: книга завершилась ошибкой листа ОСВ» снято: в v2.0 для него нет кода, а
текст ошибки и так называет лист ОСВ. Цена: аналитик не видит списка листов форм, оставшихся неразобранными.
- **P4-15.** Статус документа ОСВ — прежние условия листа: контрольные суммы §1.4 сошлись и строки ИНН без нарушений
@ -1063,34 +1100,21 @@ tea pulls create --login home --repo RSHB/arm-tabular-parser --head feat/<issue>
## Чего не хватает в API пакета для адаптера Excel (O-n)
План 1 этим планом не правится; контроллер передаёт пункты владельцу плана 1.
Пункты O-1…O-7 и O-9 закрыты сведением с планом 1 (18.10.2026, его P1-21…P1-31); маркеры сохранены.
- **O-1.** Нет публичной функции «столбец кодов листа / сетки» и «число колонок сумм»: адаптеру они нужны для вида листа,
границы шапки и аргументов `sheet_joins(…, sheet_amount_columns, form_amount_columns, …)`. Адаптер повторяет
упрощённое правило (б) на `parse_code` и `Rules.code_ranges.min_share/min_codes` (P4-6, P4-8) — риск разойтись с шагом 2
пакета. Предложение: экспортировать `code_column(rows, rules) -> int | None` и `amount_columns(rows, code_col, rules) ->
int` над `rows` кандидата.
- **O-2.** Не задана и не экспортирована нормализация текста, под которую записаны `Rules.headers.labels`, `auditor`,
`legal_form`, `continuation`, `form_titles`, `okud`. `normalize_caption` (внутренний) заменяет всю пунктуацию пробелом —
под ним не сработают перенесённые метки `местонахождение\s*\(адрес\)`, `по\s+окопф\s*/\s*окфс`, `с\.?` маркера и
`legal_form` `"`, `«`. Нужно: в плане 1 зафиксировать нормализацию меток (как `normalize_text` tabular-parser) и
экспортировать её (`normalize_label`), иначе P4-7.
- **O-3.** У `StatementDocument` пакета нет `form_name`, а в схеме v2.0 `header.form_name` есть: хост берёт его из
`evidence.title` кандидата (P4-11). Если поле нужно и scan-gateway — его место в пакете.
- **O-4.** `continuation_type` возвращает `None` и для «не маркер», и для «маркер без ОКУД» — адаптер узнаёт маркер по
`Rules.headers.continuation` напрямую. Предложение: `is_continuation(text, rules) -> bool` в API.
- **O-5.** Для заметок чтения книги, относящихся и к формам (xls без кодовой страницы), нет кода в
`statement_warnings.yaml` / таблице §11 (P4-13). Предложение: `XLS_NO_CODEPAGE` (`info`, файл).
- **O-6.** Ключи `Rules.headers.labels` (`organization`, `inn`, `okpo`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`,
`address`, `date`) — контракт между пакетом и адаптером, но в API плана 1 они не перечислены (сказано лишь «дословно из
tabular-parser»). Нужна строка в разделе API и тест ключей в пакете.
- **O-7.** В плане 1 у `sheet_joins` и `continuation_type` написано «для адаптера Excel плана 3» — адаптер Excel в плане 4
(план 3 — arm-core). Описка, на имена не влияет.
- **O-1.** Закрыт: `code_column(rows, rules, *, relaxed)` и `amount_columns(rows, code_col, rules, *, source) ->
tuple[int, ...]` в API (P1-28); адаптер зовёт их через `sheet_code_column` / `sheet_amount_columns` (задача 4), своего
правила (б) нет.
- **O-2.** Закрыт: `normalize_label` и `normalize_caption` в API, таблица «ключ словаря → нормализация» (P1-22); P4-7.
- **O-3.** Закрыт: `StatementHeader.form_name` в пакете (P1-29); адаптер `form_name` не ставит.
- **O-4.** Закрыт: `is_continuation(text, rules) -> bool` в API; `continuation_marker` на нём (задача 4).
- **O-5.** Закрыт: код `XLS_NO_CODEPAGE` (`info`, файл) в словаре пакета, таблице спеки и §11.9 (P1-23); P4-13.
- **O-6.** Закрыт: ключи `Rules.headers.labels` перечислены в API плана 1 (`LABEL_KEYS`), загрузчик пакета их проверяет.
- **O-7.** Закрыт: описка «плана 3» в плане 1 исправлена на «плана 4».
- **O-8.** Покрытие словарей форм реальными файлами теряется (P4-19): в пакете тесты только на синтетике. Предложение:
тест покрытия `Rules.headers.*` в tabular-parser на `statements-xlsx/**` (как покрытие словарей ОСВ) — follow-up.
- **O-9.** Порог слабого продолжения (`min_codes_continuation: 1` в словаре tabular-parser) в словарь пакета не
перенесён; адаптер использует 1 и `code_ranges.min_codes` как границы «1–2 кода». Если порог должен быть данными —
ключ в `statement_code_ranges.yaml`.
- **O-9.** Закрыт: `min_codes_continuation: 1` — в `statement_code_ranges.yaml` пакета (`Rules.code_ranges`), им
пользуются ослабленный поиск `code_column` и `sheet_joins` (P1-28).
- **O-10.** Сверено с планом 2 (появился в ветке во время написания): подготовку хоста делает его задача 8, задача 2
этого плана сведена к сверке и `--in-place`; `.dockerignore` не меняется (план 2: «`api/arm_statements` не добавлять в
`.dockerignore`» — тесты копии попадают в образ, это его решение). Пин `pytest` план 2 не вводит — здесь тоже нет.

View File

@ -15,8 +15,10 @@
нормализатор — три слоя чистых функций: `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`, из которого воркер, как и прежде, собирает файл результата и конверт.
контрактом — функции пакета `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.
@ -77,16 +79,18 @@ scan-gateway красный на машине с fixtures — окно держ
- `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` пакета (уровень — из словаря
пакета).
`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`, `normalizer/contract_map.py`
— чистые: без I/O, без настроек, без логов (как `ocr/*` сейчас).
- Функции `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; в этом плане не меняется.
@ -148,7 +152,6 @@ scan-gateway/
│ └── 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)
@ -160,7 +163,7 @@ scan-gateway/
└── unit/normalizer/
├── test_adapter.py # задача 2
├── test_answer_set.py # задача 3 (в том числе сквозной тест дат)
├── test_contract_map.py # задача 4
├── 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)
@ -434,21 +437,19 @@ Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W"
**Interfaces:**
- Consumes: задача 2 (`header_cells`, `table_candidate`, `TableStructureError`); из пакета — `normalize_candidate`,
`envelope_status`, `make_warning`, `EnvelopeStatus`, `StatementDocument`, `StatementWarning`, `Rules`; из
`src.contract` — `ErrorCode`.
`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("JSON_FAILED", f"{source_code}: {message}",
rules, locator=f"file:{name}")`; элемент без имени — `name = f"files[{index}]"`
методом `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) 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, ...],
- `@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`
@ -461,11 +462,12 @@ Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W"
→ `JSON_FAILED` «ответ ИС без table_structure»; пустой список → `JSON_FAILED` «в ответе ИС нет таблиц» (P5-7);
`locator` у всех трёх — `file:<имя>`.
3. Каждая таблица `i`: `table_candidate` и `normalize_candidate` в одном `try`; любое `Exception` →
`make_warning("TABLE_PARSE_ERROR", f"BACKEND_PARSING_ERROR: {type(exc).__name__}: {exc}", rules,
`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` (с `form_name`), `file_warnings` результата — в `file_warnings`. Порядок `file_warnings` — порядок файлов,
`documents` (заголовок таблицы — `document.header.form_name`, его ставит пакет из `evidence.title`), `file_warnings`
результата — в `file_warnings`. Порядок `file_warnings` — порядок файлов,
внутри файла — порядок таблиц.
4. `status = envelope_status([d.document.status for d in documents], file_warnings)`; `ERROR` → нет успешного json —
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**
@ -515,11 +517,11 @@ def merged(*files: tuple[str, Any]) -> dict[str, Any]:
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] == [
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.document.header.inn.value for d in res.documents} == {"7700000000"}
assert res.documents[0].document.status is DocStatus.DONE and res.error is None
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:
@ -541,7 +543,7 @@ def test_table_exception_is_file_warning(monkeypatch: pytest.MonkeyPatch) -> Non
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]
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: сломалось"
@ -605,7 +607,7 @@ def test_ocr_column_dates_end_to_end(label: str, expected: date) -> None:
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
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)
```
@ -643,19 +645,18 @@ Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W"
- Copy: `docs/contracts/*.schema.json` без `*.v<N>.schema.json` → `$WT/api/contracts/`; `docs/contracts/examples/*.json`
(только верхний уровень) → `$WT/api/contracts/examples/` (источник — `$WS/docs`, docs `main` после #117)
- Modify: `$WT/api/src/contract/{common,request,message,result,__init__}.py`
- Create: `$WT/api/src/normalizer/contract_map.py`
- Modify: `$WT/api/src/normalizer/__init__.py`, `$WT/api/src/normalizer/ocr/__init__.py`, `$WT/api/src/core/worker.py`,
`$WT/api/src/app.py`
- Delete: `$WT/api/src/normalizer/ocr/document.py`, `$WT/api/tests/unit/normalizer/test_document.py`
- Test: `$WT/api/tests/unit/test_contract.py`, `$WT/api/tests/unit/normalizer/test_contract_map.py`,
- 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`, `TableDocument`, `FileError.to_warning`); из пакета —
`to_jsonable`, `statements_version`, `default_rules`, `Rules`, `StatementDocument`, `StatementWarning`.
- 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 **запроса**
@ -671,28 +672,29 @@ Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W"
`<поле>_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)`;
`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` задан».
- 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` остаётся
- Перевод в контракт — пакетом (план 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())` →
`file_warning_to_contract`); итог прежний (`NOT_IMPLEMENTED` / код первого)
`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` по значению), документы `document_to_contract`, `warnings` `file_warning_to_contract`, `metrics =
`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,
@ -711,7 +713,9 @@ cp $WS/docs/contracts/recognition-request.schema.json $WS/docs/contracts/recogni
cp $WS/docs/contracts/examples/*.json $WT/api/contracts/examples/
```
Тесты (`tests/unit/normalizer/test_contract_map.py` — новые; в `test_contract.py` — добавить к существующему набору;
Тесты (`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`):
@ -731,10 +735,19 @@ def test_mapped_document_passes_schema_v2() -> None:
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_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:
@ -765,14 +778,14 @@ def test_normalizer_requires_statements_version(monkeypatch: pytest.MonkeyPatch)
- [ ] **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`.
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: `contract_map.py`, `NormalizeOutcome`, `NullNormalizer`, `OcrNormalizer`, `worker.build_result`,
`_outcome_metrics`, `app.py`** по Interfaces; удалить `ocr/document.py`, старый код `ocr/__init__.py` и
- [ ] **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`).
@ -1006,8 +1019,8 @@ Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W"
ссылка на §11.9; «Эталоны» — формат v2; «Переменные окружения» — без `SCAN_RULES_DIR`; «Проверки» — `statements-check`.
CLAUDE.md: абзац «Что это» — `statementDocument` v2.0 общим кодом; «Правила» — пункты про перенос `ocr/*`, `api/rules`,
статус и строки предупреждений заменить на: адаптер (`normalizer/ocr/adapter.py`), обход набора
(`normalizer/ocr/answer_set.py`), граница контракта (`normalizer/contract_map.py`), копия `api/arm_statements/` не
правится (`sync-statements`), предупреждения — структуры; «Добавлено» — новые модули, удалённые — убрать.
(`normalizer/ocr/answer_set.py`), граница контракта (`to_contract` пакета в `normalizer/ocr/__init__.py`), копия
`api/arm_statements/` не правится (`sync-statements`), предупреждения — структуры; «Добавлено» — новые модули, удалённые — убрать.
- [ ] **Step 3: Полная проверка**
@ -1054,10 +1067,11 @@ git -C $WT push -u origin feat/<issue>-statements-v2
- **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).
`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).
@ -1080,39 +1094,38 @@ git -C $WT push -u origin feat/<issue>-statements-v2
- **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-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`).
импортов `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; план 1 не правится)
## Чего не хватает в 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.** `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` шлюзу не нужна.
- **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)