diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa9cdf..18640d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.5 — 2026-11-07 + +- `split_by_form(candidate, rules)` — разрез кандидата по смене формы (AURORA-1734): сегмент со своей шапкой, первые + `min_codes` кодов которого — другой формы, начинает новую часть со своими колонками и периодами (`locator` с хвостом + `;form:`, `type_hint = None`). Хост вызывает разрез перед `normalize_candidate`; без вызова поведение прежнее. +- Редкий столбец с неразобранной подписью между колонками периодов (число не более чем в одной строке с кодом при трёх и + более у каждой колонки периода и числах всех колонок периода в строке его числа) — не колонка сумм; его числа — в `NUMBERS_OUTSIDE_TABLE` с текстом «числа вне колонок + формы не учитываются» (AURORA-1735). + ## 0.1.4 — 2026-11-06 - `statement_checks.yaml`: необязательный ключ `group` — группа альтернатив мягких соотношений одной цели. В колонке diff --git a/VERSION b/VERSION index 845639e..9faa1b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.4 +0.1.5 diff --git a/arm_statements/__init__.py b/arm_statements/__init__.py index 34ad4e5..2ee1fa1 100644 --- a/arm_statements/__init__.py +++ b/arm_statements/__init__.py @@ -53,6 +53,7 @@ from arm_statements.recompute import ( recompute, ) from arm_statements.serialize import candidate_from_dict, to_jsonable +from arm_statements.split import split_by_form from arm_statements.status import ( StatusInput, StatusLine, @@ -129,6 +130,7 @@ __all__ = [ "recompute", "resolve_relative", "sheet_joins", + "split_by_form", "statements_version", "status_input", "status_input_from_contract", diff --git a/arm_statements/columns.py b/arm_statements/columns.py index e3ab9d6..f8061af 100644 --- a/arm_statements/columns.py +++ b/arm_statements/columns.py @@ -15,6 +15,10 @@ from arm_statements.model import Cell, PeriodKind, Source, StatementColumn, Stat _FULL_CONFIDENCE = 100.0 _MAX_REFS = 10 +_SPARSE_MAX_NUMBERS = 1 # редкий столбец: число не более чем в одной строке с кодом (AURORA-1735) +_PERIOD_MIN_NUMBERS = 3 # … при числах не менее чем в трёх строках у каждой колонки периода +_RIGHT_TEXT = "числа правее таблицы формы не учитываются" +_BESIDE_TEXT = "числа вне колонок формы не учитываются" @dataclass(frozen=True, slots=True) @@ -59,6 +63,15 @@ def has_numbers( # noqa: PLR0913, PLR0917 - сигнатура задана п return any(_is_number(cell_at(rows, r, col), source, rules) for r in _code_rows(rows, data_rows, code_col, rules)) +def _number_count( # noqa: PLR0913, PLR0917 - как has_numbers + rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules +) -> int: + """Сколько строк с кодом содержат в столбце число.""" + return sum( + 1 for r in _code_rows(rows, data_rows, code_col, rules) if _is_number(cell_at(rows, r, col), source, rules) + ) + + def _width(rows: Grid, row_indexes: Sequence[int]) -> int: return max((len(rows[r]) for r in row_indexes if 0 <= r < len(rows)), default=0) @@ -97,7 +110,9 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана warnings: list[StatementWarning] = [] columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows])) last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules) + dated = _dated_counts(rows, header_rows, data_rows, columns, code_col, source, rules) outside: list[int] = [] + beside: list[int] = [] for col in columns: numbers = has_numbers(rows, data_rows, col, code_col, source, rules) if not header_rows: @@ -113,16 +128,67 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана drafts.append(ColumnDraft(col, cell.text, cell.confidence, parsed.kind, parsed.date)) elif numbers and cell is None and last_dated is not None and col > last_dated: outside.append(col) # числа правее последней колонки с подписью периода — не колонка сумм + elif numbers and cell is not None and _is_sparse(col, dated, rows, data_rows, code_col, source, rules): + beside.append(col) # обрывок подписи периода с посторонним числом — не колонка сумм (AURORA-1735) elif numbers: label, conf = (cell.text, cell.confidence) if cell is not None else ("", None) message = f"у столбца {col} с числами нет разобранной подписи периода" warnings.append(make_warning(WarningCode.COLUMN_WITHOUT_DATE, message, rules, column_index=col)) drafts.append(ColumnDraft(col, label, conf, PeriodKind.NONE, None)) - if outside: - warnings.append(_outside_table_warning(rows, data_rows, outside, code_col, source, rules)) + if outside or beside: + warnings.append( + _outside_table_warning(rows, data_rows, [*beside, *outside], code_col, source, rules, beside=bool(beside)) + ) return drafts, warnings +def _dated_counts( # noqa: PLR0913, PLR0917 - внутренняя сборка + rows: Grid, + header_rows: tuple[int, ...], + data_rows: Sequence[int], + columns: range, + code_col: int, + source: Source, + rules: Rules, +) -> dict[int, int]: + """Столбцы сегмента с подписью периода и числами → число строк с кодом, где есть число.""" + counts: dict[int, int] = {} + if not header_rows: + return counts + for col in columns: + cell = _label(rows, header_rows, col) + if cell is None: + continue + parsed = parse_period_label(cell, rules) + if parsed.excluded or parsed.kind is PeriodKind.NONE: + continue + count = _number_count(rows, data_rows, col, code_col, source, rules) + if count: + counts[col] = count + return counts + + +def _is_sparse( # noqa: PLR0913, PLR0917 - внутренняя проверка + col: int, + dated: dict[int, int], + rows: Grid, + data_rows: Sequence[int], + code_col: int, + source: Source, + rules: Rules, +) -> bool: + """Редкий столбец: колонки периода левее и правее, у него ≤ 1 числа, у каждой колонки периода ≥ 3, и в строке его + числа у всех колонок периода тоже есть числа — число лишнее, а не сдвинутое OCR из пустой ячейки периода.""" + if not any(c < col for c in dated) or not any(c > col for c in dated): + return False + if any(count < _PERIOD_MIN_NUMBERS for count in dated.values()): + return False + hits = [r for r in _code_rows(rows, data_rows, code_col, rules) if _is_number(cell_at(rows, r, col), source, rules)] + if len(hits) > _SPARSE_MAX_NUMBERS: + return False + return all(_is_number(cell_at(rows, r, period), source, rules) for r in hits for period in dated) + + def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы rows: Grid, header_rows: tuple[int, ...], @@ -149,7 +215,14 @@ def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сбо def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя сборка предупреждения - rows: Grid, data_rows: Sequence[int], outside: Sequence[int], code_col: int, source: Source, rules: Rules + rows: Grid, + data_rows: Sequence[int], + outside: Sequence[int], + code_col: int, + source: Source, + rules: Rules, + *, + beside: bool = False, ) -> StatementWarning: refs = [ cell.ref @@ -157,13 +230,15 @@ def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя с for col in outside if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules) ] - return outside_table_warning(refs, rules) + return outside_table_warning(refs, rules, beside=beside) -def outside_table_warning(refs: Sequence[str], rules: Rules) -> StatementWarning: - """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы (`locator` — все, через перевод строки).""" +def outside_table_warning(refs: Sequence[str], rules: Rules, *, beside: bool = False) -> StatementWarning: + """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек вне колонок формы (`locator` — все, через перевод строки). + + `beside` — среди адресов есть редкий столбец между колонками периодов (AURORA-1735): текст «вне колонок».""" shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "") - message = f"числа правее таблицы формы не учитываются: {shown}" + message = f"{_BESIDE_TEXT if beside else _RIGHT_TEXT}: {shown}" return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator="\n".join(refs)) @@ -171,12 +246,14 @@ def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> l """Предупреждения `NUMBERS_OUTSIDE_TABLE` сегментов — в одно (адреса без повторов).""" refs: dict[str, None] = {} rest: list[StatementWarning] = [] + beside = False for warning in warnings: if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE: refs.update(dict.fromkeys((warning.locator or "").split("\n"))) + beside = beside or warning.message.startswith(_BESIDE_TEXT) else: rest.append(warning) - return [*rest, outside_table_warning(list(refs), rules)] if refs else rest + return [*rest, outside_table_warning(list(refs), rules, beside=beside)] if refs else rest def trailing_outside_refs( # noqa: PLR0913, PLR0917 - внутренняя сборка diff --git a/arm_statements/segments.py b/arm_statements/segments.py index 821925e..dc42a3c 100644 --- a/arm_statements/segments.py +++ b/arm_statements/segments.py @@ -180,6 +180,29 @@ def _split_and_analyse(candidate: FormCandidate, rules: Rules) -> tuple[list[_An return result, section +@dataclass(frozen=True, slots=True) +class SegmentForm: + """Сегмент для разреза по смене формы: первая строка, своя шапка, коды столбца кодов.""" + + first: int + has_header: bool + codes: tuple[str, ...] + + +def split_segments(candidate: FormCandidate, rules: Rules) -> list[SegmentForm]: + """Сегменты кандидата по правилу шага 2 (тот же разбор, что у раскладки); сегменты без столбца кодов пропущены.""" + segments, _ = _split_and_analyse(candidate, rules) + return [ + SegmentForm( + seg.first, + bool(seg.header_rows), + tuple(_segment_codes(candidate.rows, seg.first, seg.last, seg.code_col, rules)), + ) + for seg in segments + if seg.code_col is not None + ] + + def _map_by_date( drafts: Sequence[ColumnDraft], columns: Sequence[StatementColumn] ) -> tuple[tuple[int, int], ...] | None: diff --git a/arm_statements/split.py b/arm_statements/split.py new file mode 100644 index 0000000..cb0a2ee --- /dev/null +++ b/arm_statements/split.py @@ -0,0 +1,75 @@ +"""Разрез кандидата по смене формы до шагов 2–8 (спека «Шаг 2 — разрез кандидата по смене формы», AURORA-1734). + +ИС Сканирование может вернуть ББ и ОФР одной таблицей: шапка ОФР внутри таблицы — повторная шапка, и без разреза строки +ОФР попадают в ББ под его колонками. Часть начинается с сегмента со своей шапкой, первые `min_codes` кодов которого — +одной формы, другой, чем у текущей части (тип части — по большинству кодов её первого сегмента с кодами). +""" + +from dataclasses import replace + +from arm_statements.codes import document_type_of, majority_section, section_of +from arm_statements.issues import WarningCode +from arm_statements.loader import Rules, default_rules +from arm_statements.model import DocumentType, Evidence, FormCandidate +from arm_statements.segments import split_segments + +# предупреждения источника, нужные каждой части: без числовых форматов xlsb шаг 4 иначе проверяет масштаб (ревью I-2) +_SOURCE_WARNINGS = frozenset({WarningCode.XLSB_NO_FORMATS}) + + +def _opening_type(codes: tuple[str, ...], rules: Rules) -> DocumentType | None: + """Тип формы, которой сегмент открывается: первые `min_codes` кодов одной формы; иначе `None`.""" + head = codes[: rules.code_ranges.min_codes] + if len(head) < rules.code_ranges.min_codes: + return None + kinds = {None if (s := section_of(code, rules)) is None else document_type_of(s) for code in head} + return kinds.pop() if len(kinds) == 1 else None + + +def _cuts(candidate: FormCandidate, rules: Rules) -> list[int]: + """Строки сетки, с которых начинаются части после первой.""" + cuts: list[int] = [] + current: DocumentType | None = None + for segment in split_segments(candidate, rules): + if current is None: + section = majority_section(list(segment.codes), rules) + current = None if section is None else document_type_of(section) + continue + opening = _opening_type(segment.codes, rules) + if segment.has_header and opening is not None and opening is not current and segment.first > 0: + cuts.append(segment.first) + current = opening + return cuts + + +def split_by_form(candidate: FormCandidate, rules: Rules | None = None) -> tuple[FormCandidate, ...]: + """Части кандидата по смене формы; без смены — `(candidate,)` тем же объектом. + + Первая часть сохраняет поля исходного кандидата (кроме строк и границ листов); часть `k` (с 1) — `locator` + с хвостом `;form:`, `type_hint = None`, пустые `evidence`, из `warnings` — только предупреждения источника + (`XLSB_NO_FORMATS`): тип — только по кодам на шаге 6. + """ + rules = rules if rules is not None else default_rules() + cuts = _cuts(candidate, rules) + if not cuts: + return (candidate,) + bounds = [0, *cuts, len(candidate.rows)] + parts: list[FormCandidate] = [] + for k, (start, end) in enumerate(zip(bounds, bounds[1:], strict=False)): + breaks = tuple(b - start for b in candidate.segment_breaks if start <= b < end) + rows = candidate.rows[start:end] + if k == 0: + parts.append(replace(candidate, rows=rows, segment_breaks=breaks)) + else: + parts.append( + replace( + candidate, + locator=f"{candidate.locator};form:{k}", + type_hint=None, + evidence=Evidence(okud=None, title=None), + rows=rows, + segment_breaks=breaks, + warnings=tuple(w for w in candidate.warnings if w.code in _SOURCE_WARNINGS), + ) + ) + return tuple(parts) 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)" +``` diff --git a/tests/test_model.py b/tests/test_model.py index dd7fad0..b6b400d 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -77,6 +77,7 @@ PUBLIC = { "type_hint", "continuation_type", "sheet_joins", + "split_by_form", "parse_code", "parse_amount", "parse_date", diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index b7df080..c53a911 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -210,3 +210,62 @@ def test_numbers_right_of_table_do_not_break_status_in_repeated_segments(rules: assert [c.column_index for c in doc.columns] == [2] assert [(w.code, w.severity) for w in doc.warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)] assert all(len(line.values) == 1 for line in doc.lines) + + +# --- редкий столбец с неразобранной подписью между периодами (AURORA-1735, решение владельца 06.11.2026) --- + +SPARSE_COL = 3 # столбец «31» раскладки Калтана +KALTAN_HEAD = ["Наименование показателя", "Коды", "За 1 полугодие 2026 года", "31", "За I полугодие 2025 года"] + + +def kaltan_rows(caption: str = "31", second: str | None = None) -> list[list[str | None]]: + head = [*KALTAN_HEAD] + head[3] = caption + return [ + head, + ["Выручка", "2110", "9671", None, "15405"], + ["Себестоимость продаж", "2120", "-7084", second, "-10446"], + ["Валовая прибыль (убыток)", "2100", "2587", None, "4959"], + ["Прочие расходы", "2350", "-486", "——-", "-348"], + ["Чистая прибыль (убыток)", "2400", "1935", "$301 .", "4182"], + ] + + +def test_sparse_column_between_periods_is_outside(rules: Rules) -> None: + doc = normalize_candidate(ocr(kaltan_rows()), rules).document + assert doc is not None + assert [c.column_index for c in doc.columns] == [2, 4] + assert all(v.column_index != SPARSE_COL for line in doc.lines for v in line.values) + (warning,) = [w for w in doc.warnings if w.code == "NUMBERS_OUTSIDE_TABLE"] + assert warning.severity is Severity.INFO + assert warning.message == "числа вне колонок формы не учитываются: row:5" + assert "COLUMN_WITHOUT_DATE" not in [w.code for w in doc.warnings] + + +def test_unparsed_caption_with_two_numbers_stays_column(rules: Rules) -> None: + doc = normalize_candidate(ocr(kaltan_rows("Прим.", second="12")), rules).document + assert doc is not None + assert [c.column_index for c in doc.columns] == [2, 3, 4] + assert [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"] == [3] + assert "NUMBERS_OUTSIDE_TABLE" not in [w.code for w in doc.warnings] + + +def test_sparse_column_without_period_to_the_right_stays(rules: Rules) -> None: + rows = kaltan_rows("За I п\n20:") + rows[0][4] = "юлугодие\n25 года" + doc = normalize_candidate(ocr(rows), rules).document + assert doc is not None + assert [c.column_index for c in doc.columns] == [2, 3, 4] + assert SPARSE_COL in [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"] + + +def test_shifted_value_in_sparse_column_keeps_column(rules: Rules) -> None: + """Единственное число столбца стоит в строке, где колонка периода пуста, — это сдвиг OCR, не мусор (ревью I-1).""" + rows = kaltan_rows() + rows[5][3] = None # без постороннего «$301»: единственное число столбца — сдвинутое значение 2460 + rows.insert(5, ["Прочее", "2460", "12", "55", None]) + doc = normalize_candidate(ocr(rows), rules).document + assert doc is not None + assert [c.column_index for c in doc.columns] == [2, 3, 4] + assert SPARSE_COL in [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"] + assert doc.status is DocStatus.NEEDS_CHECK diff --git a/tests/test_split.py b/tests/test_split.py new file mode 100644 index 0000000..dba9e1d --- /dev/null +++ b/tests/test_split.py @@ -0,0 +1,113 @@ +"""Разрез кандидата по смене формы (AURORA-1734, решение владельца 06.11.2026).""" + +from datetime import date + +from arm_statements import WarningCode, make_warning, normalize_candidate, split_by_form +from arm_statements.loader import Rules +from arm_statements.model import DocumentType, Evidence +from tests.builders import excel, hdr, ocr + +BAL_HEAD = ["Наименование показателя", "Коды", "На 30 июня 2026 года", "На 31 декабря 2025 года"] +INC_HEAD = ["Наименование показателя", "Коды", "За I полугодие 2026 года", "За I полугодие 2025 года"] +HEADER = hdr(organization_name="КФХ «АК-ЮЛ»", inn="5634000539", report_date="30.06.2026", units="тыс. руб.") +LOCATOR = "file:x.json;table:0" + + +def balance_rows() -> list[list[str | None]]: + """ББ на две страницы: актив, повторная шапка, пассив.""" + return [ + BAL_HEAD, + ["НМА", "1110", "10", "10"], + ["Основные средства", "1150", "90", "90"], + ["Итого по разделу I", "1100", "100", "100"], + ["Запасы", "1210", "50", "50"], + ["Итого по разделу II", "1200", "50", "50"], + ["БАЛАНС", "1600", "150", "150"], + BAL_HEAD, + ["Уставный капитал", "1310", "150", "150"], + ["Итого по разделу III", "1300", "150", "150"], + ["БАЛАНС", "1700", "150", "150"], + ] + + +def income_rows() -> list[list[str | None]]: + return [ + INC_HEAD, + ["Выручка", "2110", "4451", "2162"], + ["Себестоимость продаж", "2120", "-2700", "-1332"], + ["Валовая прибыль (убыток)", "2100", "1751", "830"], + ["Чистая прибыль (убыток)", "2400", "1751", "830"], + ] + + +def test_balance_and_income_in_one_table_split(rules: Rules) -> None: + rows = [*balance_rows(), *income_rows()] + cand = ocr(rows, header=HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс", locator=LOCATOR) + parts = split_by_form(cand, rules) + assert len(parts) == 2 # noqa: PLR2004 - две формы + bal, inc = parts + assert bal.locator == LOCATOR and bal.type_hint is DocumentType.BALANCE and bal.evidence == cand.evidence + assert len(bal.rows) == len(balance_rows()) + assert inc.locator == f"{LOCATOR};form:1" + assert inc.type_hint is None and inc.evidence == Evidence(okud=None, title=None) and inc.warnings == () + assert inc.rows[0] == cand.rows[len(balance_rows())] + assert inc.header == cand.header and inc.source is cand.source + + bal_doc = normalize_candidate(bal, rules).document + inc_doc = normalize_candidate(inc, rules).document + assert bal_doc is not None and inc_doc is not None + assert bal_doc.document_type is DocumentType.BALANCE + assert not [line.line_code for line in bal_doc.lines if (line.line_code or "").startswith("2")] + assert inc_doc.document_type is DocumentType.INCOME + assert [c.period_date for c in inc_doc.columns] == [date(2026, 6, 30), date(2025, 6, 30)] + assert "TYPE_CONFLICT" not in [w.code for w in inc_doc.warnings] + + +def test_same_form_repeat_header_not_split(rules: Rules) -> None: + cand = ocr(balance_rows(), header=HEADER, type_hint=DocumentType.BALANCE, locator=LOCATOR) + result = split_by_form(cand, rules) + assert result == (cand,) + assert result[0] is cand + + +def test_income_tail_25xx_not_split(rules: Rules) -> None: + tail = [ + INC_HEAD, + ["Результат от переоценки", "2510", "0", "0"], + ["Результат от прочих операций", "2520", "0", "0"], + ["Совокупный финансовый результат", "2500", "1751", "830"], + ] + cand = ocr([*income_rows(), *tail], locator=LOCATOR) + assert split_by_form(cand, rules) == (cand,) + + +def test_two_foreign_codes_not_split(rules: Rules) -> None: + foreign = [INC_HEAD, ["Выручка", "2110", "4451", "2162"], ["Чистая прибыль (убыток)", "2400", "1751", "830"]] + cand = ocr([*balance_rows(), *foreign], locator=LOCATOR) + assert split_by_form(cand, rules) == (cand,) + + +def test_foreign_segment_without_header_not_split(rules: Rules) -> None: + cand = ocr([*balance_rows(), *income_rows()[1:]], locator=LOCATOR) + assert split_by_form(cand, rules) == (cand,) + + +def test_excel_breaks_shifted(rules: Rules) -> None: + continuation = [["Прочие доходы", "2340", "1", "1"], ["Прочие расходы", "2350", "-1", "-1"]] + cand = excel([("Б", balance_rows()), ("О", income_rows()), ("О2", continuation)], header=HEADER) + cut = len(balance_rows()) + assert cand.segment_breaks == (cut, cut + len(income_rows())) + bal, inc = split_by_form(cand, rules) + assert bal.segment_breaks == () + assert inc.segment_breaks == (0, len(income_rows())) + assert inc.locator == f"{cand.locator};form:1" + assert len(inc.rows) == len(income_rows()) + len(continuation) + + +def test_xlsb_source_warning_kept_in_every_part(rules: Rules) -> None: + """`XLSB_NO_FORMATS` — свойство источника, нужно каждой части для проверки масштаба (ревью I-2).""" + no_formats = make_warning(WarningCode.XLSB_NO_FORMATS, "xlsb без числовых форматов", rules) + cand = excel([("Б", balance_rows()), ("О", income_rows())], header=HEADER, warnings=[no_formats]) + bal, inc = split_by_form(cand, rules) + assert bal.warnings == (no_formats,) + assert inc.warnings == (no_formats,)