Andrey Epifancev 462ea9ee32 feat(normalizer): строки таблицы OCR — шапки, классы строк, тип по кодам (§11.1 п. 3–5)
Перенос _classify_row, _parse_table_rows, _synthetic_header_cells, _resolve_table_type
из reference-old-arm с адаптацией к новой архитектуре:
- Cell dataclass вместо dict
- ParsedTable вместо list возврата
- Fallback для row_index если не int
- Безопасная обработка пустых кодов в table_type_by_codes

14 тестов пройдено (2 на реальных данных fixtures).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:21:20 +04:00

142 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Строки таблицы OCR (§11.1 п. 3–5): шапка настоящая/повторная/синтетическая, классы строк, тип по кодам."""
import json
from pathlib import Path
import pytest
from src.normalizer.ocr.layout import ColumnLayout, 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]
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
KNOWN = frozenset({"1105", "1110", "1120", "1130", "1150", "1600", "2110", "2120", "2100"})
def _row(index: int, *cells: tuple[int, str, float]) -> dict:
return {"row_index": index, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]}
HEADER = _row(
0,
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 30 июня\n2025 г", 100),
(4, "На 31 декабря\n2024 г.", 100),
)
def _real_table(name: str, index: int) -> list[dict]:
if not REAL_FIXTURES.is_dir():
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
data = json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8"))
return list(data["data_ocr"]["table_structure"][index]["rows"])
class TestClassifyRow:
layout = ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2,))
@pytest.mark.parametrize(
("label", "code", "expected"),
[
("Выручка", "2110", RowKind.ROW),
("Итого по разделу I", "1100", RowKind.TOTAL),
("Баланс", "1600", RowKind.BALANCE),
("Наименование", "Код", RowKind.HEADER),
("в том числе:", "", None),
],
)
def test_kinds(self, label: str, code: str, expected: RowKind | None) -> None:
cells = cells_by_index(_row(1, (0, label, 100), (1, code, 100), (2, "1", 100)))
assert classify_row(cells, self.layout) is expected
class TestParseTableRows:
def test_header_then_rows(self) -> None:
rows = [
HEADER,
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100), (4, "", 100)),
_row(2, (0, "в том числе:", 93), (1, "", 100), (2, "13 070", 100), (4, "", 100)),
]
parsed = parse_table_rows(rows, KNOWN)
assert isinstance(parsed, ParsedTable) and not parsed.synthetic_header
assert [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW]
assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4))
assert parsed.rows[1].ordinal == 1
def test_second_header_overrides_layout(self) -> None:
"""Review Focus 1: после повторной шапки с другим набором колонок строки идут по новой раскладке."""
rows = [
HEADER,
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100), (4, "2", 100)),
_row(2, (0, "Наименование показателя", 100), (1, "Код", 100), (3, "За 2024 г.", 100)),
_row(3, (0, "Себестоимость", 100), (1, "2120", 100), (3, "(5)", 100)),
]
parsed = parse_table_rows(rows, KNOWN)
assert parsed is not None
kinds = [r.kind for r in parsed.rows]
assert kinds == [RowKind.HEADER, RowKind.ROW, RowKind.ROW] # повторная шапка не становится строкой
assert parsed.rows[1].layout.value_idxs == (2, 4)
assert parsed.rows[2].layout.value_idxs == (3,)
def test_rows_before_header_use_inferred_layout(self) -> None:
rows = [
_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)),
_row(1, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "2024", 100)),
_row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100)),
]
parsed = parse_table_rows(rows, KNOWN)
assert parsed is not None and not parsed.synthetic_header
assert [r.ordinal for r in parsed.rows] == [-1, 0, 2] # шапка встаёт раньше первой строки данных
assert parsed.rows[0].kind is RowKind.HEADER
def test_synthetic_header_when_none(self) -> None:
rows = [
_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)),
_row(1, (1, "1150", 100), (0, "Основные средства", 100), (2, "7", 100)),
]
parsed = parse_table_rows(rows, KNOWN)
assert parsed is not None and parsed.synthetic_header
header = parsed.rows[0]
assert header.kind is RowKind.HEADER and header.ordinal == -1
assert header.cells[1].value == "Код" and header.cells[0].value == "Наименование показателя"
assert header.layout.value_idxs == (2,) and header.cells.get(2) is None # тексты периодов пустые
def test_no_header_no_codes_is_none(self) -> None:
assert parse_table_rows([_row(0, (0, "текст", 100), (1, "мусор", 100))], KNOWN) is None
assert parse_table_rows([], KNOWN) is None
@pytest.mark.fixtures
def test_aep3_balance_without_header(self) -> None:
parsed = parse_table_rows(_real_table("aep_3.json", 0), KNOWN)
assert parsed is not None and parsed.synthetic_header
assert parsed.rows[0].layout.code_idx == 1
assert any(r.cells[1].value.strip() == "1120" for r in parsed.rows[1:])
@pytest.mark.fixtures
def test_aep6_balance_with_header(self) -> None:
parsed = parse_table_rows(_real_table("aep_6.json", 0), KNOWN)
assert parsed is not None and not parsed.synthetic_header
assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4, 5))
assert table_type_by_codes(parsed) == "BALANCE"
class TestTableType:
def test_by_first_digit(self) -> None:
rows = [
HEADER,
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)),
_row(2, (0, "Себестоимость", 100), (1, "2120", 100), (2, "1", 100)),
_row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)),
]
assert table_type_by_codes(parse_table_rows(rows, KNOWN)) == "INCOME"
def test_tie_or_empty_is_none(self) -> None:
rows = [
HEADER,
_row(1, (0, "a", 100), (1, "2110", 100), (2, "1", 100)),
_row(2, (0, "b", 100), (1, "1150", 100), (2, "1", 100)),
]
assert table_type_by_codes(parse_table_rows(rows, KNOWN)) is None
assert table_type_by_codes(ParsedTable(rows=[], synthetic_header=False)) is None