From c81ec9a66ce490a343015eab0dad943364231263 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 12:59:22 +0400 Subject: [PATCH] =?UTF-8?q?feat(normalizer):=20=D1=80=D0=B0=D0=B7=D0=BB?= =?UTF-8?q?=D0=B5=D0=BF=D0=BB=D1=8F=D1=82=D1=8C=20=D1=81=D0=BA=D0=BB=D0=B5?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=98=D0=A1=20=C2=AB1500\n1700=C2=BB=20(#16,=20OQ-156?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ИС возвращает соседние строки формы одной строкой: в ячейке кода несколько целых кодов через перевод строки, суммы — тоже. Раньше строка пропускалась как нечисловой код, и в ББ РБ и Счастливочной за 1 кв. 2025 не было 1500 и 1700. - rows.split_merged_row: N ≥ 2 целых кодов и ровно N частей в каждой непустой ячейке значений → N строк до классификации; наименование делится так же или остаётся целым; уверенность — исходной ячейки. Иначе — поведение прежнее. - Предупреждение таблицы «строк с несколькими кодами разлеплено: N». - Решение №36 в design.md Среза 3a (отклонение от источника), README. Эталоны нормализатора — отдельный PR в arm-ka-fixtures; правило — §11.1 п. 4. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++ api/src/normalizer/ocr/document.py | 3 + api/src/normalizer/ocr/rows.py | 76 ++++++++++++++++--- api/tests/unit/normalizer/test_document.py | 13 ++++ api/tests/unit/normalizer/test_rows.py | 75 +++++++++++++++++- .../design.md | 1 + 6 files changed, 161 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6970222..50ff796 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,7 @@ pydantic-валидацией; битый файл (код не из 4–5 ци | таблица | `нет шапки и столбца кода` (таблица пропущена, остальные разбираются; также когда `rows` не список или у таблицы есть шапка, но нет ни одной строки данных) | | таблица | `строк с нечисловым кодом пропущено: ` (непустой код, не целое число: `1150 :`, `изо :`; одно на таблицу) | | таблица | `строк без кода с суммами пропущено: ` (пустой код, но в колонках значений есть разобранная сумма: «БАЛАНС», «Выручка»; как в Excel-пути, OQ-141; строки без кода и без сумм — заголовки групп, «в том числе:» — не считаются; одно на таблицу) | +| таблица | `строк с несколькими кодами разлеплено: ` (ИС склеила соседние строки: код `1500\n1700`, суммы через перевод строки; строка разлеплена на N строк, OQ-156; одно на таблицу) | | таблица | `шапка синтезирована: колонки без дат` (в таблице нет строки-шапки: раскладка выведена по данным) | | таблица | `колонка без даты: <подпись колонки>` | | таблица | `нет колонок значений` | @@ -477,6 +478,11 @@ make golden-update # пересобрать эталоны нормализ таблица получает предупреждения `строк с нечисловым кодом пропущено: ` и `строк без кода с суммами пропущено: ` (последнее — только если в строке есть сумма), то есть `needs_check` (решение №29). Сами строки не восстанавливаются (OQ-149): в реальных сканах так теряются, например, «БАЛАНС» и «Выручка» с пустым кодом. +- Склеенная строка ИС — несколько целых кодов в ячейке через перевод строки (`1500\n1700`, ББ РБ и Счастливочной за + 1 кв. 2025) — до классификации разлепляется на столько строк, сколько кодов, если в каждой непустой ячейке значений + столько же частей (наименование делится так же, иначе остаётся целым); иначе строка пропускается как нечисловой код. + Предупреждение `строк с несколькими кодами разлеплено: `, статус `needs_check` (OQ-156, решение владельца 08.10.2026; + расхождение с источником `_classify_row`). - Скобка, распознанная как `}` или `]` (`(7744536}`), знак минус не даёт: сумма остаётся положительной с `is_recognized = false` (решение №12, дословный перенос `_parse_amount`; расширение набора закрывающих скобок — после проверки на стенде, вынесено в открытые вопросы docs). diff --git a/api/src/normalizer/ocr/document.py b/api/src/normalizer/ocr/document.py index b56e8c6..776fb71 100644 --- a/api/src/normalizer/ocr/document.py +++ b/api/src/normalizer/ocr/document.py @@ -224,6 +224,9 @@ def build_document( build.warn(f"строк с нечисловым кодом пропущено: {parsed.skipped_non_numeric_code}") if parsed.skipped_no_code_with_amounts: build.warn(f"строк без кода с суммами пропущено: {parsed.skipped_no_code_with_amounts}") + # OQ-156: склеенные строки ИС разлеплены — разбивку подтверждает аналитик + if parsed.split_merged_codes: + build.warn(f"строк с несколькими кодами разлеплено: {parsed.split_merged_codes}") codes = [clean_value(cell_text(row.cells, row.layout.code_idx)) for row in data_rows] edition = _choose_edition(header, columns, codes, rules, build) lines: list[StatementLine] = [] diff --git a/api/src/normalizer/ocr/rows.py b/api/src/normalizer/ocr/rows.py index 0f97eee..0f3235d 100644 --- a/api/src/normalizer/ocr/rows.py +++ b/api/src/normalizer/ocr/rows.py @@ -1,6 +1,7 @@ """Строки таблицы OCR (§11.1 п. 3–5). Перенос `_classify_row`, `_parse_table_rows`, `_synthetic_header_cells`, `_resolve_table_type` старого ядра: шапка — строка с «Код» (повторная перезаписывает раскладку), без шапки раскладка -выводится по данным и шапка синтезируется; строки до первой шапки — по выведенной раскладке.""" +выводится по данным и шапка синтезируется; строки до первой шапки — по выведенной раскладке. Расхождение с источником — +склеенные строки ИС (`1500\n1700`) разлепляются до классификации (OQ-156, решение владельца 08.10.2026).""" from collections.abc import Collection, Mapping, Sequence from dataclasses import dataclass @@ -10,7 +11,15 @@ from typing import Literal from src.normalizer.ocr.amounts import parse_amount from src.normalizer.ocr.layout import Cell, Cells, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout -__all__ = ["ParsedRow", "ParsedTable", "RowKind", "classify_row", "parse_table_rows", "table_type_by_codes"] +__all__ = [ + "ParsedRow", + "ParsedTable", + "RowKind", + "classify_row", + "parse_table_rows", + "split_merged_row", + "table_type_by_codes", +] HEADER_CODE = "Код" HEADER_LABEL = "Наименование показателя" @@ -42,6 +51,7 @@ class ParsedTable: # шапки (в том числе повторные) в счёт не идут skipped_non_numeric_code: int = 0 # код непустой и не целое число (`classify_row` → HEADER) skipped_no_code_with_amounts: int = 0 # код пуст, но в колонках значений раскладки строки есть разобранная сумма + split_merged_codes: int = 0 # OQ-156: склеенных строк ИС («1500\n1700»), разлеплённых на несколько def classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None: @@ -71,6 +81,54 @@ def _data_row(cells: Cells, layout: ColumnLayout, ordinal: int) -> ParsedRow | N class _Skipped: non_numeric_code: int = 0 no_code_with_amounts: int = 0 + split_merged_codes: int = 0 + + +def _parts(text: str) -> list[str]: + return [part.strip() for part in text.split("\n") if part.strip()] + + +def split_merged_row(cells: Cells, layout: ColumnLayout) -> list[Cells] | None: + """OQ-156 (§11.1 п. 4): ИС склеила N соседних строк формы в одну — в ячейке кода N ≥ 2 целых кодов через перевод + строки, в каждой непустой ячейке значений ровно N частей. Возвращает N строк (i-й код ↔ i-я часть значений; + наименование — i-я часть, если частей тоже N, иначе целиком; уверенность — исходной ячейки) или `None`, если строка + не склеенная или число частей где-то не сходится (тогда она разбирается как раньше).""" + codes = _parts(cell_text(cells, layout.code_idx)) + if len(codes) < 2 or not all(code.isdigit() for code in codes): # noqa: PLR2004 — склейка хотя бы двух строк + return None + values: dict[int, list[str]] = {} + for idx in layout.value_idxs: + parts = _parts(cell_text(cells, idx)) + if parts and len(parts) != len(codes): + return None + values[idx] = parts + labels = _parts(cell_text(cells, layout.label_idx)) + split: list[Cells] = [] + for i, code in enumerate(codes): + row = dict(cells) + row[layout.code_idx] = Cell(code, cells[layout.code_idx].confidence) + if layout.label_idx is not None and layout.label_idx in cells and len(labels) == len(codes): + row[layout.label_idx] = Cell(labels[i], cells[layout.label_idx].confidence) + for idx, parts in values.items(): + if parts: + row[idx] = Cell(parts[i], cells[idx].confidence) + split.append(row) + return split + + +def _data_rows(cells: Cells, layout: ColumnLayout, ordinal: int, skipped: _Skipped) -> list[ParsedRow]: + """Строки данных из одной строки OCR: склеенная разлепляется (OQ-156), отброшенная — учитывается в `skipped`.""" + split = split_merged_row(cells, layout) + if split is not None: + skipped.split_merged_codes += 1 + rows: list[ParsedRow] = [] + for part in split or [cells]: + parsed = _data_row(part, layout, ordinal) + if parsed is not None: + rows.append(parsed) + else: + _count_skipped(part, layout, skipped) + return rows def _count_skipped(cells: Cells, layout: ColumnLayout, skipped: _Skipped) -> None: @@ -107,11 +165,7 @@ def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Co skipped = _Skipped() if inferred is not None: for ordinal, cells in before_header: - parsed = _data_row(cells, inferred, ordinal) - if parsed is not None: - leading_rows.append(parsed) - else: - _count_skipped(cells, inferred, skipped) + leading_rows.extend(_data_rows(cells, inferred, ordinal, skipped)) if first_header == len(rows_cells): if not leading_rows or inferred is None: @@ -123,6 +177,7 @@ def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Co synthetic_header=True, skipped_non_numeric_code=skipped.non_numeric_code, skipped_no_code_with_amounts=skipped.no_code_with_amounts, + split_merged_codes=skipped.split_merged_codes, ) header_row_index, header_cells = rows_cells[first_header] @@ -136,16 +191,13 @@ def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Co if header_layout is not None: layout = header_layout continue - parsed = _data_row(cells, layout, ordinal) - if parsed is not None: - parsed_rows.append(parsed) - else: - _count_skipped(cells, layout, skipped) + parsed_rows.extend(_data_rows(cells, layout, ordinal, skipped)) return ParsedTable( parsed_rows, synthetic_header=False, skipped_non_numeric_code=skipped.non_numeric_code, skipped_no_code_with_amounts=skipped.no_code_with_amounts, + split_merged_codes=skipped.split_merged_codes, ) diff --git a/api/tests/unit/normalizer/test_document.py b/api/tests/unit/normalizer/test_document.py index b16a205..9c2f467 100644 --- a/api/tests/unit/normalizer/test_document.py +++ b/api/tests/unit/normalizer/test_document.py @@ -538,6 +538,19 @@ class TestSkippedRowsWarnings: assert "строк без кода с суммами пропущено: 1" in built.warnings assert "шапка синтезирована: колонки без дат" in built.warnings + def test_merged_codes_are_split_with_warning(self) -> None: + """OQ-156: склеенная строка ИС «1500\\n1700» даёт две строки документа и предупреждение таблицы.""" + table = _table( + "Бухгалтерский баланс", + CLEAN_BALANCE["rows"][0], + _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), + _row(2, (0, "Итого V\nБАЛАНС", 100), (1, "1500\n1700", 100), (2, "3\n4", 100), (3, "5\n6", 100)), + ) + built = _build(table) + assert [line.line_code for line in built.document.lines] == ["1150", "1500", "1700"] + assert "строк с несколькими кодами разлеплено: 1" in built.warnings + assert built.fully_recognized is False + @pytest.mark.fixtures class TestSkippedRowsRealFixtures: diff --git a/api/tests/unit/normalizer/test_rows.py b/api/tests/unit/normalizer/test_rows.py index c2c2c5c..d308a1f 100644 --- a/api/tests/unit/normalizer/test_rows.py +++ b/api/tests/unit/normalizer/test_rows.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from src.normalizer.ocr.layout import ColumnLayout, cells_by_index +from src.normalizer.ocr.layout import ColumnLayout, cell_text, cells_by_index from src.normalizer.ocr.rows import ParsedTable, RowKind, classify_row, parse_table_rows, table_type_by_codes API_DIR = Path(__file__).resolve().parents[3] @@ -205,6 +205,79 @@ class TestSkippedRows: assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (non_numeric, no_code_amounts) +class TestMergedCodeRows: + """OQ-156 (§11.1 п. 4): ИС склеила соседние строки формы — код `1500\\n1700`, суммы через перевод строки.""" + + MERGED = _row( + 7, + (0, "Итого по разделу V\n\nБАЛАНС", 97), + (1, "1500\n1700", 99), + (2, "917 775\n2 371 333", 100), + (4, "783 941\n2 096 754", 90), + ) + + @staticmethod + def _data(parsed: ParsedTable | None) -> list[tuple[str, str, str, str]]: + assert parsed is not None + return [ + (cell_text(r.cells, 0), cell_text(r.cells, 1), cell_text(r.cells, 2), cell_text(r.cells, 4)) + for r in parsed.rows + if r.kind is not RowKind.HEADER + ] + + def test_split_into_rows_by_parts(self) -> None: + parsed = parse_table_rows([HEADER, self.MERGED], KNOWN) + assert self._data(parsed) == [ + ("Итого по разделу V", "1500", "917 775", "783 941"), + ("БАЛАНС", "1700", "2 371 333", "2 096 754"), + ] + assert parsed is not None + assert [r.ordinal for r in parsed.rows[1:]] == [7, 7] + assert parsed.rows[1].kind is RowKind.TOTAL + assert (parsed.rows[2].cells[1].confidence, parsed.rows[2].cells[4].confidence) == (99, 90) + assert (parsed.split_merged_codes, parsed.skipped_non_numeric_code) == (1, 0) + + def test_blank_value_cell_stays_blank_in_every_part(self) -> None: + row = _row(1, (0, "А\nБ", 100), (1, "1150\n1160", 100), (2, "5\n6", 100), (4, "", 100)) + assert self._data(parse_table_rows([HEADER, row], KNOWN)) == [("А", "1150", "5", ""), ("Б", "1160", "6", "")] + + def test_label_parts_mismatch_keeps_whole_label(self) -> None: + row = _row(1, (0, "Итого", 100), (1, "1150\n1160", 100), (2, "5\n6", 100)) + assert [d[0] for d in self._data(parse_table_rows([HEADER, row], KNOWN))] == ["Итого", "Итого"] + + @pytest.mark.parametrize( + "row", + [ + _row(1, (0, "А\nБ", 100), (1, "1150\n1160", 100), (2, "5", 100)), # одна сумма на два кода + _row(1, (0, "А\nБ", 100), (1, "1150\n116O", 100), (2, "5\n6", 100)), # вторая часть — не целое + _row(1, (0, "А\nБ", 100), (1, "1150\n1160", 100), (2, "5\n6", 100), (4, "1\n2\n3", 100)), # 3 части + ], + ) + def test_parts_mismatch_is_skipped_as_before(self, row: dict) -> None: + parsed = parse_table_rows([HEADER, row], KNOWN) + assert self._data(parsed) == [] + assert parsed is not None and (parsed.split_merged_codes, parsed.skipped_non_numeric_code) == (0, 1) + + def test_rows_before_header_are_split_too(self) -> None: + rows = [ + _row(0, (1, "1110", 100), (0, "НМА", 100), (2, "5", 100)), + _row(1, (1, "1150\n1160", 100), (0, "ОС\nДоходные вложения", 100), (2, "7\n8", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None and parsed.synthetic_header + assert [r.cells[1].value for r in parsed.rows[1:]] == ["1110", "1150", "1160"] + assert parsed.split_merged_codes == 1 + + @pytest.mark.fixtures + @pytest.mark.parametrize("name", ["Бухгалтерская отчетность 1 кв.25.json", "БО 1 кв 2025.json"]) + def test_real_balance_keeps_1500_and_1700(self, name: str) -> None: + parsed = parse_table_rows(_real_table(name, 0), KNOWN) + assert parsed is not None + codes = [r.cells[r.layout.code_idx].value for r in parsed.rows if r.kind is not RowKind.HEADER] + assert "1500" in codes and "1700" in codes + assert parsed.split_merged_codes == 1 + + class TestTableType: def test_by_first_digit(self) -> None: rows = [ diff --git a/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md b/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md index 6b32a75..51a7778 100644 --- a/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md +++ b/openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md @@ -175,3 +175,4 @@ docs: bounded-PR (`03-architecture.md`, OQ-137, §11.1 п. 6 и 9, новые OQ | 33 | 2026-10-07 | R-15 (финальное ревью, T1.3). Загрузчик yaml отклоняет повторяющиеся ключи (`SafeLoader` с проверкой) | спека: «Коды справочника SHALL быть без дублей»; `safe_load` молча перезаписывает дубли, тест `len(set)` тавтологичен; владелец заполняет `fsbu4` вручную | битый справочник не стартует — это и нужно | | 34 | 2026-10-07 | R-16 (финальное ревью I-2). №15 (повтор кода → остаётся первая строка) НЕ меняется: в aep_6 ОФР код 2410 повторён, и оставленная первая строка пуста, а отброшенная (`(649)`) — со значением; выбор «первая с суммой» отложен владельцу как OQ | паритет с Excel-путём 2b (§12 п. 5); эвристика «первая с суммой» для aep_3 даёт иное, чем для aep_6 — нужна проверка на стенде | сумма отброшенной строки остаётся только в `raw/`; предупреждение «код N повторяется» сигнализирует аналитику | | 35 | 2026-10-07 | R-17. `put` при уже существующем назначении ничего не пишет и не трогает `.part` (при включённой очистке — снимает существующий `.part`); `.part` от прерванной записи перед новой записью удаляется с подавлением ошибки; запись поверх оставшегося `.part` без назначения — только при `st_nlink == 1` | после №32 неудаляемый `.part` (нет права удаления, OQ-144) на повторе задания давал бы `PermissionError` → `RECOGNITION_FAILED` для уже доставленного файла | редкий остаток `.part` в `Tmp` без права удаления | +| 36 | 2026-10-08 | Решение владельца (OQ-156, RSHB/arm-scan-gateway#16; после архивации Среза 3a). Отклонение от дословного переноса `_classify_row`: склеенная строка ИС — N ≥ 2 целых кодов в ячейке через перевод строки (`1500\n1700`) при ровно N частях в каждой непустой ячейке значений — разлепляется на N строк до классификации (`rows.split_merged_row`); наименование делится так же или остаётся целым; предупреждение «строк с несколькими кодами разлеплено: N» | в ответах настоящей ИС по ББ РБ и Счастливочной за 1 кв. 2025 строки 1500 и 1700 склеены — источник отбрасывал строку как нечисловой код, и в документах не было итога раздела V и БАЛАНСА | ошибочная разбивка видна аналитику (`needs_check`, предупреждение); при несходящемся числе частей поведение прежнее |