From ce42892662cfa812103f1e0c7308cda5134e16a0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 09:11:03 +0400 Subject: [PATCH] =?UTF-8?q?AURORA-1734/1735:=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plans/2026-11-07-form-split.md | 160 +++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 plans/2026-11-07-form-split.md diff --git a/plans/2026-11-07-form-split.md b/plans/2026-11-07-form-split.md new file mode 100644 index 0000000..1ab2f00 --- /dev/null +++ b/plans/2026-11-07-form-split.md @@ -0,0 +1,160 @@ +# Разрез по смене формы и редкая колонка — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** AURORA-1734 — таблица ИС с ББ и ОФР режется на два кандидата; AURORA-1735 — редкий столбец с неразобранной подписью между периодами не становится колонкой сумм. + +**Architecture:** Новая публичная функция `split_by_form` (модуль `arm_statements/split.py`) ищет сегменты тем же кодом шага 2 (`segments._split_and_analyse`) и режет `FormCandidate` по смене типа формы; шаги 2–8 не меняются. Правило редкого столбца — в `columns.segment_columns`, адреса идут в общее `NUMBERS_OUTSIDE_TABLE`. + +**Tech Stack:** Python 3.11, pytest, ruff, mypy (`make check`). + +**Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/form-split/docs/openspec/changes/statement-form-split/` (proposal.md, design.md, specs/statement-normalization/spec.md) + +## Global Constraints + +- Версия библиотеки — 0.1.5 (`VERSION`, `CHANGELOG.md`, дата 2026-11-07). +- Порог разреза — `rules.ranges.min_codes` (сейчас 3), новых ключей словаря нет. +- `locator` части k ≥ 1 — `f"{candidate.locator};form:{k}"`; первая часть — исходный кандидат без изменений полей. +- Часть k ≥ 1: `type_hint=None`, `evidence=Evidence(okud=None, title=None)`, `warnings=()`, `header` и `source` исходного. +- Редкий столбец: ≤ 1 число в строках с кодом; у каждой колонки периода сегмента (с числами) ≥ 3; колонка периода есть левее и правее. +- Текст: «числа вне колонок формы не учитываются: …», если среди адресов есть адрес редкого столбца; иначе прежний «числа правее таблицы формы не учитываются: …». +- Комментарии и сообщения — по-русски; идентификаторы — английские. +- Зелёным считается только весь `make check`. + +## Review Focus + +- Кандидат без смены формы (обычный ББ с повторной шапкой второй страницы) должен вернуться **тем же объектом** — `split_by_form(c) == (c,)`; тест в Task 2. +- Сегмент без шапки с кодами другой формы (лист-продолжение Excel) не режет — тест в Task 2. +- Excel-кандидат с `segment_breaks` после точки разреза: границы части сдвинуты на начало части — тест в Task 2. +- Столбец с подписью-мусором и числами во многих строках (`"| | г"`) остаётся `COLUMN_WITHOUT_DATE` — существующий сценарий не ломается; тест в Task 1. +- Обрывок подписи правее последнего периода (нет периода справа) — правило не срабатывает, `COLUMN_WITHOUT_DATE`; тест в Task 1. + +--- + +### Task 1: Редкий столбец с неразобранной подписью (AURORA-1735) + +**Files:** +- Modify: `arm_statements/columns.py` (`segment_columns`, `_outside_table_warning`, `outside_table_warning`, `merge_outside_table`) +- Test: `tests/test_pipeline.py` (рядом с тестом `NUMBERS_OUTSIDE_TABLE`, строка ~211) + +**Interfaces:** +- Produces: `outside_table_warning(refs: Sequence[str], rules: Rules, *, beside: bool = False) -> StatementWarning` — `beside=True` даёт текст «числа вне колонок формы не учитываются: …»; `merge_outside_table` выбирает текст «вне колонок», если хоть одно входное предупреждение имеет этот текст. + +- [ ] **Step 1: Write the failing tests** (через `normalize_candidate(ocr(...))`, раскладка Калтана): + +```python +def test_sparse_column_between_periods_is_outside(rules: Rules) -> None: + rows = [ + ["Наименование показателя", "Коды", "За 1 полугодие 2026 года", "31", "За I полугодие 2025 года"], + ["Выручка", "2110", "9671", None, "15405"], + ["Себестоимость продаж", "2120", "-7084", None, "-10446"], + ["Валовая прибыль (убыток)", "2100", "2587", None, "4959"], + ["Прочие расходы", "2350", "-486", "——-", "-348"], + ["Чистая прибыль (убыток)", "2400", "1935", "$301 .", "4182"], + ] + doc = normalize_candidate(ocr(rows), rules).document + assert [c.column_index for c in doc.columns] == [2, 4] + assert all(v.column_index != 3 for line in doc.lines for v in line.values) + (w,) = [w for w in doc.warnings if w.code == "NUMBERS_OUTSIDE_TABLE"] + assert w.severity is Severity.INFO + assert w.message == "числа вне колонок формы не учитываются: row:5" + assert "COLUMN_WITHOUT_DATE" not in [w.code for w in doc.warnings] +``` + +Ещё два теста той же раскладки: +- `test_unparsed_caption_with_two_numbers_stays_column`: подпись столбца 3 — «Прим.», числа в столбце 3 в двух строках с кодом → колонки `[2, 3, 4]`, есть `COLUMN_WITHOUT_DATE` с `column_index == 3`, нет `NUMBERS_OUTSIDE_TABLE`. +- `test_sparse_column_without_period_to_the_right_stays`: подпись столбца 4 — «юлугодие 25 года» (не период), столбец 3 «За I п 20:» с одним числом → столбец 3 остаётся колонкой с `COLUMN_WITHOUT_DATE` (правее нет колонки периода). + +Существующий тест `NUMBERS_OUTSIDE_TABLE` (строка ~211) должен остаться с прежним текстом «числа правее таблицы формы…». + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python -m pytest tests/test_pipeline.py -k "sparse or two_numbers" -v` +Expected: `test_sparse_column_between_periods_is_outside` FAIL (колонки `[2, 3, 4]`); два других могут пройти уже сейчас — это охранные тесты. + +- [ ] **Step 3: Implement** + +В `segment_columns`: до цикла посчитать для каждого столбца число строк с кодом, где есть число (`_number_count(rows, data_rows, col, code_col, source, rules) -> int`, по образцу `has_numbers`), и множество колонок периода сегмента с числами (подпись разобрана как период, не исключение). В ветке `elif numbers:` (будущий `COLUMN_WITHOUT_DATE`) перед добавлением проверить: `cell is not None`, есть колонка периода левее и правее `col`, `_number_count(col) <= 1`, у каждой колонки периода счёт ≥ 3 → столбец в отдельный список `beside`, не в drafts и без `COLUMN_WITHOUT_DATE`. После цикла адреса `outside` и `beside` — в одно предупреждение; `beside=True`, если список `beside` не пуст. Константы: `_SPARSE_MAX_NUMBERS = 1`, `_PERIOD_MIN_NUMBERS = 3`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make check` +Expected: все тесты зелёные, ruff и mypy чистые. + +- [ ] **Step 5: Commit** + +```bash +git add arm_statements/columns.py tests/test_pipeline.py +git commit -m "AURORA-1735: редкий столбец с неразобранной подписью между периодами — числа вне колонок (#5)" +``` + +### Task 2: `split_by_form` — разрез кандидата по смене формы (AURORA-1734) + +**Files:** +- Create: `arm_statements/split.py` +- Modify: `arm_statements/__init__.py` (импорт и `__all__`) +- Test: `tests/test_split.py` + +**Interfaces:** +- Consumes: `segments._split_and_analyse(candidate, rules) -> tuple[list[_Analysed], CodeSection | None]` (поля `_Analysed`: `first`, `last`, `header_rows`, `code_col`); `segments._segment_codes(rows, first, last, code_col, rules) -> list[str]`; `codes.majority_section`, `codes.document_type_of`; `rules.ranges.min_codes`. +- Produces: `split_by_form(candidate: FormCandidate, rules: Rules | None = None) -> tuple[FormCandidate, ...]` — экспорт в `arm_statements.__all__`. + +- [ ] **Step 1: Write the failing tests** в `tests/test_split.py` (кандидаты через `tests.builders.ocr` / `excel`): + +- `test_balance_and_income_in_one_table_split`: сетка — шапка «Наименование показателя | Коды | На 30 июня 2026 года | На 31 декабря 2025 года», строки 1110, 1150, 1100, 1210, 1200, 1600; повторная шапка той же формы + 1310, 1300, 1700; затем шапка «Наименование показателя | Коды | За I полугодие 2026 года | За I полугодие 2025 года» и строки 2110, 2120, 2100, 2400. `type_hint=DocumentType.BALANCE`, `title="Бухгалтерский баланс"`, `locator="file:x.json;table:0"`. Ожидание: две части; `parts[0].locator == "file:x.json;table:0"`, `parts[0].type_hint is DocumentType.BALANCE`, `len(parts[0].rows)` — до строки шапки ОФР; `parts[1].locator == "file:x.json;table:0;form:1"`, `parts[1].type_hint is None`, `parts[1].evidence == Evidence(okud=None, title=None)`, `parts[1].rows[0]` — строка шапки ОФР, `parts[1].header == candidate.header`. Затем `normalize_candidate` по частям: документы `BALANCE` и `INCOME`, у ОФР колонки с датами 2026-06-30 и 2025-06-30, нет `TYPE_CONFLICT`, у ББ нет кодов 2xxx. +- `test_same_form_repeat_header_not_split`: ББ с повторной шапкой → `split_by_form(c) == (c,)`, `result[0] is c`. +- `test_income_tail_25xx_not_split`: ОФР + повторная шапка со строками 2510, 2520, 2500 → одна часть. +- `test_two_foreign_codes_not_split`: после ББ повторная шапка с двумя кодами 2110, 2400 → одна часть. +- `test_foreign_segment_without_header_not_split`: строки 2110, 2120, 2100, 2400 идут после ББ без шапки → одна часть. +- `test_excel_breaks_shifted`: `excel` с листами «Б» (ББ, шапка) и «Б2» (шапка ОФР + 4 кода ОФР) в одном кандидате… если `excel` builder даёт по листу кандидата — собрать `FormCandidate` вручную через `dataclasses.replace(c, rows=..., segment_breaks=(k, k+3))`; ожидание: `parts[1].segment_breaks == (0, 3)` при разрезе на строке `k` (границы вне `0 < b < len(rows)` допустимы — `_chunks` их отбрасывает; храним сдвинутые `b - start` для `b >= start`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python -m pytest tests/test_split.py -v` +Expected: FAIL — `ImportError: cannot import name 'split_by_form'`. + +- [ ] **Step 3: Implement `split_by_form` в `arm_statements/split.py`** + +Алгоритм: сегменты `_split_and_analyse`; идём по сегментам с `code_col is not None`; тип сегмента — `document_type_of(majority_section(_segment_codes(...)))` (секции нет — `None`); тип текущей части — первый определённый; сегмент режет, если `header_rows` непусты, `len(codes) >= rules.ranges.min_codes`, тип определён и `!=` типу части → точка разреза `seg.first`, новая часть получает тип сегмента. Нет точек — вернуть `(candidate,)`. Иначе части: `dataclasses.replace` — первая только с `rows=rows[:cut1]` и `segment_breaks` `< cut1`; следующие — по Global Constraints, `rows=rows[cut_k:cut_{k+1}]`, `segment_breaks=tuple(b - cut_k for b in breaks if cut_k <= b < cut_{k+1})`. Docstring — ссылка на требование «Шаг 2 — разрез кандидата по смене формы». + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git add arm_statements/split.py arm_statements/__init__.py tests/test_split.py +git commit -m "AURORA-1734: split_by_form — разрез кандидата по смене формы (#5)" +``` + +### Task 3: Версия 0.1.5 и прогон реальных ответов ИС + +**Files:** +- Modify: `VERSION`, `CHANGELOG.md` + +- [ ] **Step 1:** `VERSION` → `0.1.5`; в `CHANGELOG.md` раздел `## 0.1.5 — 2026-11-07`: `split_by_form` (AURORA-1734; хост вызывает перед `normalize_candidate`; без вызова поведение прежнее), редкий столбец (AURORA-1735). +- [ ] **Step 2: Прогон реальных JSON** (вне репозитория, не коммитится): python из venv scan-gateway (`/Users/andrey.epifancev/projects/awp-ca/.wt/review-1677/scan-gateway/api/.venv`, venv не менять) с `PYTHONPATH` = корень этой ветки библиотеки (идёт раньше установленного 0.1.3) и обёрткой над адаптером scan-gateway, которая вызывает `split_by_form` перед `normalize_candidate`; JSON — `~/.local/share/sdlc-hub/downloads/2375066/…json` (АК-ЮЛ) и `…/2375226/…json` (Калтан). Ожидание: АК-ЮЛ — два документа (`BALANCE` без кодов 2xxx, `INCOME` со своими колонками, `locator` с `;form:1`); Калтан ОФР — колонки 4 и 6, `NUMBERS_OUTSIDE_TABLE` с `row:21`, без `COLUMN_WITHOUT_DATE`. +- [ ] **Step 3:** `make check` зелёный. +- [ ] **Step 4: Commit** + +```bash +git add VERSION CHANGELOG.md +git commit -m "AURORA-1734/1735: 0.1.5 (#5)" +``` + +### Task 4: Документы контекста (arm-ka-docs, ветка `feat/5-form-split`) + +**Files (в `/Users/andrey.epifancev/projects/awp-ca/.wt/form-split/docs`):** +- Modify: `context/05-domain-rules.md` — §11 п. 2 «Сегменты» (абзац о разрезе по смене формы, ссылка AURORA-1734), п. 6 «Подпись без периода» (редкий столбец, AURORA-1735; в перечне «Не меняется» — уточнение «кроме редкого столбца»); таблица предупреждений, строка `NUMBERS_OUTSIDE_TABLE` — «правее последней подписанной колонки или в редком столбце между периодами»; §11 «Повтор формы»/OQ-77 не трогать. +- Modify: `openspec/changes/statement-form-split/design.md` — дописать в «Decisions made without discussion» решения, принятые при исполнении (если были). + +- [ ] **Step 1:** правки текста по спеке change. +- [ ] **Step 2:** `make check` в docs и `openspec validate statement-form-split --strict` — зелёные. +- [ ] **Step 3: Commit** + +```bash +git add context/05-domain-rules.md openspec/changes/statement-form-split/design.md +git commit -m "AURORA-1734/1735: §11 — разрез по смене формы и редкий столбец (RSHB/pylib_ai_arm_statements#5)" +```