Files
admins dbe36d3323 fix(normalize): confidence-aware guard переобобщения → честные 73,7%
Прежний guard снимал эмбеддинг-матч, если услуга собрала в клинике больше
8 разных названий. Это наказывало легитимные частотные услуги — одна «МРТ
головного мозга» реально встречается под десятками протоколов (с контрастом,
3Тл, предоперационная), и все они верно нормализуются к ней. Процент падал
до 69%.

Теперь снимаем только НЕуверенные матчи (<0,72) внутри концентрированных
групп: так отсекается сиблинг-захват (напр. «Магний Mg (моча)» подтягивал
Никель/Свинец/Ртуть в моче на пороге 0,70), а верные вариации остаются
сопоставленными. Признак ошибки — низкая уверенность, а не число названий.

Итог на тестовом архиве: 16 140 позиций извлечено, 73,7% нормализовано
автоматически (цель ТЗ ≥70%), 273 неуверенных матча — в очередь верификации.
Цифры выровнены в README, ARCHITECTURE, питч-деках, PPTX, quality_report.

Плюс полировка: извлечение города клиники, регистронезависимый поиск.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:42:21 +05:00

205 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура 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) для роста автонормализации.