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 — сравнение цен на медуслуги
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Найдите услугу — сравните цены в клиниках
+ Единый каталог услуг и цен из архива прайсов клиник-партнёров.
+
+
+
+
+
+
+
+
+
+
+
+ Дашборд обработки
+
+
+
+
+
+
+
Загрузить архив прайсов
+
ZIP с прайсами клиник (pdf, xlsx, xls, docx). Система распакует, распознает и догрузит позиции в базу.
+
+
+
+
+
+
+
+
+
+
+
Очередь верификации
+
+
+ Система предлагает кандидата из справочника — подтвердите, выберите другой или пропустите. Подтверждение обучает систему.
+
+
+
+
+
+
+
+
+