From 37d58a486905f7d5f8c3a91936c8f2eea7871784 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 12:56:04 +0400 Subject: [PATCH] =?UTF-8?q?plan(#116):=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?4=20=D0=B8=205=20=E2=80=94=20=D0=BF=D0=BE=D1=82=D1=80=D0=B5?= =?UTF-8?q?=D0=B1=D0=B8=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B4=D0=BE=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20API=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../plans/2026-10-18-plan-4-tabular-parser.md | 212 ++++++++++-------- .../plans/2026-10-18-plan-5-scan-gateway.md | 157 +++++++------ 2 files changed, 203 insertions(+), 166 deletions(-) diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md index dc9ae2f..8345fcf 100644 --- a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md @@ -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 = <лист>!`) — в адаптере; разбор ОСВ не меняется, меняется только вид его предупреждений и статус листа. @@ -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:[,…]`; у ячейки `confidence = 100`, `text` — как показывает числовой формат, `value` — число по формату (`Decimal`), дата или `bool`; `ref = <лист>!`, имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф внутри имени @@ -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, )` → `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/ - **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/ ## Чего не хватает в 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 не вводит — здесь тоже нет. diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md index 70f9ad4..9625e05 100644 --- a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md @@ -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:`, документа у таблицы нет); `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.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/-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:.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:.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/-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:` у таблицы); выбран - `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)