Files
medtech-hackathon/etl/dictionary.py
T
admins a6a6609c48 feat: Фаза 0 — каркас, контракты, загрузчик справочника
- db/migrations/001_init.sql — схема (4 сущности, prices jsonb, pgvector/pg_trgm)
- contracts/models.py — общие модели (RawRow, ParsedDocument, MatchResult, *Out)
- etl/dictionary.py — загрузчик справочника (1281 услуга, нормализация)
- api/main.py — FastAPI: 7 эндпоинтов ТЗ + /stats (контракт)
- infra/ — docker-compose (pg+pgvector, api, web), Caddyfile
- scripts/proof/phase0_proof.py — проверка каскада на реальных данных (35% без эмбеддингов)
- data/ в .gitignore: данные клиник в публичный репо не коммитятся

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:23:06 +05:00

87 lines
4.3 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] = []
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 # отбрасываем мусорные коды, оставляем только валидный формат
service = Service(
service_id=f"{row[col['ID']]}-{row[col['Code']]}",
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