diff --git a/README.md b/README.md index da098fc..abeb298 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ XLSX/XLS), извлекает услуги и цены, нормализует | Фронт | Одностраничный (Tailwind): поиск и сравнение цен, экран верификации оператора, дашборд обработки | | Деплой | Контейнер API за Caddy на Selectel KZ | +Подробно об устройстве системы — **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** (поток данных, карта модулей, модель данных, обоснование решений). + ## Запуск локально ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..822ffd5 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 файлов): 15 086 позиций извлечено, **76,1% +нормализовано автоматически** (код 4397, точное 254, эмбеддинги 6717, нечёткое 110), остальное — +в очередь. + +--- + +## 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) для роста автонормализации.