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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user