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>
This commit is contained in:
2026-06-27 19:42:21 +05:00
parent 1a4cc039b2
commit dbe36d3323
10 changed files with 120 additions and 28 deletions
+3
View File
@@ -27,3 +27,6 @@ Thumbs.db
# Данные клиник: реальный архив и справочник в публичный репозиторий не коммитим
data/
*.sqlite
# Внутренние рабочие доки хакатона (план, спеки, дизайн) — не в публичный репо
_internal/
+2 -2
View File
@@ -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`.
+6 -2
View File
@@ -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
+3 -3
View File
@@ -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'ом переобобщения в очередь.
---
+10 -10
View File
@@ -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
}
}
}
+18
View File
@@ -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(
+73 -6
View File
@@ -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)
Binary file not shown.
+3 -3
View File
@@ -73,8 +73,8 @@
<section>
<h2>Охват вырос за день</h2>
<div class="kpi">
<div class="card"><span class="num">15 086</span><span class="lbl">позиций извлечено (день 1: 8 871)</span></div>
<div class="card"><span class="num">76,1%</span><span class="lbl">нормализовано авто (цель ≥70%)</span></div>
<div class="card"><span class="num">16 140</span><span class="lbl">позиций извлечено (день 1: 8 871)</span></div>
<div class="card"><span class="num">73,7%</span><span class="lbl">нормализовано авто (цель ≥70%)</span></div>
<div class="card"><span class="num">10</span><span class="lbl">документов обработано</span></div>
<div class="card"><span class="num">live</span><span class="lbl">всё на домене, обновлено</span></div>
</div>
@@ -121,7 +121,7 @@
<tr><td>Клиника 3 — PDF, битый текстовый слой</td><td class="pct">151</td><td class="pct up">788</td><td>Gemini Vision поверх битого текста</td></tr>
<tr><td>Клиника 4 — PDF, мультитариф</td><td class="pct">137</td><td class="pct up">437</td><td>Gemini Vision</td></tr>
</table>
<p class="lead" style="margin-top:.7rem">Vision включается автоматически, когда нативный разбор пуст или подозрительно беден — большие документы не жжём зря. Итог: <b>8 871 → 15 086</b> позиций за два дня.</p>
<p class="lead" style="margin-top:.7rem">Vision включается автоматически, когда нативный разбор пуст или подозрительно беден — большие документы не жжём зря. Итог: <b>8 871 → 16 140</b> позиций за два дня.</p>
</section>
<!-- 7 -->
+2 -2
View File
@@ -102,8 +102,8 @@
<section>
<h2>Что уже работает</h2>
<div class="kpi">
<div class="card"><span class="num">15 086</span><span class="lbl">позиции извлечено</span></div>
<div class="card"><span class="num">76,1%</span><span class="lbl">нормализовано авто (цель ≥70%)</span></div>
<div class="card"><span class="num">16 140</span><span class="lbl">позиции извлечено</span></div>
<div class="card"><span class="num">73,7%</span><span class="lbl">нормализовано авто (цель ≥70%)</span></div>
<div class="card"><span class="num">8 / 10</span><span class="lbl">клиник / прайсов</span></div>
<div class="card"><span class="num">live</span><span class="lbl">развёрнуто на домене</span></div>
</div>