Merge pull request 'Нормализатор: разлеплять склеенные строки ИС «1500\n1700» (#16, OQ-156)' (#17) from feat/16-split-merged-codes into main

Reviewed-on: #17
This commit is contained in:
aep 2026-10-08 13:54:41 +03:00
commit e67aab35d8
6 changed files with 161 additions and 13 deletions

View File

@ -264,6 +264,7 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
| таблица | `нет шапки и столбца кода` (таблица пропущена, остальные разбираются; также когда `rows` не список или у таблицы есть шапка, но нет ни одной строки данных) |
| таблица | `строк с нечисловым кодом пропущено: <N>` (непустой код, не целое число: `1150 :`, `изо :`; одно на таблицу) |
| таблица | `строк без кода с суммами пропущено: <N>` (пустой код, но в колонках значений есть разобранная сумма: «БАЛАНС», «Выручка»; как в Excel-пути, OQ-141; строки без кода и без сумм — заголовки групп, «в том числе:» — не считаются; одно на таблицу) |
| таблица | `строк с несколькими кодами разлеплено: <N>` (ИС склеила соседние строки: код `1500\n1700`, суммы через перевод строки; строка разлеплена на N строк, OQ-156; одно на таблицу) |
| таблица | `шапка синтезирована: колонки без дат` (в таблице нет строки-шапки: раскладка выведена по данным) |
| таблица | `колонка <i> без даты: <подпись колонки>` |
| таблица | `нет колонок значений` |
@ -477,6 +478,11 @@ make golden-update # пересобрать эталоны нормализ
таблица получает предупреждения `строк с нечисловым кодом пропущено: <N>` и `строк без кода с суммами пропущено: <N>`
(последнее — только если в строке есть сумма), то есть `needs_check` (решение №29). Сами строки не восстанавливаются
(OQ-149): в реальных сканах так теряются, например, «БАЛАНС» и «Выручка» с пустым кодом.
- Склеенная строка ИС — несколько целых кодов в ячейке через перевод строки (`1500\n1700`, ББ РБ и Счастливочной за
1 кв. 2025) — до классификации разлепляется на столько строк, сколько кодов, если в каждой непустой ячейке значений
столько же частей (наименование делится так же, иначе остаётся целым); иначе строка пропускается как нечисловой код.
Предупреждение `строк с несколькими кодами разлеплено: <N>`, статус `needs_check` (OQ-156, решение владельца 08.10.2026;
расхождение с источником `_classify_row`).
- Скобка, распознанная как `}` или `]` (`(7744536}`), знак минус не даёт: сумма остаётся положительной с
`is_recognized = false` (решение №12, дословный перенос `_parse_amount`; расширение набора закрывающих скобок — после
проверки на стенде, вынесено в открытые вопросы docs).

View File

@ -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] = []

View File

@ -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,
)

View File

@ -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:

View File

@ -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 = [

View File

@ -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`, предупреждение); при несходящемся числе частей поведение прежнее |