Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69a9884db5 | |||
| 89928511ba | |||
| 1ff5d93cd8 | |||
| 9c0ae2c0bf | |||
| 117cf6221e | |||
| d0e0532053 | |||
| 79e308cb0c | |||
| 1ce11155f0 | |||
| 3d7b12d63d | |||
| 787092dc02 | |||
| 985faadf36 | |||
| 1de66eaa85 | |||
| 14462e8c10 | |||
| efae54fbad | |||
| a6a6609c48 | |||
| f35957a31e | |||
| 39b45567a1 | |||
| 1e2ce2caf7 | |||
| 062ff5fc11 | |||
| fa2fbc59b0 |
+146
@@ -0,0 +1,146 @@
|
||||
# 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).
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
email admin@secondbrain.tools
|
||||
email laukhin@dementiy.com
|
||||
acme_ca https://acme-v02.api.letsencrypt.org/directory
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Демо-сценарий (≈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` проверен с чужого устройства/сети перед выходом.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Питч — структура (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. Демо > слова.
|
||||
+18
-18
@@ -9,18 +9,18 @@
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = { theme: { extend: {
|
||||
colors: { brand: { DEFAULT: '#ff4713', soft: '#ffede7', dark: '#e63e0f' },
|
||||
colors: { nomad: { DEFAULT: '#ff4713', soft: '#ffede7', dark: '#e63e0f' },
|
||||
tg: '#6b788e', lg: '#f5f7f9', ink: '#111827' },
|
||||
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-brand w-full"></div>
|
||||
<div class="h-1 bg-nomad 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-brand">Archive</span></span>
|
||||
<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>
|
||||
<nav class="text-sm text-tg flex gap-4">
|
||||
@@ -33,12 +33,12 @@
|
||||
|
||||
<!-- Поиск + сравнение -->
|
||||
<section id="view-search">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mt-6 leading-tight">Найдите услугу — <span class="text-brand">сравните цены</span> в клиниках</h1>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mt-6 leading-tight">Найдите услугу — <span class="text-nomad">сравните цены</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-brand focus:ring-2 focus:ring-brand/40"
|
||||
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"
|
||||
oninput="debouncedSearch()" autocomplete="off">
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input id="zip" type="file" accept=".zip" class="hidden" onchange="uploadArchive(this)">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ingest-result" class="mt-3 text-sm hidden"></div>
|
||||
@@ -72,7 +72,7 @@
|
||||
</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-brand hover:underline hidden">Загрузить ещё →</button>
|
||||
<button id="loadmore" onclick="loadQueue()" class="mt-3 text-sm text-nomad hover:underline hidden">Загрузить ещё →</button>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
@@ -98,7 +98,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-brand-soft"
|
||||
? items.map(s => `<button class="block w-full text-left px-4 py-2.5 hover:bg-nomad-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,7 +119,7 @@ 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-brand">' + money(spread) + '</b>' : ''}</div>
|
||||
<div class="text-sm text-tg">${rows.length} клиник(и) · медиана ${money(median)}${spread ? ' · разброс цен до <b class="text-nomad">' + money(spread) + '</b>' : ''}</div>
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-lg text-tg text-left">
|
||||
@@ -136,8 +136,8 @@ 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-brand-soft' : ''}">
|
||||
<td class="px-5 py-3">${r.partner_name}${cheapest ? ' <span class="text-brand text-xs font-semibold">· выгодно</span>' : ''}</td>
|
||||
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>
|
||||
@@ -155,7 +155,7 @@ 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-brand">${v}</div>
|
||||
<div class="text-2xl font-bold text-nomad">${v}</div>
|
||||
<div class="text-xs text-tg mt-1">${l}</div></div>`).join('');
|
||||
loadQueue();
|
||||
}
|
||||
@@ -175,9 +175,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-brand-soft border border-[#ffd9cc] rounded-lg p-3">
|
||||
box.innerHTML = `<div class="bg-nomad-soft border border-[#ffd9cc] rounded-lg p-3">
|
||||
✓ Архив обработан: <b>${r.documents}</b> прайс(ов), <b>${r.items}</b> позиций,
|
||||
автонормализация <b class="text-brand">${r.auto_matched_pct}%</b>${r.new_partners ? ', новых клиник ' + r.new_partners : ''}.${note}
|
||||
автонормализация <b class="text-nomad">${r.auto_matched_pct}%</b>${r.new_partners ? ', новых клиник ' + r.new_partners : ''}.${note}
|
||||
<div class="text-tg mt-1">Новые позиции уже доступны в поиске и в очереди верификации.</div></div>`;
|
||||
loadAdmin(); // обновить дашборд и очередь под новые данные
|
||||
}
|
||||
@@ -201,7 +201,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-brand">${c.learned_synonyms}</b>`;
|
||||
$('vprogress').innerHTML = `осталось <b>${c.remaining}</b> · обучено синонимов <b class="text-nomad">${c.learned_synonyms}</b>`;
|
||||
}
|
||||
|
||||
function renderCard(it) {
|
||||
@@ -211,7 +211,7 @@ 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-brand text-white text-sm rounded-lg px-3 py-1.5 hover:bg-brand-dark">✓ Подтвердить</button>`
|
||||
? `<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>`
|
||||
: '';
|
||||
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">
|
||||
@@ -228,7 +228,7 @@ function renderCard(it) {
|
||||
</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-brand">
|
||||
class="w-full bg-white border border-[#d1d5db] rounded-lg px-3 py-2 text-sm outline-none focus:border-nomad">
|
||||
<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 +256,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-brand-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-nomad-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