Andrey Epifancev c1288d08fb fix(normalizer): итоги финального ревью — Money, статус при ошибочном файле, пропущенные строки, длина дат, дубли yaml
- C-1 (№30): сумма с более чем двумя знаками после запятой не принимается (amount=null, string_value, is_recognized=false);
  confidence ячеек и шапки приводится к [0, 100], NaN и бесконечность дают 0
- F-A (№28): ошибочный файл набора при успешном понижает статус до needs_check
- F-B: «ИНН не распознан» только когда ИНН не извлечён, иначе «ИНН распознан неуверенно»
- №29: предупреждения «строк с нечисловым кодом пропущено: N» и «строк без кода с суммами пропущено: N»
- I-3 (№31): тексты дат длиннее 200 символов не разбираются и не проходят fix_lookalikes
- T1.3 (№33): загрузчик yaml отклоняет повторяющиеся ключи; тавтологичный тест дублей заменён настоящим
- гигиена: докстринг amounts.py описывает фактическое поведение (все точки удаляются), мёртвые noqa в ocr/__init__.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 03:35:10 +04:00

104 lines
4.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 п. 2): дословный перенос `date_utils` старого ядра, вход — после `fix_lookalikes`."""
import time
from datetime import date
import pytest
from src.normalizer.ocr.dates import MAX_DATE_TEXT_LENGTH, is_date_sized, parse_date
from src.normalizer.ocr.text import fix_lookalikes
LOOKALIKES = {"О": "0", "о": "0", "З": "3", "l": "1", "I": "1", "|": "1", "б": "6", "S": "5", "Ч": "4"}
@pytest.mark.parametrize(
("raw", "expected"),
[
("на 30 сентября 2025 г.", date(2025, 9, 30)),
("На 30 июня\n2025 г", date(2025, 6, 30)),
("31 декабря 2024 года", date(2024, 12, 31)),
("30 сентября 2025 r.", date(2025, 9, 30)), # латинская r вместо г
("с 01.01.2025 по 30.09.2025", date(2025, 9, 30)), # диапазон — конец
("I квартал 2026", date(2026, 3, 31)),
("1-й кв. 2026", date(2026, 3, 31)),
("первый квартал 2025 г.", date(2025, 3, 31)),
("1 полугодие 2026", date(2026, 6, 30)),
("1 п/г 2026", date(2026, 6, 30)),
("9 месяцев 2025", date(2025, 9, 30)),
("9 мес. 2025", date(2025, 9, 30)),
("За январь - июнь\n2025 г.", date(2025, 6, 30)),
("январь – сентябрь 2025", date(2025, 9, 30)),
("за 2025 г.", date(2025, 12, 31)),
("2024 год", date(2024, 12, 31)),
("30.09.2025", date(2025, 9, 30)),
("2025 03 31", date(2025, 3, 31)),
("30/09/25", date(2025, 9, 30)),
("2 0 2 4", date(2024, 12, 31)),
("30 09 2 0 2 5", date(2025, 9, 30)),
("30092025", date(2025, 9, 30)),
("20250930", date(2025, 9, 30)),
("30 09 25", date(2025, 9, 30)),
("30.09.2025 0:00:00", date(2025, 9, 30)),
("2|0|2|4|", date(2024, 12, 31)), # через fix_lookalikes
("3l.12.2О24", date(2024, 12, 31)),
],
)
def test_parses_real_forms(raw: str, expected: date) -> None:
assert parse_date(fix_lookalikes(raw, LOOKALIKES)) == expected
@pytest.mark.parametrize(
"raw",
[
"Общество с ограниченной ответственностью", # Review Focus 3: название организации — не дата
"ЗАКРЫТОЕ АКЦИОНЕРНОЕ ОБЩЕСТВО",
"",
"+",
"тыс. руб.",
"1999999999999", # 13 цифр — ни одна форма
],
)
def test_organization_name_is_not_a_date(raw: str) -> None:
assert parse_date(fix_lookalikes(raw, LOOKALIKES)) is None
def test_non_string_is_none() -> None:
assert parse_date(None) is None
assert parse_date(20250930) is None
def test_year_out_of_range_is_none() -> None:
assert parse_date("1 2 3 4") is None # 1234 вне 1990–2100
class TestLengthCap:
"""Решение №31: текст длиннее `MAX_DATE_TEXT_LENGTH` не разбирается; патологические строки не вешают процесс."""
PATHOLOGICAL = {
"spaces-then-letter": "1" + " " * 4000 + "b",
"newlines": "1" + "\n" * 4000, # после strip — «1»: короткий текст, отсекается не длиной
"newlines-inside": "1" + "\n" * 4000 + "b",
"lookalikes-after-digit": "1" + "О" * 5000,
}
@pytest.mark.parametrize("raw", PATHOLOGICAL.values(), ids=PATHOLOGICAL.keys())
def test_pathological_text_is_fast_and_none(self, raw: str) -> None:
start = time.perf_counter()
assert parse_date(raw) is None
assert parse_date(fix_lookalikes(raw, LOOKALIKES)) is None if is_date_sized(raw) else True
assert time.perf_counter() - start < 1.0
def test_long_text_is_not_date_sized(self) -> None:
assert not is_date_sized("1" + " " * 4000 + "b") and not is_date_sized("1" + "О" * 5000)
def test_boundary(self) -> None:
date_text = "на 30 сентября 2025 г."
padded = date_text + " " * 300 # пробелы по краям не считаются
assert is_date_sized(padded) and parse_date(padded) == date(2025, 9, 30)
at_limit = date_text + "x" * (MAX_DATE_TEXT_LENGTH - len(date_text))
assert len(at_limit) == MAX_DATE_TEXT_LENGTH and is_date_sized(at_limit)
assert parse_date(at_limit) == date(2025, 9, 30) # на пределе дата ещё разбирается
over = date_text + "x" * (MAX_DATE_TEXT_LENGTH + 1 - len(date_text))
assert len(over) == MAX_DATE_TEXT_LENGTH + 1 and not is_date_sized(over)
assert parse_date(over) is None # одним символом больше — не разбирается, хотя дата в начале есть