fix(#8): шаг 2 — нумерация со сбоем OCR под заголовком, повтор шапки со склеенной нумерацией (AURORA-1517)

Строка сразу под строкой заголовка сегмента проверяется на нумерацию мягко: ячейка со сбоем OCR
даёт своё единственное целое («• ■ 1», «3гола», «гола\n4»), ячейка без цифр («/», «V») — пропуск.
Подписи шапки сравниваются без хвоста-номера столбца («Коды\n2» → «коды»), поэтому склеенная с
нумерацией повторная шапка становится шапкой сегмента.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 16:36:06 +04:00
parent 3390f92dad
commit 72e2e72909
3 changed files with 71 additions and 11 deletions

View File

@ -17,6 +17,7 @@ _MIN_NUMBERING_CELLS = 3
_SHORT_NOTE = 3 # «Пояснения» без подписи: значения не длиннее 3 символов _SHORT_NOTE = 3 # «Пояснения» без подписи: значения не длиннее 3 символов
_SKIP_STEP = 2 # скачок через одну пропущенную цифру нумерации _SKIP_STEP = 2 # скачок через одну пропущенную цифру нумерации
_NUMBER = re.compile(r"[0-9]+") _NUMBER = re.compile(r"[0-9]+")
_NUMBERING_TAIL = re.compile(r"\s+[0-9]{1,2}$")
_FULL_CONFIDENCE = 100.0 _FULL_CONFIDENCE = 100.0
@ -24,6 +25,11 @@ def _text(cell: Cell | None) -> str:
return "" if cell is None else cell.text.strip() return "" if cell is None else cell.text.strip()
def caption_text(text: str) -> str:
"""Подпись шапки для сравнения: `normalize_caption` без номера столбца, склеенного снизу («Коды\\n2» → «коды»)."""
return _NUMBERING_TAIL.sub("", normalize_caption(text))
def cell_at(rows: Grid, row: int, col: int) -> Cell | None: def cell_at(rows: Grid, row: int, col: int) -> Cell | None:
"""Ячейка сетки; вне строки или сетки — `None`.""" """Ячейка сетки; вне строки или сетки — `None`."""
if row < 0 or row >= len(rows) or col < 0: if row < 0 or row >= len(rows) or col < 0:
@ -138,17 +144,27 @@ def first_code_row(rows: Grid, first: int, last: int, code_col: int, rules: Rule
return None return None
def is_numbering_row(cells: Sequence[Cell | None], rules: Rules) -> bool: def is_numbering_row(cells: Sequence[Cell | None], rules: Rules, *, noisy: bool = False) -> bool:
"""≥ 3 непустых ячеек-целых, слева направо +1, начало 1 или 2, не больше одного пропуска.""" """≥ 3 непустых ячеек-целых, слева направо +1, начало 1 или 2, не больше одного пропуска.
`noisy` — строка сразу под шапкой (AURORA-1517): ячейка со сбоем OCR даёт своё единственное целое («• ■ 1» → 1,
«3гола» → 3), ячейка без цифр («/», «V») — пропуск, два целых в ячейке — не нумерация.
"""
numbers: list[int] = [] numbers: list[int] = []
for cell in cells: for cell in cells:
text = _text(cell) text = _text(cell)
if not text: if not text:
continue continue
fixed = fix_lookalikes(text, rules.lookalikes, anywhere=True).strip() fixed = fix_lookalikes(text, rules.lookalikes, anywhere=True).strip()
if _NUMBER.fullmatch(fixed) is None: if _NUMBER.fullmatch(fixed) is not None:
numbers.append(int(fixed))
continue
if not noisy:
return False return False
numbers.append(int(fixed)) found = _NUMBER.findall(text)
if len(found) > 1:
return False
numbers.extend(int(number) for number in found)
if len(numbers) < _MIN_NUMBERING_CELLS or numbers[0] not in (1, 2): if len(numbers) < _MIN_NUMBERING_CELLS or numbers[0] not in (1, 2):
return False return False
skips = 0 skips = 0
@ -212,7 +228,7 @@ def find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rul
"""Строка заголовка (одна или две, склеенные); не найдена — `()`.""" """Строка заголовка (одна или две, склеенные); не найдена — `()`."""
candidates = [r for r in range(first, first_code) if not is_numbering_row(rows[r], rules)] candidates = [r for r in range(first, first_code) if not is_numbering_row(rows[r], rules)]
captioned = [ captioned = [
r for r in candidates if rules.headers.code_caption.search(normalize_caption(_text(cell_at(rows, r, code_col)))) r for r in candidates if rules.headers.code_caption.search(caption_text(_text(cell_at(rows, r, code_col))))
] ]
header: int | None = captioned[-1] if captioned else None header: int | None = captioned[-1] if captioned else None
if header is None: if header is None:
@ -229,15 +245,17 @@ def find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rul
def segment_roles(rows: Grid, first: int, last: int, header_rows: tuple[int, ...], rules: Rules) -> list[RowRole]: def segment_roles(rows: Grid, first: int, last: int, header_rows: tuple[int, ...], rules: Rules) -> list[RowRole]:
"""Роли строк `first..last`: выше заголовка — TITLE (только при найденном заголовке).""" """Роли строк `first..last`: выше заголовка — TITLE (только при найденном заголовке); строка сразу под заголовком —
нумерация и при сбое OCR (`is_numbering_row(noisy=True)`)."""
roles: list[RowRole] = [] roles: list[RowRole] = []
top = min(header_rows) if header_rows else None top = min(header_rows) if header_rows else None
under = max(header_rows) + 1 if header_rows else None
for row in range(first, last + 1): for row in range(first, last + 1):
if row in header_rows: if row in header_rows:
roles.append(RowRole.HEADER) roles.append(RowRole.HEADER)
elif top is not None and row < top: elif top is not None and row < top:
roles.append(RowRole.TITLE) roles.append(RowRole.TITLE)
elif is_numbering_row(rows[row], rules): elif is_numbering_row(rows[row], rules, noisy=row == under):
roles.append(RowRole.NUMBERING) roles.append(RowRole.NUMBERING)
else: else:
roles.append(RowRole.DATA) roles.append(RowRole.DATA)
@ -251,7 +269,7 @@ def _caption_column(
for col, cell in enumerate(rows[row]): for col, cell in enumerate(rows[row]):
if col == code_col or (left_of_code and col > code_col): if col == code_col or (left_of_code and col > code_col):
continue continue
if pattern.search(normalize_caption(_text(cell))): if pattern.search(caption_text(_text(cell))):
return col return col
return None return None

View File

@ -18,6 +18,7 @@ from arm_statements.columns import (
from arm_statements.issues import WarningCode, make_warning from arm_statements.issues import WarningCode, make_warning
from arm_statements.layout import ( from arm_statements.layout import (
Grid, Grid,
caption_text,
cell_at, cell_at,
find_code_column, find_code_column,
find_header_rows, find_header_rows,
@ -35,7 +36,6 @@ from arm_statements.model import (
StatementHeader, StatementHeader,
StatementWarning, StatementWarning,
) )
from arm_statements.text import normalize_caption
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -100,7 +100,7 @@ def _chunks(rows: Grid, breaks: Sequence[int]) -> list[tuple[int, int]]:
def _caption_cells(rows: Grid, header_rows: Sequence[int]) -> frozenset[str]: def _caption_cells(rows: Grid, header_rows: Sequence[int]) -> frozenset[str]:
cells = (normalize_caption(_text(rows, r, c)) for r in header_rows for c in range(len(rows[r]))) cells = (caption_text(_text(rows, r, c)) for r in header_rows for c in range(len(rows[r])))
return frozenset(c for c in cells if c) return frozenset(c for c in cells if c)
@ -110,7 +110,7 @@ def _is_repeat_header(rows: Grid, row: int, code_col: int | None, reference: fro
return False return False
if code_col is not None and is_code_cell(_text(rows, row, code_col), rules): if code_col is not None and is_code_cell(_text(rows, row, code_col), rules):
return False return False
normalized = [normalize_caption(c.text) for c in rows[row] if c is not None and c.text.strip()] normalized = [caption_text(c.text) for c in rows[row] if c is not None and c.text.strip()]
if any(rules.headers.code_caption.search(text) for text in normalized): if any(rules.headers.code_caption.search(text) for text in normalized):
return True return True
return sum(1 for text in normalized if text and text in reference) >= _MIN_SAME_CELLS return sum(1 for text in normalized if text and text in reference) >= _MIN_SAME_CELLS

View File

@ -215,3 +215,45 @@ def test_headerless_continuation_sheet_with_outside_numbers(rules: Rules) -> Non
assert layout.warnings[0].locator == "Лист1!F2\nЛист2!F1" assert layout.warnings[0].locator == "Лист1!F2\nЛист2!F1"
assert layout.segments[1].value_cols == ((3, 3), (4, 4)) assert layout.segments[1].value_cols == ((3, 3), (4, 4))
assert layout.segments[1].extra_cols == () assert layout.segments[1].extra_cols == ()
BALANCE_CAPTIONS = ["Наименование показателя", "Коды", "На 30 июня\n2026 года", "На 31 декабря\n2025 года"]
ASSETS = [["Запасы", "1210", "5", "4"], ["Дебиторская задолженность", "1230", "3", "2"], ["Итого", "1200", "8", "6"]]
LIABILITIES = [["Заемные средства", "1510", "3", "2"], ["Кредиторская задолженность", "1520", "5", "4"]]
def test_numbering_with_ocr_noise_right_under_header(rules: Rules) -> None:
"""AURORA-1517: нумерация под шапкой со сбоем OCR («/» вместо 1, мусор, протёкшее «года», «V» вместо 4)."""
for numbering in (
["/", "2", "3", "4"],
["• ■ 1", "2", "3", "4"],
["1", "2", "3гола", "гола\n4"],
["1", "2", "3", "V"],
):
layout = find_layout(ocr([BALANCE_CAPTIONS, numbering, *ASSETS]), parse_header(hdr(), rules), rules)
assert layout.roles[1] is RowRole.NUMBERING, numbering
def test_numbering_with_ocr_noise_under_repeat_header(rules: Rules) -> None:
"""AURORA-1517, «Светлый путь»: «• ■ 1 2 3 4 5» под повторной шапкой второй страницы."""
rows = [BALANCE_CAPTIONS, ["1", "2", "3", "4"], *ASSETS, BALANCE_CAPTIONS, ["• ■ 1", "2", "3", "4"], *LIABILITIES]
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
assert [s.first_row for s in layout.segments] == [0, 5]
assert layout.roles[6] is RowRole.NUMBERING and layout.roles[7] is RowRole.DATA
def test_noisy_numbering_elsewhere_stays_data(rules: Rules) -> None:
"""Мягкая проверка — только сразу под шапкой; строка с кодом формы — не нумерация."""
rows = [BALANCE_CAPTIONS, ["Запасы 1", "1210", "2", "3"], *ASSETS, ["/", "2", "3", "4"], *LIABILITIES]
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
assert layout.roles[1] is RowRole.DATA and layout.roles[5] is RowRole.DATA
def test_repeat_header_glued_with_numbering(rules: Rules) -> None:
"""AURORA-1517, «Будамшинское»: повторная шапка, в каждой подписи которой снизу номер столбца."""
glued = ["Наименование показателя\n1", "Коды\n2", "На 30 июня\n2026 года\n3", "На 31 декабря\n2025 года\n4"]
rows = [BALANCE_CAPTIONS, ["1", "2", "3", "4"], *ASSETS, glued, *LIABILITIES]
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
assert [s.first_row for s in layout.segments] == [0, 5]
assert layout.roles[5] is RowRole.HEADER and layout.roles[6] is RowRole.DATA
assert layout.segments[1].value_cols == ((2, 2), (3, 3))