From 5f56f62dbbfead8350cc89e8a6690f7712b0f628 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 00:06:14 +0400 Subject: [PATCH] =?UTF-8?q?feat(normalizer):=20=D1=80=D0=B0=D0=B7=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D0=B4=D0=B0=D1=82=20=D0=B8=D0=B7=20OCR=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=BE=D1=81=D0=BB=D0=BE=D0=B2=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20date=5Fut?= =?UTF-8?q?ils=20=D1=81=D1=82=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20=D1=8F=D0=B4?= =?UTF-8?q?=D1=80=D0=B0=20(=C2=A711.1=20=D0=BF.=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Портирован модуль 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/normalizer/ocr/dates.py | 195 ++++++++++++++++++++++++ api/tests/unit/normalizer/test_dates.py | 70 +++++++++ 2 files changed, 265 insertions(+) create mode 100644 api/src/normalizer/ocr/dates.py create mode 100644 api/tests/unit/normalizer/test_dates.py diff --git a/api/src/normalizer/ocr/dates.py b/api/src/normalizer/ocr/dates.py new file mode 100644 index 0000000..c72e44b --- /dev/null +++ b/api/src/normalizer/ocr/dates.py @@ -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", " ")) diff --git a/api/tests/unit/normalizer/test_dates.py b/api/tests/unit/normalizer/test_dates.py new file mode 100644 index 0000000..f8ae5f6 --- /dev/null +++ b/api/tests/unit/normalizer/test_dates.py @@ -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