fix(normalizer): строка нумерации колонок — часть шапки, пробел внутри суммы не тысячи не верифицируется (AURORA-1517, 1511, 1519, #39)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 00:31:58 +04:00
parent 36d3f5fbf0
commit 9b0f01ef1c
4 changed files with 76 additions and 3 deletions

View File

@ -1,7 +1,9 @@
"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием — """Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием —
порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`). порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`).
Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид Что делает разбор: пробелы долой (но пробел между цифрами вне схемы тысяч `1 234`, `12 345 678` — догадка, `verified`
сбрасывается: «42831 1» → 428311, не чистое; AURORA-1511/1519);
если текст целиком (а при скобках на всю строку — их содержимое) имеет вид
`[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `(4,200)` → `-4200`, `[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `(4,200)` → `-4200`,
`1,234,567` → `1234567`); то же для точек (`4.200`, `837.994`, `(4.200)`, `1.234.567`, а также `1234.567` → `1,234,567` → `1234567`); то же для точек (`4.200`, `837.994`, `(4.200)`, `1.234.567`, а также `1234.567` →
`1234567`: ровно три цифры после точки, цифр до неё сколько угодно; scan-gateway#37); `1234567`: ровно три цифры после точки, цифр до неё сколько угодно; scan-gateway#37);
@ -27,6 +29,10 @@ _ALLOWED_RE = re.compile(r"[^\d+\-.]")
_BRACKET_RE = re.compile(r"\(([^()]*)\)") _BRACKET_RE = re.compile(r"\(([^()]*)\)")
# запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): «4,200», «-1,234,567»; только если строка целиком # запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): «4,200», «-1,234,567»; только если строка целиком
# (или содержимое пары скобок, занимающей всю строку) совпала; «(4,200) руб», «1,234(5,678)» — прежний разбор # (или содержимое пары скобок, занимающей всю строку) совпала; «(4,200) руб», «1,234(5,678)» — прежний разбор
# пробел между цифрами (AURORA-1511/1519, «42831 1»): допустим только как разделитель тысяч — первая группа 1–3 цифры,
# дальше ровно по три; любой другой — догадка, сумма собирается из цифр, но verified = False
_DIGIT_SPACE_RUN_RE = re.compile(r"\d+(?:\s+\d+)+")
_SPACE_THOUSANDS_RE = re.compile(r"\d{1,3}(?:\s\d{3})+")
_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+") _THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+")
# то же для точки (AURORA-1405, scan-gateway#37): ИС Сканирование иногда возвращает «4.200», «837.994»; смешанные # то же для точки (AURORA-1405, scan-gateway#37): ИС Сканирование иногда возвращает «4.200», «837.994»; смешанные
# «1.234,56» / «1,234.56» не подходят ни под одну из двух регулярок (разделитель один и тот же по всей строке) # «1.234,56» / «1,234.56» не подходят ни под одну из двух регулярок (разделитель один и тот же по всей строке)
@ -54,6 +60,7 @@ def parse_amount(raw: str) -> ParsedAmount:
verified=True только если в исходной строке не пришлось отбрасывать символы verified=True только если в исходной строке не пришлось отбрасывать символы
(после удаления скобок). (после удаления скобок).
""" """
spaces_ok = all(_SPACE_THOUSANDS_RE.fullmatch(run.group(0)) for run in _DIGIT_SPACE_RUN_RE.finditer(raw.strip()))
s = raw.strip().replace(" ", "") s = raw.strip().replace(" ", "")
inner = _BRACKET_RE.search(s) inner = _BRACKET_RE.search(s)
core = inner.group(1) if inner and inner.group(0) == s else s core = inner.group(1) if inner and inner.group(0) == s else s
@ -79,9 +86,9 @@ def parse_amount(raw: str) -> ParsedAmount:
if bracketed: if bracketed:
s = "-" + s.lstrip("+-") s = "-" + s.lstrip("+-")
try: try:
return ParsedAmount(Decimal(s), verified) return ParsedAmount(Decimal(s), verified and spaces_ok)
except (InvalidOperation, ValueError): except (InvalidOperation, ValueError):
return ParsedAmount(None, verified) return ParsedAmount(None, verified and spaces_ok)
def is_blank(text: str) -> bool: def is_blank(text: str) -> bool:

View File

