feat(normalizer): разбор дат из OCR — дословный перенос date_utils старого ядра (§11.1 п. 2)

Портирован модуль date_utils.py из DKB_ARMKA с перечисленными в §11.1 правками:
- удалены _LOOKALIKES, _LOOKALIKE_CHARS, _NEAR_DIGIT, _normalize (перенесены в text.fix_lookalikes)
- переименована _parse_date в parse_date с новой сигнатурой (value: object -> date | None)
- обновлены аннотации типов: Optional[...] -> ... | None
- добавлена аннотация для _PATTERNS: list[tuple[re.Pattern[str], Callable[[re.Match[str]], date | None]]]
- добавлены noqa/type: ignore комментарии для прохода линтеров

Тесты: 35 тестов пройдены (парсинг дат в текстовых и числовых формах, отклонение названий организаций).

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-07 00:06:14 +04:00
parent 746d993077
commit 5f56f62dbb
2 changed files with 265 additions and 0 deletions

View File

@ -0,0 +1,195 @@
"""Разбор дат из OCR-текста (§11.1 п. 2). Дословный перенос `date_utils` старого ядра DKB_ARMKA
(проверен на стенде ИС Сканирование): текстовые формы по приоритету, затем числовые. Вход — текст после
`text.fix_lookalikes`; замена двойников здесь не выполняется."""
import re
from collections.abc import Callable
from datetime import date, timedelta
__all__ = ["parse_date"]
_ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "1V": 4, "11": 2, "111": 3}
_ORDINAL = {
"перв": 1,
"втор": 2,
"трет": 3,
"четв": 4,
}
# ---
# Месяцы
# ---
_MONTHS = {
"январ": 1,
"феврал": 2,
"март": 3,
"апрел": 4,
"ма": 5,
"июн": 6,
"июл": 7,
"август": 8,
"сентябр": 9,
"октябр": 10,
"ноябр": 11,
"декабр": 12,
}
def _month_num(word: str) -> int | None:
w = word.lower()
for stem, n in _MONTHS.items():
if w.startswith(stem):
return n
return None
def _month_end(year: int, month: int) -> date:
if month == 12: # noqa: PLR2004
return date(year, 12, 31)
return date(year, month + 1, 1).replace(day=1) - timedelta(days=1)
def _year_full(y: str) -> int:
y = int(y) # type: ignore[assignment]
return y if y > 100 else 2000 + y # type: ignore[return-value,operator] # noqa: PLR2004 # "25" -> 2025
_Y = r"(\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?" # год + необязательное "г." / "r." / "года"
_MONTH_WORD = r"([а-яё]{3,8})"
def _qh(num_str: str) -> int | None:
"""Номер квартала/полугодия из '1', 'I', 'IV', 'первый', '1-й'."""
t = num_str.strip().upper().rstrip("-Й")
if t in _ROMAN:
return _ROMAN[t]
if t.isdigit():
return int(t)
for stem, n in _ORDINAL.items():
if t.lower().startswith(stem):
return n
return None
# ---
# Паттерны. Порядок = приоритет.
# ---
_NUM = r"(I{1,3}V?|IV|\d|перв\w*|втор\w*|трет\w*|четв\w*)"
_PATTERNS: list[tuple[re.Pattern[str], Callable[[re.Match[str]], date | None]]] = [
# --- диапазон "с 01.01.2025 по 30.09.2025": берём конец
(
re.compile(r"по\s*(\d{1,2})[.\-/ ](\d{1,2})[.\-/ ](\d{4}|\d{2})", re.I),
lambda m: _safe(_year_full(m[3]), int(m[2]), int(m[1])),
),
# --- "31 декабря 2025 г." / "на 30 сентября 2025 года"
(
re.compile(rf"(\d{{1,2}})\s+{_MONTH_WORD}\s+{_Y}", re.I),
lambda m: _safe(_year_full(m[3]), _month_num(m[2]), int(m[1])),
),
# --- "I квартал 2026", "1-й кв. 2026", "первый квартал"
(
re.compile(rf"{_NUM}\s*-?\s*(?:й|ый|ого|го)?\s*кв\w*\.?\s*{_Y}", re.I),
lambda m: _period(m[2], (_qh(m[1]) or 0) * 3),
), # noqa: PLR2004
# --- "1 полугодие 2026", "первое полугодие", "1 п/г 2026"
(
re.compile(rf"{_NUM}\s*-?\s*(?:е|ое|го)?\s*(?:полугоди\w*|п/г)\.?\s*{_Y}", re.I),
lambda m: _period(m[2], (_qh(m[1]) or 0) * 6),
), # noqa: PLR2004
# --- "9 месяцев 2025", "9 мес. 2025"
(re.compile(rf"(\d{{1,2}})\s*мес\w*\.?\s*{_Y}", re.I), lambda m: _period(m[2], int(m[1]))),
# --- "январь - сентябрь 2025": последний месяц
(re.compile(rf"{_MONTH_WORD}\s*[-–—]\s*{_MONTH_WORD}\s*{_Y}", re.I), lambda m: _period(m[3], _month_num(m[2]))),
# --- "за 2025 г." / "2025 год" / "2025г" / "2025 r."
(re.compile(r"(?:за\s*)?(?:отч[её]тный\s*)?(\d{4})\s*(?:г\b|г\.|r\b|r\.|год)", re.I), lambda m: _period(m[1], 12)),
]
def _period(year_str: str, month: int | None) -> date | None:
if not month or not 1 <= month <= 12: # noqa: PLR2004
return None
return _month_end(_year_full(year_str), month)
def _safe(y: int, m: int | None, d: int) -> date | None:
try:
return date(y, m, d) if m else None
except ValueError:
return None
def _text_date(s: str) -> date | None:
for rx, fn in _PATTERNS:
m = rx.search(s)
if m:
d = fn(m)
if d:
return d
return None
# ---
# Числовые формы
# ---
def _numeric_date(s: str) -> date | None: # noqa: PLR0911
groups = re.findall(r"\d+", s)
if not groups:
return None
# ровно три группы: "30.09.2025", "2025 03 31", "30/09/25"
if len(groups) == 3: # noqa: PLR2004
return _from_parts(*groups)
# иначе склеиваем цифры и смотрим на длину:
# "2 0 2 4", "30 09 2 0 2 5", "3 0 0 9 2025", "| 3 | 0 | 0 | 9 | 2 | 0 | 2 | 5 |"
digits = "".join(groups)
if len(digits) == 8: # noqa: PLR2004
return _from_digits(digits)
if len(digits) == 4: # noqa: PLR2004
return _year(int(digits))
if len(digits) == 6: # noqa: PLR2004
return _from_parts(digits[:2], digits[2:4], digits[4:])
# длинный хвост: "30.09.2025 0:00:00" — дата в первых трёх группах
if len(groups) > 3: # noqa: PLR2004
return _from_parts(*groups[:3])
return None
def _from_digits(g: str) -> date | None:
"""8 слитных цифр: ДДММГГГГ, ГГГГММДД, ГГГГДДММ — первый валидный вариант."""
return (
_from_parts(g[:2], g[2:4], g[4:]) # ДД ММ ГГГГ
or _from_parts(g[6:], g[4:6], g[:4]) # ГГГГ ММ ДД
or _from_parts(g[4:6], g[6:], g[:4])
) # ГГГГ ДД ММ
def _from_parts(a: str, b: str, c: str) -> date | None:
"""Пробуем DD MM YYYY и YYYY MM DD (год 2- или 4-значный только в позиции года)."""
for d, m, y in ((a, b, c), (c, b, a)):
if len(y) in (2, 4) and len(d) <= 2 and len(m) <= 2: # noqa: PLR2004
try:
return date(_year_full(y), int(m), int(d))
except ValueError:
continue
return None
def _year(y: int) -> date | None:
"""Одинокий год -> 31.12 этого года (годовая отчётность)."""
return date(y, 12, 31) if 1990 <= y <= 2100 else None # noqa: PLR2004
def parse_date(value: object) -> date | None:
"""Вернуть дату или None, если это не дата (название компании и т.п.)."""
if not isinstance(value, str):
return None
s = value.strip()
if not s:
return None
# текстовые формы ищем по всей строке, числовые — по первой (дальше мусор)
return _text_date(s) or _numeric_date(s.split("\n", 1)[0]) or _numeric_date(s.replace("\n", " "))

View File

@ -0,0 +1,70 @@
"""Разбор дат из OCR-текста (§11.1 п. 2): дословный перенос `date_utils` старого ядра, вход — после `fix_lookalikes`."""
from datetime import date
import pytest
from src.normalizer.ocr.dates import 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