pylib_ai_arm_statements/plans/2026-11-07-form-split.md
Andrey Epifancev ce42892662 AURORA-1734/1735: план реализации (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 09:11:03 +04:00

161 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Разрез по смене формы и редкая колонка — план реализации
> **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)"
```