16 KiB
Разрез по смене формы и редкая колонка — план реализации
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(...)), раскладка Калтана):
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
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 кода ОФР) в одном кандидате… еслиexcelbuilder даёт по листу кандидата — собрать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
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
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
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)"