feat(ingest+поиск): приём ZIP через интерфейс, регистронезависимый поиск, чистка очереди

- Загрузка архива (§4.1): POST /ingest распаковывает ZIP, дораспознаёт и
  догружает прайсы в базу (append). Виджет в админке. Кэш эмбеддингов справочника
  (dict_emb.npy) — догрузка считает только новый файл, эмбеддинги best-effort с
  откатом на fuzzy при сбое Gemini.
- Поиск по name_norm (lowercase): «УЗИ» и «узи» дают одинаковый результат —
  SQLite LIKE не сворачивает регистр для кириллицы.
- Фильтр названий: код-мнемоники («МОЗО.15») больше не попадают в очередь.
- /stats считается из базы — цифры на дашборде обновляются после загрузки.
- Деплой через Dockerfile (зависимости в образе, код монтируется томом).
- ruff: ядро и весь репозиторий проходят проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 11:35:12 +05:00
parent 89928511ba
commit 69a9884db5
25 changed files with 2217 additions and 182 deletions
+145 -16
View File
@@ -4,6 +4,7 @@
задаётся переменной окружения MEDARCHIVE_DB. Ядро WOW — /services/{id}/partners:
«кто оказывает услугу и по какой цене», отсортировано от выгодной.
"""
from __future__ import annotations
import json
@@ -13,13 +14,15 @@ import sqlite3
import sys
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
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"))
DB_PATH = os.environ.get(
"MEDARCHIVE_DB", str(Path(__file__).resolve().parents[1] / "data/medarchive.db")
)
app = FastAPI(
title="MedArchive API",
@@ -28,9 +31,7 @@ app = FastAPI(
# За Caddy сервис живёт на /api — без этого Swagger ищет спеку в корне и падает.
root_path="/api",
)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
def db() -> sqlite3.Connection:
@@ -68,16 +69,21 @@ def list_services(specialty: str | None = None, q: str | None = None, limit: int
sql += " AND specialty = ?"
args.append(specialty)
if q:
sql += " AND name_ru LIKE ?"
args.append(f"%{q}%")
# Поиск по нормализованной колонке: SQLite LIKE сворачивает регистр только для
# латиницы, поэтому «УЗИ» и «узи» совпадут лишь при сравнении lowercase-форм.
sql += " AND name_norm LIKE ?"
args.append(f"%{_norm(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="Кто оказывает услугу и по какой цене")
@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,
@@ -114,11 +120,17 @@ def list_partners(city: str | None = None):
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)]
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="Все услуги партнёра с ценами")
@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
@@ -130,18 +142,22 @@ def partner_services(partner_id: str, limit: int = 500):
@app.get("/search", summary="Полнотекстовый поиск по услугам и партнёрам")
def search(q: str = Query(..., min_length=1), limit: int = 20):
# Сравниваем по name_norm (lowercase): иначе кириллический регистр («УЗИ» vs «узи»)
# не сворачивается и часть запросов даёт «ничего не найдено».
like = f"%{_norm(q)}%"
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],
"SELECT service_id, specialty, name_ru FROM service WHERE name_norm LIKE ? LIMIT ?",
[like, limit],
)
]
partners = [
dict(r)
for r in conn.execute(
"SELECT partner_id, name, city FROM partner WHERE name LIKE ? LIMIT ?", [f"%{q}%", limit]
"SELECT partner_id, name, city FROM partner WHERE name_norm LIKE ? LIMIT ?",
[like, limit],
)
]
return {"services": services, "partners": partners}
@@ -203,6 +219,102 @@ def skip_item(item_id: int):
return {"item_id": item_id, "status": "skipped"}
_SUPPORTED_EXT = (".pdf", ".xlsx", ".xls", ".docx")
def _safe_member_name(member) -> str:
"""Имя файла из ZIP: чинит кириллицу (cp866) и срезает путь (защита от traversal)."""
name = member.filename
if not (member.flag_bits & 0x800): # нет UTF-8-флага — вероятно cp437/cp866 (рус. Windows)
try:
name = name.encode("cp437").decode("cp866")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
return os.path.basename(name.replace("\\", "/"))
@app.post("/ingest", summary="Загрузить ZIP-архив прайсов и обработать (догрузка в базу)")
def ingest_archive(file: UploadFile = File(...)):
"""Приём архива через интерфейс (ТЗ §4.1): распаковать ZIP и догрузить прайсы в базу.
Тяжёлые зависимости (конвейер, Gemini) импортируются лениво — read-only API живёт и
без них. Эмбеддинги — по возможности: при сбое Gemini обрабатываем без них (fuzzy),
архив всё равно принимается.
"""
import shutil
import tempfile
import zipfile
name = (file.filename or "").lower()
if not name.endswith(".zip"):
raise HTTPException(400, "ожидается ZIP-архив (.zip)")
workdir = Path(tempfile.mkdtemp(prefix="medarchive_ingest_"))
try:
zip_path = workdir / "upload.zip"
with zip_path.open("wb") as out:
shutil.copyfileobj(file.file, out)
extract_dir = workdir / "files"
extract_dir.mkdir()
try:
with zipfile.ZipFile(zip_path) as zf:
members = [m for m in zf.infolist() if not m.is_dir()]
for member in members:
fname = _safe_member_name(member)
if not fname or not fname.lower().endswith(_SUPPORTED_EXT):
continue
with zf.open(member) as src, (extract_dir / fname).open("wb") as dst:
shutil.copyfileobj(src, dst)
except zipfile.BadZipFile as exc:
raise HTTPException(400, "файл не является корректным ZIP-архивом") from exc
prices = [p for p in extract_dir.iterdir() if p.is_file()]
if not prices:
raise HTTPException(
422, "в архиве не найдено прайсов поддерживаемых форматов (pdf, xlsx, xls, docx)"
)
from etl.pipeline import ingest # noqa: PLC0415 — ленивый импорт тяжёлых зависимостей
embedder = None
if os.environ.get("GEMINI_API_KEY"):
try:
from etl.normalize.embedding import GeminiEmbedder # noqa: PLC0415
embedder = GeminiEmbedder()
except Exception: # ключа/SDK нет — нормализуем без эмбеддингов
embedder = None
dict_path = str(Path(DB_PATH).parent / "reference/dictionary.xlsx")
kwargs = dict(data_dir=str(extract_dir), db_path=DB_PATH, dict_path=dict_path)
try:
summary = ingest(embedder=embedder, **kwargs)
degraded = False
except Exception:
# Сбой эмбеддингов (квота/сеть) не должен ронять приём архива — обрабатываем без них.
if embedder is None:
raise
summary = ingest(embedder=None, **kwargs)
degraded = True
return {
"status": "ok",
"files_accepted": len(prices),
"documents": summary["documents"],
"new_partners": summary["partners"],
"items": summary["items"],
"auto_matched": summary["auto_matched"],
"auto_matched_pct": summary["auto_matched_pct"],
"by_method": summary["by_method"],
"embeddings": not degraded and embedder is not None,
}
finally:
import shutil as _sh
_sh.rmtree(workdir, ignore_errors=True)
@app.post("/match", summary="Ручное сопоставление позиции (с дообучением)")
def manual_match(item_id: int, service_id: str):
with db() as conn:
@@ -214,7 +326,8 @@ def manual_match(item_id: int, service_id: str):
if not row:
raise HTTPException(404, "позиция не найдена")
conn.execute(
"UPDATE price_item SET service_id = ?, map_method = 'manual', map_confidence = 1.0 WHERE item_id = ?",
"UPDATE price_item SET service_id = ?, map_method = 'manual', map_confidence = 1.0 "
"WHERE item_id = ?",
[service_id, item_id],
)
# Дообучение: запоминаем синоним — следующие прогоны сопоставят его автоматически.
@@ -228,6 +341,22 @@ def manual_match(item_id: int, service_id: str):
@app.get("/stats", response_model=Stats, summary="Сводка качества обработки")
def stats():
"""Считается из базы — так после загрузки нового архива цифры обновляются сразу."""
with db() as conn:
docs = conn.execute("SELECT COUNT(*) FROM price_document").fetchone()[0]
items = conn.execute("SELECT COUNT(*) FROM price_item WHERE is_active = 1").fetchone()[0]
matched = conn.execute(
"SELECT COUNT(*) FROM price_item WHERE is_active = 1 AND service_id IS NOT NULL"
).fetchone()[0]
if items:
return Stats(
documents_total=docs,
documents_done=docs,
items_total=items,
auto_matched_pct=round(100 * matched / items, 1),
unmatched_total=items - matched,
)
# Фолбэк на отчёт сборки, если база ещё пуста.
report = Path(DB_PATH).parent / "quality_report.json"
if report.exists():
data = json.loads(report.read_text(encoding="utf-8"))