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/
|
data/
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
|
# Внутренние рабочие доки хакатона (план, спеки, дизайн) — не в публичный репо
|
||||||
|
_internal/
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ XLSX/XLS), извлекает услуги и цены, нормализует
|
|||||||
|
|
||||||
## Результат на тестовом архиве (8 клиник, 10 файлов)
|
## Результат на тестовом архиве (8 клиник, 10 файлов)
|
||||||
|
|
||||||
- **15 086 позиций** извлечено: нативными парсерами + трудные PDF (битый текстовый слой,
|
- **16 140 позиций** извлечено: нативными парсерами + трудные PDF (битый текстовый слой,
|
||||||
нестандартная вёрстка) — через Gemini Vision; обработаны все 8 клиник.
|
нестандартная вёрстка) — через Gemini Vision; обработаны все 8 клиник.
|
||||||
- **76,1% позиций нормализованы автоматически** (цель ТЗ — не ниже 70%).
|
- **73,7% позиций нормализованы автоматически** (цель ТЗ — не ниже 70%).
|
||||||
- Остальное уходит в очередь ручной разметки, как и предполагает ТЗ.
|
- Остальное уходит в очередь ручной разметки, как и предполагает ТЗ.
|
||||||
|
|
||||||
Полный отчёт о качестве — `docs/quality_report.json`.
|
Полный отчёт о качестве — `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,
|
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,
|
a.price_resident AS new_price, a.effective_date AS new_date,
|
||||||
(SELECT b.price_resident FROM price_item b
|
(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
|
AND b.is_active = 0 AND b.price_resident IS NOT NULL
|
||||||
ORDER BY b.effective_date DESC LIMIT 1) AS old_price,
|
ORDER BY b.effective_date DESC LIMIT 1) AS old_price,
|
||||||
(SELECT b.effective_date FROM price_item b
|
(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
|
AND b.is_active = 0 AND b.price_resident IS NOT NULL
|
||||||
ORDER BY b.effective_date DESC LIMIT 1) AS old_date
|
ORDER BY b.effective_date DESC LIMIT 1) AS old_date
|
||||||
FROM price_item a
|
FROM price_item a
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ HNSW-индексом, в остальном модель та же.
|
|||||||
пересчитывается, эмбеддится только новый файл. Подтверждение оператора пишет `learned_synonym` —
|
пересчитывается, эмбеддится только новый файл. Подтверждение оператора пишет `learned_synonym` —
|
||||||
следующий прогон сопоставит синоним автоматически (система дообучается).
|
следующий прогон сопоставит синоним автоматически (система дообучается).
|
||||||
|
|
||||||
**Качество на тестовом архиве** (8 клиник, 10 файлов): 15 086 позиций извлечено, **76,1%
|
**Качество на тестовом архиве** (8 клиник, 10 файлов): 16 140 позиций извлечено, **73,7%
|
||||||
нормализовано автоматически** (код 4397, точное 254, эмбеддинги 6717, нечёткое 110), остальное —
|
нормализовано автоматически** (код 4397, точное 291, эмбеддинги 7083, нечёткое 128), плюс 273 неуверенных
|
||||||
в очередь.
|
матча сняты guard'ом переобобщения в очередь.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"documents": 10,
|
"documents": 10,
|
||||||
"partners": 8,
|
"partners": 8,
|
||||||
"items": 8871,
|
"items": 16140,
|
||||||
"auto_matched": 6555,
|
"auto_matched": 11899,
|
||||||
"auto_matched_pct": 73.9,
|
"auto_matched_pct": 73.7,
|
||||||
"unmatched": 2316,
|
"unmatched": 4241,
|
||||||
"by_method": {
|
"by_method": {
|
||||||
"code": 1414,
|
"code": 4397,
|
||||||
"exact": 167,
|
"exact": 291,
|
||||||
"embedding": 4903,
|
"embedding": 7083,
|
||||||
"fuzzy": 71,
|
"fuzzy": 128,
|
||||||
"null": 2316
|
"over_match": 273
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,24 @@ def partner_from_name(name: str) -> str:
|
|||||||
return re.sub(r"\s+", " ", base).strip()
|
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:
|
def detect_format(path: str) -> str:
|
||||||
ext = Path(path).suffix.lower()
|
ext = Path(path).suffix.lower()
|
||||||
return {".xlsx": "xlsx", ".xls": "xls", ".docx": "docx", ".pdf": "pdf"}.get(
|
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]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from etl.dictionary import load_dictionary, normalize_name
|
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.normalize import Matcher
|
||||||
from etl.validate import validate_item
|
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
|
partners[partner_norm] = partner_id
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO partner VALUES (?,?,?,?)",
|
"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
|
report["new_partners"] += 1
|
||||||
|
|
||||||
@@ -161,11 +161,55 @@ def _process_documents(conn, data_dir, matcher, partners, today, use_vision) ->
|
|||||||
return report
|
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:
|
def _apply_versioning(conn) -> None:
|
||||||
"""У партнёра по той же услуге более свежий прайс вытесняет старые (is_active = 0)."""
|
"""Более свежий прайс клиники вытесняет старые (is_active = 0). Сопоставленные позиции
|
||||||
|
версионируются по услуге справочника (имена в разных прайсах могут отличаться —
|
||||||
|
напр. native 2026 vs Vision 2025), несопоставленные — по сырому названию.
|
||||||
|
"""
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE price_item SET is_active = 0
|
"""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
|
SELECT 1 FROM price_item b
|
||||||
WHERE b.partner_id = price_item.partner_id
|
WHERE b.partner_id = price_item.partner_id
|
||||||
AND b.service_name_raw = price_item.service_name_raw
|
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(
|
def run(
|
||||||
data_dir: str,
|
data_dir: str,
|
||||||
db_path: str,
|
db_path: str,
|
||||||
@@ -208,10 +273,11 @@ def run(
|
|||||||
conn = _open(db_path)
|
conn = _open(db_path)
|
||||||
_load_services(conn, services)
|
_load_services(conn, services)
|
||||||
partners: dict[str, str] = {}
|
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)
|
_apply_versioning(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
summary = _summary(report)
|
summary = _summary_db(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
@@ -248,6 +314,7 @@ def ingest(
|
|||||||
for row in conn.execute("SELECT partner_id, name_norm FROM partner")
|
for row in conn.execute("SELECT partner_id, name_norm FROM partner")
|
||||||
}
|
}
|
||||||
report = _process_documents(conn, data_dir, matcher, partners, today, use_vision)
|
report = _process_documents(conn, data_dir, matcher, partners, today, use_vision)
|
||||||
|
_guard_over_matching(conn) # снять ложные эмбеддинг-матчи и у догрузки
|
||||||
_apply_versioning(conn)
|
_apply_versioning(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
summary = _summary(report)
|
summary = _summary(report)
|
||||||
|
|||||||
Binary file not shown.
@@ -73,8 +73,8 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>Охват вырос за день</h2>
|
<h2>Охват вырос за день</h2>
|
||||||
<div class="kpi">
|
<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">16 140</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">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">10</span><span class="lbl">документов обработано</span></div>
|
||||||
<div class="card"><span class="num">live</span><span class="lbl">всё на домене, обновлено</span></div>
|
<div class="card"><span class="num">live</span><span class="lbl">всё на домене, обновлено</span></div>
|
||||||
</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>Клиника 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>
|
<tr><td>Клиника 4 — PDF, мультитариф</td><td class="pct">137</td><td class="pct up">437</td><td>Gemini Vision</td></tr>
|
||||||
</table>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- 7 -->
|
<!-- 7 -->
|
||||||
|
|||||||
@@ -102,8 +102,8 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>Что уже работает</h2>
|
<h2>Что уже работает</h2>
|
||||||
<div class="kpi">
|
<div class="kpi">
|
||||||
<div class="card"><span class="num">15 086</span><span class="lbl">позиции извлечено</span></div>
|
<div class="card"><span class="num">16 140</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">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">8 / 10</span><span class="lbl">клиник / прайсов</span></div>
|
||||||
<div class="card"><span class="num">live</span><span class="lbl">развёрнуто на домене</span></div>
|
<div class="card"><span class="num">live</span><span class="lbl">развёрнуто на домене</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user