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:
@@ -27,3 +27,6 @@ Thumbs.db
|
||||
# Данные клиник: реальный архив и справочник в публичный репозиторий не коммитим
|
||||
data/
|
||||
*.sqlite
|
||||
|
||||
# Внутренние рабочие доки хакатона (план, спеки, дизайн) — не в публичный репо
|
||||
_internal/
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user