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:
parent
ecdf9a4e13
commit
cea8dfe485
3
api/src/normalizer/ocr/__init__.py
Normal file
3
api/src/normalizer/ocr/__init__.py
Normal 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-обвязка не переносится."""
|
||||
70
api/src/normalizer/ocr/text.py
Normal file
70
api/src/normalizer/ocr/text.py
Normal 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(("на ", "за "))
|
||||
99
api/tests/unit/normalizer/test_text.py
Normal file
99
api/tests/unit/normalizer/test_text.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user