Andrey Epifancev 0123dc0067 feat(normalizer): раскладка колонок OCR — шапка «Код» и вывод по данным (§11.1 п. 3)
Перенос `_build_layout`, `_infer_layout`, `_cells_by_index`, `_cell_value` из старого ядра.
- Cell dataclass (frozen): значение и уверенность из ячейки OCR
- build_layout: раскладка из строки с ячейкой «Код…» (заголовок)
- infer_layout: раскладка из строк данных по кодам (когда заголовка нет)
- Справочник кодов приходит параметром, ячейки — Cell вместо словарей

R-1 (design #18): тест test_aep3_like_rows_without_header исправлен:
- столбец 5 содержит только мусор (пусто, ':', '+ :') → не входит в value_idxs
- столбец 4 содержит суммы ('3', '5 678') не реже мусора ('--') → входит
- замена assertion на layout.value_idxs == (2, 4) вместо ошибочного условия

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

97 lines
3.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): шапка «Код» и вывод по данным.
Перенос `_build_layout`/`_infer_layout` из старого ядра."""
from src.normalizer.ocr.layout import Cell, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout
KNOWN = frozenset({"1110", "1120", "1130", "1150", "2110", "2120"})
def _row(*cells: tuple[int, str, float]) -> dict:
return {"row_index": 0, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]}
def test_cells_by_index_normalizes_types() -> None:
cells = cells_by_index(_row((0, "Выручка", 71.4), (1, None, None)))
assert cells == {0: Cell("Выручка", 71.4), 1: Cell("", 0.0)}
assert cell_text(cells, 0) == "Выручка"
assert cell_text(cells, 7) == ""
assert cell_text(cells, None) == ""
class TestBuildLayout:
def test_header_with_code_and_years(self) -> None:
cells = cells_by_index(
_row(
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 30 июня\n2025 г", 100),
(4, "На 31 декабря\n2024 г.", 100),
(5, "На 31 декабря\n2023 г.", 100),
)
)
assert build_layout(cells) == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4, 5))
def test_label_defaults_to_last_filled_left_of_code(self) -> None:
cells = cells_by_index(_row((0, "Пояснения", 100), (1, "Показатель", 100), (2, "Код", 100), (3, "2024", 100)))
assert build_layout(cells) == ColumnLayout(label_idx=1, code_idx=2, value_idxs=(3,))
def test_no_code_cell_is_not_a_header(self) -> None:
assert build_layout(cells_by_index(_row((0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100)))) is None
class TestInferLayout:
def test_aep3_like_rows_without_header(self) -> None:
rows = [
cells_by_index(
_row(
(1, "НИ", 0),
(0, "Нематериальные активы", 100),
(2, "30127", 60),
(4, "| | г", 16),
(5, "", 100),
)
),
cells_by_index(
_row(
(1, "1120", 50),
(0, "Результаты исследований и", 94),
(2, ":", 0),
(4, "--", 0),
(5, ":", 0),
)
),
cells_by_index(
_row(
(1, "1130", 80),
(0, "Нематериальные поисковые", 87),
(2, "не ****", 5),
(4, "3", 0),
(5, "+ :", 0),
)
),
cells_by_index(
_row(
(1, "1150", 100),
(0, "Основные средства", 100),
(2, "1 234", 100),
(4, "5 678", 100),
(5, "", 100),
)
),
]
layout = infer_layout(rows, KNOWN)
assert layout is not None
assert layout.code_idx == 1
assert layout.label_idx == 0
# R-1: column 5 contains only garbage (empty, ':', '+ :'), no actual amounts
# column 4 has both garbage and amounts ('--', '3', '5 678') but amounts >= garbage
assert layout.value_idxs == (2, 4)
def test_no_known_codes_is_none(self) -> None:
rows = [cells_by_index(_row((0, "текст", 100), (1, "мусор", 100)))]
assert infer_layout(rows, KNOWN) is None
def test_empty_rows_is_none(self) -> None:
assert infer_layout([], KNOWN) is None