diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..1405066 --- /dev/null +++ b/api/main.py @@ -0,0 +1,375 @@ +"""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, 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") +) + +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: + # Поиск по нормализованной колонке: 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="Кто оказывает услугу и по какой цене", +) +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): + # Сравниваем по 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_norm LIKE ? LIMIT ?", + [like, limit], + ) + ] + partners = [ + dict(r) + for r in conn.execute( + "SELECT partner_id, name, city FROM partner WHERE name_norm LIKE ? LIMIT ?", + [like, 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"} + + +_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: + 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(): + """Считается из базы — так после загрузки нового архива цифры обновляются сразу.""" + 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")) + 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()} diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000..d571839 --- /dev/null +++ b/design/README.md @@ -0,0 +1,28 @@ +# Дизайн-заготовка (фирменная палитра) + +Готовые токены для фронта (агент D). Полная дизайн-система: `Base/_Inbox/Хакатон Medtech/Дизайн-система`. + +## Применение + +1. `tailwind.tokens.ts` → импортировать в `tailwind.config.ts`, разложить в `theme.extend`. +2. `globals.css` → переменные в `:root` (shadcn/ui). +3. Шрифт **Geologica** — `next/font` или `` на fonts.bunny.net (веса 300–700). + +## Шпаргалка + +| | | +|---|---| +| Primary (CTA) | `#ff4713`, текст белый, `rounded-lg`, hover `#e63e0f` | +| Вторичный | `#6b788e` (t-gray) | +| Фон страницы / карточек | `#f3f4f6` / белый `rounded-2xl shadow-card` | +| Успех / ошибка | `#0f973d` / `#dc2626` (фон `#fbe9e9`) | +| Шрифт | Geologica (заголовки 600–700, текст 400) | +| Контейнер | `max-w-container` (1280px) | + +## Ключевой компонент — таблица сравнения цен + +- Шапка `bg-light-gray`, текст `t-gray` 600/14px. +- Строки белые, разделители `#ebedf0`, hover `bg-brand-soft`, цены вправо `tabular-nums`. +- Лучшая/нормализованная цена — бейдж `bg-brand text-white rounded-full`. +- Статусы нормализации: success `#0f973d` · конфликт `#dc2626` · «проверить» `#ff4713`. +- shadcn: `Table` + `Badge` (variants default=оранжевый / success / destructive) + TanStack `DataTable`. diff --git a/design/globals.css b/design/globals.css new file mode 100644 index 0000000..a5c9599 --- /dev/null +++ b/design/globals.css @@ -0,0 +1,26 @@ +/* shadcn/ui тема (фирменная палитра) — вставить в app/globals.css (:root) + Подключить шрифт Geologica: next/font или + */ +:root { + --radius: 0.5rem; /* база кнопок/инпутов */ + --background: 220 14% 96%; /* #f3f4f6 фон страницы */ + --foreground: 222 47% 11%; /* #111827 текст */ + --card: 0 0% 100%; /* белые карточки */ + --card-foreground: 222 47% 11%; + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + --primary: 11 100% 54%; /* #ff4713 бренд-оранжевый */ + --primary-foreground: 0 0% 100%; + --secondary: 218 15% 49%; /* #6b788e t-gray */ + --secondary-foreground: 0 0% 100%; + --muted: 210 20% 97%; /* #f5f7f9 light-gray */ + --muted-foreground: 220 9% 46%; /* #6b7280 */ + --accent: 16 100% 95%; /* #ffede7 brand-soft */ + --accent-foreground: 11 100% 45%; + --destructive: 0 72% 51%; /* #dc2626 */ + --destructive-foreground: 0 0% 100%; + --success: 142 82% 32%; /* #0f973d */ + --border: 220 13% 91%; /* #ebedf0 */ + --input: 220 13% 85%; /* #d1d5db */ + --ring: 11 100% 54%; /* оранжевое кольцо фокуса */ +} diff --git a/design/tailwind.tokens.ts b/design/tailwind.tokens.ts new file mode 100644 index 0000000..1a15a77 --- /dev/null +++ b/design/tailwind.tokens.ts @@ -0,0 +1,18 @@ +// Дизайн-токены (фирменная палитра) — вставить в tailwind.config.ts → theme.extend +export const brandTheme = { + colors: { + brand: { DEFAULT: "#ff4713", soft: "#ffede7", dark: "#e63e0f" }, // бренд-оранжевый + "t-gray": "#6b788e", // сине-серый (вторичный) + "light-gray": "#f5f7f9", // фон секций/карточек + link: "#2563eb", + success: { DEFAULT: "#0f973d", soft: "#e7f6ec" }, + error: { DEFAULT: "#dc2626", soft: "#fbe9e9" }, + ink: "#111827", // основной текст + }, + fontFamily: { + sans: ["Geologica", "ui-sans-serif", "system-ui", "sans-serif"], + }, + borderRadius: { lg: "0.5rem", xl: "0.75rem", "2xl": "1rem", "3xl": "1.5rem" }, + maxWidth: { container: "80rem" }, // 1280px — контейнер + boxShadow: { card: "0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1)" }, +}; diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..de157bc --- /dev/null +++ b/web/index.html @@ -0,0 +1,264 @@ + + + + + +MedArchive — сравнение цен на медуслуги + + + + + + + +
+ +
+
+ MedArchive + прайсы клиник-партнёров +
+ +
+ +
+ + + + + + + +
+ + + +