Files
medtech-hackathon/api/main.py
T
admins 89928511ba feat(верификация): рабочая очередь с пагинацией + /skip
- автодогрузка порций + кнопка «Загрузить ещё»; разбор сверху по уверенности
- POST /skip фиксирует пропуск (таблица skipped_item), очередь его исключает

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

247 lines
11 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.
"""MedArchive API — эндпоинты ТЗ §4.5 плюс /stats для дашборда.
Читает базу, собранную конвейером (etl/pipeline.py). Для MVP это SQLite; путь
задаётся переменной окружения MEDARCHIVE_DB. Ядро WOW — /services/{id}/partners:
«кто оказывает услугу и по какой цене», отсортировано от выгодной.
"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import sys
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from contracts.models import PartnerOut, PriceOut, ServiceOut, Stats # noqa: E402
DB_PATH = os.environ.get("MEDARCHIVE_DB", str(Path(__file__).resolve().parents[1] / "data/medarchive.db"))
app = FastAPI(
title="MedArchive API",
version="1.0.0",
description="Поиск услуг и цен по архиву прайсов клиник-партнёров.",
# За Caddy сервис живёт на /api — без этого Swagger ищет спеку в корне и падает.
root_path="/api",
)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
def db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
# Отложенные оператором позиции (чтобы пропущенное не возвращалось в начало очереди).
conn.execute("CREATE TABLE IF NOT EXISTS skipped_item (item_id INTEGER PRIMARY KEY)")
return conn
_WS = re.compile(r"\s+")
def _norm(text: str | None) -> str:
"""Нормализованная форма названия (для запоминания синонима при верификации)."""
return _WS.sub(" ", (text or "").strip().lower().replace("ё", "е").replace("-", " ")).strip()
def _price_out(row: sqlite3.Row) -> PriceOut:
return PriceOut(
partner_id=row["partner_id"],
partner_name=row["partner_name"],
price_resident_kzt=row["price_resident"],
price_nonresident_kzt=row["price_nonresident"],
prices=json.loads(row["prices"] or "{}"),
effective_date=row["effective_date"],
)
@app.get("/services", response_model=list[ServiceOut], summary="Услуги справочника")
def list_services(specialty: str | None = None, q: str | None = None, limit: int = 100):
sql = "SELECT service_id, specialty, name_ru, tarificator_code FROM service WHERE 1=1"
args: list = []
if specialty:
sql += " AND specialty = ?"
args.append(specialty)
if q:
sql += " AND name_ru LIKE ?"
args.append(f"%{q}%")
sql += " ORDER BY name_ru LIMIT ?"
args.append(limit)
with db() as conn:
return [ServiceOut(**dict(r)) for r in conn.execute(sql, args)]
@app.get("/services/{service_id}/partners", response_model=list[PriceOut],
summary="Кто оказывает услугу и по какой цене")
def service_partners(service_id: str):
"""Одна строка на клинику с показательной ценой (резидент или минимальный тариф)."""
sql = """SELECT pi.partner_id, p.name AS partner_name, pi.price_resident, pi.price_nonresident,
pi.prices, pi.effective_date
FROM price_item pi JOIN partner p ON pi.partner_id = p.partner_id
WHERE pi.service_id = ? AND pi.is_active = 1"""
best: dict[str, PriceOut] = {}
with db() as conn:
for r in conn.execute(sql, [service_id]):
prices = json.loads(r["prices"] or "{}")
display = r["price_resident"] or (min(prices.values()) if prices else None)
current = best.get(r["partner_id"])
if current is None or (
display is not None
and (current.price_resident_kzt is None or display < current.price_resident_kzt)
):
best[r["partner_id"]] = PriceOut(
partner_id=r["partner_id"],
partner_name=r["partner_name"],
price_resident_kzt=display,
price_nonresident_kzt=r["price_nonresident"],
prices=prices,
effective_date=r["effective_date"],
)
return sorted(best.values(), key=lambda x: x.price_resident_kzt or float("inf"))
@app.get("/partners", response_model=list[PartnerOut], summary="Партнёры")
def list_partners(city: str | None = None):
sql = "SELECT partner_id, name, city FROM partner WHERE 1=1"
args: list = []
if city:
sql += " AND city = ?"
args.append(city)
sql += " ORDER BY name"
with db() as conn:
return [PartnerOut(partner_id=r["partner_id"], name=r["name"], city=r["city"]) for r in conn.execute(sql, args)]
@app.get("/partners/{partner_id}/services", response_model=list[PriceOut],
summary="Все услуги партнёра с ценами")
def partner_services(partner_id: str, limit: int = 500):
sql = """SELECT pi.partner_id, p.name AS partner_name, pi.price_resident, pi.price_nonresident,
pi.prices, pi.effective_date
FROM price_item pi JOIN partner p ON pi.partner_id = p.partner_id
WHERE pi.partner_id = ? AND pi.is_active = 1 LIMIT ?"""
with db() as conn:
return [_price_out(r) for r in conn.execute(sql, [partner_id, limit])]
@app.get("/search", summary="Полнотекстовый поиск по услугам и партнёрам")
def search(q: str = Query(..., min_length=1), limit: int = 20):
with db() as conn:
services = [
dict(r)
for r in conn.execute(
"SELECT service_id, specialty, name_ru FROM service WHERE name_ru LIKE ? LIMIT ?",
[f"%{q}%", limit],
)
]
partners = [
dict(r)
for r in conn.execute(
"SELECT partner_id, name, city FROM partner WHERE name LIKE ? LIMIT ?", [f"%{q}%", limit]
)
]
return {"services": services, "partners": partners}
@app.get("/unmatched", summary="Очередь верификации: позиции с предложенным кандидатом")
def unmatched(limit: int = 50, offset: int = 0):
"""Несопоставленные позиции с лучшим кандидатом из справочника (сортировка — по уверенности)."""
sql = """SELECT pi.item_id, pi.service_name_raw, pi.prices, pi.unit, p.name AS partner_name,
pi.suggested_service_id, pi.suggested_score,
s.name_ru AS suggested_name, s.specialty AS suggested_specialty
FROM price_item pi
JOIN partner p ON pi.partner_id = p.partner_id
LEFT JOIN service s ON pi.suggested_service_id = s.service_id
WHERE pi.service_id IS NULL AND pi.is_active = 1
AND pi.item_id NOT IN (SELECT item_id FROM skipped_item)
ORDER BY pi.suggested_score DESC, pi.item_id
LIMIT ? OFFSET ?"""
with db() as conn:
return [
{
"item_id": r["item_id"],
"service_name_raw": r["service_name_raw"],
"partner_name": r["partner_name"],
"unit": r["unit"],
"prices": json.loads(r["prices"] or "{}"),
"suggested": (
{
"service_id": r["suggested_service_id"],
"name_ru": r["suggested_name"],
"specialty": r["suggested_specialty"],
"score": r["suggested_score"],
}
if r["suggested_service_id"]
else None
),
}
for r in conn.execute(sql, [limit, offset])
]
@app.get("/unmatched/count", summary="Сколько позиций ещё в очереди верификации")
def unmatched_count():
with db() as conn:
n = conn.execute(
"""SELECT COUNT(*) FROM price_item
WHERE service_id IS NULL AND is_active = 1
AND item_id NOT IN (SELECT item_id FROM skipped_item)"""
).fetchone()[0]
learned = conn.execute("SELECT COUNT(*) FROM learned_synonym").fetchone()[0]
return {"remaining": n, "learned_synonyms": learned}
@app.post("/skip", summary="Отложить позицию (пропустить в очереди верификации)")
def skip_item(item_id: int):
with db() as conn:
conn.execute("INSERT OR IGNORE INTO skipped_item (item_id) VALUES (?)", [item_id])
conn.commit()
return {"item_id": item_id, "status": "skipped"}
@app.post("/match", summary="Ручное сопоставление позиции (с дообучением)")
def manual_match(item_id: int, service_id: str):
with db() as conn:
if not conn.execute("SELECT 1 FROM service WHERE service_id = ?", [service_id]).fetchone():
raise HTTPException(404, "услуга справочника не найдена")
row = conn.execute(
"SELECT service_name_raw FROM price_item WHERE item_id = ?", [item_id]
).fetchone()
if not row:
raise HTTPException(404, "позиция не найдена")
conn.execute(
"UPDATE price_item SET service_id = ?, map_method = 'manual', map_confidence = 1.0 WHERE item_id = ?",
[service_id, item_id],
)
# Дообучение: запоминаем синоним — следующие прогоны сопоставят его автоматически.
conn.execute(
"INSERT OR REPLACE INTO learned_synonym (name_norm, service_id) VALUES (?, ?)",
[_norm(row["service_name_raw"]), service_id],
)
conn.commit()
return {"item_id": item_id, "service_id": service_id, "status": "matched", "learned": True}
@app.get("/stats", response_model=Stats, summary="Сводка качества обработки")
def stats():
report = Path(DB_PATH).parent / "quality_report.json"
if report.exists():
data = json.loads(report.read_text(encoding="utf-8"))
return Stats(
documents_total=data.get("documents", 0),
documents_done=data.get("documents", 0),
items_total=data.get("items", 0),
auto_matched_pct=data.get("auto_matched_pct", 0.0),
unmatched_total=data.get("unmatched", 0),
)
return Stats()
@app.get("/health", summary="Проверка живости")
def health():
return {"status": "ok", "db": Path(DB_PATH).exists()}