Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 KiB
Архитектура 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):
- Код тарификатора — точное совпадение по канонической части кода, если клиника его указала (бесплатно и точно).
- Точное совпадение нормализованного названия со справочником.
- Эмбеддинги Gemini (
gemini-embedding-001, 768 измерений; справочник —RETRIEVAL_DOCUMENT, запрос —RETRIEVAL_QUERY) — семантическая близость названий. Порог косинуса 0.70: у Gemini эмбеддинги «в конусе» (несвязанные названия дают 0.6–0.7), ниже порога ловится мусор. - Нечёткое сравнение (RapidFuzz) — запасной сигнал.
- Иначе — очередь ручной разметки (
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) для роста автонормализации.