AURORA-1734/1735: change statement-form-split — разрез кандидата по смене формы и редкая колонка (RSHB/pylib_ai_arm_statements#5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-07 08:54:42 +04:00
parent f74d5ccf9f
commit 1b92f2b2ca
4 changed files with 244 additions and 0 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-06

View File

@ -0,0 +1,44 @@
## Context
Решения владельца 06.11.2026 (выбраны рекомендованные варианты): 1734 — резать по смене формы у себя; 1735 — отсеивать
редкую колонку с неразобранной подписью. Разбор на JSON из задач (прогон нормализатора scan-gateway на пакете 0.1.3):
- АК-ЮЛ: один документ `BALANCE`, 54 кода (ББ + ОФР), `SEGMENT_COLUMNS_MISMATCH` у сегмента строки 19, десять жёстких
нарушений ББ. Строка 41 — шапка с «Коды» (столбец кодов сдвинут в столбец 2), сегмент открывается, но остаётся в ББ.
- Калтан, ОФР: колонки 4 / 5 («31») / 6, `COLUMN_WITHOUT_DATE` по столбцу 5; в столбце 5 разбирается одно число —
«$301 .» (тире «—», «——-» числами не считаются).
## Decisions
- **Разрез — отдельная функция до раскладки.** `normalize_candidate` возвращает один документ, хосты вызывают его на
каждый кандидат с изоляцией ошибок. Менять `CandidateResult` на список ломает API у трёх потребителей; разрез на
кандидаты сохраняет шаги 2–8 нетронутыми — каждая часть проходит их как обычный кандидат (свои колонки, периоды,
§7). Сегменты для разреза ищутся тем же кодом шага 2 (`_split_and_analyse`), что и для раскладки.
- **Условие разреза:** своя шапка + ≥ `min_codes` (3) кодов + другой `document_type` по большинству кодов. Шапка нужна,
чтобы не резать по случайным строкам; порог — чтобы пара строк другой формы (OCR-мусор, «пояснения» в хвосте) не
создавала документ. Тип части — по первому сегменту части с определённым типом.
- **Часть `k ≥ 1`:** `type_hint = null`, пустые `evidence` и `warnings` — метка таблицы ИС относится к первой форме, а
предупреждения адаптера (единицы, шапка) уже есть у первой части; повторять их в каждой части — дубли.
- **Редкий столбец:** «между» — есть колонка периода левее и правее; «редкий» — число не более чем в одной строке с
кодом при ≥ 3 у каждой колонки периода сегмента. Правило применяется в `segment_columns` (у каждого сегмента со своей
шапкой), адреса идут в общее `NUMBERS_OUTSIDE_TABLE` формы.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-11-06 | `locator` части — `<locator>;form:<k>` (k с 1); первая часть — прежний `locator` | Locator нигде не разбирается (ядро и UI показывают как строку); формат читаемый, у первой части ничего не меняется — golden и уже сохранённые документы не съезжают | Низкая: строка в карточке документа; поменять формат — одна строка кода |
| 2026-11-06 | Порог разреза — существующий `min_codes` словаря диапазонов (3), не новый ключ | Тот же смысл «достаточно кодов, чтобы считать это формой» уже есть в словаре | Низкая: порог — данные |
| 2026-11-06 | Разрез — общий для OCR и Excel | Правила шага 2 общие (как Р-44); у Excel формы обычно на разных листах — разрез почти не срабатывает, но лист «ББ + ОФР» с повторной шапкой тоже станет двумя документами | Средняя: golden tabular-parser может измениться — проверяется при подъёме пина, при сюрпризе — флаг источника |
| 2026-11-06 | Текст `NUMBERS_OUTSIDE_TABLE`: «числа вне колонок формы не учитываются», если есть адрес редкого столбца, иначе прежний | Прежний текст «правее таблицы» неверен для столбца посередине; прежние случаи и их golden не меняются | Нулевая: текст предупреждения |
| 2026-11-06 | «Редкий»: ≤ 1 число в столбце и ≥ 3 у каждой колонки периода | Калтан: 1 против 10+; столбец с двумя числами уже может быть данными — оставляем аналитику | Низкая: порог — константа пакета |
| 2026-11-06 | Реальные JSON АК-ЮЛ и Калтана в fixtures не кладём; тесты пакета — на синтетических кандидатах (`tests/builders.py`), прогон реальных файлов — вручную через нормализатор scan-gateway | Файлы заказчика с контура; синтетика воспроизводит обе раскладки строка в строку | Низкая |
## Risks / Trade-offs
- АК-ЮЛ после разреза: ОФР — отдельный документ, но подписи его колонок 5 («За I п / 20:») и 6 («юлугодие / 25 года»)
ИС тоже разорвала, и ни одна не разбирается как период — столбец 5 не «между периодами», правило редкого столбца не
срабатывает: обе колонки с `COLUMN_WITHOUT_DATE`, документ `needs_check`, аналитик ставит дату. Это честный результат
на испорченной шапке; ББ после разреза теряет 2xxx-строки и жёсткие нарушения, вызванные ими.
- Саракташагротехремонт — JSON в задаче нет (только PDF); ожидаем ту же раскладку, проверим после выкладки.
- Хостам нужен один вызов `split_by_form`; без него поведение прежнее — подъём пина без правки хоста ничего не ломает.

View File

@ -0,0 +1,42 @@
## Why
Замечания тестировщика заказчика 21.10.2026 (Яшин Д.О.):
- **AURORA-1734** — «Баланс АК-ЮЛ.pdf», «Баланс Саракташагротехремонт.pdf»: ИС Сканирование вернула ББ и ОФР **одной
таблицей** `table_structure[0]` с меткой «Бухгалтерский баланс». У нас таблица ИС — один кандидат, шапка ОФР на строке
41 — повторная шапка того же документа: строки ОФР попадают в ББ («Прочие строки»), под колонками и периодами ББ.
- **AURORA-1735** — «Баланск Калтан.pdf»: подпись «За I полугодие 2025 года» ИС разорвала на «31» (столбец 5) и «а I
полугодие 2025 года» (столбец 6); под обрывком «31» — одно постороннее число «$301» в строке 2400. Столбец 5 становится
колонкой без даты (`COLUMN_WITHOUT_DATE`, `error`), в ОФР три колонки вместо двух.
Оба дефекта — в ответе ИС, но владелец решил (06.11.2026) обрабатывать их у себя по общим правилам пакета.
## What Changes
- **Разрез кандидата по смене формы** (пакет `arm_statements`, новая функция `split_by_form`): сегмент со своей
шапкой, у которого не меньше `min_codes` кодов и тип формы по кодам другой, чем у текущей части, начинает новую часть —
отдельный документ со своими колонками и периодами. Хосты (scan-gateway, tabular-parser) вызывают разрез перед
`normalize_candidate`.
- **Редкий столбец с неразобранной подписью** между колонками периодов (число не более чем в одной строке с кодом при
трёх и более у колонок периодов) — не колонка сумм; его числа — в `NUMBERS_OUTSIDE_TABLE` (`info`) с текстом «числа вне
колонок формы не учитываются».
- Документы контекста: `05-domain-rules.md` §11 (п. 2 «Сегменты», п. 6 «Подпись без периода»), таблица предупреждений.
- Контракт, DBML, ядро — без изменений: части различаются `file_ordinal` (Р-10), `locator` части — строка с хвостом
`;form:<k>`.
## Non-goals
- Склейка разорванной подписи в период («31» + «а I полугодие 2025 года» → дата не восстанавливается из обрывков;
колонка 6 и так разбирается).
- Тип по метке файла ИС вместо метки таблицы (`TYPE_CONFLICT` у ОФР Калтана: `data_ocr.label` — «ОТЧЕТ О ФИНАНСОВЫХ
РЕЗУЛЬТАТАХ», метка таблицы — «Бухгалтерский баланс») — отдельное правило адаптера scan-gateway, не в этом change.
- Правило ИС «ОФР без ИНН — одним документом» (уточнение аналитика в AURORA-1734): мы режем по кодам, не по ИНН.
- Перераспознавание уже загруженных файлов.
## Impact
- Спека `statement-normalization`: новое требование «Шаг 2 — разрез кандидата по смене формы», изменены «Шаг 1 —
многостраничная форма» и «Шаг 2 — подпись и дата колонки».
- Репозитории: pylib_ai_arm_statements (0.1.5, RSHB/pylib_ai_arm_statements#5), arm-ka-docs (этот change); вызов
`split_by_form` и пин 0.1.5 в arm-scan-gateway и arm-tabular-parser — после merge AURORA-1677 (холд владельца до
выкатки гудвилла). arm-core — только пин (пересчёт не режет: документы уже разрезаны).

View File

@ -0,0 +1,156 @@
## ADDED Requirements
### Requirement: Шаг 2 — разрез кандидата по смене формы
Перед шагами 2–8 кандидат SHALL разрезаться на части по смене формы (AURORA-1734, решение владельца 06.11.2026).
Сегменты кандидата ищутся по правилу «Шаг 2 — сегменты, повторная шапка и границы листов». Тип формы сегмента — по
большинству его кодов (отрезок → `document_type`, как на шаге 6). Новая часть SHALL начинаться с первой строки сегмента,
если одновременно: у сегмента своя строка-шапка; кодов в нём не меньше порога `min_codes` словаря диапазонов; тип по
его кодам определён и отличается от типа текущей части (тип части — по первому сегменту части с определённым типом).
Сегменты без шапки, с малым числом кодов или того же типа остаются в текущей части.
Часть — обычный `FormCandidate` для шагов 2–8: строки сетки с первой строки сегмента до начала следующей части,
`segment_breaks` — сдвинутые на начало части; `header` — тот же, что у исходного кандидата (шапка файла ИС одна на
таблицу); `source` тот же. Первая часть сохраняет `locator`, `type_hint`, `evidence` и `warnings` исходного кандидата;
следующая часть `k` (с 1) получает `locator = "<locator исходного>;form:<k>"`, `type_hint = null`, пустые `evidence` и
`warnings` — её тип определяется на шаге 6 только по кодам, без `TYPE_CONFLICT` от заголовка таблицы. Кандидат без
смены формы SHALL оставаться одним кандидатом без изменений.
Пакет SHALL экспортировать функцию разреза (`split_by_form(candidate, rules) → tuple[FormCandidate, ...]`); хост
вызывает её на каждый кандидат и передаёт части в `normalize_candidate` по порядку. Документы частей — отдельные
документы результата (ядро различает их по `file_ordinal`, Р-10); модель данных и контракт не меняются. Правило общее
для OCR и Excel.
#### Scenario: ББ и ОФР одной таблицей ИС (АК-ЮЛ)
- **WHEN** таблица ИС «Бухгалтерский баланс» содержит строки 1110…1700 (две страницы ББ с повторной шапкой на строке
19), а со строки 41 — шапку «Наименование показателя | Коды | За I полугодие 2026 года | …» и строки 2110…2400
- **THEN** две части: ББ — строки 0–40, `locator = "file:<json>;table:0"`, колонки ББ; ОФР — строки 41–60,
`locator = "file:<json>;table:0;form:1"`, колонки и периоды по своей шапке, `TYPE_CONFLICT` нет; повторная шапка
строки 19 разреза не даёт (тот же тип)
#### Scenario: Раздел 25xx после ОФР
- **WHEN** в таблице ОФР повторная шапка открывает сегмент со строками 2510…2910
- **THEN** разреза нет: тип сегмента тот же (`INCOME`), сегмент — продолжение ОФР
#### Scenario: Пара строк другой формы
- **WHEN** после строк ББ повторная шапка открывает сегмент с двумя кодами 2xxx (меньше порога `min_codes`)
- **THEN** разреза нет, сегмент остаётся в части ББ (как раньше)
## MODIFIED Requirements
### Requirement: Шаг 1 — многостраничная форма
Требование относится к адаптеру Excel: у OCR каждая таблица ИС — отдельный кандидат, склейки таблиц нет (повторная
шапка внутри таблицы ИС — сегмент шага 2; смена формы внутри таблицы — разрез кандидата, требование «Шаг 2 — разрез
кандидата по смене формы»). У Excel одна форма SHALL быть одной сеткой на выходе шага 1. Строки продолжения дописываются в конец сетки по маркеру
«Форма … с.2» или по позиции листа; граница листа записывается в `segment_breaks`. Тип из маркера продолжения SHALL
сверяться с `type_hint` формы: расхождение — отдельный кандидат и предупреждение `CONTINUATION_TYPE_CONFLICT`
(`error`). Лист без маркера и без шапки присоединяется, только если его коды в диапазоне открытой формы и, при трёх и
более кодах, число колонок сумм совпадает с формой; иначе — отдельный кандидат и предупреждение
`SHEET_NOT_JOINED` (`error`).
#### Scenario: Лист-продолжение с одной строкой
- **WHEN** после листа ОФР идёт лист без шапки с единственной строкой 2400
- **THEN** лист присоединяется к ОФР (код в диапазоне формы, кодов меньше трёх — число колонок не сверяется), граница
листа — в `segment_breaks`
#### Scenario: Лист другой ширины с тремя кодами
- **WHEN** лист без маркера и шапки содержит три кода ББ и четыре колонки сумм при трёх у открытой формы
- **THEN** лист — отдельный кандидат, предупреждение `SHEET_NOT_JOINED` уровня `error`
### Requirement: Шаг 2 — подпись и дата колонки
Подпись колонки SHALL разбираться одним словарём периодов `statement_periods.yaml`, объединяющим словарь
tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dates.py::test_parses_real_forms` scan-gateway —
обязательные тесты словаря. Результат — `period_date` (конец периода колонки) и `period_kind`:
- абсолютная подпись («На 31 марта 2025 г.», «31.12.2024», «30/09/25», «30092025», «За 9 месяцев 2025 г.», «с
01.01.2025 по 31.03.2025», «2024», хвост «0:00:00», клетки «2|0|2|4|») → `absolute`;
- относительная: «На отчётную дату», «За отчётный период» → `relative:current` (= эффективная дата); «На 31 декабря
предыдущего года» → `relative:prev_year_end` (31.12 года эффективной даты − 1); «На 31 декабря года,
предшествующего предыдущему» → `relative:prev_prev_year_end` (31.12 года − 2); «За аналогичный период предыдущего
года» → `relative:prev_period` (эффективная дата − 1 год, 29 февраля → 28 февраля);
- подписи нет → `none`.
Эффективная дата документа SHALL быть датой отчёта из шапки, иначе самой поздней абсолютной датой колонок; нет ни
той, ни другой → у относительных колонок `period_date = null`, `period_kind` остаётся относительным.
Подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») SHALL пропускаться с
предупреждением `COLUMN_SKIPPED` (`info`). Подпись, не разобранная ни как период, ни как исключение, при числах в
столбце в строках с кодами SHALL оставлять колонку с подписью как есть, `period_date = null`, `period_kind = none` и
предупреждением `COLUMN_WITHOUT_DATE` (`error`, `column_index`); без чисел — пропуск. Подпись OCR с уверенностью ниже
100, разобранная в дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два
года, 31 декабря предыдущего и предшествующего ему года; иначе предупреждение `COLUMN_DATE_UNCERTAIN` (`error`,
`column_index`). Уверенность подписи — `label_confidence` (у Excel 100; подписи нет — `null`).
Если среди колонок значений сегмента есть хотя бы одна с подписью периода (абсолютной или относительной), столбец без
текста подписи правее **последней** такой колонки SHALL НЕ считаться колонкой сумм: его числа не попадают в значения
строк, `COLUMN_WITHOUT_DATE` не выдаётся, вместо этого выдаётся одно предупреждение `NUMBERS_OUTSIDE_TABLE` (`info`) с
адресами ячеек, собранными по всем сегментам формы (в тексте — первые 10, дальше «и ещё N»). Предупреждение SHALL НЕ
влиять на статус документа. Решение владельца 18.10.2026 (Р-44). Правило общее для Excel и OCR и живёт в пакете.
У сегмента без подписанных колонок (лист-продолжение без шапки) столбцы без текста подписи сверх числа колонок документа
SHALL считаться числами правее таблицы. Подписанный столбец без чисел в строках с кодами границей таблицы не служит.
Адрес ячейки — `<лист>!<A1>` у Excel и `row:<n>` у OCR. Без подписанных колонок, а также для столбца без подписи между
подписанными и для столбца с любым текстом подписи, не разобранным как период или исключение, поведение прежнее (`COLUMN_WITHOUT_DATE`),
кроме редкого столбца ниже.
**Редкий столбец с неразобранной подписью** (AURORA-1735, решение владельца 06.11.2026) SHALL НЕ считаться колонкой
сумм, если одновременно: у столбца есть текст подписи, не разобранный ни как период, ни как исключение; левее и правее
него в сегменте есть колонки значений с подписью периода; число стоит не более чем в одной строке с кодом этого
столбца, а в каждой колонке значений сегмента с подписью периода — не менее чем в трёх. Его числа не попадают в значения
строк, `COLUMN_WITHOUT_DATE` не выдаётся; адреса его чисел добавляются в то же предупреждение `NUMBERS_OUTSIDE_TABLE`
(`info`). Если среди адресов предупреждения есть адреса такого столбца, текст — «числа вне колонок формы не
учитываются: …», иначе прежний «числа правее таблицы формы не учитываются: …». Это разорванная OCR подпись периода
(«31» + «а I полугодие 2025 года») с посторонним числом под обрывком; столбец с числами в двух и более строках с кодом
остаётся колонкой с `COLUMN_WITHOUT_DATE`.
Подтверждение SHALL опираться только на уверенные источники, чтобы неуверенное не подтверждало само себя: для
неуверенной подписи эффективная дата — дата отчёта с уверенностью 100, иначе самая поздняя абсолютная дата колонок с
уверенной подписью (`label_confidence = 100`); такой нет — подпись не подтверждена. Неуверенная дата отчёта (шаг 8, поля
шапки) подтверждается только самой поздней абсолютной датой колонок с уверенной подписью. Для периода документа и
относительных подписей эффективная дата считается, как выше, по всем источникам.
#### Scenario: Бланк 66н без впечатанных дат
- **WHEN** подписи колонок ББ — «На отчетную дату», «На 31 декабря предыдущего года», «На 31 декабря года,
предшествующего предыдущему», дата отчёта шапки 30.09.2025
- **THEN** `period_date` колонок 2025-09-30, 2024-12-31, 2023-12-31, `period_kind` — `relative:current`,
`relative:prev_year_end`, `relative:prev_prev_year_end`; предупреждений о дате колонок нет
#### Scenario: Неуверенная подпись не подтверждает сама себя
- **WHEN** даты отчёта в шапке нет, единственная колонка с абсолютной датой — подпись OCR «На 30.09.2О25» с уверенностью
60, остальные колонки без подписи
- **THEN** эффективная дата для подтверждения строится без этой подписи — уверенных источников нет, подпись не
подтверждена: `COLUMN_DATE_UNCERTAIN` уровня `error`; период документа (`period_known`) по-прежнему определяется этой
датой
#### Scenario: Мусорная подпись над числами
- **WHEN** подпись колонки — `"| | г"` с уверенностью 17, а в колонке суммы
- **THEN** колонка остаётся, `period_date = null`, `period_kind = none`, предупреждение `COLUMN_WITHOUT_DATE` уровня
`error` с `column_index`, документ `needs_check`
#### Scenario: Числа справа от таблицы
- **WHEN** у ББ подписи периодов стоят над столбцами M, Q, S, а правее таблицы в строках с кодами лежат формулы
аналитика (V45, W57, U58, V58, V72, W72) над пустой шапкой (Межениновская 2025-Q3)
- **THEN** колонок значений три, числа U–W в значения не попадают, `COLUMN_WITHOUT_DATE` и `SEGMENT_COLUMNS_MISMATCH`
нет; одно предупреждение `NUMBERS_OUTSIDE_TABLE` уровня `info` с адресами этих ячеек; документ `done`, если нет других
причин
#### Scenario: Столбец без подписи между подписанными
- **WHEN** между колонками с подписями периода стоит столбец без подписи с числами
- **THEN** он остаётся колонкой с `COLUMN_WITHOUT_DATE` уровня `error`, `NUMBERS_OUTSIDE_TABLE` нет
#### Scenario: Подписанных колонок нет
- **WHEN** ни у одного столбца значений нет подписи периода
- **THEN** правило «числа справа от таблицы» не применяется, столбцы с числами — колонки с `COLUMN_WITHOUT_DATE`
#### Scenario: Обрывок подписи периода (Калтан, AURORA-1735)
- **WHEN** у ОФР подписи «За 1 полугодие 2026 года» (столбец 4), «31» (столбец 5), «а I полугодие 2025 года» (столбец 6);
в столбцах 4 и 6 суммы в десятке строк с кодом, в столбце 5 — одно число «$301 .» в строке 2400 и тире
- **THEN** колонок значений две (4 и 6), `COLUMN_WITHOUT_DATE` нет; `NUMBERS_OUTSIDE_TABLE` уровня `info` с адресом
ячейки строки 2400 столбца 5 и текстом «числа вне колонок формы не учитываются: row:21»; число 301 в значения не
попадает
#### Scenario: Неразобранная подпись над двумя числами
- **WHEN** между двумя колонками периодов стоит столбец с подписью «Прим.» (не из словаря исключений), числа в нём — в
двух строках с кодом
- **THEN** столбец остаётся колонкой с `COLUMN_WITHOUT_DATE` уровня `error`
#### Scenario: Колонка «Изменение»
- **WHEN** правее периодов колонка «Изменение, %»
- **THEN** колонка пропускается, предупреждение `COLUMN_SKIPPED` уровня `info`