From 1b92f2b2cae1acc60c86b5a8662df8a7f47d2ae4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 08:54:42 +0400 Subject: [PATCH] =?UTF-8?q?AURORA-1734/1735:=20change=20statement-form-spl?= =?UTF-8?q?it=20=E2=80=94=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D0=B7=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=8B=20=D0=B8=20=D1=80=D0=B5=D0=B4=D0=BA=D0=B0=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B0=20(RSHB/pylib=5Fai=5Farm?= =?UTF-8?q?=5Fstatements#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../statement-form-split/.openspec.yaml | 2 + .../changes/statement-form-split/design.md | 44 +++++ .../changes/statement-form-split/proposal.md | 42 +++++ .../specs/statement-normalization/spec.md | 156 ++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 openspec/changes/statement-form-split/.openspec.yaml create mode 100644 openspec/changes/statement-form-split/design.md create mode 100644 openspec/changes/statement-form-split/proposal.md create mode 100644 openspec/changes/statement-form-split/specs/statement-normalization/spec.md diff --git a/openspec/changes/statement-form-split/.openspec.yaml b/openspec/changes/statement-form-split/.openspec.yaml new file mode 100644 index 0000000..8a94d76 --- /dev/null +++ b/openspec/changes/statement-form-split/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-06 diff --git a/openspec/changes/statement-form-split/design.md b/openspec/changes/statement-form-split/design.md new file mode 100644 index 0000000..a019652 --- /dev/null +++ b/openspec/changes/statement-form-split/design.md @@ -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` части — `;form:` (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`; без него поведение прежнее — подъём пина без правки хоста ничего не ломает. diff --git a/openspec/changes/statement-form-split/proposal.md b/openspec/changes/statement-form-split/proposal.md new file mode 100644 index 0000000..e50fa71 --- /dev/null +++ b/openspec/changes/statement-form-split/proposal.md @@ -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:`. + +## 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 — только пин (пересчёт не режет: документы уже разрезаны). diff --git a/openspec/changes/statement-form-split/specs/statement-normalization/spec.md b/openspec/changes/statement-form-split/specs/statement-normalization/spec.md new file mode 100644 index 0000000..3dbd082 --- /dev/null +++ b/openspec/changes/statement-form-split/specs/statement-normalization/spec.md @@ -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 = ";form:"`, `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:;table:0"`, колонки ББ; ОФР — строки 41–60, + `locator = "file:;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 считаться числами правее таблицы. Подписанный столбец без чисел в строках с кодами границей таблицы не служит. +Адрес ячейки — `<лист>!` у Excel и `row:` у 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`