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:
2026-06-26 15:23:06 +05:00
parent f35957a31e
commit a6a6609c48
10 changed files with 492 additions and 3 deletions
+11
View File
@@ -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
View File
@@ -24,7 +24,6 @@ pitch/deck/reveal/
.DS_Store
Thumbs.db
# Данные (реальные прайсы НЕ коммитим; синтетика в fixtures/raw — коммитим)
data/raw/
data/uploads/
# Данные клиник: реальный архив и справочник в публичный репозиторий не коммитим
data/
*.sqlite
+69
View File
@@ -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"}
+80
View File
@@ -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
+78
View File
@@ -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;
+86
View File
@@ -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
+15
View File
@@ -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
}
}
+48
View File
@@ -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:
+27
View File
@@ -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"] # стиль, ошибки, импорты, апгрейды, баги
+76
View File
@@ -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})")