Files
medtech-hackathon/etl/dictionary.py
T
admins 69a9884db5 feat(ingest+поиск): приём ZIP через интерфейс, регистронезависимый поиск, чистка очереди
- Загрузка архива (§4.1): POST /ingest распаковывает ZIP, дораспознаёт и
  догружает прайсы в базу (append). Виджет в админке. Кэш эмбеддингов справочника
  (dict_emb.npy) — догрузка считает только новый файл, эмбеддинги best-effort с
  откатом на fuzzy при сбое Gemini.
- Поиск по name_norm (lowercase): «УЗИ» и «узи» дают одинаковый результат —
  SQLite LIKE не сворачивает регистр для кириллицы.
- Фильтр названий: код-мнемоники («МОЗО.15») больше не попадают в очередь.
- /stats считается из базы — цифры на дашборде обновляются после загрузки.
- Деплой через Dockerfile (зависимости в образе, код монтируется томом).
- ruff: ядро и весь репозиторий проходят проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:35:12 +05:00

96 lines
4.5 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.
"""Загрузчик целевого справочника медицинских услуг.
Справочник (`Справочник услуг.xlsx`) — это эталон, к которому нормализуются все
извлечённые из прайсов позиции. Колонки исходного файла:
ID | Специальность | Code | Name_ru | TarificatrCode
Синонимов в справочнике нет, поэтому автоматическое сопоставление опирается на
код тарификатора (точное совпадение) и на эмбеддинги названий. Этот модуль
отвечает только за загрузку и подготовку индексов; само сопоставление — в
`etl/normalize`.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from openpyxl import load_workbook
# Код тарификатора в формате РК: буква + три группы цифр, например «A02.004.000».
TARIFICATOR_RE = re.compile(r"^[A-ZА-Я]\d{2}\.\d{3}\.\d{3}$")
_WHITESPACE_RE = re.compile(r"\s+")
def normalize_name(text: str | None) -> str:
"""Привести название услуги к форме для сравнения.
Нижний регистр, унификация «ё»→«е», дефисы и кратные пробелы схлопываются в
один пробел. Исходное написание при этом нигде не теряется — нормализованная
форма используется только как ключ сопоставления.
"""
s = (text or "").strip().lower().replace("ё", "е")
s = s.replace("-", " ")
return _WHITESPACE_RE.sub(" ", s).strip()
@dataclass
class Service:
"""Одна запись эталонного справочника услуг."""
service_id: str # стабильный идентификатор «<ID>-<Code>»
specialty: str # специальность — служит и категорией, и сужением поиска
name_ru: str # официальное название
tarificator_code: str | None # код тарификатора (если задан)
name_norm: str = field(default="") # нормализованная форма name_ru
def load_dictionary(path: str | Path) -> list[Service]:
"""Загрузить справочник из xlsx и вернуть список услуг с нормализованными именами.
Заголовки ищутся по именам колонок, а не по позиции, — так загрузчик переживёт
перестановку столбцов. Строки с пустым названием пропускаются.
"""
workbook = load_workbook(path, read_only=True, data_only=True)
sheet = workbook.active
rows = sheet.iter_rows(values_only=True)
header = [str(c).strip() if c is not None else "" for c in next(rows)]
col = {name: header.index(name) for name in header if name}
name_idx = col.get("Name_ru")
if name_idx is None:
raise ValueError(f"В справочнике нет колонки Name_ru. Заголовки: {header}")
services: list[Service] = []
seen_ids: dict[str, int] = {}
for row in rows:
raw_name = row[name_idx]
if not raw_name or not str(raw_name).strip():
continue
tar_raw = row[col["TarificatrCode"]] if "TarificatrCode" in col else None
tar = str(tar_raw).strip() if tar_raw else None
if tar and not TARIFICATOR_RE.match(tar):
tar = None # отбрасываем мусорные коды, оставляем только валидный формат
# «<ID>-<Code>» иногда повторяется в справочнике — гарантируем уникальность.
sid = f"{row[col['ID']]}-{row[col['Code']]}"
repeat = seen_ids.get(sid, 0)
seen_ids[sid] = repeat + 1
if repeat:
sid = f"{sid}#{repeat}"
service = Service(
service_id=sid,
specialty=str(row[col["Специальность"]]).strip() if row[col["Специальность"]] else "",
name_ru=str(raw_name).strip(),
tarificator_code=tar,
)
service.name_norm = normalize_name(service.name_ru)
services.append(service)
return services