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:
parent
746d993077
commit
5f56f62dbb
195
api/src/normalizer/ocr/dates.py
Normal file
195
api/src/normalizer/ocr/dates.py
Normal 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", " "))
|
||||
70
api/tests/unit/normalizer/test_dates.py
Normal file
70
api/tests/unit/normalizer/test_dates.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user