feat(normalizer): очистка OCR-текста — двойники, разделитель клеток, clean_value (§11.1 п. 1, 6)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 23:56:16 +04:00
parent ecdf9a4e13
commit cea8dfe485
3 changed files with 172 additions and 0 deletions

View File

@ -0,0 +1,3 @@
"""Нормализатор OCR: конвейер чистых функций `text` → `dates` → `amounts` → `layout` → `rows` → `header` → `document`
(D-4, §11.1). Проверенные на стенде функции старого ядра (`../reference-old-arm/api/src/core/PDFWorker/`) перенесены
дословно; DB-обвязка не переносится."""

View File

@ -0,0 +1,70 @@
"""Очистка OCR-текста (§11.1 п. 1, 6). Перенос из `date_utils._normalize`, `utils._clear_value`,
`_keep_digits_only`, `_remove_spec_space`, `_normalize_header`, `_is_code_header`, `_is_value_header`
старого ядра; алгоритмы без изменений."""
import re
from collections.abc import Mapping
__all__ = [
"clean_value",
"collapse_whitespace",
"digits_only",
"fix_lookalikes",
"is_code_header",
"is_value_header",
"normalize_header",
]
_YEAR_RE = re.compile(r"\d{4}")
# первая строка значения из цифр, пробелов, `|`, точек и т. п. — `|` в ней разделяет клетки формы, а не единица
_CELL_SEPARATOR_LINE = re.compile(r"[\s\d|•·.:,]*")
def fix_lookalikes(text: str, lookalikes: Mapping[str, str]) -> str:
"""Буквы-двойники → цифры только вплотную к цифре, в несколько проходов («2ОО5» → «2005»); `|` — разделитель
клеток, если первая строка состоит из цифр, пробелов и пунктуации («2|0|2|4|» → «2 0 2 4 »)."""
text = text.replace(" ", " ")
if _CELL_SEPARATOR_LINE.fullmatch(text.split("\n", 1)[0]):
text = text.replace("|", " ")
if not lookalikes:
return text
chars = re.escape("".join(lookalikes))
near_digit = re.compile(rf"(?<=\d)[{chars}]|[{chars}](?=\d)")
table = str.maketrans(dict(lookalikes))
previous = None
while previous != text: # «2ОО5» требует двух проходов
previous = text
text = near_digit.sub(lambda match: match.group(0).translate(table), text)
return text
def clean_value(text: str) -> str:
"""Остаются буквы, цифры, пробел и точка; переносы строк и табуляции — пробел; края обрезаются."""
cleaned = "".join(
char for char in text.replace("\n", " ").replace("\t", " ") if char.isdigit() or char.isalpha() or char in " ."
)
return cleaned.strip()
def digits_only(text: str) -> str:
return "".join(char for char in text if char.isdigit())
def collapse_whitespace(text: str) -> str:
return text.replace("\n", " ").replace("\t", " ").strip()
def normalize_header(text: object) -> str:
"""Нижний регистр, всё не-буквенно-цифровое → пробел, пробелы схлопнуты (для сравнения заголовков и label)."""
lowered = str(text or "").lower()
return " ".join("".join(char if char.isalnum() else " " for char in lowered).split())
def is_code_header(text: str) -> bool:
"""Ячейка шапки «Код…» (текст уже нормализован `normalize_header`)."""
return text.startswith("код")
def is_value_header(text: str) -> bool:
"""Ячейка шапки колонки значений: есть четыре цифры года или текст начинается с «на » / «за »."""
return bool(_YEAR_RE.search(text)) or text.startswith(("на ", "за "))

View File

@ -0,0 +1,99 @@
"""Очистка OCR-текста (§11.1 п. 1, 6; D-4 `text.py`): перенос функций старого ядра, поведение — как в источнике."""
import pytest
from src.normalizer.ocr.text import (
clean_value,
collapse_whitespace,
digits_only,
fix_lookalikes,
is_code_header,
is_value_header,
normalize_header,
)
LOOKALIKES = {
"О": "0",
"о": "0",
"O": "0",
"o": "0",
"З": "3",
"з": "3",
"l": "1",
"I": "1",
"|": "1",
"і": "1",
"б": "6",
"S": "5",
"s": "5",
"Ч": "4",
}
class TestFixLookalikes:
@pytest.mark.parametrize(
("raw", "expected"),
[
("2ОО5", "2005"), # два прохода: О после 2, затем О после 0
("3l.12.2О24", "31.12.2024"),
("Общество", "Общество"), # буквы без соседней цифры не трогаются
("2|0|2|4|", "2 0 2 4 "), # первая строка — только цифры и |: это разделитель клеток
("3l", "31"), # | не разделитель, если есть буквы
("", ""),
],
)
def test_replaces_only_next_to_digits(self, raw: str, expected: str) -> None:
assert fix_lookalikes(raw, LOOKALIKES) == expected
def test_separator_rule_looks_at_first_line_only(self) -> None:
assert fix_lookalikes("2|0|2|4|\nмусор", LOOKALIKES) == "2 0 2 4 \nмусор"
def test_empty_table_keeps_text(self) -> None:
assert fix_lookalikes("2ОО5", {}) == "2ОО5"
@pytest.mark.parametrize(
("raw", "expected"),
[
("13 070", "13 070"),
("(7744536}", "7744536"),
("изо :", "изо"),
("На 30 июня\n2025 г", "На 30 июня 2025 г"),
("", ""),
],
)
def test_clean_value(raw: str, expected: str) -> None:
assert clean_value(raw) == expected
def test_digits_only() -> None:
assert digits_only("3|1 21 2| о! О' 0 0; 3: 51") == "3121200351"
assert digits_only("+") == ""
def test_collapse_whitespace() -> None:
assert collapse_whitespace(" На 31 декабря\n2024 г.\t") == "На 31 декабря 2024 г."
class TestHeaders:
def test_normalize_header(self) -> None:
assert normalize_header("Наименование\nпоказателя:") == "наименование показателя"
assert normalize_header(None) == ""
def test_code_header(self) -> None:
assert is_code_header(normalize_header("Код"))
assert is_code_header(normalize_header("Код строки"))
assert not is_code_header(normalize_header("Наименование"))
@pytest.mark.parametrize(
("text", "expected"),
[
("на 30 июня 2025 г", True),
("за январь июнь 2025 г", True),
("2024", True),
("пояснения", False),
("", False),
],
)
def test_value_header(self, text: str, expected: bool) -> None:
assert is_value_header(text) is expected