feat(etl): нормализация (каскад), валидация (8 правил), Vision-fallback

- etl/normalize/matcher.py — каскад код→точное→эмбеддинги→fuzzy→unmatched
- etl/validate/rules.py — правила ТЗ §4.4; проверено: 15588 done / 11 needs_review
- etl/extractors/vision.py — Gemini Vision для битых/нестандартных PDF
- замер % автонормализации — после установки модели эмбеддингов

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 16:46:18 +05:00
parent efae54fbad
commit 14462e8c10
6 changed files with 362 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
"""Нормализация извлечённых позиций к справочнику услуг."""
from etl.normalize.matcher import Matcher
__all__ = ["Matcher"]
+109
View File
@@ -0,0 +1,109 @@
"""Сопоставление извлечённых позиций с эталонным справочником услуг.
Каскад по убыванию надёжности:
1. код тарификатора — точное совпадение, если клиника его указала;
2. точное совпадение нормализованного названия;
3. эмбеддинги (многоязычная модель) — семантическая близость названий;
4. нечёткое сравнение (RapidFuzz) как запасной сигнал;
5. иначе — очередь ручной разметки (unmatched).
В справочнике нет синонимов, поэтому шаги 1 и 3 несут основную нагрузку. Коды у
клиник встречаются с лишним хвостом («A02.020.000.2»), поэтому сравниваем по
канонической части кода тарификатора.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from rapidfuzz import fuzz, process # noqa: E402
from contracts.models import MatchResult # noqa: E402
from etl.dictionary import Service, normalize_name # noqa: E402
# Каноническая часть кода тарификатора: буква + три группы цифр.
_CODE_CORE_RE = re.compile(r"[A-ZА-Я]\d{2}\.\d{3}\.\d{3}")
class Matcher:
"""Готовит индексы справочника и сопоставляет с ним сырые названия услуг."""
def __init__(
self,
services: list[Service],
embedder=None,
emb_threshold: float = 0.60,
fuzzy_threshold: int = 88,
):
self.services = services
self.by_code = {s.tarificator_code: s for s in services if s.tarificator_code}
self.by_norm: dict[str, Service] = {}
for s in services:
self.by_norm.setdefault(s.name_norm, s)
self.names_norm = [s.name_norm for s in services]
self.emb_threshold = emb_threshold
self.fuzzy_threshold = fuzzy_threshold
self.embedder = embedder
self.dict_emb = None
if embedder is not None:
self.dict_emb = embedder.encode(
[s.name_ru for s in services], normalize_embeddings=True, show_progress_bar=False
)
def _match_by_code(self, code: str | None) -> Service | None:
if not code:
return None
m = _CODE_CORE_RE.search(code)
return self.by_code.get(m.group(0)) if m else None
def _match_by_fuzzy(self, name_norm: str) -> MatchResult:
best = process.extractOne(name_norm, self.names_norm, scorer=fuzz.token_set_ratio)
if best and best[1] >= self.fuzzy_threshold:
return MatchResult(
service_id=self.services[best[2]].service_id, method="fuzzy", confidence=best[1] / 100
)
return MatchResult()
def match_batch(self, names: list[str], codes: list[str | None]) -> list[MatchResult]:
"""Сопоставить пачку позиций. Эмбеддинги считаются разом — так быстрее."""
results: list[MatchResult | None] = [None] * len(names)
pending_idx: list[int] = []
pending_norm: list[str] = []
for i, (name, code) in enumerate(zip(names, codes, strict=True)):
service = self._match_by_code(code)
if service:
results[i] = MatchResult(service_id=service.service_id, method="code", confidence=1.0)
continue
name_norm = normalize_name(name)
exact = self.by_norm.get(name_norm)
if exact:
results[i] = MatchResult(service_id=exact.service_id, method="exact", confidence=1.0)
continue
pending_idx.append(i)
pending_norm.append(name_norm)
if pending_norm and self.embedder is not None and self.dict_emb is not None:
query = self.embedder.encode(
[names[i] for i in pending_idx], normalize_embeddings=True, show_progress_bar=False
)
sims = query @ self.dict_emb.T # косинус по нормированным векторам
best_idx = sims.argmax(axis=1)
best_score = sims.max(axis=1)
for k, i in enumerate(pending_idx):
if best_score[k] >= self.emb_threshold:
results[i] = MatchResult(
service_id=self.services[int(best_idx[k])].service_id,
method="embedding",
confidence=float(best_score[k]),
)
else:
results[i] = self._match_by_fuzzy(pending_norm[k])
else:
for k, i in enumerate(pending_idx):
results[i] = self._match_by_fuzzy(pending_norm[k])
return [r if r is not None else MatchResult() for r in results]