Перенос `_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
97 lines
3.8 KiB
Python
97 lines
3.8 KiB
Python
"""Раскладка колонок таблицы 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
|