1ff5d93cd8
- очередь верификации: предложенный кандидат (топ эмбеддинг), подтвердить/другое/пропустить - POST /match пополняет синонимы (learned_synonym) — обучающаяся нормализация - сравнение: медиана + разброс цен + отклонение клиники (переплата/выгода) - эмбеддер: дедуп названий + ретрай при 429 (квота Gemini) - развёрнуто на med.secondbrain.tools Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
10 KiB
Python
234 lines
10 KiB
Python
"""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
|
||
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
|
||
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"
|
||
).fetchone()[0]
|
||
learned = conn.execute("SELECT COUNT(*) FROM learned_synonym").fetchone()[0]
|
||
return {"remaining": n, "learned_synonyms": learned}
|
||
|
||
|
||
@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()}
|