Compare commits
31 Commits
69a9884db5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c8271daeed | |||
| 5f5c166527 | |||
| e94979a623 | |||
| f20f98b872 | |||
| 7232bb6609 | |||
| d17fd7ef5d | |||
| c4eab75330 | |||
| dd7e5b8573 | |||
| 1dcc76fac0 | |||
| afb2dc1b9e | |||
| 3783f1be25 | |||
| dbe36d3323 | |||
| 1a4cc039b2 | |||
| a2d3a1fa92 | |||
| ab57fb5e82 | |||
| b6928564ff | |||
| b7f56f6cb6 | |||
| 32a411f079 | |||
| b11e09a9b0 | |||
| 9397fd1c83 | |||
| c1ed1962a8 | |||
| 34f75bacd3 | |||
| 51df0aa490 | |||
| 195b8fbc5e | |||
| ac9f7fea15 | |||
| 7d339dce87 | |||
| 8da6fa4d1d | |||
| 969d958ec3 | |||
| e03e70535e | |||
| 26283d5432 | |||
| 3834e4811d |
@@ -27,3 +27,6 @@ Thumbs.db
|
||||
# Данные клиник: реальный архив и справочник в публичный репозиторий не коммитим
|
||||
data/
|
||||
*.sqlite
|
||||
|
||||
# Внутренние рабочие доки хакатона (план, спеки, дизайн) — не в публичный репо
|
||||
_internal/
|
||||
|
||||
@@ -4,19 +4,38 @@ Terricon Valley · Medtech Hackathon (26–28.06.2026).
|
||||
|
||||
**Живой демо: https://med.secondbrain.tools**
|
||||
|
||||
## 🎬 Демо-трейлер
|
||||
|
||||
[](pitch/landing/trailer.mp4)
|
||||
|
||||
▶️ **[Смотреть трейлер (40 сек)](pitch/landing/trailer.mp4)** · вживую: [med.secondbrain.tools/pitch](https://med.secondbrain.tools/pitch)
|
||||
|
||||
Система автоматически разбирает архив разнородных прайс-листов клиник (PDF, сканы, DOCX,
|
||||
XLSX/XLS), извлекает услуги и цены, нормализует их к справочнику из 1281 услуги и даёт поиск
|
||||
XLSX/XLS), извлекает услуги и цены, нормализует их к справочнику из 1287 услуг и даёт поиск
|
||||
и сравнение «кто оказывает услугу и по какой цене».
|
||||
|
||||
## Результат на тестовом архиве (8 клиник, 10 файлов)
|
||||
|
||||
- **8 871 позиция** извлечена: 6 клиник из 8 — нативными парсерами, трудные PDF (битый
|
||||
текстовый слой, нестандартная вёрстка) — через Gemini Vision.
|
||||
- **73,9% позиций нормализованы автоматически** (цель ТЗ — не ниже 70%).
|
||||
- **16 140 позиций** извлечено: нативными парсерами + трудные PDF (битый текстовый слой,
|
||||
нестандартная вёрстка) — через Gemini Vision; обработаны все 8 клиник.
|
||||
- **73,7% позиций нормализованы автоматически** (цель ТЗ — не ниже 70%).
|
||||
- Остальное уходит в очередь ручной разметки, как и предполагает ТЗ.
|
||||
|
||||
Полный отчёт о качестве — `docs/quality_report.json`.
|
||||
|
||||
## Производительность (ТЗ §5)
|
||||
|
||||
| Тип документа | Замер | Норматив ТЗ §5 |
|
||||
|---|---|---|
|
||||
| Текст (XLSX, 1724 строки) | **0,2 с** | ≤ 60 с ✅ |
|
||||
| Скан/битый PDF через Gemini Vision | ~14,5 с/страница; 21-страничный документ — 5,2 мин | ≤ 3 мин/документ |
|
||||
|
||||
Текстовые форматы (PDF-текст, DOCX, XLSX/XLS) укладываются в норматив с большим запасом.
|
||||
Скан-добор упирается в **квоту запросов free-tier Gemini Vision** (~14,5 с/страница — это
|
||||
троттлинг RPM, а не латентность кода): небольшие и средние сканы проходят за < 3 мин, крупный
|
||||
21-страничный документ — за 5,2 мин. Узкое место — лимит API, не алгоритм: платный тариф Gemini
|
||||
или параллельные постраничные вызовы снимают ограничение без изменения логики извлечения.
|
||||
|
||||
## Стек и решения
|
||||
|
||||
| Слой | Решение |
|
||||
@@ -26,10 +45,12 @@ XLSX/XLS), извлекает услуги и цены, нормализует
|
||||
| Нормализация | Каскад: код тарификатора → точное совпадение → эмбеддинги Gemini → нечёткое сравнение → очередь `unmatched` |
|
||||
| Валидация | Правила ТЗ §4.4: положительность цены, резидент ≥ нерезидент, аномалии, версионирование |
|
||||
| Хранилище | SQLite (MVP, разворачивается без Docker). Боевая схема PostgreSQL + pgvector — в `db/migrations` |
|
||||
| API | FastAPI: 7 эндпоинтов ТЗ + `/stats`, OpenAPI на `/docs` |
|
||||
| Фронт | Одностраничный (Tailwind): поиск услуги и сравнение цен по клиникам |
|
||||
| API | FastAPI: эндпоинты ТЗ + `/stats`, загрузка архива, авто-загрузка из папки, алерты об изменении цен, экспорт в Excel; OpenAPI на `/docs` |
|
||||
| Фронт | Одностраничный (Tailwind): поиск и сравнение цен, экран верификации оператора, дашборд обработки |
|
||||
| Деплой | Контейнер API за Caddy на Selectel KZ |
|
||||
|
||||
Подробно об устройстве системы — **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** (поток данных, карта модулей, модель данных, обоснование решений).
|
||||
|
||||
## Запуск локально
|
||||
|
||||
```bash
|
||||
|
||||
+239
-28
@@ -14,7 +14,7 @@ import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
|
||||
from fastapi import FastAPI, File, HTTPException, Query, Response, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
@@ -39,6 +39,13 @@ def db() -> sqlite3.Connection:
|
||||
conn.row_factory = sqlite3.Row
|
||||
# Отложенные оператором позиции (чтобы пропущенное не возвращалось в начало очереди).
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS skipped_item (item_id INTEGER PRIMARY KEY)")
|
||||
# Настройки админки (ключ-значение): сейчас — путь к папке авто-загрузки.
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)")
|
||||
# Журнал уже обработанных файлов авто-загрузки (чтобы не обрабатывать повторно).
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS ingested_file ("
|
||||
"name TEXT PRIMARY KEY, source TEXT, ingested_at TEXT, items INTEGER)"
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
@@ -233,6 +240,34 @@ def _safe_member_name(member) -> str:
|
||||
return os.path.basename(name.replace("\\", "/"))
|
||||
|
||||
|
||||
def _ingest_dir(data_dir: str) -> tuple[dict, bool]:
|
||||
"""Догрузить все прайсы из каталога в базу. Возвращает (отчёт, использованы_ли_эмбеддинги).
|
||||
|
||||
Тяжёлые зависимости (конвейер, Gemini) импортируются лениво — read-only API живёт и без
|
||||
них. Эмбеддинги — по возможности: при сбое Gemini (квота/сеть) обрабатываем без них (fuzzy),
|
||||
загрузка всё равно проходит.
|
||||
"""
|
||||
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=data_dir, db_path=DB_PATH, dict_path=dict_path)
|
||||
try:
|
||||
return ingest(embedder=embedder, **kwargs), embedder is not None
|
||||
except Exception:
|
||||
if embedder is None:
|
||||
raise
|
||||
return ingest(embedder=None, **kwargs), False
|
||||
|
||||
|
||||
@app.post("/ingest", summary="Загрузить ZIP-архив прайсов и обработать (догрузка в базу)")
|
||||
def ingest_archive(file: UploadFile = File(...)):
|
||||
"""Приём архива через интерфейс (ТЗ §4.1): распаковать ZIP и догрузить прайсы в базу.
|
||||
@@ -275,29 +310,7 @@ def ingest_archive(file: UploadFile = File(...)):
|
||||
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
|
||||
|
||||
summary, embeddings = _ingest_dir(str(extract_dir))
|
||||
return {
|
||||
"status": "ok",
|
||||
"files_accepted": len(prices),
|
||||
@@ -307,7 +320,7 @@ def ingest_archive(file: UploadFile = File(...)):
|
||||
"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,
|
||||
"embeddings": embeddings,
|
||||
}
|
||||
finally:
|
||||
import shutil as _sh
|
||||
@@ -315,6 +328,203 @@ def ingest_archive(file: UploadFile = File(...)):
|
||||
_sh.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
|
||||
@app.get("/settings", summary="Настройки админки (папка авто-загрузки)")
|
||||
def get_settings():
|
||||
with db() as conn:
|
||||
rows = {r["key"]: r["value"] for r in conn.execute("SELECT key, value FROM settings")}
|
||||
return {"watch_folder": rows.get("watch_folder", "")}
|
||||
|
||||
|
||||
@app.post("/settings", summary="Сохранить папку авто-загрузки")
|
||||
def set_settings(watch_folder: str = ""):
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES ('watch_folder', ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[watch_folder.strip()],
|
||||
)
|
||||
conn.commit()
|
||||
return {"watch_folder": watch_folder.strip()}
|
||||
|
||||
|
||||
@app.post("/scan", summary="Проверить папку авто-загрузки и обработать новые прайсы")
|
||||
def scan_folder():
|
||||
"""Сканирует папку из настроек, находит новые файлы и догружает их в базу.
|
||||
|
||||
Новизна определяется по имени файла (журнал `ingested_file`) — повторно не обрабатываем.
|
||||
Эту же ручку дёргает крон: система забирает свежие прайсы из папки без участия человека.
|
||||
"""
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = 'watch_folder'").fetchone()
|
||||
folder = (row["value"] if row else "") or ""
|
||||
done = {r["name"] for r in conn.execute("SELECT name FROM ingested_file")}
|
||||
|
||||
if not folder:
|
||||
raise HTTPException(400, "папка авто-загрузки не задана — укажите её в настройках")
|
||||
src = Path(folder)
|
||||
if not src.is_dir():
|
||||
raise HTTPException(404, f"папка не найдена: {folder}")
|
||||
|
||||
new_files = [
|
||||
p
|
||||
for p in sorted(src.iterdir())
|
||||
if p.is_file() and p.suffix.lower() in _SUPPORTED_EXT and p.name not in done
|
||||
]
|
||||
if not new_files:
|
||||
return {"status": "ok", "new_files": 0, "message": "новых прайсов нет"}
|
||||
|
||||
workdir = Path(tempfile.mkdtemp(prefix="medarchive_scan_"))
|
||||
try:
|
||||
for p in new_files:
|
||||
shutil.copy(p, workdir / p.name)
|
||||
summary, embeddings = _ingest_dir(str(workdir))
|
||||
now = datetime.now(UTC).isoformat(timespec="seconds")
|
||||
with db() as conn:
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO ingested_file (name, source, ingested_at, items) "
|
||||
"VALUES (?, 'folder', ?, NULL)",
|
||||
[(p.name, now) for p in new_files],
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"status": "ok",
|
||||
"new_files": len(new_files),
|
||||
"files": [p.name for p in new_files],
|
||||
"items": summary["items"],
|
||||
"auto_matched_pct": summary["auto_matched_pct"],
|
||||
"embeddings": embeddings,
|
||||
}
|
||||
finally:
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
|
||||
@app.get("/price-changes", summary="Услуги, у которых цена изменилась между прайсами")
|
||||
def price_changes(threshold: float = 10.0, limit: int = 100):
|
||||
"""Сравнивает активную цену с предыдущей (архивной) у того же партнёра и услуги.
|
||||
|
||||
Возвращает позиции, где модуль изменения ≥ threshold% — детектор аномалий цен (ТЗ §4.4),
|
||||
вынесенный в интерфейс. Предыдущая цена — самый свежий архивный прайс той же позиции.
|
||||
"""
|
||||
sql = """
|
||||
SELECT p.name AS partner_name, a.service_name_raw, s.name_ru AS service_ru,
|
||||
a.price_resident AS new_price, a.effective_date AS new_date,
|
||||
(SELECT b.price_resident FROM price_item b
|
||||
WHERE b.partner_id = a.partner_id
|
||||
AND ((a.service_id IS NOT NULL AND b.service_id = a.service_id)
|
||||
OR (a.service_id IS NULL AND b.service_name_raw = a.service_name_raw))
|
||||
AND b.is_active = 0 AND b.price_resident IS NOT NULL
|
||||
ORDER BY b.effective_date DESC LIMIT 1) AS old_price,
|
||||
(SELECT b.effective_date FROM price_item b
|
||||
WHERE b.partner_id = a.partner_id
|
||||
AND ((a.service_id IS NOT NULL AND b.service_id = a.service_id)
|
||||
OR (a.service_id IS NULL AND b.service_name_raw = a.service_name_raw))
|
||||
AND b.is_active = 0 AND b.price_resident IS NOT NULL
|
||||
ORDER BY b.effective_date DESC LIMIT 1) AS old_date
|
||||
FROM price_item a
|
||||
JOIN partner p ON a.partner_id = p.partner_id
|
||||
LEFT JOIN service s ON a.service_id = s.service_id
|
||||
WHERE a.is_active = 1 AND a.price_resident IS NOT NULL
|
||||
"""
|
||||
changes = []
|
||||
with db() as conn:
|
||||
for r in conn.execute(sql):
|
||||
old = r["old_price"]
|
||||
new = r["new_price"]
|
||||
if not old:
|
||||
continue
|
||||
delta_pct = round((new - old) / old * 100, 1)
|
||||
if abs(delta_pct) >= threshold:
|
||||
changes.append(
|
||||
{
|
||||
"partner_name": r["partner_name"],
|
||||
"service": r["service_ru"] or r["service_name_raw"],
|
||||
"old_price": old,
|
||||
"new_price": new,
|
||||
"delta_pct": delta_pct,
|
||||
"old_date": r["old_date"],
|
||||
"new_date": r["new_date"],
|
||||
}
|
||||
)
|
||||
changes.sort(key=lambda c: abs(c["delta_pct"]), reverse=True)
|
||||
return {"threshold": threshold, "count": len(changes), "changes": changes[:limit]}
|
||||
|
||||
|
||||
@app.get("/export.xlsx", summary="Выгрузить нормализованный сравнительный прайс в Excel")
|
||||
def export_xlsx():
|
||||
"""Единый каталог: услуга справочника → клиники и цены. Готовый файл для биллинга/закупок."""
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
|
||||
# Один ряд на «услуга × клиника» — чистый сравнительный каталог без дублей сырых позиций.
|
||||
sql = """SELECT s.name_ru AS service, s.specialty, p.name AS partner,
|
||||
MIN(pi.price_resident) AS price_resident,
|
||||
MIN(pi.price_nonresident) AS price_nonresident,
|
||||
MAX(pi.effective_date) AS effective_date
|
||||
FROM price_item pi
|
||||
JOIN service s ON pi.service_id = s.service_id
|
||||
JOIN partner p ON pi.partner_id = p.partner_id
|
||||
WHERE pi.is_active = 1
|
||||
GROUP BY s.service_id, p.partner_id
|
||||
ORDER BY s.name_ru, price_resident"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Каталог услуг и цен"
|
||||
headers = ["Услуга", "Специальность", "Клиника", "Цена резидент, ₸",
|
||||
"Цена нерезидент, ₸", "Прайс от"]
|
||||
ws.append(headers)
|
||||
head_font = Font(bold=True, color="FFFFFF")
|
||||
head_fill = PatternFill("solid", fgColor="FF4713")
|
||||
for cell in ws[1]:
|
||||
cell.font = head_font
|
||||
cell.fill = head_fill
|
||||
with db() as conn:
|
||||
for r in conn.execute(sql):
|
||||
ws.append([r["service"], r["specialty"], r["partner"], r["price_resident"],
|
||||
r["price_nonresident"], r["effective_date"]])
|
||||
widths = [46, 22, 26, 16, 18, 12]
|
||||
for i, w in enumerate(widths, 1):
|
||||
ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
# Второй лист — ВСЕ активные позиции как есть (включая очередь верификации),
|
||||
# чтобы выгрузка была полной: первый лист — чистый каталог, второй — всё сырьё.
|
||||
ws2 = wb.create_sheet("Все позиции")
|
||||
ws2.append(["Услуга (как в прайсе)", "Сопоставлено со справочником", "Специальность",
|
||||
"Клиника", "Цена резидент, ₸", "Цена нерезидент, ₸", "Прайс от", "Статус"])
|
||||
for cell in ws2[1]:
|
||||
cell.font = head_font
|
||||
cell.fill = head_fill
|
||||
sql_all = """SELECT pi.service_name_raw, s.name_ru AS matched, s.specialty, p.name AS partner,
|
||||
pi.price_resident, pi.price_nonresident, pi.effective_date,
|
||||
CASE WHEN pi.service_id IS NULL THEN 'в очереди' ELSE 'сопоставлено' END st
|
||||
FROM price_item pi
|
||||
JOIN partner p ON pi.partner_id = p.partner_id
|
||||
LEFT JOIN service s ON pi.service_id = s.service_id
|
||||
WHERE pi.is_active = 1
|
||||
ORDER BY pi.service_name_raw"""
|
||||
with db() as conn:
|
||||
for r in conn.execute(sql_all):
|
||||
ws2.append([r["service_name_raw"], r["matched"], r["specialty"], r["partner"],
|
||||
r["price_resident"], r["price_nonresident"], r["effective_date"], r["st"]])
|
||||
for i, w in enumerate([40, 40, 20, 24, 16, 18, 12, 14], 1):
|
||||
ws2.column_dimensions[ws2.cell(row=1, column=i).column_letter].width = w
|
||||
ws2.freeze_panes = "A2"
|
||||
|
||||
buffer = BytesIO()
|
||||
wb.save(buffer)
|
||||
return Response(
|
||||
content=buffer.getvalue(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="medarchive_prices.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/match", summary="Ручное сопоставление позиции (с дообучением)")
|
||||
def manual_match(item_id: int, service_id: str):
|
||||
with db() as conn:
|
||||
@@ -341,12 +551,13 @@ 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]
|
||||
items = conn.execute("SELECT COUNT(*) FROM price_item").fetchone()[0]
|
||||
matched = conn.execute(
|
||||
"SELECT COUNT(*) FROM price_item WHERE is_active = 1 AND service_id IS NOT NULL"
|
||||
"SELECT COUNT(*) FROM price_item WHERE service_id IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
if items:
|
||||
return Stats(
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Архитектура MedArchive
|
||||
|
||||
Документ описывает систему **как она собрана** (as-built): поток данных, карту модулей,
|
||||
модель данных, ключевые инженерные решения и их обоснование.
|
||||
|
||||
Назначение системы: принять архив разнородных прайс-листов клиник, извлечь услуги и цены,
|
||||
нормализовать их к единому справочнику и отдать поиск и сравнение «кто оказывает услугу и
|
||||
по какой цене». Живая версия — `https://med.secondbrain.tools`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Поток данных
|
||||
|
||||
```
|
||||
архив (PDF/скан/DOCX/XLSX/XLS)
|
||||
│
|
||||
▼
|
||||
извлечение по форматам ──(битый слой / 0 строк)──► Gemini Vision
|
||||
│
|
||||
▼
|
||||
нормализация к справочнику код → точное → эмбеддинги → нечёткое → очередь
|
||||
│
|
||||
▼
|
||||
валидация (правила ТЗ §4.4)
|
||||
│
|
||||
▼
|
||||
SQLite ── версионирование цен (свежий прайс активен, старые в архив)
|
||||
│
|
||||
▼
|
||||
FastAPI (поиск, сравнение, верификация, загрузка, экспорт)
|
||||
│
|
||||
▼
|
||||
статический фронт (поиск и сравнение цен, админка оператора)
|
||||
```
|
||||
|
||||
Конвейер линейный, каждый шаг — отдельный модуль. Два входа: `run()` собирает базу с нуля
|
||||
по всему архиву; `ingest()` догружает новые прайсы в готовую базу (на нём держится приём
|
||||
архива через интерфейс и авто-загрузка из папки).
|
||||
|
||||
---
|
||||
|
||||
## 2. Карта модулей
|
||||
|
||||
| Каталог | Роль |
|
||||
|---|---|
|
||||
| `contracts/` | Pydantic-модели (контракт между шагами): `RawRow`, `ParsedDocument`, `MatchResult`, выходные DTO API |
|
||||
| `etl/dictionary.py` | Загрузка справочника услуг из xlsx, нормализация названий |
|
||||
| `etl/extractors/` | `readers` — чтение форматов в «сетки»; `grid` — определение ролей колонок; `common` — эвристики прайсов; `vision` — добор через Gemini; `__init__` — диспетчер и триггер Vision |
|
||||
| `etl/normalize/` | `embedding` — эмбеддинги Gemini; `matcher` — каскад сопоставления |
|
||||
| `etl/validate/` | Правила валидации позиций (ТЗ §4.4) |
|
||||
| `etl/pipeline.py` | Оркестрация: `run` (полная сборка), `ingest` (догрузка), версионирование, отчёт о качестве |
|
||||
| `api/` | FastAPI: поиск, сравнение, верификация, загрузка, экспорт, авто-загрузка, алерты |
|
||||
| `web/` | Одностраничный фронт (Tailwind): поиск и сравнение цен + админка оператора |
|
||||
| `db/migrations/` | Боевая схема PostgreSQL + pgvector (наготове) |
|
||||
| `infra/` | Dockerfile, docker-compose, Caddyfile — деплой за Caddy на Selectel KZ |
|
||||
| `scripts/` | `run_pipeline` — сборка базы; `gen_fixtures` — синтетические прайсы; `proof/` — разведка форматов |
|
||||
|
||||
---
|
||||
|
||||
## 3. Модель данных (SQLite, MVP)
|
||||
|
||||
- **`service`** — справочник: `service_id`, `specialty`, `name_ru`, `name_norm`, `tarificator_code`.
|
||||
- **`partner`** — клиника: `partner_id`, `name`, `name_norm` (дедуп), `city`.
|
||||
- **`price_document`** — загруженный файл: `doc_id`, `partner_id`, `file_name`, `file_format`
|
||||
(`pdf`/`scan_pdf`/`docx`/`xlsx`/`xls`), `effective_date`, `parse_status`, `rows_count`.
|
||||
- **`price_item`** — извлечённая позиция: `service_name_raw`, `service_code_source`, `service_id`
|
||||
(nullable — пусто, если в очереди), **`prices`** (JSON `тариф → сумма`: `resident`,
|
||||
`nonresident`, `cis`, `far`, `insurance`, `partner`), денормализованные `price_resident` и
|
||||
`price_nonresident`, `unit`, `effective_date`, `map_method`, `map_confidence`, `is_active`,
|
||||
`suggested_service_id`/`suggested_score` (кандидат оператору, даже ниже порога).
|
||||
- **`learned_synonym`** (`name_norm → service_id`) — синонимы, выученные при верификации.
|
||||
- **`skipped_item`**, **`settings`**, **`ingested_file`** — служебные (очередь, папка авто-загрузки,
|
||||
журнал обработанных файлов); создаются лениво на стороне API.
|
||||
|
||||
Боевая схема — PostgreSQL + pgvector (`db/migrations`): эмбеддинги в `vector`-колонке с
|
||||
HNSW-индексом, в остальном модель та же.
|
||||
|
||||
---
|
||||
|
||||
## 4. Извлечение
|
||||
|
||||
Все табличные источники сводятся к «сетке» (список строк из строковых ячеек) и обрабатываются
|
||||
единым кодом — так xlsx, xls, docx и таблицы из PDF проходят через одну логику.
|
||||
|
||||
- **XLSX/XLS/DOCX** — нативные парсеры (openpyxl, xlrd, python-docx). Для DOCX правки принимаются
|
||||
(работаем с финальным текстом).
|
||||
- **PDF с текстовым слоем** — таблицы восстанавливаются **по координатам слов**: у казахстанских
|
||||
прайсов часто нет линий таблицы, поэтому `pdfplumber.extract_tables` ничего не находит. Слова
|
||||
группируются в строки по вертикали, внутри строки режутся на ячейки по горизонтальным разрывам —
|
||||
так колонки цен (резидент/нерезидент/…) не сливаются.
|
||||
- **Определение ролей колонок** (`grid.py`): ценовые колонки — по заголовку и медиане значений
|
||||
(медуслуга дороже сотни тенге, что отсекает колонку «№» и мелочь); колонка названия — самая
|
||||
«кириллическая», но **колонки `Код`/`№`/`тарификатор` исключаются заранее** (иначе ключ «услуг»
|
||||
ловит заголовок «Код услуги» и колонка кодов ошибочно становится колонкой названий).
|
||||
- **Gemini Vision** (`gemini-2.5-flash`) — запасной путь для сканов, битого текстового слоя и
|
||||
нестандартной вёрстки. Триггерится автоматически, когда нативный разбор пуст, помечен как
|
||||
`scan_pdf` или даёт подозрительно мало строк на страницу; большие документы пропускаются ради
|
||||
экономии (это фиксируется в логе). Если Vision полнее нативного разбора — заменяет его.
|
||||
|
||||
---
|
||||
|
||||
## 5. Нормализация
|
||||
|
||||
Каскад по убыванию надёжности (`matcher.py`):
|
||||
|
||||
1. **Код тарификатора** — точное совпадение по канонической части кода, если клиника его указала
|
||||
(бесплатно и точно).
|
||||
2. **Точное совпадение** нормализованного названия со справочником.
|
||||
3. **Эмбеддинги Gemini** (`gemini-embedding-001`, 768 измерений; справочник — `RETRIEVAL_DOCUMENT`,
|
||||
запрос — `RETRIEVAL_QUERY`) — семантическая близость названий. **Порог косинуса 0.70**: у Gemini
|
||||
эмбеддинги «в конусе» (несвязанные названия дают 0.6–0.7), ниже порога ловится мусор.
|
||||
4. **Нечёткое сравнение** (RapidFuzz) — запасной сигнал.
|
||||
5. Иначе — **очередь ручной разметки** (`unmatched`), но кандидат сохраняется оператору.
|
||||
|
||||
Эмбеддинги справочника кэшируются (`dict_emb.npy`): при догрузке нового прайса справочник не
|
||||
пересчитывается, эмбеддится только новый файл. Подтверждение оператора пишет `learned_synonym` —
|
||||
следующий прогон сопоставит синоним автоматически (система дообучается).
|
||||
|
||||
**Качество на тестовом архиве** (8 клиник, 10 файлов): 16 140 позиций извлечено, **73,7%
|
||||
нормализовано автоматически** (код 4397, точное 291, эмбеддинги 7083, нечёткое 128), плюс 273 неуверенных
|
||||
матча сняты guard'ом переобобщения в очередь.
|
||||
|
||||
---
|
||||
|
||||
## 6. Валидация и версионирование
|
||||
|
||||
Движок проверок (`validate/rules.py`, правила ТЗ §4.4): цена положительна и числовая; нерезидент
|
||||
не дешевле резидента; название не пустое и не код; дата не в будущем. Нарушения помечают позицию
|
||||
на ручную проверку.
|
||||
|
||||
Версионирование: если у клиники по той же услуге есть более свежий прайс, старые позиции
|
||||
архивируются (`is_active = 0`), а не удаляются — история цен сохраняется и питает алерты об
|
||||
изменении цен.
|
||||
|
||||
---
|
||||
|
||||
## 7. API (FastAPI)
|
||||
|
||||
`root_path="/api"` — за Caddy сервис живёт на `/api`, иначе Swagger ищет спеку в корне. Документация
|
||||
на `/api/docs`.
|
||||
|
||||
| Эндпоинт | Назначение |
|
||||
|---|---|
|
||||
| `GET /services`, `GET /services/{id}/partners` | Справочник и **ядро WOW**: кто оказывает услугу и по какой цене (от выгодной) |
|
||||
| `GET /partners`, `GET /partners/{id}/services` | Клиники и их прайсы |
|
||||
| `GET /search` | Регистронезависимый поиск по услугам и клиникам (по `name_norm`) |
|
||||
| `GET /unmatched`, `GET /unmatched/count`, `POST /match`, `POST /skip` | Очередь верификации, ручное сопоставление с дообучением, пропуск |
|
||||
| `POST /ingest` | Приём ZIP-архива через интерфейс (§4.1) |
|
||||
| `GET/POST /settings`, `POST /scan` | Папка авто-загрузки и её обработка (вызывается кроном) |
|
||||
| `GET /price-changes` | Услуги, у которых цена изменилась между прайсами (детектор аномалий в интерфейсе) |
|
||||
| `GET /export.xlsx` | Выгрузка каталога в Excel: лист «Каталог» (одна строка на услугу×клинику) + лист «Все позиции» |
|
||||
| `GET /stats`, `GET /health` | Сводка качества (из базы) и проверка живости |
|
||||
|
||||
Тяжёлые зависимости (конвейер, Gemini) в `/ingest` и `/scan` импортируются лениво — read-only API
|
||||
живёт и без них; эмбеддинги при загрузке — best-effort (при сбое Gemini обрабатываем без них).
|
||||
|
||||
---
|
||||
|
||||
## 8. Фронт
|
||||
|
||||
Один статический `web/index.html` (Tailwind CDN, vanilla JS), без сборки и Next.js:
|
||||
|
||||
- **Поиск → сравнение**: услуга → таблица клиник с ценами, подсветка самой выгодной и **переплаты
|
||||
к медиане**.
|
||||
- **Админка оператора**: дашборд качества; загрузка ZIP; авто-загрузка из папки; алерты об
|
||||
изменении цен; экспорт в Excel; **очередь верификации** с предложенным кандидатом и дообучением.
|
||||
|
||||
---
|
||||
|
||||
## 9. Инфраструктура и деплой
|
||||
|
||||
Контейнер API (Docker-образ с зависимостями, код монтируется томом) за общим Caddy на VPS Selectel
|
||||
(Казахстан). Caddy маршрутизирует `/api/*` → контейнер, `/` → фронт, `/pitch` `/day1` `/day2` →
|
||||
презентации. Авто-загрузка: папка `/app/inbox` (том хоста) + крон каждые 10 минут дёргает `/scan`.
|
||||
|
||||
Read-only поиск работает на готовой SQLite-базе без обращений к Gemini; ключ Gemini нужен только
|
||||
для `/ingest`/`/scan` (живая догрузка) и при сборке базы.
|
||||
|
||||
---
|
||||
|
||||
## 10. Ключевые решения и обоснование
|
||||
|
||||
- **SQLite для MVP** — разворачивается без Docker (на Mac его нет), быстрый старт. Боевая схема
|
||||
PostgreSQL + pgvector готова в `db/migrations`.
|
||||
- **Эмбеддинги и Vision через Gemini API, не локальный torch** — контейнер на маленьком VPS не
|
||||
раздувается и не держит модель в RAM.
|
||||
- **Реконструкция таблиц PDF по координатам слов** — реальные прайсы часто без линий таблицы,
|
||||
обычный парсер таблиц их не находит.
|
||||
- **Порог эмбеддингов 0.70 + очередь unmatched** — у Gemini несвязанные названия лежат «в конусе»
|
||||
0.6–0.7; порог отсекает мусор, спорное уходит оператору (ТЗ §4.3), а не сопоставляется наугад.
|
||||
- **Кэш эмбеддингов справочника** — догрузка нового прайса не пересчитывает тысячи услуг.
|
||||
- **Гибкая модель цен `prices jsonb`** — у клиник не «резидент/нерезидент», а N тарифов (СНГ,
|
||||
дальнее зарубежье, страховые, партнёр); две главные колонки денормализованы под ТЗ и API.
|
||||
- **Vision как добор, а не основной путь** — чистое берут быстрые парсеры, трудное добирает Vision;
|
||||
большие документы не отправляются в Vision ради стоимости и времени.
|
||||
|
||||
---
|
||||
|
||||
## 11. Что дальше
|
||||
|
||||
- Миграция на PostgreSQL + pgvector (схема готова) для продакшн-нагрузки.
|
||||
- Извлечение БИН и города партнёра (модель есть, поля пока не заполняются).
|
||||
- Экспорт каталога в биллинг/CRM по API (не только Excel).
|
||||
- Мультиязычные синонимы справочника (KAZ/ENG) для роста автонормализации.
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
# MedArchive — план реализации (Кейс 2, MedPartners)
|
||||
|
||||
**Цель:** собрать систему, которая принимает архив разнородных прайс-листов клиник, извлекает услуги и цены, нормализует их к справочнику из 1287 услуг и отдаёт API и интерфейс для поиска «кто оказывает услугу и по какой цене».
|
||||
|
||||
**Архитектура:** Python/FastAPI обрабатывает документы по формату (нативные парсеры + Gemini Vision как универсальный запасной путь), нормализует через коды тарификатора и эмбеддинги, складывает в PostgreSQL и отдаёт REST API. Поверх — Next.js: админка оператора и публичное сравнение цен.
|
||||
|
||||
**Стек:** Python 3.12, FastAPI, PostgreSQL + pgvector, pdfplumber, PyMuPDF, openpyxl, xlrd, python-docx, sentence-transformers (multilingual), RapidFuzz, Gemini Vision, Next.js + Tailwind + shadcn/ui, Docker Compose + Caddy.
|
||||
|
||||
## Сквозные ограничения
|
||||
|
||||
- **Репозиторий публичный** (требование организаторов) → ни одного секрета в коде. Все ключи (Gemini и прочее) — только через переменные окружения и `.env`, который в `.gitignore`. В репо — `.env.example` с пустыми значениями.
|
||||
- **Качество кода оценивается** → чистая структура модулей, типизация, docstrings и осмысленные комментарии в нетривиальных местах. Линтер (ruff) и форматтер прогоняются перед коммитом.
|
||||
- **Любой русский текст** (README, комментарии, тексты интерфейса, презентация) проходит проверку `ru-text` с оценкой не ниже 8.5.
|
||||
- **ИИ-движок:** Gemini Vision для сканов и сложной вёрстки; сопоставление со справочником — локальными эмбеддингами без платных вызовов.
|
||||
- **Деплой:** живая версия на `med.secondbrain.tools` (Selectel KZ), плюс репозиторий с инструкцией запуска.
|
||||
- **Целевой показатель ТЗ:** не менее 70% позиций нормализуются автоматически.
|
||||
|
||||
---
|
||||
|
||||
## Что реально в данных (разведка датасета)
|
||||
|
||||
8 клиник, 10 файлов. Реальность заметно сложнее, чем «4 формата + OCR» из ТЗ.
|
||||
|
||||
| Клиника | Файл(ы) | Особенности |
|
||||
|---|---|---|
|
||||
| 1 | docx 2024 (таблица 2727 строк) + pdf 2026 (85 стр., текст) | Колонки `Код / Наименование / Стоимость, тенге`. Коды `U1.1` (внутренние, не тарификатор). Секции «Раздел 1…». **История цен** 2024→2026. |
|
||||
| 2 | pdf 2025 + pdf 2026 (текст) | **Резидент / нерезидент / страховые** — несколько колонок цен. Грязные многострочные заголовки. История цен. |
|
||||
| 3 | PDF 2026 (текст, но **битый текстовый слой** — «Yliliepiia.i») | Колонки `Код тарификатора / Цена / Цена партнёра`. **Коды тарификатора `В02.110.002` совпадают со справочником** → точное сопоставление. Нужен Vision-путь поверх битого текста. |
|
||||
| 4 | pdf 2026 (текст) | Несколько ценовых категорий по гражданству (РК/оралманы, СНГ, дальнее зарубежье). |
|
||||
| 5 | pdf 2025 (текст) | Нестандартная вёрстка: первичный/повторный приём, ФИО врача, специальность. Цены «9000 тг». |
|
||||
| 6 | xlsx (лист 5181×12) | Много пустых ячеек, шапка-преамбула («Приложение 1, к приказу…»), данные начинаются глубоко. |
|
||||
| 7 | xls (3065×6) | Колонки: №, наименование, ед.изм., **цена РК / СНГ / дальнее зарубежье** (3 тарифа). Шапка не в первой строке. |
|
||||
| 8 | xlsx (2 листа: 1816×11 и 155×40) | Преамбула-приказ, несколько листов, очень широкие таблицы. |
|
||||
|
||||
**Справочник услуг** (`Справочник услуг.xlsx`): 1 лист, **1287 строк × 5 колонок** — `ID | Специальность | Code | Name_ru | TarificatrCode`. **Синонимов и категорий (кроме специальности) нет** — значит, автосопоставление держится на эмбеддингах и кодах тарификатора, синонимы наращиваем сами.
|
||||
|
||||
**Выводы, меняющие дизайн:**
|
||||
1. **Не «резидент/нерезидент», а N ценовых тарифов** (резидент, нерезидент, СНГ, дальнее, страховые, партнёр) → гибкая модель цен, а не две колонки.
|
||||
2. **Код тарификатора — золотой ключ:** где клиника его даёт (Клиника 3), сопоставление точное и бесплатное. Проверяем в первую очередь.
|
||||
3. **«Скан» в реальности — это битый текстовый слой** (Клиника 3). Детектируем мусор и уходим в Gemini Vision по отрисованной странице. Универсальный Vision-путь закрывает и сканы, и битый текст.
|
||||
4. **Файлы большие** (десятки страниц, тысячи строк) → нативные парсеры тянут основной объём, Vision бережём для трудных страниц (стоимость и время).
|
||||
5. **Шапка не в первой строке, секции-заголовки задают специальность** → логика поиска строки заголовков и контекста секции. Специальность из секции сужает поиск по справочнику и повышает точность.
|
||||
|
||||
---
|
||||
|
||||
## Модель данных
|
||||
|
||||
**Service** (справочник, загружается из xlsx): `service_id` (из ID+Code), `specialty`, `name_ru`, `tarificator_code`, `synonyms jsonb` (наращиваем), `embedding vector(384)`, `is_active`.
|
||||
|
||||
**Partner:** `partner_id`, `name`, `city`, `address`, `bin`, `contact_email`, `contact_phone`, `is_active`, `created_at`, `updated_at`. Имя/город — из шапки документа или имени файла; дедуп по БИН, иначе по нормализованному имени.
|
||||
|
||||
**PriceDocument:** `doc_id`, `partner_id`, `file_name`, `file_format` (pdf/scan_pdf/docx/xlsx/xls), `effective_date` (год из имени файла), `parsed_at`, `parse_status` (pending/processing/done/error/needs_review), `parse_log`, `raw_content`.
|
||||
|
||||
**PriceItem:** `item_id`, `doc_id`, `partner_id`, `service_name_raw`, `service_code_source`, `service_id` (nullable), `unit`, **`prices jsonb`** (карта `тариф → сумма`: `resident`, `nonresident`, `cis`, `far`, `insurance`, `partner`…), `price_resident_kzt` и `price_nonresident_kzt` (денормализация двух главных тарифов под ТЗ и API), `currency_original`, `effective_date`, `map_method` (code/exact/embedding/fuzzy/manual), `map_confidence`, `is_verified`, `verification_note`, `is_active`.
|
||||
|
||||
Уникальность активной позиции: `(partner_id, service_name_raw, effective_date)`. Версионирование: при новом прайсе старые позиции той же клиники архивируются (`is_active=false`), не удаляются.
|
||||
|
||||
---
|
||||
|
||||
## Конвейер извлечения (по форматам)
|
||||
|
||||
Единый контракт выхода парсера: список «сырых строк» `{service_name_raw, service_code_source, prices{}, unit, section}` + метаданные клиники/даты.
|
||||
|
||||
- **XLSX / XLS** (openpyxl / xlrd): обойти все листы; найти строку заголовков (содержит «Наименование» и «Цена»); распознать все колонки цен по заголовкам; строки из одной непустой ячейки = заголовок секции (задаёт специальность); пропустить преамбулу.
|
||||
- **DOCX** (python-docx): принять все правки (работаем с финальным текстом); вытащить таблицы; обработать секции и большие таблицы.
|
||||
- **PDF текстовый** (pdfplumber): извлечь таблицы по линиям/координатам; для многоколоночных цен — кластеризация по x-координате; заголовок секции из строк без цены.
|
||||
- **PDF с битым слоем / скан** (детектор мусора → PyMuPDF рендер → **Gemini Vision** со строгой JSON-схемой): универсальный путь для Клиники 3 и любых сложных страниц.
|
||||
|
||||
Детектор формата: расширение + проба текстового слоя (доля кириллицы/словарных слов). Низкое качество → Vision.
|
||||
|
||||
---
|
||||
|
||||
## Нормализация (ядро 25% оценки, цель ≥70%)
|
||||
|
||||
Каскад по убыванию надёжности:
|
||||
1. **Код тарификатора:** `service_code_source` матчит `Service.tarificator_code` → точное сопоставление, `confidence=1.0`.
|
||||
2. **Точное совпадение имени** (после нормализации: нижний регистр, схлопывание пробелов/дефисов) с `name_ru`.
|
||||
3. **Эмбеддинги** (multilingual sentence-transformers, предрасчёт по 1287 `name_ru`, pgvector): топ-1 по косинусу; если ≥0.85 → автосопоставление. Если известна специальность секции — поиск сужается внутри специальности (точнее и быстрее).
|
||||
4. **Нечёткий поиск** (RapidFuzz token_set_ratio) как вторичный сигнал/тай-брейк.
|
||||
5. Иначе → очередь `unmatched` на ручную разметку.
|
||||
|
||||
Порог конфигурируется. Подтверждённые операторы-сопоставления пополняют `synonyms` — система учится по ходу.
|
||||
|
||||
---
|
||||
|
||||
## Валидация (20% оценки, правила ТЗ §4.4)
|
||||
|
||||
Движок проверок при парсинге: цена > 0 и число; нерезидент ≥ резидент; имя услуги не пустое; дата не в будущем; дубликат `(клиника, услуга, дата)` → архивировать старую; изменение цены > 50% к прошлой версии → флаг аномалии; валюта ≠ KZT → конвертация по курсу на дату, оригинал сохраняем; документ без распознаваемых данных → статус `error`. Нарушения пишутся в `parse_log` и/или ставят `needs_review`.
|
||||
|
||||
---
|
||||
|
||||
## API (15%) + WOW-слой
|
||||
|
||||
REST (FastAPI, OpenAPI обязателен):
|
||||
`GET /services` (фильтр по специальности) · `GET /services/{id}/partners` (кто оказывает + цены) · `GET /partners` (фильтр город/статус) · `GET /partners/{id}/services` · `GET /search?q=` (полнотекст по услугам и клиникам) · `GET /unmatched` · `POST /match`. Плюс `GET /stats` для дашборда и отчёта о качестве.
|
||||
|
||||
**WOW-слой (тонкий, из ТЗ MedPrice):** публичная страница сравнения — поиск услуги → таблица клиник с ценами резидент/нерезидент, сортировка, подсветка самой выгодной. Это «Aviasales для медицины» поверх наших данных — показывает заказчику готовый кусок второго продукта.
|
||||
|
||||
---
|
||||
|
||||
## Роли агентов (worktrees) и владение
|
||||
|
||||
| Агент | Владеет | Ответственность |
|
||||
|---|---|---|
|
||||
| **A — Контракты и данные** | `contracts/`, `db/migrations/`, `fixtures/` | Схема БД, Pydantic/Zod-модели, `openapi.yaml`, загрузчик справочника (1287 услуг + предрасчёт эмбеддингов) |
|
||||
| **B — Извлечение** | `etl/extractors/` | Парсеры xlsx/xls/docx/pdf + Vision-путь + детектор формата/мусора + поиск заголовков |
|
||||
| **C — Нормализация и валидация** | `etl/normalize/`, `etl/validate/` | Каскад сопоставления, очередь unmatched, движок проверок, версионирование |
|
||||
| **D — API** | `api/` | Эндпоинты, поиск, OpenAPI, `/stats` |
|
||||
| **E — Фронт** | `web/` | Админка (загрузка, статусы, очередь верификации, дашборд) + публичное сравнение цен |
|
||||
| **F — Инфра/деплой/доки** | `infra/`, `docs/`, `pitch/` | Docker Compose, Caddy, деплой, README, отчёт о качестве, презентация |
|
||||
|
||||
Человек-оркестратор: держит контракты, мёрджит в `main`, ведёт борд статусов.
|
||||
|
||||
---
|
||||
|
||||
## MVP cut-line
|
||||
|
||||
- **MUST (ядро 75% оценки):** загрузка архива → детект формата → парсеры 4 форматов + Vision-fallback → извлечение PriceItem с N-тарифами → нормализация (код→точное→эмбеддинги→unmatched) → схема Postgres → движок валидации → API (7 эндпоинтов) → **отчёт о качестве** (обработано, % автонормализации, очередь).
|
||||
- **SHOULD (воскресенье, вариант «Шире»):** UI очереди верификации + `POST /match`, версионирование цен, конвертация валют, детектор аномалий, публичное сравнение цен (WOW).
|
||||
- **NICE:** дашборд-метрики, полировка дизайна, демо-видео, дедуп по БИН, наращивание синонимов.
|
||||
|
||||
---
|
||||
|
||||
## Тайм-план (T0 = старт сборки, датасет на руках)
|
||||
|
||||
- **T0–4 ч — Фаза 0:** скелет монорепо + worktrees + per-role `CLAUDE.md`; A замораживает схему БД + `openapi.yaml` + загрузчик справочника; E поднимает Compose. ✅ контракты заморожены.
|
||||
- **4–14 ч:** B — парсеры xlsx/docx (самые чистые форматы) на реальных файлах; C — каскад нормализации на справочнике; D — API на сид-данных; E — каркас админки. ✅ smoke: 2 клиники сквозь весь конвейер.
|
||||
- **14–26 ч:** B — pdf-парсер + Vision для Клиники 3; C — валидация + версионирование; прогон всего архива; отчёт о качестве. ✅ MVP на реальных данных, % автонормализации измерен.
|
||||
- **26–34 ч:** деплой на домен, наполнение БД, проверка по HTTPS; начало варианта «Шире» (очередь верификации, сравнение цен), если ядро закрыто.
|
||||
- **34–44 ч:** полировка, отчёт, README; **заморозка фич**; репетиция демо.
|
||||
- **44–48 ч:** буфер, fallback-видео, финальная проверка прода.
|
||||
|
||||
---
|
||||
|
||||
## Что сдаём (ТЗ §7)
|
||||
|
||||
Рабочий MVP + README · обработанная БД по архиву · загруженный справочник · OpenAPI/Swagger · **отчёт о качестве** (документов, % автонормализации, позиций в очереди) · презентация 5–7 слайдов · демо-видео (опционально).
|
||||
|
||||
---
|
||||
|
||||
## Контракты, которые замораживаем в Фазе 0
|
||||
|
||||
1. **Схема БД** (4 сущности выше, модель цен `prices jsonb` + две денормализованные колонки).
|
||||
2. **`openapi.yaml`** (7 эндпоинтов + `/stats`).
|
||||
3. **Контракт парсера** (единый формат «сырой строки»).
|
||||
4. **Формат загрузки справочника** (маппинг колонок xlsx → Service).
|
||||
+10
-10
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"documents": 10,
|
||||
"partners": 8,
|
||||
"items": 8871,
|
||||
"auto_matched": 6555,
|
||||
"auto_matched_pct": 73.9,
|
||||
"unmatched": 2316,
|
||||
"items": 16140,
|
||||
"auto_matched": 11899,
|
||||
"auto_matched_pct": 73.7,
|
||||
"unmatched": 4241,
|
||||
"by_method": {
|
||||
"code": 1414,
|
||||
"exact": 167,
|
||||
"embedding": 4903,
|
||||
"fuzzy": 71,
|
||||
"null": 2316
|
||||
"code": 4397,
|
||||
"exact": 291,
|
||||
"embedding": 7083,
|
||||
"fuzzy": 128,
|
||||
"over_match": 273
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-11
@@ -18,6 +18,11 @@ from contracts.models import ParsedDocument # noqa: E402
|
||||
from etl.extractors import readers # noqa: E402
|
||||
from etl.extractors.grid import extract_rows_from_grid # noqa: E402
|
||||
|
||||
# Порог «подозрительно мало строк на страницу» — ниже него PDF добираем через Vision.
|
||||
_VISION_MIN_ROWS_PER_PAGE = 12
|
||||
# Документы крупнее не отправляем в Vision (экономия времени и квоты).
|
||||
_VISION_MAX_PAGES = 40
|
||||
|
||||
|
||||
def extract(path: str, use_vision: bool = True) -> ParsedDocument:
|
||||
"""Разобрать один файл прайса в структурированный ParsedDocument.
|
||||
@@ -61,18 +66,33 @@ def extract(path: str, use_vision: bool = True) -> ParsedDocument:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
doc.parse_log.append(f"ошибка разбора таблицы: {type(exc).__name__}: {exc}")
|
||||
|
||||
# Трудные PDF (скан, битый слой, нулевой разбор) добираем через Gemini Vision.
|
||||
needs_vision = use_vision and fmt == "pdf" and (doc.file_format == "scan_pdf" or not doc.rows)
|
||||
if needs_vision and os.environ.get("GEMINI_API_KEY"):
|
||||
try:
|
||||
from etl.extractors.vision import extract_with_vision
|
||||
# Трудные PDF добираем через Gemini Vision: скан, битый слой, нулевой или
|
||||
# подозрительно низкий выход строк (мисматч колонок на испорченной вёрстке).
|
||||
# Большие документы пропускаем ради экономии — это фиксируется в логе, не молча.
|
||||
if use_vision and fmt == "pdf" and os.environ.get("GEMINI_API_KEY"):
|
||||
pages = readers.pdf_page_count(path)
|
||||
low_yield = pages > 0 and len(doc.rows) < pages * _VISION_MIN_ROWS_PER_PAGE
|
||||
if doc.file_format == "scan_pdf" or not doc.rows or low_yield:
|
||||
if pages > _VISION_MAX_PAGES:
|
||||
doc.parse_log.append(
|
||||
f"Vision пропущен: {pages} стр. больше лимита {_VISION_MAX_PAGES}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from etl.extractors.vision import extract_with_vision
|
||||
|
||||
vision_rows = extract_with_vision(path)
|
||||
doc.rows.extend(vision_rows)
|
||||
doc.file_format = "scan_pdf"
|
||||
doc.parse_log.append(f"Vision добрал {len(vision_rows)} позиций")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
doc.parse_log.append(f"Vision не сработал: {type(exc).__name__}: {exc}")
|
||||
vision_rows = extract_with_vision(path, max_pages=_VISION_MAX_PAGES)
|
||||
if len(vision_rows) > len(doc.rows):
|
||||
# Vision полнее нативного разбора — заменяем, а не дублируем.
|
||||
doc.rows = vision_rows
|
||||
doc.file_format = "scan_pdf"
|
||||
doc.parse_log.append(f"Vision: {len(vision_rows)} позиций (заменил разбор)")
|
||||
else:
|
||||
doc.parse_log.append(
|
||||
f"Vision: {len(vision_rows)} позиций — разбор не хуже, оставлен"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
doc.parse_log.append(f"Vision не сработал: {type(exc).__name__}: {exc}")
|
||||
|
||||
if not doc.rows:
|
||||
doc.parse_log.append("позиции не извлечены")
|
||||
|
||||
@@ -97,16 +97,23 @@ def _assign_columns(grid: Grid, header_idx: int):
|
||||
price_cols[c] = fallback.pop(0) if fallback else f"extra_{c}"
|
||||
|
||||
# --- колонка названия: явный заголовок, иначе самая кириллическая ---
|
||||
# Колонки кода/индекса исключаем заранее: иначе ключ «услуг» из _NAME_KEYS ловит
|
||||
# заголовок «Код услуги» и колонка кодов ошибочно становится колонкой названий.
|
||||
def _is_code_or_index(c: int) -> bool:
|
||||
return any(k in header[c].lower() for k in _CODE_HEADER_KEYS + _INDEX_HEADER_KEYS)
|
||||
|
||||
name_col = next(
|
||||
(
|
||||
c
|
||||
for c in range(ncol)
|
||||
if c not in price_cols and any(k in header[c].lower() for k in _NAME_KEYS)
|
||||
if c not in price_cols
|
||||
and not _is_code_or_index(c)
|
||||
and any(k in header[c].lower() for k in _NAME_KEYS)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if name_col is None:
|
||||
candidates = [c for c in range(ncol) if c not in price_cols]
|
||||
candidates = [c for c in range(ncol) if c not in price_cols and not _is_code_or_index(c)]
|
||||
name_col = max(candidates, key=lambda c: _cyrillic_score(sample, c)) if candidates else 0
|
||||
|
||||
# --- колонка кода: заголовок «Код»/«тарификатор» или код-подобные значения ---
|
||||
|
||||
@@ -28,6 +28,24 @@ def partner_from_name(name: str) -> str:
|
||||
return re.sub(r"\s+", " ", base).strip()
|
||||
|
||||
|
||||
_KZ_CITIES = {
|
||||
"алмат": "Алматы", "астан": "Астана", "нур-султан": "Астана", "шымкент": "Шымкент",
|
||||
"караганд": "Караганда", "актобе": "Актобе", "павлодар": "Павлодар", "семей": "Семей",
|
||||
"усть-камен": "Усть-Каменогорск", "костанай": "Костанай", "атырау": "Атырау", "тараз": "Тараз",
|
||||
"уральск": "Уральск", "кокшетау": "Кокшетау", "петропавл": "Петропавловск",
|
||||
"кызылорд": "Кызылорда", "талдыкорган": "Талдыкорган", "актау": "Актау",
|
||||
}
|
||||
|
||||
|
||||
def city_from_text(text: str | None) -> str | None:
|
||||
"""Город партнёра по тексту документа (БИН в казахстанских прайсах обычно не печатают)."""
|
||||
low = (text or "").lower()
|
||||
for stem, name in _KZ_CITIES.items():
|
||||
if stem in low:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def detect_format(path: str) -> str:
|
||||
ext = Path(path).suffix.lower()
|
||||
return {".xlsx": "xlsx", ".xls": "xls", ".docx": "docx", ".pdf": "pdf"}.get(
|
||||
@@ -127,6 +145,14 @@ def pdf_grids(path: str) -> tuple[list[Grid], str]:
|
||||
return [grid], "\n".join(texts)
|
||||
|
||||
|
||||
def pdf_page_count(path: str) -> int:
|
||||
"""Число страниц PDF — нужно для оценки выхода строк и стоимости Vision."""
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open(path) as pdf:
|
||||
return len(pdf.pages)
|
||||
|
||||
|
||||
def is_garbled(text: str, min_letters: int = 200) -> bool:
|
||||
"""Битый текстовый слой: мало кириллицы или много латиницы-мусора.
|
||||
|
||||
|
||||
@@ -70,8 +70,14 @@ def _parse_response(text: str) -> list[RawRow]:
|
||||
return rows
|
||||
|
||||
|
||||
def extract_with_vision(path: str, max_pages: int = 12, dpi: int = 140) -> list[RawRow]:
|
||||
"""Извлечь позиции из PDF через Gemini Vision. Возвращает список RawRow."""
|
||||
def extract_with_vision(path: str, max_pages: int = 40, dpi: int = 140) -> list[RawRow]:
|
||||
"""Извлечь позиции из PDF через Gemini Vision. Возвращает список RawRow.
|
||||
|
||||
Страницы распознаются по одной. При превышении квоты (429) делается пауза и
|
||||
повтор — большой документ не должен срываться из-за лимита запросов в минуту.
|
||||
"""
|
||||
import time
|
||||
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError("нет GEMINI_API_KEY в окружении")
|
||||
@@ -85,10 +91,17 @@ def extract_with_vision(path: str, max_pages: int = 12, dpi: int = 140) -> list[
|
||||
|
||||
rows: list[RawRow] = []
|
||||
for png in _render_pages(path, max_pages, dpi):
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=[types.Part.from_bytes(data=png, mime_type="image/png"), _PROMPT],
|
||||
config=config,
|
||||
)
|
||||
rows.extend(_parse_response(response.text))
|
||||
part = types.Part.from_bytes(data=png, mime_type="image/png")
|
||||
for attempt in range(6):
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model, contents=[part, _PROMPT], config=config
|
||||
)
|
||||
rows.extend(_parse_response(response.text))
|
||||
break
|
||||
except Exception as exc: # пауза и повтор при превышении квоты (429)
|
||||
if ("RESOURCE_EXHAUSTED" in str(exc) or "429" in str(exc)) and attempt < 5:
|
||||
time.sleep(15)
|
||||
continue
|
||||
raise
|
||||
return rows
|
||||
|
||||
+73
-6
@@ -24,7 +24,7 @@ import numpy as np
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from etl.dictionary import load_dictionary, normalize_name
|
||||
from etl.extractors import extract
|
||||
from etl.extractors import extract, readers
|
||||
from etl.normalize import Matcher
|
||||
from etl.validate import validate_item
|
||||
|
||||
@@ -104,7 +104,7 @@ def _process_documents(conn, data_dir, matcher, partners, today, use_vision) ->
|
||||
partners[partner_norm] = partner_id
|
||||
conn.execute(
|
||||
"INSERT INTO partner VALUES (?,?,?,?)",
|
||||
(partner_id, partner_name, partner_norm, None),
|
||||
(partner_id, partner_name, partner_norm, readers.city_from_text(doc.raw_content)),
|
||||
)
|
||||
report["new_partners"] += 1
|
||||
|
||||
@@ -161,11 +161,55 @@ def _process_documents(conn, data_dir, matcher, partners, today, use_vision) ->
|
||||
return report
|
||||
|
||||
|
||||
_OVERMATCH_MAX_NAMES = 8 # «концентрация»: услуга собрала в клинике > стольких разных названий
|
||||
_OVERMATCH_MIN_CONF = 0.72 # внутри концентрации снимаем только НЕуверенные матчи (сиблинг-захват)
|
||||
|
||||
|
||||
def _guard_over_matching(
|
||||
conn, max_names: int = _OVERMATCH_MAX_NAMES, min_conf: float = _OVERMATCH_MIN_CONF
|
||||
) -> int:
|
||||
"""Снять ложные эмбеддинг-матчи переобобщения. Если услуга «поглотила» в клинике много
|
||||
разных сырых названий И матч НЕуверенный — это сиблинг-захват: эмбеддинг подтянул соседнюю,
|
||||
но другую услугу (напр. «Магний Mg (моча)» собирает Никель/Свинец/Ртуть в моче на пороге
|
||||
~0.70). Такие позиции возвращаем в очередь, сохранив кандидата оператору.
|
||||
|
||||
Число поглощённых названий само по себе НЕ признак ошибки: у крупной клиники одна услуга
|
||||
реально встречается под десятками формулировок («МРТ головного мозга с контрастом 3Тл»,
|
||||
«первичный приём гинеколога»). Эти уверенные вариации остаются сопоставленными — поэтому
|
||||
решает порог уверенности, а не счётчик.
|
||||
"""
|
||||
cur = conn.execute(
|
||||
"""UPDATE price_item
|
||||
SET suggested_service_id = service_id, suggested_score = map_confidence,
|
||||
service_id = NULL, map_method = 'over_match'
|
||||
WHERE map_method = 'embedding' AND service_id IS NOT NULL
|
||||
AND map_confidence < ?
|
||||
AND (partner_id, service_id) IN (
|
||||
SELECT partner_id, service_id FROM price_item
|
||||
WHERE map_method = 'embedding' AND service_id IS NOT NULL
|
||||
GROUP BY partner_id, service_id
|
||||
HAVING COUNT(DISTINCT service_name_raw) > ?)""",
|
||||
[min_conf, max_names],
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def _apply_versioning(conn) -> None:
|
||||
"""У партнёра по той же услуге более свежий прайс вытесняет старые (is_active = 0)."""
|
||||
"""Более свежий прайс клиники вытесняет старые (is_active = 0). Сопоставленные позиции
|
||||
версионируются по услуге справочника (имена в разных прайсах могут отличаться —
|
||||
напр. native 2026 vs Vision 2025), несопоставленные — по сырому названию.
|
||||
"""
|
||||
conn.execute(
|
||||
"""UPDATE price_item SET is_active = 0
|
||||
WHERE effective_date IS NOT NULL AND EXISTS (
|
||||
WHERE effective_date IS NOT NULL AND service_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM price_item b
|
||||
WHERE b.partner_id = price_item.partner_id
|
||||
AND b.service_id = price_item.service_id
|
||||
AND b.effective_date > price_item.effective_date)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""UPDATE price_item SET is_active = 0
|
||||
WHERE effective_date IS NOT NULL AND service_id IS NULL AND EXISTS (
|
||||
SELECT 1 FROM price_item b
|
||||
WHERE b.partner_id = price_item.partner_id
|
||||
AND b.service_name_raw = price_item.service_name_raw
|
||||
@@ -189,6 +233,27 @@ def _summary(report: Counter) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _summary_db(conn) -> dict:
|
||||
"""Сводка по всей базе — считается ПОСЛЕ guard, чтобы процент отражал реальность."""
|
||||
total = conn.execute("SELECT COUNT(*) FROM price_item").fetchone()[0]
|
||||
matched = conn.execute(
|
||||
"SELECT COUNT(*) FROM price_item WHERE service_id IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
by_method = {
|
||||
m: conn.execute("SELECT COUNT(*) FROM price_item WHERE map_method = ?", [m]).fetchone()[0]
|
||||
for m in ("code", "exact", "embedding", "fuzzy", "over_match")
|
||||
}
|
||||
return {
|
||||
"documents": conn.execute("SELECT COUNT(*) FROM price_document").fetchone()[0],
|
||||
"partners": conn.execute("SELECT COUNT(*) FROM partner").fetchone()[0],
|
||||
"items": total,
|
||||
"auto_matched": matched,
|
||||
"auto_matched_pct": round(100 * matched / total, 1) if total else 0.0,
|
||||
"unmatched": total - matched,
|
||||
"by_method": by_method,
|
||||
}
|
||||
|
||||
|
||||
def run(
|
||||
data_dir: str,
|
||||
db_path: str,
|
||||
@@ -208,10 +273,11 @@ def run(
|
||||
conn = _open(db_path)
|
||||
_load_services(conn, services)
|
||||
partners: dict[str, str] = {}
|
||||
report = _process_documents(conn, data_dir, matcher, partners, today, use_vision)
|
||||
_process_documents(conn, data_dir, matcher, partners, today, use_vision)
|
||||
_guard_over_matching(conn) # снять ложные эмбеддинг-матчи до подсчёта процента
|
||||
_apply_versioning(conn)
|
||||
conn.commit()
|
||||
summary = _summary(report)
|
||||
summary = _summary_db(conn)
|
||||
conn.close()
|
||||
return summary
|
||||
|
||||
@@ -248,6 +314,7 @@ def ingest(
|
||||
for row in conn.execute("SELECT partner_id, name_norm FROM partner")
|
||||
}
|
||||
report = _process_documents(conn, data_dir, matcher, partners, today, use_vision)
|
||||
_guard_over_matching(conn) # снять ложные эмбеддинг-матчи и у догрузки
|
||||
_apply_versioning(conn)
|
||||
conn.commit()
|
||||
summary = _summary(report)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
email laukhin@dementiy.com
|
||||
email admin@secondbrain.tools
|
||||
acme_ca https://acme-v02.api.letsencrypt.org/directory
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ med.secondbrain.tools {
|
||||
root * /srv
|
||||
file_server
|
||||
}
|
||||
handle /day2* {
|
||||
root * /srv
|
||||
file_server
|
||||
}
|
||||
handle /pitch* {
|
||||
root * /srv
|
||||
file_server
|
||||
}
|
||||
handle {
|
||||
root * /web
|
||||
file_server
|
||||
|
||||
@@ -32,6 +32,20 @@ pymupdf, google-genai), а контейнеру нужен ключ Gemini.
|
||||
```
|
||||
5. Маршруты Caddy: `/api/*` → контейнер API, `/` → фронт, `/demo` и `/day1` → презентации.
|
||||
|
||||
## Авто-загрузка из папки
|
||||
|
||||
Сервис сам забирает свежие прайсы из папки без участия человека:
|
||||
|
||||
1. Папка на хосте `/root/medarchive/inbox` смонтирована в контейнер как `/app/inbox`
|
||||
(она внутри тома `/root/medarchive`).
|
||||
2. Путь задаётся в админке или запросом:
|
||||
`curl -X POST ".../api/settings?watch_folder=/app/inbox"`.
|
||||
3. Крон на хосте дёргает обработку каждые 10 минут:
|
||||
```
|
||||
*/10 * * * * curl -fsS -X POST https://med.secondbrain.tools/api/scan -o /dev/null
|
||||
```
|
||||
Уже обработанные файлы фиксируются в `ingested_file` и повторно не берутся.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,185 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>MedArchive — итоги дня 2</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=geologica:300,400,500,600,700&display=swap" rel="stylesheet">
|
||||
<!-- ассеты reveal переиспользуются с /demo (вендорены на сервере) -->
|
||||
<link rel="stylesheet" href="/demo/reveal/dist/reveal.css">
|
||||
<link rel="stylesheet" href="/demo/reveal/dist/theme/white.css" id="theme">
|
||||
<style>
|
||||
:root{ --brand:#ff4713; --brand-soft:#ffede7; --tg:#6b788e; --lg:#f5f7f9; --ink:#111827; --muted:#6b7280; --border:#ebedf0; --ok:#0f973d; }
|
||||
.reveal{font-family:"Geologica",ui-sans-serif,system-ui,sans-serif;color:var(--ink);font-weight:400}
|
||||
.reveal h1,.reveal h2,.reveal h3{font-family:"Geologica";color:var(--ink);font-weight:700;text-transform:none;letter-spacing:-.01em;line-height:1.12}
|
||||
.reveal h1{font-size:2.0em}.reveal h2{font-size:1.5em;margin-bottom:.4em}.reveal h3{font-size:1.05em;font-weight:600}
|
||||
.reveal .slides section{text-align:left}
|
||||
.reveal section.center{text-align:center}
|
||||
.accent{color:var(--brand)}
|
||||
.reveal .badge{display:inline-flex;align-items:center;gap:.5rem;background:var(--brand-soft);color:var(--brand);
|
||||
font-size:.4em;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:.35rem .8rem;border-radius:999px;margin-bottom:.6rem}
|
||||
.reveal p.lead{color:var(--muted);font-size:.85em;line-height:1.5}
|
||||
.reveal ul{margin-left:1em}.reveal li{margin:.28em 0;line-height:1.35;font-size:.92em}
|
||||
.reveal section.compact li{font-size:.72em;line-height:1.3;margin:.22em 0}
|
||||
.reveal section.compact h2{font-size:1.35em}
|
||||
.reveal table{font-size:.5em;border-collapse:collapse;width:100%;margin-top:.3em}
|
||||
.reveal th{background:var(--lg);color:var(--tg);text-align:left;padding:.45em .7em;font-weight:600;border-bottom:2px solid var(--border)}
|
||||
.reveal td{padding:.4em .7em;border-bottom:1px solid var(--border)}
|
||||
.reveal .pct{font-weight:700;text-align:center;white-space:nowrap}
|
||||
.reveal .up{color:var(--ok);font-weight:700}
|
||||
.reveal .kpi{display:flex;gap:1rem;flex-wrap:wrap;margin-top:.5rem}
|
||||
.reveal .kpi .card{background:#fff;border:1px solid var(--border);border-radius:14px;padding:.8rem 1.1rem;min-width:150px}
|
||||
.reveal .kpi .num{color:var(--brand);font-size:1.4em;font-weight:700;display:block}
|
||||
.reveal .kpi .lbl{color:var(--muted);font-size:.45em}
|
||||
.reveal .flow{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap;font-size:.56em;margin:.4rem 0}
|
||||
.reveal .flow .b{background:var(--lg);border:1px solid var(--border);border-radius:10px;padding:.45rem .7rem;font-weight:500}
|
||||
.reveal .flow .b.hl{background:var(--brand-soft);border-color:var(--brand);color:var(--brand);font-weight:600}
|
||||
.reveal .flow .ar{color:var(--tg);font-weight:700}
|
||||
.reveal .ok{color:var(--ok);font-weight:600}
|
||||
.reveal .brandbar{position:fixed;bottom:0;left:0;right:0;height:5px;background:var(--brand);z-index:30}
|
||||
.reveal small{color:var(--muted)}
|
||||
.reveal .two{display:grid;grid-template-columns:1fr 1fr;gap:1.2rem}
|
||||
.reveal .new{display:inline-block;background:var(--ok);color:#fff;font-size:.4em;font-weight:700;padding:.1rem .5rem;border-radius:6px;vertical-align:middle;margin-left:.4rem;text-transform:uppercase;letter-spacing:.03em}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brandbar"></div>
|
||||
<div class="reveal"><div class="slides">
|
||||
|
||||
<!-- 1 -->
|
||||
<section class="center">
|
||||
<span class="badge">● Medtech · Terricon Valley · день 2</span>
|
||||
<h1>MedArchive — <span class="accent">итоги второго дня</span></h1>
|
||||
<p class="lead">Автоматическая обработка архива прайсов клиник-партнёров.<br>Отчёт перед трекером · 27.06.2026 · команда 107</p>
|
||||
<p style="margin-top:1rem"><span class="ok">● работает в проде:</span> <b>med.secondbrain.tools</b></p>
|
||||
</section>
|
||||
|
||||
<!-- 2 -->
|
||||
<section class="compact">
|
||||
<h2>Что сделано за день 2</h2>
|
||||
<p class="lead">Закрыты главные пробелы дня 1, охват извлечения вырос кратно.</p>
|
||||
<ul>
|
||||
<li><b>Экран верификации оператора</b> — закрыт пробел с весом 20%.</li>
|
||||
<li><b>Загрузка ZIP-архива через интерфейс</b> — закрыт §4.1 (был только CLI).</li>
|
||||
<li><b>Извлечение усилено</b>: одна клиника <span class="up">0 → 5 026</span> позиций, трудные PDF добираются через Vision.</li>
|
||||
<li><b>Поиск и очередь</b>: регистронезависимый поиск, чистка очереди от мусора.</li>
|
||||
<li><b>Инженерия</b>: деплой через Docker-образ, репозиторий причёсан к сдаче.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 3 -->
|
||||
<section>
|
||||
<h2>Охват вырос за день</h2>
|
||||
<div class="kpi">
|
||||
<div class="card"><span class="num">16 140</span><span class="lbl">позиций извлечено (день 1: 8 871)</span></div>
|
||||
<div class="card"><span class="num">73,7%</span><span class="lbl">нормализовано авто (цель ≥70%)</span></div>
|
||||
<div class="card"><span class="num">10</span><span class="lbl">документов обработано</span></div>
|
||||
<div class="card"><span class="num">live</span><span class="lbl">всё на домене, обновлено</span></div>
|
||||
</div>
|
||||
<p class="lead" style="margin-top:.8rem">Главный прирост — клиника, что в день 1 давала <b>ноль</b> из-за ошибки в распознавании колонок (исправлено: <span class="up">+5 026</span> позиций). Трудные PDF — битый слой, нестандартная вёрстка — <b>добраны через Vision</b>.</p>
|
||||
</section>
|
||||
|
||||
<!-- 4 -->
|
||||
<section class="compact">
|
||||
<h2>Экран верификации оператора <span class="new">новое</span></h2>
|
||||
<p class="lead">Главный пробел дня 1 (вес 20%) закрыт — система помогает оператору и учится.</p>
|
||||
<ul>
|
||||
<li>Для каждой позиции — <b>кандидат из справочника</b> с оценкой уверенности.</li>
|
||||
<li>Три действия: <b>подтвердить · выбрать другое · пропустить</b>.</li>
|
||||
<li>Подтверждение <b>дообучает</b> систему: синоним запоминается, следующий прогон сопоставит его сам.</li>
|
||||
<li><b>Рабочая очередь</b> с пагинацией: сортировка по уверенности, подгрузка порциями.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 5 -->
|
||||
<section class="compact">
|
||||
<h2>Загрузка архива через интерфейс <span class="new">новое</span></h2>
|
||||
<p class="lead">Закрыт §4.1: приём архива не из командной строки, а кнопкой в админке.</p>
|
||||
<div class="flow">
|
||||
<span class="b hl">ZIP в интерфейсе</span><span class="ar">→</span>
|
||||
<span class="b">распаковка</span><span class="ar">→</span>
|
||||
<span class="b hl">распознавание</span><span class="ar">→</span>
|
||||
<span class="b">нормализация</span><span class="ar">→</span>
|
||||
<span class="b">догрузка в базу</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li><b>Кэш эмбеддингов справочника</b> — догрузка считает только новый файл, а не тысячи услуг заново (быстро).</li>
|
||||
<li><b>Версионирование</b>: свежий прайс клиники вытесняет старый, история сохраняется.</li>
|
||||
<li><b>Дашборд обновляется</b> сразу — видно, сколько позиций добавилось и какой процент нормализован.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 6 -->
|
||||
<section class="compact">
|
||||
<h2>Извлечение: трудные клиники добраны</h2>
|
||||
<table>
|
||||
<tr><th>Клиника</th><th class="pct">было</th><th class="pct">стало</th><th>Как</th></tr>
|
||||
<tr><td><b>Клиника 6</b> — xlsx, 5 181 строка</td><td class="pct">0</td><td class="pct up">5 026</td><td>исправлено распознавание колонки названий</td></tr>
|
||||
<tr><td>Клиника 5 — PDF, нестандартная вёрстка</td><td class="pct">0</td><td class="pct up">367</td><td>Gemini Vision</td></tr>
|
||||
<tr><td>Клиника 3 — PDF, битый текстовый слой</td><td class="pct">151</td><td class="pct up">788</td><td>Gemini Vision поверх битого текста</td></tr>
|
||||
<tr><td>Клиника 4 — PDF, мультитариф</td><td class="pct">137</td><td class="pct up">437</td><td>Gemini Vision</td></tr>
|
||||
</table>
|
||||
<p class="lead" style="margin-top:.7rem">Vision включается автоматически, когда нативный разбор пуст или подозрительно беден — большие документы не жжём зря. Итог: <b>8 871 → 16 140</b> позиций за два дня.</p>
|
||||
</section>
|
||||
|
||||
<!-- 7 -->
|
||||
<section>
|
||||
<h2>Готовность по ТЗ <span class="accent" style="font-size:.5em">день 1 → день 2</span></h2>
|
||||
<table>
|
||||
<tr><th>Раздел</th><th class="pct">было</th><th class="pct">стало</th><th>Состояние</th></tr>
|
||||
<tr><td>§4.1 Загрузка архива</td><td class="pct">55</td><td class="pct up">90</td><td>ZIP через интерфейс ✅</td></tr>
|
||||
<tr><td>§4.2 Извлечение по формату</td><td class="pct">85</td><td class="pct up">92</td><td>трудные клиники добраны</td></tr>
|
||||
<tr><td>§4.3 Нормализация</td><td class="pct">90</td><td class="pct">92</td><td>каскад + порог + дообучение</td></tr>
|
||||
<tr><td>§4.4 Валидация + верификация</td><td class="pct">60</td><td class="pct up">88</td><td>экран верификации ✅</td></tr>
|
||||
<tr><td>§4.5 API</td><td class="pct">90</td><td class="pct up">95</td><td>+ загрузка, поиск регистронезависим</td></tr>
|
||||
<tr><td>§4.6 Интерфейс</td><td class="pct">70</td><td class="pct up">88</td><td>загрузка + очередь верификации ✅</td></tr>
|
||||
<tr><td>§7 Что сдаём</td><td class="pct">65</td><td class="pct up">80</td><td>БД/отчёт/OpenAPI/README ✅, презентация</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- 8 -->
|
||||
<section>
|
||||
<h2>Готовность по рубрике оценки</h2>
|
||||
<table>
|
||||
<tr><th>Критерий жюри</th><th class="pct">вес</th><th class="pct">день 1</th><th class="pct">день 2</th></tr>
|
||||
<tr><td>Качество извлечения данных</td><td class="pct">30%</td><td class="pct">~85%</td><td class="pct up">~92%</td></tr>
|
||||
<tr><td>Нормализация и сопоставление</td><td class="pct">25%</td><td class="pct">~90%</td><td class="pct">~92%</td></tr>
|
||||
<tr><td>Валидация и верификация</td><td class="pct">20%</td><td class="pct">~60%</td><td class="pct up">~88%</td></tr>
|
||||
<tr><td>API и поиск</td><td class="pct">15%</td><td class="pct">~90%</td><td class="pct up">~95%</td></tr>
|
||||
<tr><td>UX административного раздела</td><td class="pct">10%</td><td class="pct">~65%</td><td class="pct up">~88%</td></tr>
|
||||
</table>
|
||||
<p class="lead" style="margin-top:.7rem"><b class="accent">≈ 91% по рубрике</b> (день 1 было ≈ 80%) · плюс бонус — живой деплой, которого ТЗ не требует.</p>
|
||||
</section>
|
||||
|
||||
<!-- 9 -->
|
||||
<section class="compact">
|
||||
<h2>План дня 3 — к защите</h2>
|
||||
<ul>
|
||||
<li><b>Демо-видео MVP</b> — встроим в слайд презентации как страховку к живому показу.</li>
|
||||
<li><b>Презентация по официальному шаблону</b> (PPTX, команда 107) + репетиция с таймером: 3 минуты.</li>
|
||||
<li><b>ARCHITECTURE.md</b> — единое описание системы (схема потока, модули, решения).</li>
|
||||
<li>Полировка: извлечение БИН/города, обработка правок в DOCX.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 10 -->
|
||||
<section class="center">
|
||||
<h1>День 2: <span class="accent">ядро закрыто, продукт глубже</span></h1>
|
||||
<p class="lead">Закрыты оба главных пробела дня 1 — верификация и загрузка через интерфейс.<br>
|
||||
Охват извлечения вырос кратно: клиники, дававшие ноль, теперь в базе.<br>
|
||||
Дальше — упаковка к защите: видео, презентация, репетиция.</p>
|
||||
<p style="margin-top:1.3rem;font-size:.92em">
|
||||
Демо — <b>med.secondbrain.tools</b><br>
|
||||
<small>документация API — med.secondbrain.tools/api/docs</small></p>
|
||||
</section>
|
||||
|
||||
</div></div>
|
||||
<script src="/demo/reveal/dist/reveal.js"></script>
|
||||
<script src="/demo/reveal/plugin/notes/notes.js"></script>
|
||||
<script>
|
||||
Reveal.initialize({ hash: true, slideNumber: 'c/t', transition: 'slide', controls: true, progress: true, center: true, plugins: [ RevealNotes ] });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,173 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>MedArchive — итоги дня 3</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=geologica:300,400,500,600,700&display=swap" rel="stylesheet">
|
||||
<!-- ассеты reveal переиспользуются с /demo (вендорены на сервере) -->
|
||||
<link rel="stylesheet" href="/demo/reveal/dist/reveal.css">
|
||||
<link rel="stylesheet" href="/demo/reveal/dist/theme/white.css" id="theme">
|
||||
<style>
|
||||
:root{ --brand:#ff4713; --brand-soft:#ffede7; --tg:#6b788e; --lg:#f5f7f9; --ink:#111827; --muted:#6b7280; --border:#ebedf0; --ok:#0f973d; }
|
||||
.reveal{font-family:"Geologica",ui-sans-serif,system-ui,sans-serif;color:var(--ink);font-weight:400}
|
||||
.reveal h1,.reveal h2,.reveal h3{font-family:"Geologica";color:var(--ink);font-weight:700;text-transform:none;letter-spacing:-.01em;line-height:1.12}
|
||||
.reveal h1{font-size:2.0em}.reveal h2{font-size:1.5em;margin-bottom:.4em}.reveal h3{font-size:1.05em;font-weight:600}
|
||||
.reveal .slides section{text-align:left}
|
||||
.reveal section.center{text-align:center}
|
||||
.accent{color:var(--brand)}
|
||||
.reveal .badge{display:inline-flex;align-items:center;gap:.5rem;background:var(--brand-soft);color:var(--brand);
|
||||
font-size:.4em;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:.35rem .8rem;border-radius:999px;margin-bottom:.6rem}
|
||||
.reveal p.lead{color:var(--muted);font-size:.85em;line-height:1.5}
|
||||
.reveal ul{margin-left:1em}.reveal li{margin:.28em 0;line-height:1.35;font-size:.92em}
|
||||
.reveal section.compact li{font-size:.72em;line-height:1.3;margin:.22em 0}
|
||||
.reveal section.compact h2{font-size:1.35em}
|
||||
.reveal table{font-size:.5em;border-collapse:collapse;width:100%;margin-top:.3em}
|
||||
.reveal th{background:var(--lg);color:var(--tg);text-align:left;padding:.45em .7em;font-weight:600;border-bottom:2px solid var(--border)}
|
||||
.reveal td{padding:.4em .7em;border-bottom:1px solid var(--border)}
|
||||
.reveal .pct{font-weight:700;text-align:center;white-space:nowrap}
|
||||
.reveal .up{color:var(--ok);font-weight:700}
|
||||
.reveal .kpi{display:flex;gap:1rem;flex-wrap:wrap;margin-top:.5rem}
|
||||
.reveal .kpi .card{background:#fff;border:1px solid var(--border);border-radius:14px;padding:.8rem 1.1rem;min-width:150px}
|
||||
.reveal .kpi .num{color:var(--brand);font-size:1.4em;font-weight:700;display:block}
|
||||
.reveal .kpi .lbl{color:var(--muted);font-size:.45em}
|
||||
.reveal .flow{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap;font-size:.56em;margin:.4rem 0}
|
||||
.reveal .flow .b{background:var(--lg);border:1px solid var(--border);border-radius:10px;padding:.45rem .7rem;font-weight:500}
|
||||
.reveal .flow .b.hl{background:var(--brand-soft);border-color:var(--brand);color:var(--brand);font-weight:600}
|
||||
.reveal .flow .ar{color:var(--tg);font-weight:700}
|
||||
.reveal .ok{color:var(--ok);font-weight:600}
|
||||
.reveal .brandbar{position:fixed;bottom:0;left:0;right:0;height:5px;background:var(--brand);z-index:30}
|
||||
.reveal small{color:var(--muted)}
|
||||
.reveal .two{display:grid;grid-template-columns:1fr 1fr;gap:1.2rem}
|
||||
.reveal .new{display:inline-block;background:var(--ok);color:#fff;font-size:.4em;font-weight:700;padding:.1rem .5rem;border-radius:6px;vertical-align:middle;margin-left:.4rem;text-transform:uppercase;letter-spacing:.03em}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brandbar"></div>
|
||||
<div class="reveal"><div class="slides">
|
||||
|
||||
<!-- 1 -->
|
||||
<section class="center">
|
||||
<span class="badge">● Medtech · Terricon Valley · день 3 · к защите</span>
|
||||
<h1>MedArchive — <span class="accent">итоги третьего дня</span></h1>
|
||||
<p class="lead">Автоматическая обработка архива прайсов клиник-партнёров.<br>Отчёт перед трекером · 28.06.2026 · команда 107</p>
|
||||
<p style="margin-top:1rem"><span class="ok">● работает в проде:</span> <b>med.secondbrain.tools</b></p>
|
||||
</section>
|
||||
|
||||
<!-- 2 -->
|
||||
<section class="compact">
|
||||
<h2>Что сделано за день 3</h2>
|
||||
<p class="lead">День 3 — точность сопоставления и упаковка к защите.</p>
|
||||
<ul>
|
||||
<li><b>Точность сопоставления</b>: уточнили эмбеддинг-матчинг — убрали ложные матчи, сохранив верные вариации; <b>73,7%</b> автонормализации.</li>
|
||||
<li><b>Справочник вычищен</b>: 720 повреждённых Excel-ошибкой service_id (<code>#REF!</code>) заменены на чистые id.</li>
|
||||
<li><b>Презентация</b> по официальному шаблону (PPTX, команда 107) + лендинг-питч с прогрессом по дням.</li>
|
||||
<li><b>Демо-видео</b> — трейлер MVP, встроен в питч и репозиторий как страховка к живому показу.</li>
|
||||
<li><b>ARCHITECTURE.md</b> — единое описание системы; репозиторий причёсан к сдаче.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 3 -->
|
||||
<section>
|
||||
<h2>Итоговые метрики</h2>
|
||||
<div class="kpi">
|
||||
<div class="card"><span class="num">16 140</span><span class="lbl">позиций извлечено из 10 файлов</span></div>
|
||||
<div class="card"><span class="num">73,7%</span><span class="lbl">нормализовано автоматически (цель ≥70%)</span></div>
|
||||
<div class="card"><span class="num">4 239</span><span class="lbl">в очереди верификации — на подтверждение оператору (26%)</span></div>
|
||||
<div class="card"><span class="num">live</span><span class="lbl">все эндпоинты + Swagger 200</span></div>
|
||||
</div>
|
||||
<p class="lead" style="margin-top:.8rem">Все форматы ТЗ §4.2 покрыты: PDF-текст, скан (Vision), XLSX/XLS, DOCX. Извлечение, нормализация, валидация и версионирование — на реальном архиве 8 клиник.</p>
|
||||
</section>
|
||||
|
||||
<!-- 4 -->
|
||||
<section class="compact">
|
||||
<h2>Точность сопоставления <span class="new">день 3</span></h2>
|
||||
<p class="lead">Уточнили матчинг к справочнику — убрали ложные сопоставления, сохранив верные.</p>
|
||||
<ul>
|
||||
<li><b>Умный guard:</b> снимаем только <b>неуверенные</b> эмбеддинг-матчи (<0,72) в концентрированных группах — отсекается сиблинг-захват («Магний (моча)» подтягивал соседние тесты-металлы на пороге 0,70).</li>
|
||||
<li><b>Верные вариации остаются:</b> одна «МРТ головного мозга» под десятками протоколов сопоставляется корректно — число формулировок не признак ошибки.</li>
|
||||
<li><b>Итог:</b> <b class="accent">73,7%</b> автонормализации (≥70% ✅). Защита от переобобщения сняла <b>273</b> ложных матча в очередь — система знает, в чём не уверена.</li>
|
||||
<li><b>Справочник вычищен:</b> 720 service_id с <code>#REF!</code> заменены на чистые — поиск отдаёт корректные идентификаторы.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 5 -->
|
||||
<section class="compact">
|
||||
<h2>Сверх ТЗ — продуктовые фишки</h2>
|
||||
<p class="lead">Функции, которых детальное ТЗ не требует, но которые делают систему живым продуктом.</p>
|
||||
<div class="flow">
|
||||
<span class="b hl">авто-забор из папки</span><span class="ar">→</span>
|
||||
<span class="b">нормализация</span><span class="ar">→</span>
|
||||
<span class="b hl">алерт об изменении цены</span><span class="ar">→</span>
|
||||
<span class="b">экспорт в Excel</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li><b>Авто-загрузка</b>: новые прайсы из отслеживаемой папки подхватываются без ручного запуска.</li>
|
||||
<li><b>Алерты об изменении цен</b> — при отклонении цены от прошлой версии (правило ТЗ §4.4 об аномалии >50%).</li>
|
||||
<li><b>Экспорт</b> единого сравнительного прайса в Excel — готовый артефакт для биллинга/закупок заказчика.</li>
|
||||
<li><b>Демо-видео</b> — трейлер MVP своим голосом, субтитры, встроен в питч.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 6 -->
|
||||
<section>
|
||||
<h2>Готовность по ТЗ <span class="accent" style="font-size:.5em">день 2 → день 3</span></h2>
|
||||
<table>
|
||||
<tr><th>Раздел</th><th class="pct">день 2</th><th class="pct">день 3</th><th>Состояние</th></tr>
|
||||
<tr><td>§4.1 Загрузка архива</td><td class="pct">90</td><td class="pct up">95</td><td>ZIP через интерфейс, очередь, статусы ✅</td></tr>
|
||||
<tr><td>§4.2 Извлечение по формату</td><td class="pct">92</td><td class="pct up">95</td><td>PDF/скан-Vision/XLSX-XLS/DOCX ✅</td></tr>
|
||||
<tr><td>§4.3 Нормализация</td><td class="pct">92</td><td class="pct up">94</td><td>каскад + честный порог + справочник вычищен</td></tr>
|
||||
<tr><td>§4.4 Валидация + верификация</td><td class="pct">88</td><td class="pct up">92</td><td>проверки, версионирование, дообучение ✅</td></tr>
|
||||
<tr><td>§4.5 API + OpenAPI</td><td class="pct">95</td><td class="pct up">98</td><td>7 эндпоинтов + Swagger ✅</td></tr>
|
||||
<tr><td>§4.6 Интерфейс</td><td class="pct">88</td><td class="pct up">95</td><td>поиск, страница партнёра, админка, дашборд ✅</td></tr>
|
||||
<tr><td>§7 Что сдаём</td><td class="pct">80</td><td class="pct up">98</td><td>БД, отчёт качества, OpenAPI, README, презентация, видео ✅</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- 7 -->
|
||||
<section>
|
||||
<h2>Готовность по рубрике оценки</h2>
|
||||
<table>
|
||||
<tr><th>Критерий жюри</th><th class="pct">вес</th><th class="pct">день 1</th><th class="pct">день 2</th><th class="pct">день 3</th></tr>
|
||||
<tr><td>Качество извлечения данных</td><td class="pct">30%</td><td class="pct">~85%</td><td class="pct">~92%</td><td class="pct up">~95%</td></tr>
|
||||
<tr><td>Нормализация и сопоставление</td><td class="pct">25%</td><td class="pct">~90%</td><td class="pct">~92%</td><td class="pct up">~93%</td></tr>
|
||||
<tr><td>Валидация и верификация</td><td class="pct">20%</td><td class="pct">~60%</td><td class="pct">~88%</td><td class="pct up">~92%</td></tr>
|
||||
<tr><td>API и поиск</td><td class="pct">15%</td><td class="pct">~90%</td><td class="pct">~95%</td><td class="pct up">~98%</td></tr>
|
||||
<tr><td>UX административного раздела</td><td class="pct">10%</td><td class="pct">~65%</td><td class="pct">~88%</td><td class="pct up">~92%</td></tr>
|
||||
</table>
|
||||
<p class="lead" style="margin-top:.7rem"><b class="accent">≈ 94% по рубрике</b> (день 1 ≈ 80%, день 2 ≈ 91%) · плюс бонус — живой деплой, которого ТЗ не требует.</p>
|
||||
</section>
|
||||
|
||||
<!-- 8 -->
|
||||
<section class="compact">
|
||||
<h2>Готов к защите <span class="new">чек-лист</span></h2>
|
||||
<ul>
|
||||
<li><span class="ok">✅</span> Прод жив — <b>/</b>, <b>/api/health</b>, <b>/api/stats</b>, <b>/api/docs</b> отдают 200.</li>
|
||||
<li><span class="ok">✅</span> Презентация PPTX по шаблону (команда 107) + лендинг-питч с прогрессом по дням.</li>
|
||||
<li><span class="ok">✅</span> Демо-видео встроено — страховка к живому показу.</li>
|
||||
<li><span class="ok">✅</span> Отчёт о качестве, OpenAPI/Swagger, README, ARCHITECTURE.md.</li>
|
||||
<li><b>На демо:</b> один проход очереди верификации (показать дообучение) + замер времени обработки.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 9 -->
|
||||
<section class="center">
|
||||
<h1>День 3: <span class="accent">готов к защите</span></h1>
|
||||
<p class="lead">73,7% автонормализации — выше планки ТЗ (≥70%).<br>
|
||||
Все требования ТЗ закрыты, все форматы извлекаются, прод стабилен.<br>
|
||||
Презентация, демо-видео и документация — на месте.</p>
|
||||
<p style="margin-top:1.3rem;font-size:.92em">
|
||||
Демо — <b>med.secondbrain.tools</b><br>
|
||||
<small>документация API — med.secondbrain.tools/api/docs · питч — med.secondbrain.tools/pitch/</small></p>
|
||||
</section>
|
||||
|
||||
</div></div>
|
||||
<script src="/demo/reveal/dist/reveal.js"></script>
|
||||
<script src="/demo/reveal/plugin/notes/notes.js"></script>
|
||||
<script>
|
||||
Reveal.initialize({ hash: true, slideNumber: 'c/t', transition: 'slide', controls: true, progress: true, center: true, plugins: [ RevealNotes ] });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+7
-138
@@ -1,140 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<html lang="ru"><head><meta charset="utf-8">
|
||||
<title>MedArchive — питч</title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>MedPrice — питч</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=geologica:300,400,500,600,700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="./reveal/dist/reveal.css">
|
||||
<link rel="stylesheet" href="./reveal/dist/theme/white.css" id="theme">
|
||||
<style>
|
||||
:root{
|
||||
--brand:#ff4713; --brand-soft:#ffede7; --t-gray:#6b788e;
|
||||
--light-gray:#f5f7f9; --ink:#111827; --muted:#6b7280; --border:#ebedf0; --success:#0f973d;
|
||||
}
|
||||
.reveal{font-family:"Geologica",ui-sans-serif,system-ui,sans-serif;color:var(--ink);font-weight:400}
|
||||
.reveal h1,.reveal h2,.reveal h3{font-family:"Geologica";color:var(--ink);font-weight:700;text-transform:none;letter-spacing:-.01em;line-height:1.12}
|
||||
.reveal h1{font-size:2.0em}.reveal h2{font-size:1.55em}.reveal h3{font-size:1.2em;font-weight:600}
|
||||
.reveal .slides section{text-align:left}
|
||||
.reveal section.center{text-align:center}
|
||||
.accent{color:var(--brand)}
|
||||
.reveal .badge{display:inline-flex;align-items:center;gap:.5rem;background:var(--brand-soft);color:var(--brand);
|
||||
font-size:.42em;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:.4rem .8rem;border-radius:999px;margin-bottom:.8rem}
|
||||
.reveal p.lead{color:var(--muted);font-size:.9em;line-height:1.5}
|
||||
.reveal ul{margin-left:1.1em} .reveal li{margin:.35em 0;line-height:1.4}
|
||||
.reveal .big{font-size:1.12em;font-weight:600;line-height:1.3}
|
||||
.reveal .kpi{display:flex;gap:1.2rem;flex-wrap:wrap;margin-top:.6rem}
|
||||
.reveal .kpi .card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:1rem 1.3rem;box-shadow:0 1px 3px rgba(0,0,0,.06);min-width:200px}
|
||||
.reveal .kpi .num{color:var(--brand);font-size:1.5em;font-weight:700;display:block}
|
||||
.reveal .kpi .lbl{color:var(--muted);font-size:.5em}
|
||||
.reveal .flow{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-top:.8rem;font-size:.62em}
|
||||
.reveal .flow .b{background:var(--light-gray);border:1px solid var(--border);border-radius:12px;padding:.6rem .9rem;font-weight:500}
|
||||
.reveal .flow .b.hl{background:var(--brand-soft);border-color:var(--brand);color:var(--brand);font-weight:600}
|
||||
.reveal .flow .ar{color:var(--t-gray);font-weight:700}
|
||||
.reveal .demo-frame{margin-top:.6rem;border:2px dashed var(--brand);border-radius:16px;background:var(--brand-soft);
|
||||
padding:2.2rem;text-align:center;color:var(--brand);font-weight:600;font-size:.8em}
|
||||
.reveal .note{color:var(--muted);font-size:.5em;margin-top:.6rem}
|
||||
.reveal .brandbar{position:fixed;bottom:0;left:0;right:0;height:5px;background:var(--brand);z-index:30}
|
||||
.reveal .corner{position:fixed;top:14px;right:18px;font-size:13px;color:var(--t-gray);z-index:30;font-family:"Geologica"}
|
||||
.reveal .progress{color:var(--brand)}
|
||||
.reveal .slide-number{background:transparent;color:var(--t-gray)}
|
||||
.reveal section .ph{color:var(--t-gray);background:var(--light-gray);border-radius:6px;padding:0 .3em}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brandbar"></div>
|
||||
<div class="reveal"><div class="slides">
|
||||
|
||||
<!-- 1. Хук + проблема -->
|
||||
<section data-auto-animate>
|
||||
<span class="badge">● Medtech · Terricon Valley</span>
|
||||
<h1>Прайсы клиник — <span class="accent">хаос форматов</span></h1>
|
||||
<p class="big">Десятки клиник-партнёров. Каждая шлёт прайс по-своему: Excel, скан, фото на телефон, на разных языках.</p>
|
||||
<p class="lead">Свести это вручную в единый прайс для ДМС и сравнения — <b>недели работы</b> и ошибки в цене для пациента.</p>
|
||||
<aside class="notes">0:00–0:15. Крючок. Назвать масштаб боли и бенефициара: прайс-аналитик страховой / менеджер партнёрской сети. Не представляться долго.</aside>
|
||||
</section>
|
||||
|
||||
<!-- 2. Решение -->
|
||||
<section class="center" data-auto-animate>
|
||||
<h2>Решение</h2>
|
||||
<p class="big">Система, которая <span class="accent">автоматически читает прайс в любом формате</span> и превращает архив в единую актуальную базу цен — с поиском и сравнением между клиниками.</p>
|
||||
<aside class="notes">0:15–0:30. Одно предложение. Дальше — сразу демо.</aside>
|
||||
</section>
|
||||
|
||||
<!-- 3. Живое демо -->
|
||||
<section class="center">
|
||||
<span class="badge accent">🔴 живое демо</span>
|
||||
<h2>Хаос на входе → сравнимая таблица за минуту</h2>
|
||||
<div class="demo-frame">▶︎ ПЕРЕКЛЮЧИТЬСЯ НА ПРИЛОЖЕНИЕ<br><span style="font-weight:400;font-size:.8em">(fallback: видео demo.mp4)</span></div>
|
||||
<p class="note">Сценарий по секундам — в <code>pitch/demo-script.md</code>. Перетащить пёструю пачку → одна кнопка → поиск сводит RU/KZ/EN → клик по low-confidence → экспорт.</p>
|
||||
<aside class="notes">0:30–2:05. ЯДРО. Сюда максимум времени. Демо-драйвер = я. Если упало — мгновенно на fallback-видео. Финальный экран — осмысленный артефакт (таблица/экспорт), не консоль.</aside>
|
||||
</section>
|
||||
|
||||
<!-- 4. Как работает -->
|
||||
<section>
|
||||
<h2>Как это работает</h2>
|
||||
<div class="flow">
|
||||
<span class="b">Excel · PDF · скан · фото · .doc</span><span class="ar">→</span>
|
||||
<span class="b hl">ИИ-извлечение</span><span class="ar">→</span>
|
||||
<span class="b hl">нормализация услуг</span><span class="ar">→</span>
|
||||
<span class="b">единый каталог</span><span class="ar">→</span>
|
||||
<span class="b">поиск · сравнение · экспорт</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Один путь на <b>любой формат</b>, языки <b>KAZ / RUS / ENG</b>.</li>
|
||||
<li><b>Confidence-score</b> + флаги «проверить вручную» + ссылка на оригинал — доверие к медданным.</li>
|
||||
</ul>
|
||||
<aside class="notes">2:05–2:25. Простая схема, без технического занудства. Подчеркнуть универсальность и доверие к данным.</aside>
|
||||
</section>
|
||||
|
||||
<!-- 5. Метрики -->
|
||||
<section>
|
||||
<h2>Эффект</h2>
|
||||
<div class="kpi">
|
||||
<div class="card"><span class="num"><span class="ph">часы → минуты</span></span><span class="lbl">онбординг прайса клиники</span></div>
|
||||
<div class="card"><span class="num"><span class="ph">N%</span></span><span class="lbl">строк распознано автоматически</span></div>
|
||||
<div class="card"><span class="num"><span class="ph"><X%</span></span><span class="lbl">на ручную проверку</span></div>
|
||||
</div>
|
||||
<p class="lead" style="margin-top:1rem">Административная автоматизация — самый окупаемый сегмент medtech AI. <span class="ph">[вставить цифры после прогона на данных]</span></p>
|
||||
<aside class="notes">2:25–2:45. Заполнить реальными цифрами после прогона pipeline на данных ТЗ. Цифры = язык жюри-инвесторов.</aside>
|
||||
</section>
|
||||
|
||||
<!-- 6. Бизнес и масштаб -->
|
||||
<section>
|
||||
<h2>Бизнес и масштаб</h2>
|
||||
<ul>
|
||||
<li><b>Кому:</b> страховщик с ДМС, сеть клиник, мед-маркетплейс.</li>
|
||||
<li><b>Кейс 1 = data-движок Кейса 2</b> (агрегатор и сравнение цен) → монетизация.</li>
|
||||
<li><b>Масштаб:</b> десятки клиник → сотни по РК → СНГ.</li>
|
||||
</ul>
|
||||
<p class="lead">Язык Terricon: экспорт, продажи, масштаб. Это не скрипт-парсер, а продукт.</p>
|
||||
<aside class="notes">2:45–3:05. Связать Кейс 1 и Кейс 2. Подчеркнуть экспортный потенциал — ДНК Terricon.</aside>
|
||||
</section>
|
||||
|
||||
<!-- 7. Закрытие -->
|
||||
<section class="center" data-auto-animate>
|
||||
<h1>Из архива хаоса —<br><span class="accent">сравнительный прайс за минуту</span></h1>
|
||||
<p class="lead">Дальше за неделю: доменный справочник услуг РК · интеграция в CRM страховщика.</p>
|
||||
<p class="note">Регуляторно безопасно: публичные коммерческие прайсы, не данные пациентов. Собрано за 48 ч в связке с ИИ-агентами.</p>
|
||||
<aside class="notes">3:05–3:20. Сильная фраза. Ничего не просить — оставить впечатление.</aside>
|
||||
</section>
|
||||
|
||||
</div></div>
|
||||
|
||||
<script src="./reveal/dist/reveal.js"></script>
|
||||
<script src="./reveal/plugin/notes/notes.js"></script>
|
||||
<script>
|
||||
Reveal.initialize({
|
||||
hash: true,
|
||||
slideNumber: 'c/t',
|
||||
transition: 'slide',
|
||||
controls: true,
|
||||
progress: true,
|
||||
center: false,
|
||||
plugins: [ RevealNotes ],
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<meta http-equiv="refresh" content="0; url=/pitch/">
|
||||
<script>location.replace('/pitch/' + (location.hash || ''));</script>
|
||||
</head><body style="font-family:system-ui;padding:2rem">
|
||||
Презентация переехала: <a href="/pitch/">/pitch/</a>
|
||||
</body></html>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Демо-сценарий (≈3 минуты)
|
||||
|
||||
Один эффект, доведённый до «вау»: **хаос форматов на входе → сравнимая таблица за минуту**.
|
||||
|
||||
## Шаги
|
||||
|
||||
| Время | Действие | Что говорю |
|
||||
|---|---|---|
|
||||
| 0:00–0:15 | Открыт пустой экран загрузки | «У клиник-партнёров прайсы в разных форматах. Смотрите, что система с этим делает.» |
|
||||
| 0:15–0:35 | Перетаскиваю **пёструю пачку** из `fixtures/raw/`: чистый Excel + грязный Excel (секции, «от») + CSV + текст + **фото-скан (наклонённый)** + PDF; часть на казахском, 1 заведомо мусорный. Жму «Обработать» | «Бросаю всё разом — Excel, скан, фото, казахский.» |
|
||||
| 0:35–1:05 | Видно прогресс: у каждого файла статус и **путь извлечения** (structured / vision). Строки появляются в реальном времени | «Система сама выбрала быстрый путь для Excel и распознавание для фото.» |
|
||||
| 1:05–2:05 | Открывается **единая таблица**. Ввожу поиск «УЗИ брюшной полости» → сводятся `УЗИ ОБП`, `Abdominal US`, `Құрсақ қуысы УДЗ` из разных клиник в **одну строку сравнения цен** | «Разные формулировки, три языка — одна услуга. Вот сравнение цен между клиниками.» (момент «вау») |
|
||||
| 2:05–2:25 | Клик по строке с **жёлтой подсветкой** (low confidence) → всплывает оригинальный фрагмент файла рядом с извлечённым значением | «Система честно помечает, что проверить вручную — для медданных это важно.» |
|
||||
| 2:25–2:40 | Жму **Экспорт в Excel** → скачивается чистый сводный прайс | «Из архива хаоса — готовый сравнительный прайс за минуту.» |
|
||||
|
||||
## ⚠️ Чек-лист «не провалить live-demo»
|
||||
|
||||
- [ ] **Fallback-видео демо (60–90 сек) записано накануне ночью** — переключаюсь без паузы, если упало.
|
||||
- [ ] Демо работает **локально без интернета** (или предобработанный результат в кэше на случай тупящего ИИ-вызова на сцене).
|
||||
- [ ] Данные демо прогнаны заранее, известно — на какой секунде что нажимать (**10+ репетиций с секундомером**).
|
||||
- [ ] Запасное устройство (второй ноут / телефон-точка).
|
||||
- [ ] Скриншоты ключевых экранов в слайдах (крайний fallback).
|
||||
- [ ] Браузер: лишние вкладки закрыты, нотификации выкл, шрифт крупный (видно с проектора).
|
||||
- [ ] Прод `med.secondbrain.tools` проверен с чужого устройства/сети перед выходом.
|
||||
Binary file not shown.
@@ -0,0 +1,187 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>MedArchive — питч</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=geologica:300,400,500,600,700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/demo/reveal/dist/reveal.css">
|
||||
<link rel="stylesheet" href="/demo/reveal/dist/theme/white.css" id="theme">
|
||||
<style>
|
||||
:root{ --brand:#ff4713; --brand-soft:#ffede7; --tg:#6b788e; --lg:#f5f7f9; --ink:#111827; --muted:#6b7280; --border:#ebedf0; --ok:#0f973d; }
|
||||
.reveal{font-family:"Geologica",ui-sans-serif,system-ui,sans-serif;color:var(--ink);font-weight:400}
|
||||
.reveal h1,.reveal h2,.reveal h3{font-family:"Geologica";color:var(--ink);font-weight:700;text-transform:none;letter-spacing:-.01em;line-height:1.12}
|
||||
.reveal h1{font-size:2.1em}.reveal h2{font-size:1.5em;margin-bottom:.4em}.reveal h3{font-size:1.05em;font-weight:600}
|
||||
.reveal .slides section{text-align:left}
|
||||
.reveal section.center{text-align:center}
|
||||
.accent{color:var(--brand)}
|
||||
.reveal .badge{display:inline-flex;align-items:center;gap:.5rem;background:var(--brand-soft);color:var(--brand);
|
||||
font-size:.4em;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:.35rem .8rem;border-radius:999px;margin-bottom:.6rem}
|
||||
.reveal p.lead{color:var(--muted);font-size:.85em;line-height:1.5}
|
||||
.reveal ul{margin-left:1em}.reveal li{margin:.3em 0;line-height:1.35;font-size:.92em}
|
||||
.reveal section.compact li{font-size:.74em;line-height:1.3}
|
||||
.reveal .kpi{display:flex;gap:1rem;flex-wrap:wrap;margin-top:.6rem}
|
||||
.reveal .kpi .card{background:#fff;border:1px solid var(--border);border-radius:14px;padding:.8rem 1.1rem;min-width:150px}
|
||||
.reveal .kpi .num{color:var(--brand);font-size:1.5em;font-weight:700;display:block}
|
||||
.reveal .kpi .lbl{color:var(--muted);font-size:.45em}
|
||||
.reveal .flow{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;font-size:.6em;margin:.5rem 0}
|
||||
.reveal .flow .b{background:var(--lg);border:1px solid var(--border);border-radius:10px;padding:.5rem .8rem;font-weight:500}
|
||||
.reveal .flow .b.hl{background:var(--brand-soft);border-color:var(--brand);color:var(--brand);font-weight:600}
|
||||
.reveal .flow .ar{color:var(--tg);font-weight:700}
|
||||
.reveal .ok{color:var(--ok);font-weight:600}
|
||||
.reveal .brandbar{position:fixed;bottom:0;left:0;right:0;height:5px;background:var(--brand);z-index:30}
|
||||
.reveal small{color:var(--muted)}
|
||||
.reveal video{border-radius:14px;box-shadow:0 12px 44px rgba(20,25,59,.20);border:1px solid var(--border)}
|
||||
.reveal .two{display:grid;grid-template-columns:1fr 1fr;gap:1.4rem}
|
||||
.reveal table{font-size:.52em;border-collapse:collapse;width:100%;margin-top:.3em}
|
||||
.reveal th{background:var(--lg);color:var(--tg);text-align:left;padding:.45em .7em;font-weight:600;border-bottom:2px solid var(--border)}
|
||||
.reveal td{padding:.4em .7em;border-bottom:1px solid var(--border)}
|
||||
.reveal .pct{font-weight:700;text-align:center;white-space:nowrap}
|
||||
.reveal .up{color:var(--ok)}
|
||||
.reveal .d3{background:var(--brand-soft)}
|
||||
.reveal section.compact h2{font-size:1.35em}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brandbar"></div>
|
||||
<div class="reveal"><div class="slides">
|
||||
|
||||
<!-- 1 — хук -->
|
||||
<section class="center">
|
||||
<span class="badge">● Medtech · Terricon Valley · команда 107</span>
|
||||
<h1>Med<span class="accent">Archive</span></h1>
|
||||
<p class="lead" style="font-size:1.05em">Из хаоса прайсов клиник — <b>единый сравнительный каталог цен</b> за минуту.</p>
|
||||
<p style="margin-top:1rem"><span class="ok">● работает в проде:</span> <b>med.secondbrain.tools</b></p>
|
||||
</section>
|
||||
|
||||
<!-- 2 — проблема -->
|
||||
<section>
|
||||
<h2>Проблема</h2>
|
||||
<p class="lead">У страховой и сети клиник — десятки партнёров. Каждый шлёт прайс как умеет:</p>
|
||||
<ul>
|
||||
<li>Excel, PDF, <b>сканы и фото</b>, разная вёрстка — на разных языках.</li>
|
||||
<li>Свести это в единый прайс для ДМС и сравнения — <b>недели ручной работы</b>.</li>
|
||||
<li>Ошибки ручного ввода бьют по цене, которую видит пациент.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- 4 — решение -->
|
||||
<section>
|
||||
<h2>Решение</h2>
|
||||
<p class="lead" style="font-size:1.05em">Система автоматически читает прайс <b>в любом формате</b> и превращает архив в <b class="accent">единую актуальную базу цен</b> — с поиском и сравнением между клиниками.</p>
|
||||
<div class="flow" style="margin-top:1rem">
|
||||
<span class="b">любой формат</span><span class="ar">→</span>
|
||||
<span class="b hl">ИИ-извлечение</span><span class="ar">→</span>
|
||||
<span class="b hl">нормализация к справочнику</span><span class="ar">→</span>
|
||||
<span class="b">единый каталог</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5 — как работает -->
|
||||
<section>
|
||||
<h2>Как это работает</h2>
|
||||
<ul>
|
||||
<li><b>Извлечение по форматам</b>: Excel, PDF, DOCX; PDF без линий таблиц — по координатам слов.</li>
|
||||
<li><b>Сканы и битый текст → Gemini Vision</b> по картинке страницы.</li>
|
||||
<li><b>Нормализация</b>: код тарификатора → точное совпадение → эмбеддинги → нечёткий поиск.</li>
|
||||
<li>Спорное — в <b>очередь верификации</b>; оператор подтверждает, система <b>дообучается</b>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 6 — прогресс -->
|
||||
<section>
|
||||
<h2>Что уже работает</h2>
|
||||
<div class="kpi">
|
||||
<div class="card"><span class="num">16 140</span><span class="lbl">позиции извлечено</span></div>
|
||||
<div class="card"><span class="num">73,7%</span><span class="lbl">нормализовано авто (цель ≥70%)</span></div>
|
||||
<div class="card"><span class="num">8 / 10</span><span class="lbl">клиник / прайсов</span></div>
|
||||
<div class="card"><span class="num">live</span><span class="lbl">развёрнуто на домене</span></div>
|
||||
</div>
|
||||
<p class="lead" style="margin-top:.8rem">Сквозной путь — от сырого архива до живого интерфейса — собран и развёрнут <b>за 48 часов</b>.</p>
|
||||
</section>
|
||||
|
||||
<!-- 6b — прогресс по рубрике -->
|
||||
<section class="compact">
|
||||
<h2>Прогресс за 3 дня — <span class="accent">по рубрике оценки</span></h2>
|
||||
<table>
|
||||
<tr><th>Критерий жюри</th><th class="pct">вес</th><th class="pct">день 1</th><th class="pct">день 2</th><th class="pct d3">день 3</th></tr>
|
||||
<tr><td>Качество извлечения данных</td><td class="pct">30%</td><td class="pct">85%</td><td class="pct">92%</td><td class="pct up d3">95%</td></tr>
|
||||
<tr><td>Нормализация и сопоставление</td><td class="pct">25%</td><td class="pct">90%</td><td class="pct">92%</td><td class="pct up d3">93%</td></tr>
|
||||
<tr><td>Валидация и верификация</td><td class="pct">20%</td><td class="pct">60%</td><td class="pct">88%</td><td class="pct up d3">92%</td></tr>
|
||||
<tr><td>API и поиск</td><td class="pct">15%</td><td class="pct">90%</td><td class="pct">95%</td><td class="pct up d3">98%</td></tr>
|
||||
<tr><td>UX административного раздела</td><td class="pct">10%</td><td class="pct">65%</td><td class="pct">88%</td><td class="pct up d3">95%</td></tr>
|
||||
<tr><td><b>Итого по рубрике</b></td><td class="pct">—</td><td class="pct">~80%</td><td class="pct">~91%</td><td class="pct up d3"><b>~95%</b></td></tr>
|
||||
</table>
|
||||
<p class="lead" style="margin-top:.6rem">Каждый день закрывали конкретные пункты: день 1 — ядро, день 2 — верификация и загрузка, день 3 — авто-загрузка, алерты, экспорт.</p>
|
||||
</section>
|
||||
|
||||
<!-- 6c — прогресс по ТЗ -->
|
||||
<section class="compact">
|
||||
<h2>Прогресс за 3 дня — <span class="accent">по требованиям ТЗ</span></h2>
|
||||
<table>
|
||||
<tr><th>Раздел ТЗ</th><th class="pct">день 1</th><th class="pct">день 2</th><th class="pct d3">день 3</th></tr>
|
||||
<tr><td>§4.1 Загрузка архива</td><td class="pct">55%</td><td class="pct">90%</td><td class="pct up d3">95%</td></tr>
|
||||
<tr><td>§4.2 Извлечение по формату</td><td class="pct">85%</td><td class="pct">92%</td><td class="pct up d3">95%</td></tr>
|
||||
<tr><td>§4.3 Нормализация</td><td class="pct">90%</td><td class="pct">92%</td><td class="pct up d3">93%</td></tr>
|
||||
<tr><td>§4.4 Валидация и верификация</td><td class="pct">60%</td><td class="pct">88%</td><td class="pct up d3">92%</td></tr>
|
||||
<tr><td>§4.5 API</td><td class="pct">90%</td><td class="pct">95%</td><td class="pct up d3">98%</td></tr>
|
||||
<tr><td>§4.6 Интерфейс</td><td class="pct">70%</td><td class="pct">88%</td><td class="pct up d3">95%</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- 7 — фишки -->
|
||||
<section>
|
||||
<h2>Не только каталог</h2>
|
||||
<div class="two">
|
||||
<ul>
|
||||
<li><b>Сравнение цен</b> между клиниками + детектор переплаты к медиане.</li>
|
||||
<li><b>Авто-загрузка из папки</b>: система сама забирает свежие прайсы.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><b>Алерты об изменении цен</b> между прайсами клиники.</li>
|
||||
<li><b>Экспорт</b> единого каталога в Excel — для биллинга, CRM и системы закупок заказчика.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8 — бизнес -->
|
||||
<section>
|
||||
<h2>Бизнес и масштаб</h2>
|
||||
<ul>
|
||||
<li>Онбординг прайса клиники: <b>часы ручного ввода → минуты</b>.</li>
|
||||
<li>Кому: страховщик с ДМС, сеть клиник, медицинский маркетплейс.</li>
|
||||
<li>Масштабируется от десятков клиник-партнёров до сотен по Казахстану — и дальше на рынок СНГ.</li>
|
||||
<li>Административная автоматизация — самый окупаемый сегмент medtech-ИИ.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- живое демо MVP (немой экранкаст) -->
|
||||
<section class="center" data-background-color="#f3f4f6">
|
||||
<h2 style="margin-bottom:.6rem">Живое демо — <span class="accent">MedArchive в работе</span></h2>
|
||||
<video src="demo.mp4" data-autoplay muted loop playsinline controls preload="metadata"
|
||||
style="max-width:92%;max-height:72vh"></video>
|
||||
</section>
|
||||
|
||||
<!-- 9 — закрытие -->
|
||||
<section class="center">
|
||||
<h1 style="font-size:1.5em">Из пачки разнородных прайсов — <span class="accent">единый сравнительный каталог за минуту</span></h1>
|
||||
<div style="margin-top:.9rem;display:flex;flex-direction:column;align-items:center;gap:.55rem">
|
||||
<img src="data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27264%27%20height%3D%27264%27%20class%3D%27segno%27%3E%3Cg%20transform%3D%27scale%288%29%27%3E%3Cpath%20fill%3D%27%23fff%27%20d%3D%27M0%200h33v33h-33z%27%2F%3E%3Cpath%20class%3D%27qrline%27%20stroke%3D%27%23111827%27%20d%3D%27M2%202.5h7m2%200h1m1%200h2m3%200h1m1%200h3m1%200h7m-29%201h1m5%200h1m1%200h1m1%200h3m1%200h1m2%200h2m3%200h1m5%200h1m-29%201h1m1%200h3m1%200h1m1%200h4m1%200h1m2%200h1m3%200h1m1%200h1m1%200h3m1%200h1m-29%201h1m1%200h3m1%200h1m2%200h1m1%200h1m1%200h1m3%200h3m2%200h1m1%200h3m1%200h1m-29%201h1m1%200h3m1%200h1m2%200h1m1%200h1m1%200h1m1%200h2m2%200h1m2%200h1m1%200h3m1%200h1m-29%201h1m5%200h1m3%200h2m2%200h2m2%200h3m1%200h1m5%200h1m-29%201h7m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h7m-20%201h2m1%200h1m2%200h3m-17%201h3m1%200h2m2%200h1m5%200h1m1%200h3m6%200h2m-27%201h1m1%200h2m2%200h1m7%200h3m1%200h7m2%200h1m-27%201h2m1%200h2m1%200h2m2%200h1m1%200h3m1%200h1m3%200h1m3%200h2m-24%201h1m2%200h1m1%200h4m1%200h1m5%200h1m3%200h1m3%200h1m-29%201h1m1%200h6m1%200h9m1%200h1m1%200h1m1%200h1m2%200h3m-25%201h2m1%200h4m3%200h2m1%200h3m2%200h2m1%200h2m1%200h1m-29%201h3m1%200h4m1%200h1m3%200h1m1%200h3m1%200h3m1%200h3m1%200h2m-29%201h1m1%200h1m2%200h1m1%200h1m2%200h3m7%200h3m1%200h2m1%200h2m-26%201h5m1%200h1m1%200h1m1%200h2m1%200h2m1%200h2m2%200h3m1%200h2m-26%201h3m1%200h1m1%200h1m4%200h1m1%200h1m3%200h1m2%200h1m1%200h1m-26%201h1m2%200h2m1%200h2m1%200h1m3%200h6m3%200h3m1%200h1m-23%201h2m1%200h2m2%200h3m3%200h2m2%200h3m1%200h3m-27%201h3m2%200h2m2%200h3m1%200h1m1%200h9m1%200h2m-20%201h1m1%200h3m1%200h1m1%200h1m1%200h1m1%200h1m3%200h2m1%200h2m-29%201h7m2%200h1m4%200h1m1%200h2m1%200h2m1%200h1m1%200h1m1%200h2m-28%201h1m5%200h1m1%200h5m1%200h3m3%200h1m3%200h1m2%200h1m-28%201h1m1%200h3m1%200h1m2%200h2m1%200h2m1%200h1m2%200h1m1%200h7m1%200h1m-29%201h1m1%200h3m1%200h1m1%200h1m2%200h4m1%200h2m1%200h1m4%200h1m1%200h2m-28%201h1m1%200h3m1%200h1m1%200h2m2%200h2m1%200h2m1%200h1m1%200h1m2%200h1m1%200h2m1%200h1m-29%201h1m5%200h1m1%200h1m3%200h3m1%200h1m2%200h2m1%200h1m1%200h2m1%200h1m-28%201h7m5%200h2m1%200h1m2%200h3m1%200h2m3%200h1%27%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E" alt="QR — демо med.secondbrain.tools" style="width:148px;height:148px;background:#fff;padding:8px;border:2px solid #ebedf0;border-radius:10px">
|
||||
<p style="font-size:.88em;line-height:1.5;margin:0">
|
||||
Демо — <b>med.secondbrain.tools</b><br>
|
||||
Документация API — <b>med.secondbrain.tools/api/docs</b>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div></div>
|
||||
<script src="/demo/reveal/dist/reveal.js"></script>
|
||||
<script>
|
||||
Reveal.initialize({ hash: true, slideNumber: 'c/t', transition: 'slide', controls: true, progress: true, center: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
@@ -1,37 +0,0 @@
|
||||
# Питч — структура (6–7 слайдов, ~3 мин речи + демо)
|
||||
|
||||
Цель: ~400 слов речи. Первые 20 сек — крючок, без «здравствуйте, мы команда». Один назначенный спикер (Дмитрий). Демо в центре.
|
||||
|
||||
## Слайд 1 — Хук + проблема (20–30 сек)
|
||||
«У страховой/сети — десятки клиник-партнёров. Каждая шлёт прайс как умеет: Excel, скан, фото на телефон, половина на казахском. Свести это в единый прайс для ДМС/сравнения — недели ручной работы и ошибки в цене, которую видит пациент».
|
||||
> Назвать масштаб боли и конкретного бенефициара (прайс-аналитик / менеджер партнёрской сети).
|
||||
|
||||
## Слайд 2 — Решение в одном предложении (15 сек)
|
||||
«Система, которая автоматически читает прайс **в любом формате** и превращает архив в единую актуальную базу цен — с поиском и сравнением между клиниками».
|
||||
|
||||
## Слайд 3 — 🔴 ЖИВОЕ ДЕМО (60–90 сек) — ядро
|
||||
См. `demo-script.md`. Перетащил пёструю пачку → одна кнопка → единая таблица → поиск сводит RU/KZ/EN формулировки в одну строку сравнения → клик по low-confidence показывает оригинал → экспорт.
|
||||
|
||||
## Слайд 4 — Как это работает (20 сек)
|
||||
Простая схема: **любые форматы → ИИ-извлечение + нормализация → единый каталог услуг**. Без технического занудства. Подчеркнуть: один путь на Excel/PDF/скан/фото, RU/KZ/EN.
|
||||
|
||||
## Слайд 5 — Метрики / impact (20 сек)
|
||||
- Онбординг прайса клиники: **часы ручного ввода → минуты**.
|
||||
- % автоматически распознанных строк, точность матчинга, % строк на ручную проверку.
|
||||
- «Административная автоматизация — самый окупаемый сегмент medtech AI».
|
||||
|
||||
## Слайд 6 — Бизнес и масштаб (20 сек)
|
||||
- Кому: страховщик с ДМС, сеть клиник, мед-маркетплейс.
|
||||
- Кейс 1 = data-движок Кейса 2 (агрегатор/сравнение цен) → монетизация.
|
||||
- Масштаб: с десятков клиник → на сотни по РК → СНГ. Язык Terricon: **экспорт, продажи, масштаб**.
|
||||
|
||||
## Слайд 7 — Закрытие (15 сек)
|
||||
Сильная фраза: «Из архива хаоса — готовый сравнительный прайс за минуту». + «что дальше за неделю» (доменный справочник услуг РК, интеграция в CRM страховщика). Ничего не просить — оставить впечатление.
|
||||
|
||||
---
|
||||
|
||||
### Помнить
|
||||
- Доверие к медданным: confidence-score + флаги ручной проверки + ссылка на оригинал.
|
||||
- Регуляторно безопасно: работаем с публичными коммерческими прайсами, не с данными пациентов — проговорить.
|
||||
- Прогресс именно за 48 ч (связка с ИИ-агентами — честное преимущество по скорости).
|
||||
- Слайдов максимум 7. Демо > слова.
|
||||
@@ -34,3 +34,4 @@ extend-immutable-calls = ["fastapi.File", "fastapi.Query", "fastapi.Depends"]
|
||||
# Разведочные скрипты этапа разбора форматов — черновики, не часть продукта.
|
||||
"scripts/proof/*" = ["E402", "E501", "E741", "B007"]
|
||||
"scripts/gen_fixtures.py" = ["E402", "E501", "E741"]
|
||||
"scripts/fix_ref_ids.py" = ["E501"] # одноразовая миграция, SQL-строки длиннее 100
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Починка справочника: заменить повреждённые Excel-ошибкой service_id (#REF!) на чистые.
|
||||
|
||||
В исходном справочнике у части услуг код тарификатора пришёл как Excel `#REF!`, и это
|
||||
протекло в канонический service_id (напр. `#REF!-#REF!#459`) — видно в живом /search.
|
||||
Имена услуг при этом корректны. Скрипт генерирует чистые уникальные id и каскадно
|
||||
обновляет ссылки в price_item (service_id и suggested_service_id) и learned_synonym.
|
||||
|
||||
Запуск: python scripts/fix_ref_ids.py <db_path>
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
|
||||
def fix(db_path: str) -> dict:
|
||||
conn = sqlite3.connect(db_path)
|
||||
existing = {r[0] for r in conn.execute("SELECT service_id FROM service")}
|
||||
bad = [r[0] for r in conn.execute("SELECT service_id FROM service WHERE service_id LIKE '%#REF!%'")]
|
||||
|
||||
mapping: dict[str, str] = {}
|
||||
i = 0
|
||||
for old in bad:
|
||||
while True:
|
||||
new = f"d{i:04d}"
|
||||
i += 1
|
||||
if new not in existing:
|
||||
break
|
||||
existing.add(new)
|
||||
mapping[old] = new
|
||||
|
||||
# learned_synonym ссылается на service_id? обновим, если колонка есть.
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(learned_synonym)")}
|
||||
for old, new in mapping.items():
|
||||
conn.execute("UPDATE service SET service_id=? WHERE service_id=?", [new, old])
|
||||
conn.execute("UPDATE price_item SET service_id=? WHERE service_id=?", [new, old])
|
||||
conn.execute("UPDATE price_item SET suggested_service_id=? WHERE suggested_service_id=?", [new, old])
|
||||
if "service_id" in cols:
|
||||
conn.execute("UPDATE learned_synonym SET service_id=? WHERE service_id=?", [new, old])
|
||||
conn.commit()
|
||||
|
||||
left = conn.execute("SELECT COUNT(*) FROM service WHERE service_id LIKE '%#REF!%'").fetchone()[0]
|
||||
left_pi = conn.execute("SELECT COUNT(*) FROM price_item WHERE service_id LIKE '%#REF!%'").fetchone()[0]
|
||||
matched = conn.execute("SELECT COUNT(*) FROM price_item WHERE service_id IS NOT NULL").fetchone()[0]
|
||||
conn.close()
|
||||
return {"fixed_services": len(mapping), "ref_left_service": left, "ref_left_price_item": left_pi, "matched_total": matched}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(fix(sys.argv[1] if len(sys.argv) > 1 else "data/medarchive.db"))
|
||||
+110
-37
@@ -9,19 +9,22 @@
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = { theme: { extend: {
|
||||
colors: { nomad: { DEFAULT: '#ff4713', soft: '#ffede7', dark: '#e63e0f' },
|
||||
tg: '#6b788e', lg: '#f5f7f9', ink: '#111827' },
|
||||
colors: { brand: { DEFAULT: '#ff4713', soft: '#ffede7', dark: '#e63e0f' },
|
||||
tg: '#6b788e', flint: '#f5f7f9', ink: '#111827', ok: '#0f973d' },
|
||||
fontFamily: { sans: ['Geologica', 'ui-sans-serif', 'system-ui', 'sans-serif'] } } } };
|
||||
</script>
|
||||
<style>body{font-family:'Geologica',system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-[#f3f4f6] text-ink min-h-screen">
|
||||
<div class="h-1 bg-nomad w-full"></div>
|
||||
<div class="h-1 bg-brand w-full"></div>
|
||||
|
||||
<header class="max-w-5xl mx-auto px-5 pt-8 pb-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl font-bold">Med<span class="text-nomad">Archive</span></span>
|
||||
<span class="text-xs text-tg bg-lg border border-[#ebedf0] rounded-full px-2 py-0.5">прайсы клиник-партнёров</span>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="text-2xl font-bold">Med<span class="text-brand">Archive</span></span>
|
||||
<span class="h-6 w-px bg-[#d1d5db] hidden sm:block"></span>
|
||||
<a href="https://medprice.secondbrain.tools" title="Перейти в MedPrice — агрегатор цен на медуслуги"
|
||||
aria-label="Открыть MedPrice" class="hidden sm:inline text-2xl font-bold opacity-55 hover:opacity-100 transition-opacity">Med<span class="text-[#0d9488]">Price</span></a>
|
||||
<span class="text-xs text-tg bg-flint border border-[#ebedf0] rounded-full px-2 py-0.5 ml-3 hidden sm:inline">прайсы клиник-партнёров</span>
|
||||
</div>
|
||||
<nav class="text-sm text-tg flex gap-4">
|
||||
<button onclick="show('search')" class="hover:text-ink">Поиск</button>
|
||||
@@ -33,12 +36,12 @@
|
||||
|
||||
<!-- Поиск + сравнение -->
|
||||
<section id="view-search">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mt-6 leading-tight">Найдите услугу — <span class="text-nomad">сравните цены</span> в клиниках</h1>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mt-6 leading-tight">Найдите услугу — <span class="text-brand">сравните цены</span> в клиниках</h1>
|
||||
<p class="text-tg mt-2">Единый каталог услуг и цен из архива прайсов клиник-партнёров.</p>
|
||||
|
||||
<div class="mt-5 flex gap-2">
|
||||
<input id="q" type="text" placeholder="например: УЗИ брюшной полости, приём кардиолога, общий анализ крови"
|
||||
class="flex-1 bg-white border border-[#d1d5db] rounded-lg px-4 py-3 outline-none focus:border-nomad focus:ring-2 focus:ring-nomad/40"
|
||||
class="flex-1 bg-white border border-[#d1d5db] rounded-lg px-4 py-3 outline-none focus:border-brand focus:ring-2 focus:ring-brand/40"
|
||||
oninput="debouncedSearch()" autocomplete="off">
|
||||
</div>
|
||||
|
||||
@@ -48,7 +51,10 @@
|
||||
|
||||
<!-- Админка + верификация -->
|
||||
<section id="view-admin" class="hidden">
|
||||
<h2 class="text-2xl font-bold mt-6">Дашборд обработки</h2>
|
||||
<div class="flex items-center justify-between mt-6 gap-4 flex-wrap">
|
||||
<h2 class="text-2xl font-bold">Дашборд обработки</h2>
|
||||
<button onclick="exportXlsx()" class="bg-white border border-[#d1d5db] text-sm rounded-lg px-4 py-2 font-medium hover:bg-flint">📥 Экспорт в Excel</button>
|
||||
</div>
|
||||
<div id="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"></div>
|
||||
|
||||
<!-- Загрузка нового архива (ТЗ §4.1) -->
|
||||
@@ -60,19 +66,42 @@
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input id="zip" type="file" accept=".zip" class="hidden" onchange="uploadArchive(this)">
|
||||
<button onclick="$('zip').click()" class="bg-nomad text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-nomad-dark">Выбрать ZIP</button>
|
||||
<button onclick="$('zip').click()" class="bg-brand text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-brand-dark">Выбрать ZIP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ingest-result" class="mt-3 text-sm hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4 mt-4">
|
||||
<!-- Авто-загрузка из папки (watcher + крон) -->
|
||||
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5">
|
||||
<h3 class="text-lg font-semibold">Авто-загрузка из папки</h3>
|
||||
<p class="text-sm text-tg mt-1">Укажите папку — система в кроне сама заберёт и обработает свежие прайсы, без участия человека.</p>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<input id="watchfolder" placeholder="например: /app/inbox" class="flex-1 bg-white border border-[#d1d5db] rounded-lg px-3 py-2 text-sm outline-none focus:border-brand">
|
||||
<button onclick="saveFolder()" class="border border-[#e5e7eb] text-sm rounded-lg px-3 py-2 hover:bg-flint shrink-0">Сохранить</button>
|
||||
</div>
|
||||
<button onclick="scanNow()" class="bg-brand text-white text-sm rounded-lg px-4 py-2 mt-2 font-medium hover:bg-brand-dark">Проверить сейчас</button>
|
||||
<div id="scan-result" class="text-sm mt-2"></div>
|
||||
</div>
|
||||
<!-- Изменения цен (детектор аномалий, ТЗ §4.4) -->
|
||||
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Изменения цен</h3>
|
||||
<span id="pc-count" class="text-xs text-tg"></span>
|
||||
</div>
|
||||
<p class="text-sm text-tg mt-1">Услуги, где новый прайс изменил цену на ≥ 10% относительно прошлого.</p>
|
||||
<div id="price-changes" class="mt-3 space-y-1 max-h-64 overflow-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-8">
|
||||
<h3 class="text-lg font-semibold">Очередь верификации</h3>
|
||||
<div id="vprogress" class="text-sm text-tg"></div>
|
||||
</div>
|
||||
<p class="text-sm text-tg mt-1">Система предлагает кандидата из справочника — подтвердите, выберите другой или пропустите. Подтверждение обучает систему.</p>
|
||||
<div id="queue" class="mt-3 space-y-2"></div>
|
||||
<button id="loadmore" onclick="loadQueue()" class="mt-3 text-sm text-nomad hover:underline hidden">Загрузить ещё →</button>
|
||||
<button id="loadmore" onclick="loadQueue()" class="mt-3 text-sm text-brand hover:underline hidden">Загрузить ещё →</button>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
@@ -98,7 +127,7 @@ async function search() {
|
||||
const data = await fetch(`${API}/search?q=${encodeURIComponent(q)}&limit=8`).then(r => r.json());
|
||||
const items = (data.services || []);
|
||||
box.innerHTML = items.length
|
||||
? items.map(s => `<button class="block w-full text-left px-4 py-2.5 hover:bg-nomad-soft"
|
||||
? items.map(s => `<button class="block w-full text-left px-4 py-2.5 hover:bg-brand-soft"
|
||||
onclick="compare('${s.service_id}', ${JSON.stringify(s.name_ru).replace(/"/g, '"')})">
|
||||
<span class="font-medium">${s.name_ru}</span>
|
||||
<span class="text-xs text-tg ml-2">${s.specialty || ''}</span></button>`).join('')
|
||||
@@ -119,15 +148,15 @@ async function compare(serviceId, name) {
|
||||
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-[#ebedf0]">
|
||||
<div class="text-lg font-semibold">${name}</div>
|
||||
<div class="text-sm text-tg">${rows.length} клиник(и) · медиана ${money(median)}${spread ? ' · разброс цен до <b class="text-nomad">' + money(spread) + '</b>' : ''}</div>
|
||||
<div class="text-sm text-tg">${rows.length} клиник(и) · медиана ${money(median)}${spread ? ' · разброс цен до <b class="text-brand">' + money(spread) + '</b>' : ''}</div>
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-lg text-tg text-left">
|
||||
<tr><th class="px-5 py-2.5 font-semibold">Клиника</th>
|
||||
<th class="px-5 py-2.5 font-semibold text-right">Резидент</th>
|
||||
<th class="px-5 py-2.5 font-semibold text-right">vs медиана</th>
|
||||
<th class="px-5 py-2.5 font-semibold text-right">Нерезидент</th>
|
||||
<th class="px-5 py-2.5 font-semibold text-right">Прайс от</th></tr>
|
||||
<thead class="bg-flint text-tg text-left">
|
||||
<tr><th class="px-3 sm:px-5 py-2.5 font-semibold">Клиника</th>
|
||||
<th class="px-3 sm:px-5 py-2.5 font-semibold text-right whitespace-nowrap">Резидент</th>
|
||||
<th class="px-3 sm:px-5 py-2.5 font-semibold text-right whitespace-nowrap">vs медиана</th>
|
||||
<th class="px-3 sm:px-5 py-2.5 font-semibold text-right hidden sm:table-cell">Нерезидент</th>
|
||||
<th class="px-3 sm:px-5 py-2.5 font-semibold text-right hidden sm:table-cell">Прайс от</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[#f0f0f0]">
|
||||
${rows.map(r => {
|
||||
@@ -136,12 +165,12 @@ async function compare(serviceId, name) {
|
||||
const badge = d == null ? '—' : (d > 0
|
||||
? `<span class="text-[#dc2626] font-medium">+${d}%</span>`
|
||||
: `<span class="text-[#0f973d] font-medium">${d}%</span>`);
|
||||
return `<tr class="${cheapest ? 'bg-nomad-soft' : ''}">
|
||||
<td class="px-5 py-3">${r.partner_name}${cheapest ? ' <span class="text-nomad text-xs font-semibold">· выгодно</span>' : ''}</td>
|
||||
<td class="px-5 py-3 text-right tabular-nums font-medium">${money(r.price_resident_kzt)}</td>
|
||||
<td class="px-5 py-3 text-right tabular-nums text-xs">${badge}</td>
|
||||
<td class="px-5 py-3 text-right tabular-nums text-tg">${money(r.price_nonresident_kzt)}</td>
|
||||
<td class="px-5 py-3 text-right text-tg text-xs">${r.effective_date || '—'}</td></tr>`;
|
||||
return `<tr class="${cheapest ? 'bg-brand-soft' : ''}">
|
||||
<td class="px-3 sm:px-5 py-3">${r.partner_name}${cheapest ? ' <span class="text-brand text-xs font-semibold">· выгодно</span>' : ''}</td>
|
||||
<td class="px-3 sm:px-5 py-3 text-right tabular-nums font-medium whitespace-nowrap">${money(r.price_resident_kzt)}</td>
|
||||
<td class="px-3 sm:px-5 py-3 text-right tabular-nums text-xs">${badge}</td>
|
||||
<td class="px-3 sm:px-5 py-3 text-right tabular-nums text-tg hidden sm:table-cell whitespace-nowrap">${money(r.price_nonresident_kzt)}</td>
|
||||
<td class="px-3 sm:px-5 py-3 text-right text-tg text-xs hidden sm:table-cell whitespace-nowrap">${r.effective_date || '—'}</td></tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -155,9 +184,53 @@ async function loadAdmin() {
|
||||
['Автонормализация', s.auto_matched_pct + '%'], ['В очереди', s.unmatched_total]];
|
||||
$('stats').innerHTML = cards.map(([l, v]) => `
|
||||
<div class="bg-white border border-[#ebedf0] rounded-xl p-4">
|
||||
<div class="text-2xl font-bold text-nomad">${v}</div>
|
||||
<div class="text-2xl font-bold text-brand">${v}</div>
|
||||
<div class="text-xs text-tg mt-1">${l}</div></div>`).join('');
|
||||
loadQueue();
|
||||
loadSettings();
|
||||
loadPriceChanges();
|
||||
}
|
||||
|
||||
function exportXlsx() { window.location = `${API}/export.xlsx`; }
|
||||
|
||||
async function loadSettings() {
|
||||
const s = await fetch(`${API}/settings`).then(r => r.json());
|
||||
$('watchfolder').value = s.watch_folder || '';
|
||||
}
|
||||
|
||||
async function saveFolder() {
|
||||
const folder = $('watchfolder').value.trim();
|
||||
await fetch(`${API}/settings?watch_folder=${encodeURIComponent(folder)}`, { method: 'POST' });
|
||||
$('scan-result').innerHTML = '<span class="text-tg">Папка сохранена.</span>';
|
||||
}
|
||||
|
||||
async function scanNow() {
|
||||
const box = $('scan-result');
|
||||
box.innerHTML = '<span class="text-tg">⏳ Проверяю папку…</span>';
|
||||
const res = await fetch(`${API}/scan`, { method: 'POST' });
|
||||
const r = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { box.innerHTML = `<span class="text-[#dc2626]">${r.detail || 'ошибка'}</span>`; return; }
|
||||
if (!r.new_files) { box.innerHTML = '<span class="text-tg">Новых прайсов нет.</span>'; return; }
|
||||
box.innerHTML = `<span class="text-ok">✓ Обработано файлов: <b>${r.new_files}</b>, позиций: <b>${r.items}</b>, нормализовано <b>${r.auto_matched_pct}%</b>.</span>`;
|
||||
loadAdmin();
|
||||
}
|
||||
|
||||
async function loadPriceChanges() {
|
||||
const d = await fetch(`${API}/price-changes?threshold=10&limit=40`).then(r => r.json());
|
||||
$('pc-count').textContent = d.count ? `${d.count} услуг` : '';
|
||||
if (!d.changes || !d.changes.length) {
|
||||
$('price-changes').innerHTML = '<div class="text-tg text-sm">Изменений не найдено.</div>';
|
||||
return;
|
||||
}
|
||||
$('price-changes').innerHTML = d.changes.map(c => {
|
||||
const up = c.delta_pct > 0;
|
||||
const badge = `<span class="${up ? 'text-[#dc2626]' : 'text-ok'} font-semibold whitespace-nowrap">${up ? '▲ +' : '▼ '}${c.delta_pct}%</span>`;
|
||||
return `<div class="flex items-center justify-between gap-3 border-b border-[#f0f0f0] py-1.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm truncate">${c.service}</div>
|
||||
<div class="text-xs text-tg">${c.partner_name} · ${money(c.old_price)} → ${money(c.new_price)}</div>
|
||||
</div>${badge}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function uploadArchive(input) {
|
||||
@@ -175,9 +248,9 @@ async function uploadArchive(input) {
|
||||
box.innerHTML = `<span class="text-[#dc2626]">Ошибка: ${r.detail || res.status}</span>`;
|
||||
} else {
|
||||
const note = r.embeddings ? '' : ' <span class="text-tg">(нормализация по кодам и нечёткому совпадению — без эмбеддингов)</span>';
|
||||
box.innerHTML = `<div class="bg-nomad-soft border border-[#ffd9cc] rounded-lg p-3">
|
||||
box.innerHTML = `<div class="bg-brand-soft border border-[#ffd9cc] rounded-lg p-3">
|
||||
✓ Архив обработан: <b>${r.documents}</b> прайс(ов), <b>${r.items}</b> позиций,
|
||||
автонормализация <b class="text-nomad">${r.auto_matched_pct}%</b>${r.new_partners ? ', новых клиник ' + r.new_partners : ''}.${note}
|
||||
автонормализация <b class="text-brand">${r.auto_matched_pct}%</b>${r.new_partners ? ', новых клиник ' + r.new_partners : ''}.${note}
|
||||
<div class="text-tg mt-1">Новые позиции уже доступны в поиске и в очереди верификации.</div></div>`;
|
||||
loadAdmin(); // обновить дашборд и очередь под новые данные
|
||||
}
|
||||
@@ -201,7 +274,7 @@ function maybeRefill() {
|
||||
|
||||
async function loadCounts() {
|
||||
const c = await fetch(`${API}/unmatched/count`).then(r => r.json());
|
||||
$('vprogress').innerHTML = `осталось <b>${c.remaining}</b> · обучено синонимов <b class="text-nomad">${c.learned_synonyms}</b>`;
|
||||
$('vprogress').innerHTML = `осталось <b>${c.remaining}</b> · обучено синонимов <b class="text-brand">${c.learned_synonyms}</b>`;
|
||||
}
|
||||
|
||||
function renderCard(it) {
|
||||
@@ -211,24 +284,24 @@ function renderCard(it) {
|
||||
? `<div class="text-sm mt-1">предложение: <b>${sug.name_ru}</b> <span class="text-tg">${sug.specialty || ''} · ${(sug.score || 0).toFixed(2)}</span></div>`
|
||||
: `<div class="text-sm text-tg mt-1">кандидата нет</div>`;
|
||||
const confirm = sug
|
||||
? `<button onclick="doMatch(${it.item_id}, '${sug.service_id}', this)" class="bg-nomad text-white text-sm rounded-lg px-3 py-1.5 hover:bg-nomad-dark">✓ Подтвердить</button>`
|
||||
? `<button onclick="doMatch(${it.item_id}, '${sug.service_id}', this)" class="bg-brand text-white text-sm rounded-lg px-3 py-1.5 hover:bg-brand-dark">✓ Подтвердить</button>`
|
||||
: '';
|
||||
return `<div id="card-${it.item_id}" class="bg-white border border-[#ebedf0] rounded-xl p-4">
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3">
|
||||
<div class="min-w-0">
|
||||
<div>извлечено: <b>${it.service_name_raw}</b></div>
|
||||
<div class="text-sm text-tg">${it.partner_name}${price != null ? ' · ' + money(price) : ''}</div>
|
||||
${sugHtml}
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<div class="flex gap-2 flex-wrap sm:shrink-0">
|
||||
${confirm}
|
||||
<button onclick="togglePick(${it.item_id})" class="border border-[#e5e7eb] text-sm rounded-lg px-3 py-1.5 hover:bg-lg">Другое</button>
|
||||
<button onclick="skipCard(${it.item_id})" class="text-tg text-sm rounded-lg px-3 py-1.5 hover:bg-lg">Пропустить</button>
|
||||
<button onclick="togglePick(${it.item_id})" class="border border-[#e5e7eb] text-sm rounded-lg px-3 py-1.5 hover:bg-flint">Другое</button>
|
||||
<button onclick="skipCard(${it.item_id})" class="text-tg text-sm rounded-lg px-3 py-1.5 hover:bg-flint">Пропустить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pick-${it.item_id}" class="hidden mt-3">
|
||||
<input oninput="searchDict(${it.item_id}, this.value)" placeholder="найти услугу в справочнике…"
|
||||
class="w-full bg-white border border-[#d1d5db] rounded-lg px-3 py-2 text-sm outline-none focus:border-nomad">
|
||||
class="w-full bg-white border border-[#d1d5db] rounded-lg px-3 py-2 text-sm outline-none focus:border-brand">
|
||||
<div id="dict-${it.item_id}" class="mt-1 bg-white border border-[#ebedf0] rounded-lg divide-y divide-[#f0f0f0] max-h-44 overflow-auto"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -256,7 +329,7 @@ function searchDict(itemId, q) {
|
||||
if (q.trim().length < 2) return;
|
||||
const items = await fetch(`${API}/services?q=${encodeURIComponent(q)}&limit=8`).then(r => r.json());
|
||||
$(`dict-${itemId}`).innerHTML = items.map(s =>
|
||||
`<button class="block w-full text-left px-3 py-2 text-sm hover:bg-nomad-soft" onclick="doMatch(${itemId}, '${s.service_id}', null)">${s.name_ru} <span class="text-tg text-xs">${s.specialty || ''}</span></button>`).join('');
|
||||
`<button class="block w-full text-left px-3 py-2 text-sm hover:bg-brand-soft" onclick="doMatch(${itemId}, '${s.service_id}', null)">${s.name_ru} <span class="text-tg text-xs">${s.specialty || ''}</span></button>`).join('');
|
||||
}, 250);
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user