diff --git a/.gitignore b/.gitignore index 81e244b..5a42876 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Thumbs.db # Данные клиник: реальный архив и справочник в публичный репозиторий не коммитим data/ *.sqlite + +# Внутренние рабочие доки хакатона (план, спеки, дизайн) — не в публичный репо +_internal/ diff --git a/README.md b/README.md index abeb298..9c7ee52 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ XLSX/XLS), извлекает услуги и цены, нормализует ## Результат на тестовом архиве (8 клиник, 10 файлов) -- **15 086 позиций** извлечено: нативными парсерами + трудные PDF (битый текстовый слой, +- **16 140 позиций** извлечено: нативными парсерами + трудные PDF (битый текстовый слой, нестандартная вёрстка) — через Gemini Vision; обработаны все 8 клиник. -- **76,1% позиций нормализованы автоматически** (цель ТЗ — не ниже 70%). +- **73,7% позиций нормализованы автоматически** (цель ТЗ — не ниже 70%). - Остальное уходит в очередь ручной разметки, как и предполагает ТЗ. Полный отчёт о качестве — `docs/quality_report.json`. diff --git a/api/main.py b/api/main.py index e50ec0b..a0e2258 100644 --- a/api/main.py +++ b/api/main.py @@ -413,11 +413,15 @@ def price_changes(threshold: float = 10.0, limit: int = 100): SELECT p.name AS partner_name, a.service_name_raw, s.name_ru AS service_ru, a.price_resident AS new_price, a.effective_date AS new_date, (SELECT b.price_resident FROM price_item b - WHERE b.partner_id = a.partner_id AND b.service_name_raw = a.service_name_raw + WHERE b.partner_id = a.partner_id + AND ((a.service_id IS NOT NULL AND b.service_id = a.service_id) + OR (a.service_id IS NULL AND b.service_name_raw = a.service_name_raw)) AND b.is_active = 0 AND b.price_resident IS NOT NULL ORDER BY b.effective_date DESC LIMIT 1) AS old_price, (SELECT b.effective_date FROM price_item b - WHERE b.partner_id = a.partner_id AND b.service_name_raw = a.service_name_raw + WHERE b.partner_id = a.partner_id + AND ((a.service_id IS NOT NULL AND b.service_id = a.service_id) + OR (a.service_id IS NULL AND b.service_name_raw = a.service_name_raw)) AND b.is_active = 0 AND b.price_resident IS NOT NULL ORDER BY b.effective_date DESC LIMIT 1) AS old_date FROM price_item a diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 822ffd5..2e6e622 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,9 +116,9 @@ HNSW-индексом, в остальном модель та же. пересчитывается, эмбеддится только новый файл. Подтверждение оператора пишет `learned_synonym` — следующий прогон сопоставит синоним автоматически (система дообучается). -**Качество на тестовом архиве** (8 клиник, 10 файлов): 15 086 позиций извлечено, **76,1% -нормализовано автоматически** (код 4397, точное 254, эмбеддинги 6717, нечёткое 110), остальное — -в очередь. +**Качество на тестовом архиве** (8 клиник, 10 файлов): 16 140 позиций извлечено, **73,7% +нормализовано автоматически** (код 4397, точное 291, эмбеддинги 7083, нечёткое 128), плюс 273 неуверенных +матча сняты guard'ом переобобщения в очередь. --- diff --git a/docs/quality_report.json b/docs/quality_report.json index 1c07a49..15b8695 100644 --- a/docs/quality_report.json +++ b/docs/quality_report.json @@ -1,15 +1,15 @@ { "documents": 10, "partners": 8, - "items": 8871, - "auto_matched": 6555, - "auto_matched_pct": 73.9, - "unmatched": 2316, + "items": 16140, + "auto_matched": 11899, + "auto_matched_pct": 73.7, + "unmatched": 4241, "by_method": { - "code": 1414, - "exact": 167, - "embedding": 4903, - "fuzzy": 71, - "null": 2316 + "code": 4397, + "exact": 291, + "embedding": 7083, + "fuzzy": 128, + "over_match": 273 } -} \ No newline at end of file +} diff --git a/etl/extractors/readers.py b/etl/extractors/readers.py index 40c9a77..0a06da9 100644 --- a/etl/extractors/readers.py +++ b/etl/extractors/readers.py @@ -28,6 +28,24 @@ def partner_from_name(name: str) -> str: return re.sub(r"\s+", " ", base).strip() +_KZ_CITIES = { + "алмат": "Алматы", "астан": "Астана", "нур-султан": "Астана", "шымкент": "Шымкент", + "караганд": "Караганда", "актобе": "Актобе", "павлодар": "Павлодар", "семей": "Семей", + "усть-камен": "Усть-Каменогорск", "костанай": "Костанай", "атырау": "Атырау", "тараз": "Тараз", + "уральск": "Уральск", "кокшетау": "Кокшетау", "петропавл": "Петропавловск", + "кызылорд": "Кызылорда", "талдыкорган": "Талдыкорган", "актау": "Актау", +} + + +def city_from_text(text: str | None) -> str | None: + """Город партнёра по тексту документа (БИН в казахстанских прайсах обычно не печатают).""" + low = (text or "").lower() + for stem, name in _KZ_CITIES.items(): + if stem in low: + return name + return None + + def detect_format(path: str) -> str: ext = Path(path).suffix.lower() return {".xlsx": "xlsx", ".xls": "xls", ".docx": "docx", ".pdf": "pdf"}.get( diff --git a/etl/pipeline.py b/etl/pipeline.py index 08e48ee..1b39852 100644 --- a/etl/pipeline.py +++ b/etl/pipeline.py @@ -24,7 +24,7 @@ import numpy as np sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from etl.dictionary import load_dictionary, normalize_name -from etl.extractors import extract +from etl.extractors import extract, readers from etl.normalize import Matcher from etl.validate import validate_item @@ -104,7 +104,7 @@ def _process_documents(conn, data_dir, matcher, partners, today, use_vision) -> partners[partner_norm] = partner_id conn.execute( "INSERT INTO partner VALUES (?,?,?,?)", - (partner_id, partner_name, partner_norm, None), + (partner_id, partner_name, partner_norm, readers.city_from_text(doc.raw_content)), ) report["new_partners"] += 1 @@ -161,11 +161,55 @@ def _process_documents(conn, data_dir, matcher, partners, today, use_vision) -> return report +_OVERMATCH_MAX_NAMES = 8 # «концентрация»: услуга собрала в клинике > стольких разных названий +_OVERMATCH_MIN_CONF = 0.72 # внутри концентрации снимаем только НЕуверенные матчи (сиблинг-захват) + + +def _guard_over_matching( + conn, max_names: int = _OVERMATCH_MAX_NAMES, min_conf: float = _OVERMATCH_MIN_CONF +) -> int: + """Снять ложные эмбеддинг-матчи переобобщения. Если услуга «поглотила» в клинике много + разных сырых названий И матч НЕуверенный — это сиблинг-захват: эмбеддинг подтянул соседнюю, + но другую услугу (напр. «Магний Mg (моча)» собирает Никель/Свинец/Ртуть в моче на пороге + ~0.70). Такие позиции возвращаем в очередь, сохранив кандидата оператору. + + Число поглощённых названий само по себе НЕ признак ошибки: у крупной клиники одна услуга + реально встречается под десятками формулировок («МРТ головного мозга с контрастом 3Тл», + «первичный приём гинеколога»). Эти уверенные вариации остаются сопоставленными — поэтому + решает порог уверенности, а не счётчик. + """ + cur = conn.execute( + """UPDATE price_item + SET suggested_service_id = service_id, suggested_score = map_confidence, + service_id = NULL, map_method = 'over_match' + WHERE map_method = 'embedding' AND service_id IS NOT NULL + AND map_confidence < ? + AND (partner_id, service_id) IN ( + SELECT partner_id, service_id FROM price_item + WHERE map_method = 'embedding' AND service_id IS NOT NULL + GROUP BY partner_id, service_id + HAVING COUNT(DISTINCT service_name_raw) > ?)""", + [min_conf, max_names], + ) + return cur.rowcount + + def _apply_versioning(conn) -> None: - """У партнёра по той же услуге более свежий прайс вытесняет старые (is_active = 0).""" + """Более свежий прайс клиники вытесняет старые (is_active = 0). Сопоставленные позиции + версионируются по услуге справочника (имена в разных прайсах могут отличаться — + напр. native 2026 vs Vision 2025), несопоставленные — по сырому названию. + """ conn.execute( """UPDATE price_item SET is_active = 0 - WHERE effective_date IS NOT NULL AND EXISTS ( + WHERE effective_date IS NOT NULL AND service_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM price_item b + WHERE b.partner_id = price_item.partner_id + AND b.service_id = price_item.service_id + AND b.effective_date > price_item.effective_date)""" + ) + conn.execute( + """UPDATE price_item SET is_active = 0 + WHERE effective_date IS NOT NULL AND service_id IS NULL AND EXISTS ( SELECT 1 FROM price_item b WHERE b.partner_id = price_item.partner_id AND b.service_name_raw = price_item.service_name_raw @@ -189,6 +233,27 @@ def _summary(report: Counter) -> dict: } +def _summary_db(conn) -> dict: + """Сводка по всей базе — считается ПОСЛЕ guard, чтобы процент отражал реальность.""" + total = conn.execute("SELECT COUNT(*) FROM price_item").fetchone()[0] + matched = conn.execute( + "SELECT COUNT(*) FROM price_item WHERE service_id IS NOT NULL" + ).fetchone()[0] + by_method = { + m: conn.execute("SELECT COUNT(*) FROM price_item WHERE map_method = ?", [m]).fetchone()[0] + for m in ("code", "exact", "embedding", "fuzzy", "over_match") + } + return { + "documents": conn.execute("SELECT COUNT(*) FROM price_document").fetchone()[0], + "partners": conn.execute("SELECT COUNT(*) FROM partner").fetchone()[0], + "items": total, + "auto_matched": matched, + "auto_matched_pct": round(100 * matched / total, 1) if total else 0.0, + "unmatched": total - matched, + "by_method": by_method, + } + + def run( data_dir: str, db_path: str, @@ -208,10 +273,11 @@ def run( conn = _open(db_path) _load_services(conn, services) partners: dict[str, str] = {} - report = _process_documents(conn, data_dir, matcher, partners, today, use_vision) + _process_documents(conn, data_dir, matcher, partners, today, use_vision) + _guard_over_matching(conn) # снять ложные эмбеддинг-матчи до подсчёта процента _apply_versioning(conn) conn.commit() - summary = _summary(report) + summary = _summary_db(conn) conn.close() return summary @@ -248,6 +314,7 @@ def ingest( for row in conn.execute("SELECT partner_id, name_norm FROM partner") } report = _process_documents(conn, data_dir, matcher, partners, today, use_vision) + _guard_over_matching(conn) # снять ложные эмбеддинг-матчи и у догрузки _apply_versioning(conn) conn.commit() summary = _summary(report) diff --git a/pitch/MedArchive_команда_107.pptx b/pitch/MedArchive_команда_107.pptx index 034aa48..3f6de52 100644 Binary files a/pitch/MedArchive_команда_107.pptx and b/pitch/MedArchive_команда_107.pptx differ diff --git a/pitch/day2/index.html b/pitch/day2/index.html index 0bb56f5..438abd2 100644 --- a/pitch/day2/index.html +++ b/pitch/day2/index.html @@ -73,8 +73,8 @@