@ -3,6 +3,7 @@
выводится по данным и шапка синтезируется; строки до первой шапки — по выведенной раскладке. Расхождение с источником — выводится по данным и шапка синтезируется; строки до первой шапки — по выведенной раскладке. Расхождение с источником —
склеенные строки ИС (`1500\n1700`) разлепляются до классификации (OQ-156, решение владельца 08.10.2026).""" склеенные строки ИС (`1500\n1700`) разлепляются до классификации (OQ-156, решение владельца 08.10.2026)."""
import re
from collections.abc import Collection, Mapping, Sequence from collections.abc import Collection, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
@ -16,6 +17,7 @@ __all__ = [
"ParsedTable", "ParsedTable",
"RowKind", "RowKind",
"classify_row", "classify_row",
"is_numbering_row",
"parse_table_rows", "parse_table_rows",
"split_merged_row", "split_merged_row",
"table_type_by_codes", "table_type_by_codes",
@ -71,6 +73,20 @@ def classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None:
return RowKind.ROW return RowKind.ROW
_NUMBERING_CELL_RE = re.compile(r"\d{1,2}")
_MIN_NUMBERING_CELLS = 3
def is_numbering_row(cells: Cells) -> bool:
"""AURORA-1517: строка нумерации колонок «1 2 3 4 5» под шапкой — часть заголовка. Все непустые ячейки — короткие
(1–2 цифры) целые строго по возрастанию, ячеек не меньше трёх (как `is_numbering_row` в tabular-parser)."""
texts = [cell.value.strip() for _, cell in sorted(cells.items()) if cell.value.strip()]
if len(texts) < _MIN_NUMBERING_CELLS or not all(_NUMBERING_CELL_RE.fullmatch(t) for t in texts):
return False
numbers = [int(t) for t in texts]
return all(a < b for a, b in zip(numbers, numbers[1:], strict=False))
def _data_row(cells: Cells, layout: ColumnLayout, ordinal: int) -> ParsedRow | None: def _data_row(cells: Cells, layout: ColumnLayout, ordinal: int) -> ParsedRow | None:
kind = classify_row(cells, layout) kind = classify_row(cells, layout)
if kind is None or kind is RowKind.HEADER: if kind is None or kind is RowKind.HEADER:
@ -119,6 +135,8 @@ def split_merged_row(cells: Cells, layout: ColumnLayout) -> list[Cells] | None:
def _data_rows(cells: Cells, layout: ColumnLayout, ordinal: int, skipped: _Skipped) -> list[ParsedRow]: def _data_rows(cells: Cells, layout: ColumnLayout, ordinal: int, skipped: _Skipped) -> list[ParsedRow]:
"""Строки данных из одной строки OCR: склеенная разлепляется (OQ-156), отброшенная — учитывается в `skipped`.""" """Строки данных из одной строки OCR: склеенная разлепляется (OQ-156), отброшенная — учитывается в `skipped`."""
if is_numbering_row(cells): # часть заголовка: в данные не попадает и в счётчики отброшенного не идёт
return []
split = split_merged_row(cells, layout) split = split_merged_row(cells, layout)
if split is not None: if split is not None:
skipped.split_merged_codes += 1 skipped.split_merged_codes += 1

View File

@ -88,6 +88,18 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
("abc(4.200)", Decimal("-4.200"), False), ("abc(4.200)", Decimal("-4.200"), False),
("(4.200)(1.000)", Decimal("-4.200"), False), ("(4.200)(1.000)", Decimal("-4.200"), False),
("1.234(5.678)", Decimal("-5.678"), False), ("1.234(5.678)", Decimal("-5.678"), False),
# пробел внутри суммы допустим только как разделитель тысяч (AURORA-1511/1519): иначе догадка, verified=False
("42831 1", Decimal("428311"), False),
("1 2", Decimal("12"), False),
("1234 567", Decimal("1234567"), False),
("12 34", Decimal("1234"), False),
("1 2345", Decimal("12345"), False),
("(42831 1)", Decimal("-428311"), False),
("1 234,5 6", Decimal("1234.56"), False),
("12 345 678", Decimal("12345678"), True),
("(12 345)", Decimal("-12345"), True),
("-1 234", Decimal("-1234"), True),
("123 456", Decimal("123456"), True),
# №13: скобки без содержимого — не сумма, но и не «неразобранный текст» # №13: скобки без содержимого — не сумма, но и не «неразобранный текст»
("()", None, True), ("()", None, True),
("( )", None, True), ("( )", None, True),

View File

@ -10,6 +10,7 @@ from src.normalizer.ocr.rows import (
ParsedTable, ParsedTable,
RowKind, RowKind,
classify_row, classify_row,
is_numbering_row,
parse_table_rows, parse_table_rows,
table_type_by_codes, table_type_by_codes,
table_type_by_unanimous_codes, table_type_by_unanimous_codes,
@ -71,6 +72,41 @@ class TestParseTableRows:
assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4)) assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4))
assert parsed.rows[1].ordinal == 1 assert parsed.rows[1].ordinal == 1
def test_numbering_row_is_not_data(self) -> None:
"""AURORA-1517: строка «1 2 3 4 5» под шапкой — часть заголовка, не строка данных с кодом «3»."""
rows = [
HEADER,
_row(1, (0, "1", 100), (1, "2", 100), (2, "3", 100), (4, "4", 100)),
_row(2, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100), (4, "", 100)),
]
parsed = parse_table_rows(rows, KNOWN)
assert parsed is not None
assert [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW]
assert parsed.rows[1].ordinal == 2
assert parsed.skipped_non_numeric_code == 0 and parsed.skipped_no_code_with_amounts == 0
def test_numbering_row_with_empty_cells(self) -> None:
rows = [
HEADER,
_row(1, (0, "1", 100), (1, "2", 100), (2, "3", 100), (3, "", 100), (4, "5", 100)),
_row(2, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100)),
]
parsed = parse_table_rows(rows, KNOWN)
assert parsed is not None and [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW]
@pytest.mark.parametrize(
"values",
[
("1", "2"), # меньше трёх ячеек
("5", "3", "4"), # не по возрастанию
("1", "2", "2"),
("1105", "2", "3"), # не короткие
("Выручка", "2110", "5"),
],
)
def test_not_numbering_row(self, values: tuple[str, ...]) -> None:
assert not is_numbering_row(cells_by_index(_row(1, *((i, v, 100) for i, v in enumerate(values)))))
def test_second_header_overrides_layout(self) -> None: def test_second_header_overrides_layout(self) -> None:
"""Review Focus 1: после повторной шапки с другим набором колонок строки идут по новой раскладке.""" """Review Focus 1: после повторной шапки с другим набором колонок строки идут по новой раскладке."""
rows = [ rows = [