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>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# Скопируй в .env и заполни значения. Файл .env в .gitignore — в публичный
|
||||||
|
# репозиторий секреты не попадают.
|
||||||
|
|
||||||
|
# Пароль базы данных PostgreSQL
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
# Строка подключения к базе (для локального запуска API вне Docker)
|
||||||
|
DATABASE_URL=postgresql://medarchive:CHANGE_ME@localhost:5432/medarchive
|
||||||
|
|
||||||
|
# Ключ Gemini для распознавания сложных страниц (Vision). Получить заранее.
|
||||||
|
GEMINI_API_KEY=
|
||||||
+2
-3
@@ -24,7 +24,6 @@ pitch/deck/reveal/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Данные (реальные прайсы НЕ коммитим; синтетика в fixtures/raw — коммитим)
|
# Данные клиник: реальный архив и справочник в публичный репозиторий не коммитим
|
||||||
data/raw/
|
data/
|
||||||
data/uploads/
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
"""MedArchive API — эндпоинты ТЗ §4.5 плюс /stats для дашборда.
|
||||||
|
|
||||||
|
Здесь зафиксирован контракт API (пути, параметры, модели ответа). Реализация
|
||||||
|
запросов к БД — задача агента D; пока эндпоинты возвращают пустые заготовки,
|
||||||
|
чтобы приложение поднималось и фронт мог разрабатываться на сгенерированной
|
||||||
|
OpenAPI-схеме.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Query
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from contracts.models import PartnerOut, PriceOut, ServiceOut, Stats # noqa: E402
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="MedArchive API",
|
||||||
|
version="0.1.0",
|
||||||
|
description="Поиск услуг и цен по архиву прайсов клиник-партнёров.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/services", response_model=list[ServiceOut], summary="Услуги справочника")
|
||||||
|
def list_services(specialty: str | None = Query(None, description="Фильтр по специальности")):
|
||||||
|
return [] # TODO(D): выборка из таблицы service
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/services/{service_id}/partners", response_model=list[PriceOut],
|
||||||
|
summary="Кто оказывает услугу и по какой цене")
|
||||||
|
def service_partners(service_id: str):
|
||||||
|
return [] # TODO(D): партнёры с ценами по услуге (ядро WOW-сравнения)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/partners", response_model=list[PartnerOut], summary="Партнёры")
|
||||||
|
def list_partners(city: str | None = None, is_active: bool | None = None):
|
||||||
|
return [] # TODO(D)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/partners/{partner_id}/services", response_model=list[PriceOut],
|
||||||
|
summary="Все услуги партнёра с ценами")
|
||||||
|
def partner_services(partner_id: str):
|
||||||
|
return [] # TODO(D)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/search", summary="Полнотекстовый поиск по услугам и партнёрам")
|
||||||
|
def search(q: str = Query(..., min_length=1)):
|
||||||
|
return {"services": [], "partners": []} # TODO(D)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/unmatched", summary="Несопоставленные позиции (для операторов)")
|
||||||
|
def unmatched(limit: int = 50, offset: int = 0):
|
||||||
|
return [] # TODO(D)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/match", summary="Ручное сопоставление позиции со справочником")
|
||||||
|
def manual_match(item_id: str, service_id: str):
|
||||||
|
return {"item_id": item_id, "service_id": service_id, "status": "todo"} # TODO(D)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stats", response_model=Stats, summary="Сводка качества обработки")
|
||||||
|
def stats():
|
||||||
|
return Stats() # TODO(D): реальные метрики из БД
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", summary="Проверка живости")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Общие модели данных — контракт между агентами (Pydantic v2).
|
||||||
|
|
||||||
|
`RawRow` и `ParsedDocument` — выход любого парсера (граница агентов B → C).
|
||||||
|
`MatchResult` — выход сопоставления (агент C). `*Out`-модели — выдача API (агент D),
|
||||||
|
её же потребляет фронт (агент E). Любая правка этих типов проходит через оркестратора.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# Карта тарифов: ключ — тип цены, значение — сумма в KZT.
|
||||||
|
# Возможные ключи: resident, nonresident, cis, far, insurance, partner.
|
||||||
|
PriceTiers = dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
class RawRow(BaseModel):
|
||||||
|
"""Одна сырая строка прайса — единый выход любого парсера."""
|
||||||
|
|
||||||
|
service_name_raw: str
|
||||||
|
service_code_source: str | None = None
|
||||||
|
prices: PriceTiers = Field(default_factory=dict)
|
||||||
|
unit: str | None = None
|
||||||
|
section: str | None = None # заголовок секции — контекст специальности
|
||||||
|
|
||||||
|
|
||||||
|
class ParsedDocument(BaseModel):
|
||||||
|
"""Результат разбора одного файла прайса."""
|
||||||
|
|
||||||
|
file_name: str
|
||||||
|
file_format: str # pdf / scan_pdf / docx / xlsx / xls
|
||||||
|
partner_name: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
effective_date: date | None = None
|
||||||
|
rows: list[RawRow] = Field(default_factory=list)
|
||||||
|
raw_content: str = ""
|
||||||
|
parse_log: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MatchResult(BaseModel):
|
||||||
|
"""Результат сопоставления строки со справочником."""
|
||||||
|
|
||||||
|
service_id: str | None = None
|
||||||
|
method: str | None = None # code / exact / embedding / fuzzy / manual
|
||||||
|
confidence: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- выдача API ---
|
||||||
|
class ServiceOut(BaseModel):
|
||||||
|
service_id: str
|
||||||
|
specialty: str
|
||||||
|
name_ru: str
|
||||||
|
tarificator_code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerOut(BaseModel):
|
||||||
|
partner_id: str
|
||||||
|
name: str
|
||||||
|
city: str | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PriceOut(BaseModel):
|
||||||
|
partner_id: str
|
||||||
|
partner_name: str
|
||||||
|
price_resident_kzt: float | None = None
|
||||||
|
price_nonresident_kzt: float | None = None
|
||||||
|
prices: PriceTiers = Field(default_factory=dict)
|
||||||
|
effective_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Stats(BaseModel):
|
||||||
|
"""Сводка для дашборда и отчёта о качестве."""
|
||||||
|
|
||||||
|
documents_total: int = 0
|
||||||
|
documents_done: int = 0
|
||||||
|
items_total: int = 0
|
||||||
|
auto_matched_pct: float = 0.0
|
||||||
|
unmatched_total: int = 0
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-- Схема MedArchive. Четыре сущности из ТЗ §3, адаптированные под реальные данные:
|
||||||
|
-- многотарифные цены (резидент/нерезидент/СНГ/…) хранятся в prices (jsonb),
|
||||||
|
-- а два главных тарифа продублированы отдельными колонками под ТЗ и API.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector для семантического сопоставления
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- триграммы для нечёткого поиска
|
||||||
|
|
||||||
|
-- Эталонный справочник услуг (1281 позиция, загружается из xlsx).
|
||||||
|
CREATE TABLE service (
|
||||||
|
service_id text PRIMARY KEY, -- «<ID>-<Code>» из справочника
|
||||||
|
specialty text NOT NULL DEFAULT '', -- специальность = категория + сужение поиска
|
||||||
|
name_ru text NOT NULL,
|
||||||
|
name_norm text NOT NULL, -- нормализованная форма для точного совпадения
|
||||||
|
tarificator_code text, -- код тарификатора, если задан
|
||||||
|
synonyms jsonb NOT NULL DEFAULT '[]'::jsonb, -- наращиваем при ручной верификации
|
||||||
|
embedding vector(384), -- эмбеддинг name_ru (multilingual)
|
||||||
|
is_active boolean NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
CREATE INDEX service_tarificator_idx ON service (tarificator_code) WHERE tarificator_code IS NOT NULL;
|
||||||
|
CREATE INDEX service_name_trgm_idx ON service USING gin (name_ru gin_trgm_ops);
|
||||||
|
CREATE INDEX service_embedding_idx ON service USING hnsw (embedding vector_cosine_ops);
|
||||||
|
|
||||||
|
-- Клиника-партнёр.
|
||||||
|
CREATE TABLE partner (
|
||||||
|
partner_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name text NOT NULL,
|
||||||
|
city text,
|
||||||
|
address text,
|
||||||
|
bin varchar(12), -- БИН организации, для дедупликации
|
||||||
|
contact_email text,
|
||||||
|
contact_phone text,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX partner_bin_idx ON partner (bin) WHERE bin IS NOT NULL;
|
||||||
|
|
||||||
|
-- Исходный прайс-документ (один файл = прайс одной клиники на дату).
|
||||||
|
CREATE TABLE price_document (
|
||||||
|
doc_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
partner_id uuid NOT NULL REFERENCES partner(partner_id),
|
||||||
|
file_name text NOT NULL,
|
||||||
|
file_format text NOT NULL, -- pdf / scan_pdf / docx / xlsx / xls
|
||||||
|
effective_date date, -- дата вступления в силу (год из имени файла)
|
||||||
|
parsed_at timestamptz,
|
||||||
|
parse_status text NOT NULL DEFAULT 'pending', -- pending/processing/done/error/needs_review
|
||||||
|
parse_log text,
|
||||||
|
raw_content text -- сырой извлечённый текст для аудита
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Позиция прайса (услуга + цены). Это рабочая лошадка системы.
|
||||||
|
CREATE TABLE price_item (
|
||||||
|
item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
doc_id uuid NOT NULL REFERENCES price_document(doc_id),
|
||||||
|
partner_id uuid NOT NULL REFERENCES partner(partner_id), -- денормализация ради скорости
|
||||||
|
service_name_raw text NOT NULL, -- название как в документе
|
||||||
|
service_code_source text, -- код из источника (внутренний или тарификатора)
|
||||||
|
service_id text REFERENCES service(service_id), -- нормализованная услуга (nullable)
|
||||||
|
prices jsonb NOT NULL DEFAULT '{}'::jsonb, -- {resident, nonresident, cis, far, insurance, partner}
|
||||||
|
price_resident_kzt numeric, -- главный тариф, продублирован под ТЗ/API
|
||||||
|
price_nonresident_kzt numeric,
|
||||||
|
currency_original text NOT NULL DEFAULT 'KZT',
|
||||||
|
unit text,
|
||||||
|
effective_date date,
|
||||||
|
map_method text, -- code/exact/embedding/fuzzy/manual
|
||||||
|
map_confidence numeric,
|
||||||
|
is_verified boolean NOT NULL DEFAULT false,
|
||||||
|
verification_note text,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX price_item_service_idx ON price_item (service_id);
|
||||||
|
CREATE INDEX price_item_partner_idx ON price_item (partner_id);
|
||||||
|
CREATE INDEX price_item_unmatched_idx ON price_item (service_id) WHERE service_id IS NULL;
|
||||||
|
CREATE INDEX price_item_raw_trgm_idx ON price_item USING gin (service_name_raw gin_trgm_ops);
|
||||||
|
-- Активная позиция уникальна по клинике, названию и дате прайса (версионирование — через is_active).
|
||||||
|
CREATE UNIQUE INDEX price_item_active_idx
|
||||||
|
ON price_item (partner_id, service_name_raw, effective_date) WHERE is_active;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Загрузчик целевого справочника медицинских услуг.
|
||||||
|
|
||||||
|
Справочник (`Справочник услуг.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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Маршрутизация MedArchive за Caddy на Selectel KZ.
|
||||||
|
# /api/* — бэкенд, /demo — презентация (reveal.js), остальное — приложение.
|
||||||
|
# Reload только через stdin (sed -i ломает bind mount).
|
||||||
|
med.secondbrain.tools {
|
||||||
|
handle_path /api/* {
|
||||||
|
reverse_proxy api:8000
|
||||||
|
}
|
||||||
|
handle /demo* {
|
||||||
|
root * /srv
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy web:3000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Стек MedArchive: PostgreSQL+pgvector, API (FastAPI), фронт (Next.js).
|
||||||
|
# Деплой — на Selectel KZ за общим Caddy (см. Caddyfile). Dockerfile'ы api/web
|
||||||
|
# добавляет агент F. Секреты — только из .env (в репозиторий не попадают).
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: medarchive
|
||||||
|
POSTGRES_USER: medarchive
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
- ../db/migrations:/docker-entrypoint-initdb.d:ro # схема накатывается при инициализации
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U medarchive -d medarchive"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: infra/api.Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://medarchive:${DB_PASSWORD}@db:5432/medarchive
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ../web
|
||||||
|
dockerfile: ../infra/web.Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_BASE: /api
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[project]
|
||||||
|
name = "medarchive"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Автоматическая обработка архива прайсов клиник-партнёров и база услуг с ценами"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"pydantic>=2.9",
|
||||||
|
"psycopg[binary]>=3.2",
|
||||||
|
"python-multipart>=0.0.12", # загрузка ZIP-архива
|
||||||
|
"openpyxl>=3.1", # xlsx
|
||||||
|
"xlrd>=2.0", # старый xls
|
||||||
|
"python-docx>=1.1", # docx (+ принятие правок)
|
||||||
|
"pdfplumber>=0.11", # PDF с текстовым слоем
|
||||||
|
"pymupdf>=1.24", # рендер страниц для Vision
|
||||||
|
"rapidfuzz>=3.10", # нечёткое сопоставление
|
||||||
|
"sentence-transformers>=3.3", # многоязычные эмбеддинги
|
||||||
|
"google-generativeai>=0.8", # Gemini Vision
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"] # стиль, ошибки, импорты, апгрейды, баги
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Phase 0 proof: справочник загружается, реальный прайс парсится, каскад работает.
|
||||||
|
|
||||||
|
Считает базовый процент автосопоставления БЕЗ эмбеддингов (только код тарификатора +
|
||||||
|
точное совпадение + RapidFuzz). Эмбеддинги в боевом каскаде поднимут этот процент —
|
||||||
|
здесь нужен нижний ориентир, чтобы убедиться, что цель 70% достижима.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(REPO))
|
||||||
|
|
||||||
|
import docx
|
||||||
|
from rapidfuzz import fuzz, process
|
||||||
|
|
||||||
|
from etl.dictionary import load_dictionary, normalize_name
|
||||||
|
|
||||||
|
svcs = load_dictionary(REPO / "data/reference/dictionary.xlsx")
|
||||||
|
print(f"Справочник: {len(svcs)} услуг | с кодом тарификатора: {sum(1 for s in svcs if s.tarificator_code)}")
|
||||||
|
|
||||||
|
by_code = {s.tarificator_code: s for s in svcs if s.tarificator_code}
|
||||||
|
by_norm: dict[str, object] = {}
|
||||||
|
for s in svcs:
|
||||||
|
by_norm.setdefault(s.name_norm, s)
|
||||||
|
names_norm = [s.name_norm for s in svcs]
|
||||||
|
|
||||||
|
# --- разобрать реальный прайс Клиники 1 (docx, таблица «Код | Наименование | Стоимость») ---
|
||||||
|
doc = docx.Document(REPO / "data/raw/Клиника 1 прайс 2024.docx")
|
||||||
|
table = doc.tables[0]
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in table.rows:
|
||||||
|
cells = [c.text.strip() for c in row.cells]
|
||||||
|
if len(cells) < 3:
|
||||||
|
continue
|
||||||
|
code, name, price = cells[0], cells[1], cells[2]
|
||||||
|
if code == name == price: # строка-заголовок секции (объединённые ячейки)
|
||||||
|
continue
|
||||||
|
if name.lower() == "наименование услуги":
|
||||||
|
continue
|
||||||
|
if not re.search(r"\d", price): # строка без цены — не позиция
|
||||||
|
continue
|
||||||
|
items.append((code, name, price))
|
||||||
|
|
||||||
|
print(f"Клиника 1 (2024): извлечено позиций — {len(items)}")
|
||||||
|
|
||||||
|
THRESHOLD = 88
|
||||||
|
matched = by_code_n = by_exact_n = by_fuzzy_n = 0
|
||||||
|
examples_ok, examples_miss = [], []
|
||||||
|
|
||||||
|
for code, name, price in items:
|
||||||
|
nn = normalize_name(name)
|
||||||
|
if code in by_code:
|
||||||
|
matched += 1; by_code_n += 1
|
||||||
|
continue
|
||||||
|
if nn in by_norm:
|
||||||
|
matched += 1; by_exact_n += 1
|
||||||
|
continue
|
||||||
|
best = process.extractOne(nn, names_norm, scorer=fuzz.token_set_ratio)
|
||||||
|
if best and best[1] >= THRESHOLD:
|
||||||
|
matched += 1; by_fuzzy_n += 1
|
||||||
|
if len(examples_ok) < 6:
|
||||||
|
examples_ok.append((name, svcs[best[2]].name_ru, round(best[1])))
|
||||||
|
elif len(examples_miss) < 6 and best:
|
||||||
|
examples_miss.append((name, svcs[best[2]].name_ru, round(best[1])))
|
||||||
|
|
||||||
|
pct = 100 * matched / max(1, len(items))
|
||||||
|
print(f"\nАВТО-СОПОСТАВЛЕНО (без эмбеддингов): {matched}/{len(items)} = {pct:.0f}%")
|
||||||
|
print(f" код тарификатора: {by_code_n} | точное имя: {by_exact_n} | fuzzy≥{THRESHOLD}: {by_fuzzy_n}")
|
||||||
|
print("\nпримеры совпадений:")
|
||||||
|
for raw, canon, sc in examples_ok:
|
||||||
|
print(f" «{raw[:46]}» → «{canon[:46]}» ({sc})")
|
||||||
|
print("\nпримеры в очередь unmatched (лучший кандидат ниже порога):")
|
||||||
|
for raw, canon, sc in examples_miss:
|
||||||
|
print(f" «{raw[:46]}» → лучший «{canon[:40]}» ({sc})")
|
||||||
Reference in New Issue
Block a user