Охват вырос за день

-
15 086позиций извлечено (день 1: 8 871)
-
76,1%нормализовано авто (цель ≥70%)
+
16 140позиций извлечено (день 1: 8 871)
+
73,7%нормализовано авто (цель ≥70%)
10документов обработано
liveвсё на домене, обновлено
@@ -121,7 +121,7 @@ Клиника 3 — PDF, битый текстовый слой151788Gemini Vision поверх битого текста Клиника 4 — PDF, мультитариф137437Gemini Vision -

Vision включается автоматически, когда нативный разбор пуст или подозрительно беден — большие документы не жжём зря. Итог: 8 871 → 15 086 позиций за два дня.

+

Vision включается автоматически, когда нативный разбор пуст или подозрительно беден — большие документы не жжём зря. Итог: 8 871 → 16 140 позиций за два дня.

diff --git a/pitch/landing/index.html b/pitch/landing/index.html index 3df4daa..1b7fe8c 100644 --- a/pitch/landing/index.html +++ b/pitch/landing/index.html @@ -102,8 +102,8 @@

Что уже работает

-
15 086позиции извлечено
-
76,1%нормализовано авто (цель ≥70%)
+
16 140позиции извлечено
+
73,7%нормализовано авто (цель ≥70%)
8 / 10клиник / прайсов
liveразвёрнуто на домене