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

16 KiB
Raw Blame History

Разрез по смене формы и редкая колонка — план реализации

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 кода ОФР) в одном кандидате… если 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
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)"