Andrey Epifancev 393d65208f feat(normalizer): справочники yaml с редакциями форм, загрузчик правил, SCAN_CLEANUP/SCAN_RULES_DIR (D-7)
Справочники нормализатора OCR:
- api/rules/statement_lines.yaml: редакции форм ББ/ОФР (66n до 2024-12-31, fsbu4 с 2025-01-01)
- api/rules/ocr_lookalikes.yaml: замены букв на цифры (из date_utils._LOOKALIKES)
- api/rules/statement_labels.yaml: подстроки label → тип документа (BALANCE/INCOME)

Модуль rules.py:
- Edition: редакция с интервалом дат и таблицей кодов → наименований
- OcrRules: все справочники + методы edition_for(), edition_by_codes(), known_codes(), editions_with()
- load_rules(): загрузчик с валидацией (коды 4–5 цифр, двойники 1→1, файлы обязательны)

Настройки:
- SCAN_CLEANUP: удаление файлов из горячей папки (по умолчанию false)
- SCAN_RULES_DIR: путь к справочникам (по умолчанию 'rules')

TDD: 10 тестов (TestRealRules, TestEditionChoice, TestValidation, test_settings_defaults)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 23:33:54 +04:00

140 lines
6.1 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 (D-7, §11.1 п. 1, 6): yaml в `SCAN_RULES_DIR`, загрузка один раз при старте.
`statement_lines.yaml` — редакции форм (`66n` до 2024-12-31, `fsbu4` с 2025-01-01) с таблицей код → наименование;
`ocr_lookalikes.yaml` — двойники букв и цифр; `statement_labels.yaml` — подстроки `label` таблицы → тип документа.
Битый файл — `ValueError` с именем файла (сервис не стартует).
"""
from collections.abc import Iterable
from datetime import date
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
__all__ = ["Edition", "OcrRules", "load_rules"]
LINES_FILE = "statement_lines.yaml"
LOOKALIKES_FILE = "ocr_lookalikes.yaml"
LABELS_FILE = "statement_labels.yaml"
DocumentKind = Literal["BALANCE", "INCOME"]
class Edition(BaseModel):
"""Редакция форм: интервал дат отчёта (включительно, открытый край — `None`) и таблица код → наименование."""
model_config = ConfigDict(frozen=True, extra="forbid")
id: str = Field(min_length=1)
valid_from: date | None = None
valid_until: date | None = None
lines: dict[str, str] = Field(default_factory=dict)
@field_validator("lines")
@classmethod
def _check_lines(cls, lines: dict[str, str]) -> dict[str, str]:
for code, name in lines.items():
if not (code.isdigit() and 4 <= len(code) <= 5): # noqa: PLR2004 — коды строк форм: 4–5 цифр
raise ValueError(f"код строки {code!r}: ожидаются 4–5 цифр")
if not name.strip():
raise ValueError(f"код строки {code!r}: пустое наименование")
return lines
def covers(self, report_date: date) -> bool:
return (self.valid_from is None or self.valid_from <= report_date) and (
self.valid_until is None or report_date <= self.valid_until
)
class _LinesFile(BaseModel):
model_config = ConfigDict(extra="forbid")
editions: list[Edition] = Field(min_length=1)
@model_validator(mode="after")
def _unique_ids(self) -> "_LinesFile":
ids = [edition.id for edition in self.editions]
if len(ids) != len(set(ids)):
raise ValueError(f"id редакций повторяются: {ids}")
return self
class _LookalikesFile(BaseModel):
model_config = ConfigDict(extra="forbid")
digits: dict[str, str]
@field_validator("digits")
@classmethod
def _single_chars(cls, digits: dict[str, str]) -> dict[str, str]:
for char, digit in digits.items():
if len(char) != 1 or len(digit) != 1 or not digit.isdigit():
raise ValueError(f"двойник {char!r} -> {digit!r}: ожидается один символ и одна цифра")
return digits
class _LabelsFile(BaseModel):
model_config = ConfigDict(extra="forbid")
labels: dict[str, DocumentKind]
class OcrRules(BaseModel):
"""Все справочники нормализатора; передаётся в конвейер параметром."""
model_config = ConfigDict(frozen=True)
editions: list[Edition]
lookalikes: dict[str, str]
labels: dict[str, DocumentKind]
def edition_for(self, report_date: date | None) -> Edition | None:
"""Редакция по дате отчёта (первая, чей интервал покрывает дату); `None` без даты или вне интервалов."""
if report_date is None:
return None
return next((edition for edition in self.editions if edition.covers(report_date)), None)
def edition_by_codes(self, codes: Iterable[str]) -> Edition | None:
"""Редакция с наибольшим числом известных кодов; при равенстве — первая; `None`, если совпадений нет."""
wanted = set(codes)
best: Edition | None = None
best_hits = 0
for edition in self.editions:
hits = len(wanted & edition.lines.keys())
if hits > best_hits:
best, best_hits = edition, hits
return best
def known_codes(self) -> frozenset[str]:
return frozenset(code for edition in self.editions for code in edition.lines)
def editions_with(self, code: str) -> list[str]:
return [edition.id for edition in self.editions if code in edition.lines]
def _read_yaml(directory: Path, name: str) -> object:
path = directory / name
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise ValueError(f"справочник {name} не прочитан ({path}): {exc}") from exc
try:
return yaml.safe_load(text)
except yaml.YAMLError as exc:
raise ValueError(f"справочник {name} не разобран как YAML: {exc}") from exc
def load_rules(directory: Path | str) -> OcrRules:
"""Загрузить три справочника из каталога; `ValueError` с именем файла при любой ошибке."""
base = Path(directory)
loaded: dict[str, BaseModel] = {}
for name, model in ((LINES_FILE, _LinesFile), (LOOKALIKES_FILE, _LookalikesFile), (LABELS_FILE, _LabelsFile)):
try:
loaded[name] = model.model_validate(_read_yaml(base, name))
except ValidationError as exc:
raise ValueError(f"справочник {name}: {exc}") from exc
lines = loaded[LINES_FILE]
lookalikes = loaded[LOOKALIKES_FILE]
labels = loaded[LABELS_FILE]
assert isinstance(lines, _LinesFile) and isinstance(lookalikes, _LookalikesFile) # noqa: S101 — сужение типов
assert isinstance(labels, _LabelsFile) # noqa: S101
return OcrRules(editions=lines.editions, lookalikes=lookalikes.digits, labels=labels.labels)