diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59ec151 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 44bcb92..81e244b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,6 @@ pitch/deck/reveal/ .DS_Store Thumbs.db -# Данные (реальные прайсы НЕ коммитим; синтетика в fixtures/raw — коммитим) -data/raw/ -data/uploads/ +# Данные клиник: реальный архив и справочник в публичный репозиторий не коммитим +data/ *.sqlite diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..9e558a3 --- /dev/null +++ b/api/main.py @@ -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"} diff --git a/contracts/models.py b/contracts/models.py new file mode 100644 index 0000000..f759509 --- /dev/null +++ b/contracts/models.py @@ -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 diff --git a/db/migrations/001_init.sql b/db/migrations/001_init.sql new file mode 100644 index 0000000..4e6e72f --- /dev/null +++ b/db/migrations/001_init.sql @@ -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, -- «-» из справочника + 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; diff --git a/etl/dictionary.py b/etl/dictionary.py new file mode 100644 index 0000000..4046ca5 --- /dev/null +++ b/etl/dictionary.py @@ -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 # стабильный идентификатор «-» + 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 diff --git a/infra/Caddyfile b/infra/Caddyfile new file mode 100644 index 0000000..2a9f3b4 --- /dev/null +++ b/infra/Caddyfile @@ -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 + } +} diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..20c74c0 --- /dev/null +++ b/infra/docker-compose.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..961cb3d --- /dev/null +++ b/pyproject.toml @@ -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"] # стиль, ошибки, импорты, апгрейды, баги diff --git a/scripts/proof/phase0_proof.py b/scripts/proof/phase0_proof.py new file mode 100644 index 0000000..e3e6813 --- /dev/null +++ b/scripts/proof/phase0_proof.py @@ -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})")