Files
medtech-hackathon/docs/ARCHITECTURE.md
T
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

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):

  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) для роста автонормализации.