feat(ingest+поиск): приём ZIP через интерфейс, регистронезависимый поиск, чистка очереди

- Загрузка архива (§4.1): POST /ingest распаковывает ZIP, дораспознаёт и
  догружает прайсы в базу (append). Виджет в админке. Кэш эмбеддингов справочника
  (dict_emb.npy) — догрузка считает только новый файл, эмбеддинги best-effort с
  откатом на fuzzy при сбое Gemini.
- Поиск по name_norm (lowercase): «УЗИ» и «узи» дают одинаковый результат —
  SQLite LIKE не сворачивает регистр для кириллицы.
- Фильтр названий: код-мнемоники («МОЗО.15») больше не попадают в очередь.
- /stats считается из базы — цифры на дашборде обновляются после загрузки.
- Деплой через Dockerfile (зависимости в образе, код монтируется томом).
- ruff: ядро и весь репозиторий проходят проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 11:35:12 +05:00
parent 89928511ba
commit 69a9884db5
25 changed files with 2217 additions and 182 deletions
+145 -16
View File
@@ -4,6 +4,7 @@
задаётся переменной окружения MEDARCHIVE_DB. Ядро WOW — /services/{id}/partners: задаётся переменной окружения MEDARCHIVE_DB. Ядро WOW — /services/{id}/partners:
«кто оказывает услугу и по какой цене», отсортировано от выгодной. «кто оказывает услугу и по какой цене», отсортировано от выгодной.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -13,13 +14,15 @@ import sqlite3
import sys import sys
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from contracts.models import PartnerOut, PriceOut, ServiceOut, Stats # noqa: E402 from contracts.models import PartnerOut, PriceOut, ServiceOut, Stats # noqa: E402
DB_PATH = os.environ.get("MEDARCHIVE_DB", str(Path(__file__).resolve().parents[1] / "data/medarchive.db")) DB_PATH = os.environ.get(
"MEDARCHIVE_DB", str(Path(__file__).resolve().parents[1] / "data/medarchive.db")
)
app = FastAPI( app = FastAPI(
title="MedArchive API", title="MedArchive API",
@@ -28,9 +31,7 @@ app = FastAPI(
# За Caddy сервис живёт на /api — без этого Swagger ищет спеку в корне и падает. # За Caddy сервис живёт на /api — без этого Swagger ищет спеку в корне и падает.
root_path="/api", root_path="/api",
) )
app.add_middleware( app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
def db() -> sqlite3.Connection: def db() -> sqlite3.Connection:
@@ -68,16 +69,21 @@ def list_services(specialty: str | None = None, q: str | None = None, limit: int
sql += " AND specialty = ?" sql += " AND specialty = ?"
args.append(specialty) args.append(specialty)
if q: if q:
sql += " AND name_ru LIKE ?" # Поиск по нормализованной колонке: SQLite LIKE сворачивает регистр только для
args.append(f"%{q}%") # латиницы, поэтому «УЗИ» и «узи» совпадут лишь при сравнении lowercase-форм.
sql += " AND name_norm LIKE ?"
args.append(f"%{_norm(q)}%")
sql += " ORDER BY name_ru LIMIT ?" sql += " ORDER BY name_ru LIMIT ?"
args.append(limit) args.append(limit)
with db() as conn: with db() as conn:
return [ServiceOut(**dict(r)) for r in conn.execute(sql, args)] return [ServiceOut(**dict(r)) for r in conn.execute(sql, args)]
@app.get("/services/{service_id}/partners", response_model=list[PriceOut], @app.get(
summary="Кто оказывает услугу и по какой цене") "/services/{service_id}/partners",
response_model=list[PriceOut],
summary="Кто оказывает услугу и по какой цене",
)
def service_partners(service_id: str): def service_partners(service_id: str):
"""Одна строка на клинику с показательной ценой (резидент или минимальный тариф).""" """Одна строка на клинику с показательной ценой (резидент или минимальный тариф)."""
sql = """SELECT pi.partner_id, p.name AS partner_name, pi.price_resident, pi.price_nonresident, sql = """SELECT pi.partner_id, p.name AS partner_name, pi.price_resident, pi.price_nonresident,
@@ -114,11 +120,17 @@ def list_partners(city: str | None = None):
args.append(city) args.append(city)
sql += " ORDER BY name" sql += " ORDER BY name"
with db() as conn: with db() as conn:
return [PartnerOut(partner_id=r["partner_id"], name=r["name"], city=r["city"]) for r in conn.execute(sql, args)] return [
PartnerOut(partner_id=r["partner_id"], name=r["name"], city=r["city"])
for r in conn.execute(sql, args)
]
@app.get("/partners/{partner_id}/services", response_model=list[PriceOut], @app.get(
summary="Все услуги партнёра с ценами") "/partners/{partner_id}/services",
response_model=list[PriceOut],
summary="Все услуги партнёра с ценами",
)
def partner_services(partner_id: str, limit: int = 500): def partner_services(partner_id: str, limit: int = 500):
sql = """SELECT pi.partner_id, p.name AS partner_name, pi.price_resident, pi.price_nonresident, sql = """SELECT pi.partner_id, p.name AS partner_name, pi.price_resident, pi.price_nonresident,
pi.prices, pi.effective_date pi.prices, pi.effective_date
@@ -130,18 +142,22 @@ def partner_services(partner_id: str, limit: int = 500):
@app.get("/search", summary="Полнотекстовый поиск по услугам и партнёрам") @app.get("/search", summary="Полнотекстовый поиск по услугам и партнёрам")
def search(q: str = Query(..., min_length=1), limit: int = 20): def search(q: str = Query(..., min_length=1), limit: int = 20):
# Сравниваем по name_norm (lowercase): иначе кириллический регистр («УЗИ» vs «узи»)
# не сворачивается и часть запросов даёт «ничего не найдено».
like = f"%{_norm(q)}%"
with db() as conn: with db() as conn:
services = [ services = [
dict(r) dict(r)
for r in conn.execute( for r in conn.execute(
"SELECT service_id, specialty, name_ru FROM service WHERE name_ru LIKE ? LIMIT ?", "SELECT service_id, specialty, name_ru FROM service WHERE name_norm LIKE ? LIMIT ?",
[f"%{q}%", limit], [like, limit],
) )
] ]
partners = [ partners = [
dict(r) dict(r)
for r in conn.execute( for r in conn.execute(
"SELECT partner_id, name, city FROM partner WHERE name LIKE ? LIMIT ?", [f"%{q}%", limit] "SELECT partner_id, name, city FROM partner WHERE name_norm LIKE ? LIMIT ?",
[like, limit],
) )
] ]
return {"services": services, "partners": partners} return {"services": services, "partners": partners}
@@ -203,6 +219,102 @@ def skip_item(item_id: int):
return {"item_id": item_id, "status": "skipped"} return {"item_id": item_id, "status": "skipped"}
_SUPPORTED_EXT = (".pdf", ".xlsx", ".xls", ".docx")
def _safe_member_name(member) -> str:
"""Имя файла из ZIP: чинит кириллицу (cp866) и срезает путь (защита от traversal)."""
name = member.filename
if not (member.flag_bits & 0x800): # нет UTF-8-флага — вероятно cp437/cp866 (рус. Windows)
try:
name = name.encode("cp437").decode("cp866")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
return os.path.basename(name.replace("\\", "/"))
@app.post("/ingest", summary="Загрузить ZIP-архив прайсов и обработать (догрузка в базу)")
def ingest_archive(file: UploadFile = File(...)):
"""Приём архива через интерфейс (ТЗ §4.1): распаковать ZIP и догрузить прайсы в базу.
Тяжёлые зависимости (конвейер, Gemini) импортируются лениво — read-only API живёт и
без них. Эмбеддинги — по возможности: при сбое Gemini обрабатываем без них (fuzzy),
архив всё равно принимается.
"""
import shutil
import tempfile
import zipfile
name = (file.filename or "").lower()
if not name.endswith(".zip"):
raise HTTPException(400, "ожидается ZIP-архив (.zip)")
workdir = Path(tempfile.mkdtemp(prefix="medarchive_ingest_"))
try:
zip_path = workdir / "upload.zip"
with zip_path.open("wb") as out:
shutil.copyfileobj(file.file, out)
extract_dir = workdir / "files"
extract_dir.mkdir()
try:
with zipfile.ZipFile(zip_path) as zf:
members = [m for m in zf.infolist() if not m.is_dir()]
for member in members:
fname = _safe_member_name(member)
if not fname or not fname.lower().endswith(_SUPPORTED_EXT):
continue
with zf.open(member) as src, (extract_dir / fname).open("wb") as dst:
shutil.copyfileobj(src, dst)
except zipfile.BadZipFile as exc:
raise HTTPException(400, "файл не является корректным ZIP-архивом") from exc
prices = [p for p in extract_dir.iterdir() if p.is_file()]
if not prices:
raise HTTPException(
422, "в архиве не найдено прайсов поддерживаемых форматов (pdf, xlsx, xls, docx)"
)
from etl.pipeline import ingest # noqa: PLC0415 — ленивый импорт тяжёлых зависимостей
embedder = None
if os.environ.get("GEMINI_API_KEY"):
try:
from etl.normalize.embedding import GeminiEmbedder # noqa: PLC0415
embedder = GeminiEmbedder()
except Exception: # ключа/SDK нет — нормализуем без эмбеддингов
embedder = None
dict_path = str(Path(DB_PATH).parent / "reference/dictionary.xlsx")
kwargs = dict(data_dir=str(extract_dir), db_path=DB_PATH, dict_path=dict_path)
try:
summary = ingest(embedder=embedder, **kwargs)
degraded = False
except Exception:
# Сбой эмбеддингов (квота/сеть) не должен ронять приём архива — обрабатываем без них.
if embedder is None:
raise
summary = ingest(embedder=None, **kwargs)
degraded = True
return {
"status": "ok",
"files_accepted": len(prices),
"documents": summary["documents"],
"new_partners": summary["partners"],
"items": summary["items"],
"auto_matched": summary["auto_matched"],
"auto_matched_pct": summary["auto_matched_pct"],
"by_method": summary["by_method"],
"embeddings": not degraded and embedder is not None,
}
finally:
import shutil as _sh
_sh.rmtree(workdir, ignore_errors=True)
@app.post("/match", summary="Ручное сопоставление позиции (с дообучением)") @app.post("/match", summary="Ручное сопоставление позиции (с дообучением)")
def manual_match(item_id: int, service_id: str): def manual_match(item_id: int, service_id: str):
with db() as conn: with db() as conn:
@@ -214,7 +326,8 @@ def manual_match(item_id: int, service_id: str):
if not row: if not row:
raise HTTPException(404, "позиция не найдена") raise HTTPException(404, "позиция не найдена")
conn.execute( conn.execute(
"UPDATE price_item SET service_id = ?, map_method = 'manual', map_confidence = 1.0 WHERE item_id = ?", "UPDATE price_item SET service_id = ?, map_method = 'manual', map_confidence = 1.0 "
"WHERE item_id = ?",
[service_id, item_id], [service_id, item_id],
) )
# Дообучение: запоминаем синоним — следующие прогоны сопоставят его автоматически. # Дообучение: запоминаем синоним — следующие прогоны сопоставят его автоматически.
@@ -228,6 +341,22 @@ def manual_match(item_id: int, service_id: str):
@app.get("/stats", response_model=Stats, summary="Сводка качества обработки") @app.get("/stats", response_model=Stats, summary="Сводка качества обработки")
def stats(): def stats():
"""Считается из базы — так после загрузки нового архива цифры обновляются сразу."""
with db() as conn:
docs = conn.execute("SELECT COUNT(*) FROM price_document").fetchone()[0]
items = conn.execute("SELECT COUNT(*) FROM price_item WHERE is_active = 1").fetchone()[0]
matched = conn.execute(
"SELECT COUNT(*) FROM price_item WHERE is_active = 1 AND service_id IS NOT NULL"
).fetchone()[0]
if items:
return Stats(
documents_total=docs,
documents_done=docs,
items_total=items,
auto_matched_pct=round(100 * matched / items, 1),
unmatched_total=items - matched,
)
# Фолбэк на отчёт сборки, если база ещё пуста.
report = Path(DB_PATH).parent / "quality_report.json" report = Path(DB_PATH).parent / "quality_report.json"
if report.exists(): if report.exists():
data = json.loads(report.read_text(encoding="utf-8")) data = json.loads(report.read_text(encoding="utf-8"))
+1
View File
@@ -4,6 +4,7 @@
`MatchResult` — выход сопоставления (агент C). `*Out`-модели — выдача API (агент D), `MatchResult` — выход сопоставления (агент C). `*Out`-модели — выдача API (агент D),
её же потребляет фронт (агент E). Любая правка этих типов проходит через оркестратора. её же потребляет фронт (агент E). Любая правка этих типов проходит через оркестратора.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
+4 -3
View File
@@ -10,6 +10,7 @@
отвечает только за загрузку и подготовку индексов; само сопоставление — в отвечает только за загрузку и подготовку индексов; само сопоставление — в
`etl/normalize`. `etl/normalize`.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
@@ -40,9 +41,9 @@ def normalize_name(text: str | None) -> str:
class Service: class Service:
"""Одна запись эталонного справочника услуг.""" """Одна запись эталонного справочника услуг."""
service_id: str # стабильный идентификатор «<ID>-<Code>» service_id: str # стабильный идентификатор «<ID>-<Code>»
specialty: str # специальность — служит и категорией, и сужением поиска specialty: str # специальность — служит и категорией, и сужением поиска
name_ru: str # официальное название name_ru: str # официальное название
tarificator_code: str | None # код тарификатора (если задан) tarificator_code: str | None # код тарификатора (если задан)
name_norm: str = field(default="") # нормализованная форма name_ru name_norm: str = field(default="") # нормализованная форма name_ru
+4 -1
View File
@@ -4,6 +4,7 @@
PDF с битым текстовым слоем помечается как scan_pdf — его добирает путь через Vision. PDF с битым текстовым слоем помечается как scan_pdf — его добирает путь через Vision.
Любой сбой чтения логируется, но не роняет обработку остального архива. Любой сбой чтения логируется, но не роняет обработку остального архива.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
@@ -26,7 +27,9 @@ def extract(path: str, use_vision: bool = True) -> ParsedDocument:
""" """
name = Path(path).name name = Path(path).name
fmt = readers.detect_format(path) fmt = readers.detect_format(path)
doc = ParsedDocument(file_name=name, file_format=fmt, partner_name=readers.partner_from_name(name)) doc = ParsedDocument(
file_name=name, file_format=fmt, partner_name=readers.partner_from_name(name)
)
year = readers.year_from_name(name) year = readers.year_from_name(name)
if year: if year:
+7 -1
View File
@@ -6,6 +6,7 @@
в числах, многотарифные колонки цен, строки-заголовки секций и шапку не в первой в числах, многотарифные колонки цен, строки-заголовки секций и шапку не в первой
строке таблицы. строке таблицы.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
@@ -85,7 +86,12 @@ def is_real_name(text) -> bool:
""" """
s = clean(text) s = clean(text)
cyrillic = sum(1 for ch in s if "а" <= ch.lower() <= "я" or ch.lower() == "ё") cyrillic = sum(1 for ch in s if "а" <= ch.lower() <= "я" or ch.lower() == "ё")
return cyrillic >= 3 and not looks_like_code(s) if cyrillic < 3 or looks_like_code(s):
return False
# Код-мнемоника: цифры + только заглавные буквы + без пробела (напр. «МОЗО.15») — не услуга.
if any(c.isdigit() for c in s) and " " not in s and not any(c.islower() for c in s):
return False
return True
def classify_price_tier(header_text) -> str | None: def classify_price_tier(header_text) -> str | None:
+22 -5
View File
@@ -9,6 +9,7 @@
- настоящая цена медуслуги — сотни и тысячи тенге, а не мелкое число; - настоящая цена медуслуги — сотни и тысячи тенге, а не мелкое число;
- название услуги — самая «кириллическая» колонка, код — цифро-точечная. - название услуги — самая «кириллическая» колонка, код — цифро-точечная.
""" """
from __future__ import annotations from __future__ import annotations
import statistics import statistics
@@ -97,7 +98,11 @@ def _assign_columns(grid: Grid, header_idx: int):
# --- колонка названия: явный заголовок, иначе самая кириллическая --- # --- колонка названия: явный заголовок, иначе самая кириллическая ---
name_col = next( name_col = next(
(c for c in range(ncol) if c not in price_cols and any(k in header[c].lower() for k in _NAME_KEYS)), (
c
for c in range(ncol)
if c not in price_cols and any(k in header[c].lower() for k in _NAME_KEYS)
),
None, None,
) )
if name_col is None: if name_col is None:
@@ -109,7 +114,9 @@ def _assign_columns(grid: Grid, header_idx: int):
( (
c c
for c in range(ncol) for c in range(ncol)
if c != name_col and c not in price_cols and any(k in header[c].lower() for k in _CODE_HEADER_KEYS) if c != name_col
and c not in price_cols
and any(k in header[c].lower() for k in _CODE_HEADER_KEYS)
), ),
None, None,
) )
@@ -123,7 +130,11 @@ def _assign_columns(grid: Grid, header_idx: int):
break break
unit_col = next( unit_col = next(
(c for c in range(ncol) if c not in price_cols and any(k in header[c].lower() for k in _UNIT_KEYS)), (
c
for c in range(ncol)
if c not in price_cols and any(k in header[c].lower() for k in _UNIT_KEYS)
),
None, None,
) )
return name_col, code_col, unit_col, price_cols return name_col, code_col, unit_col, price_cols
@@ -165,10 +176,16 @@ def extract_rows_from_grid(grid: Grid) -> list[RawRow]:
RawRow( RawRow(
service_name_raw=name, service_name_raw=name,
service_code_source=( service_code_source=(
r[code_col] if code_col is not None and code_col < len(r) and r[code_col] else None r[code_col]
if code_col is not None and code_col < len(r) and r[code_col]
else None
), ),
prices=prices, prices=prices,
unit=(r[unit_col] if unit_col is not None and unit_col < len(r) and r[unit_col] else None), unit=(
r[unit_col]
if unit_col is not None and unit_col < len(r) and r[unit_col]
else None
),
section=section, section=section,
) )
) )
+8 -2
View File
@@ -4,6 +4,7 @@
многолистовых книг и многотабличных PDF. Сырой текст нужен для аудита и для многолистовых книг и многотабличных PDF. Сырой текст нужен для аудита и для
детектора битого текстового слоя. детектора битого текстового слоя.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
@@ -29,14 +30,19 @@ def partner_from_name(name: str) -> str:
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(ext, ext.lstrip(".")) return {".xlsx": "xlsx", ".xls": "xls", ".docx": "docx", ".pdf": "pdf"}.get(
ext, ext.lstrip(".")
)
def docx_grids(path: str) -> tuple[list[Grid], str]: def docx_grids(path: str) -> tuple[list[Grid], str]:
import docx import docx
document = docx.Document(path) document = docx.Document(path)
grids = [[[clean(cell.text) for cell in row.cells] for row in table.rows] for table in document.tables] grids = [
[[clean(cell.text) for cell in row.cells] for row in table.rows]
for table in document.tables
]
text = "\n".join(p.text for p in document.paragraphs) text = "\n".join(p.text for p in document.paragraphs)
return grids, text return grids, text
+1
View File
@@ -5,6 +5,7 @@
вернуть позиции прайса строго по JSON-схеме. Требует переменную окружения вернуть позиции прайса строго по JSON-схеме. Требует переменную окружения
GEMINI_API_KEY; модель задаётся через GEMINI_MODEL (по умолчанию gemini-2.0-flash). GEMINI_API_KEY; модель задаётся через GEMINI_MODEL (по умолчанию gemini-2.0-flash).
""" """
from __future__ import annotations from __future__ import annotations
import json import json
+1
View File
@@ -1,4 +1,5 @@
"""Нормализация извлечённых позиций к справочнику услуг.""" """Нормализация извлечённых позиций к справочнику услуг."""
from etl.normalize.matcher import Matcher from etl.normalize.matcher import Matcher
__all__ = ["Matcher"] __all__ = ["Matcher"]
+4 -1
View File
@@ -4,6 +4,7 @@
раздувается и не держит модель в RAM. Размерность 768 выбрана, чтобы вектор лез в раздувается и не держит модель в RAM. Размерность 768 выбрана, чтобы вектор лез в
HNSW-индекс pgvector. Требуется переменная окружения GEMINI_API_KEY. HNSW-индекс pgvector. Требуется переменная окружения GEMINI_API_KEY.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
@@ -54,7 +55,9 @@ class GeminiEmbedder:
chunk = items[start : start + self.batch] chunk = items[start : start + self.batch]
for attempt in range(8): for attempt in range(8):
try: try:
response = self._client.models.embed_content(model=self.model, contents=chunk, config=config) response = self._client.models.embed_content(
model=self.model, contents=chunk, config=config
)
vectors.extend(embedding.values for embedding in response.embeddings) vectors.extend(embedding.values for embedding in response.embeddings)
break break
except Exception as exc: # пауза и повтор при превышении квоты (429) except Exception as exc: # пауза и повтор при превышении квоты (429)
+15 -5
View File
@@ -11,6 +11,7 @@
клиник встречаются с лишним хвостом («A02.020.000.2»), поэтому сравниваем по клиник встречаются с лишним хвостом («A02.020.000.2»), поэтому сравниваем по
канонической части кода тарификатора. канонической части кода тарификатора.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
@@ -37,6 +38,7 @@ class Matcher:
embedder=None, embedder=None,
emb_threshold: float = 0.70, emb_threshold: float = 0.70,
fuzzy_threshold: int = 88, fuzzy_threshold: int = 88,
dict_emb=None,
): ):
self.services = services self.services = services
self.by_code = {s.tarificator_code: s for s in services if s.tarificator_code} self.by_code = {s.tarificator_code: s for s in services if s.tarificator_code}
@@ -47,8 +49,10 @@ class Matcher:
self.emb_threshold = emb_threshold self.emb_threshold = emb_threshold
self.fuzzy_threshold = fuzzy_threshold self.fuzzy_threshold = fuzzy_threshold
self.embedder = embedder self.embedder = embedder
self.dict_emb = None # Готовые эмбеддинги справочника (dict_emb) переиспользуются между прогонами:
if embedder is not None: # при догрузке нового прайса не нужно заново векторизовать тысячи услуг.
self.dict_emb = dict_emb
if embedder is not None and self.dict_emb is None:
self.dict_emb = embedder.encode( self.dict_emb = embedder.encode(
[s.name_ru for s in services], [s.name_ru for s in services],
normalize_embeddings=True, normalize_embeddings=True,
@@ -65,7 +69,9 @@ class Matcher:
best = process.extractOne(name_norm, self.names_norm, scorer=fuzz.token_set_ratio) best = process.extractOne(name_norm, self.names_norm, scorer=fuzz.token_set_ratio)
if best and best[1] >= self.fuzzy_threshold: if best and best[1] >= self.fuzzy_threshold:
return MatchResult( return MatchResult(
service_id=self.services[best[2]].service_id, method="fuzzy", confidence=best[1] / 100 service_id=self.services[best[2]].service_id,
method="fuzzy",
confidence=best[1] / 100,
) )
return MatchResult() return MatchResult()
@@ -78,12 +84,16 @@ class Matcher:
for i, (name, code) in enumerate(zip(names, codes, strict=True)): for i, (name, code) in enumerate(zip(names, codes, strict=True)):
service = self._match_by_code(code) service = self._match_by_code(code)
if service: if service:
results[i] = MatchResult(service_id=service.service_id, method="code", confidence=1.0) results[i] = MatchResult(
service_id=service.service_id, method="code", confidence=1.0
)
continue continue
name_norm = normalize_name(name) name_norm = normalize_name(name)
exact = self.by_norm.get(name_norm) exact = self.by_norm.get(name_norm)
if exact: if exact:
results[i] = MatchResult(service_id=exact.service_id, method="exact", confidence=1.0) results[i] = MatchResult(
service_id=exact.service_id, method="exact", confidence=1.0
)
continue continue
pending_idx.append(i) pending_idx.append(i)
pending_norm.append(name_norm) pending_norm.append(name_norm)
+143 -29
View File
@@ -4,7 +4,11 @@
PostgreSQL + pgvector лежит в db/migrations. Здесь же — дедупликация партнёров, PostgreSQL + pgvector лежит в db/migrations. Здесь же — дедупликация партнёров,
версионирование цен (последний прайс активен, старые архивируются) и сбор отчёта о версионирование цен (последний прайс активен, старые архивируются) и сбор отчёта о
качестве для дашборда и сдачи. качестве для дашборда и сдачи.
Два входа: `run()` собирает базу с нуля (полная пересборка), `ingest()` догружает
новые прайсы в уже собранную базу (append) — на нём держится приём ZIP через интерфейс.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -15,6 +19,8 @@ from collections import Counter
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
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
@@ -46,33 +52,49 @@ CREATE INDEX IF NOT EXISTS price_item_service ON price_item(service_id);
CREATE INDEX IF NOT EXISTS price_item_partner ON price_item(partner_id); CREATE INDEX IF NOT EXISTS price_item_partner ON price_item(partner_id);
""" """
_INSERT_ITEM = """INSERT INTO price_item
(doc_id, partner_id, service_name_raw, service_code_source, service_id, prices,
price_resident, price_nonresident, unit, effective_date, map_method, map_confidence,
status, is_active, suggested_service_id, suggested_score)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)"""
def _emb_path(db_path: str) -> Path:
"""Путь к кэшу эмбеддингов справочника рядом с базой."""
return Path(db_path).parent / "dict_emb.npy"
def _open(db_path: str) -> sqlite3.Connection: def _open(db_path: str) -> sqlite3.Connection:
# Полная пересборка: удаляем файл, чтобы схема всегда создавалась свежей. # Полная пересборка: удаляем файл, чтобы схема всегда создавалась свежей.
Path(db_path).unlink(missing_ok=True) Path(db_path).unlink(missing_ok=True)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
return conn return conn
def run(data_dir: str, db_path: str, dict_path: str, embedder=None, use_vision: bool = False, def _load_services(conn, services) -> None:
today: date | None = None) -> dict: """Залить справочник услуг, если таблица ещё пуста."""
"""Прогнать весь архив в SQLite и вернуть отчёт о качестве.""" if conn.execute("SELECT 1 FROM service LIMIT 1").fetchone():
today = today or date.today() return
services = load_dictionary(dict_path)
matcher = Matcher(services, embedder=embedder)
conn = _open(db_path)
conn.executemany( conn.executemany(
"INSERT INTO service VALUES (?,?,?,?,?)", "INSERT INTO service VALUES (?,?,?,?,?)",
[(s.service_id, s.specialty, s.name_ru, s.name_norm, s.tarificator_code) for s in services], [(s.service_id, s.specialty, s.name_ru, s.name_norm, s.tarificator_code) for s in services],
) )
partners: dict[str, str] = {}
staged: list[tuple] = [] # (doc_id, partner_id, raw, code, prices, unit, eff_iso) def _process_documents(conn, data_dir, matcher, partners, today, use_vision) -> Counter:
"""Извлечь файлы из каталога, сопоставить со справочником и записать позиции.
Мутирует `conn` и словарь `partners` (норм. имя → partner_id, чтобы один и тот же
партнёр из разных файлов не задвоился). Возвращает счётчик для отчёта о качестве.
"""
report: Counter = Counter() report: Counter = Counter()
staged: list[tuple] = [] # (doc_id, partner_id, raw, code, prices, unit, eff_iso)
for path in sorted(Path(data_dir).glob("*")): for path in sorted(Path(data_dir).glob("*")):
if path.is_dir():
continue
doc = extract(str(path), use_vision=use_vision) doc = extract(str(path), use_vision=use_vision)
partner_name = doc.partner_name or path.stem partner_name = doc.partner_name or path.stem
partner_norm = normalize_name(partner_name) partner_norm = normalize_name(partner_name)
@@ -80,7 +102,11 @@ def run(data_dir: str, db_path: str, dict_path: str, embedder=None, use_vision:
if partner_id is None: if partner_id is None:
partner_id = str(uuid.uuid4()) partner_id = str(uuid.uuid4())
partners[partner_norm] = partner_id partners[partner_norm] = partner_id
conn.execute("INSERT INTO partner VALUES (?,?,?,?)", (partner_id, partner_name, partner_norm, None)) conn.execute(
"INSERT INTO partner VALUES (?,?,?,?)",
(partner_id, partner_name, partner_norm, None),
)
report["new_partners"] += 1
doc_id = str(uuid.uuid4()) doc_id = str(uuid.uuid4())
eff_iso = doc.effective_date.isoformat() if doc.effective_date else None eff_iso = doc.effective_date.isoformat() if doc.effective_date else None
@@ -90,31 +116,53 @@ def run(data_dir: str, db_path: str, dict_path: str, embedder=None, use_vision:
) )
report["documents"] += 1 report["documents"] += 1
for row in doc.rows: for row in doc.rows:
staged.append((doc_id, partner_id, row.service_name_raw, row.service_code_source, staged.append(
row.prices, row.unit, eff_iso)) (
doc_id,
partner_id,
row.service_name_raw,
row.service_code_source,
row.prices,
row.unit,
eff_iso,
)
)
# Нормализация одной пачкой (эмбеддинги считаются разом — быстрее и дешевле). # Нормализация одной пачкой (эмбеддинги считаются разом — быстрее и дешевле).
matches = matcher.match_batch([s[2] for s in staged], [s[3] for s in staged]) matches = matcher.match_batch([s[2] for s in staged], [s[3] for s in staged])
for (doc_id, partner_id, raw, code, prices, unit, eff_iso), match in zip(
for (doc_id, partner_id, raw, code, prices, unit, eff_iso), match in zip(staged, matches, strict=True): staged, matches, strict=True
):
status, _flags = validate_item(raw, prices, effective_date=None, today=today) status, _flags = validate_item(raw, prices, effective_date=None, today=today)
if match.service_id: if match.service_id:
report["matched"] += 1 report["matched"] += 1
report[f"method_{match.method}"] += 1 report[f"method_{match.method}"] += 1
conn.execute( conn.execute(
"""INSERT INTO price_item _INSERT_ITEM,
(doc_id, partner_id, service_name_raw, service_code_source, service_id, prices, (
price_resident, price_nonresident, unit, effective_date, map_method, map_confidence, doc_id,
status, is_active, suggested_service_id, suggested_score) partner_id,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)""", raw,
(doc_id, partner_id, raw, code, match.service_id, json.dumps(prices, ensure_ascii=False), code,
prices.get("resident"), prices.get("nonresident"), unit, eff_iso, match.method, match.service_id,
round(match.confidence, 3), status, match.suggested_service_id, json.dumps(prices, ensure_ascii=False),
round(match.suggested_score, 3) if match.suggested_score else None), prices.get("resident"),
prices.get("nonresident"),
unit,
eff_iso,
match.method,
round(match.confidence, 3),
status,
match.suggested_service_id,
round(match.suggested_score, 3) if match.suggested_score else None,
),
) )
report["items"] += 1 report["items"] += 1
return report
# Версионирование: если у партнёра по той же услуге есть более свежий прайс — старые архивируем.
def _apply_versioning(conn) -> None:
"""У партнёра по той же услуге более свежий прайс вытесняет старые (is_active = 0)."""
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 EXISTS (
@@ -123,19 +171,85 @@ def run(data_dir: str, db_path: str, dict_path: str, embedder=None, use_vision:
AND b.service_name_raw = price_item.service_name_raw AND b.service_name_raw = price_item.service_name_raw
AND b.effective_date > price_item.effective_date)""" AND b.effective_date > price_item.effective_date)"""
) )
conn.commit()
def _summary(report: Counter) -> dict:
total = report["items"] total = report["items"]
summary = { return {
"documents": report["documents"], "documents": report["documents"],
"partners": len(partners), "partners": report["new_partners"],
"items": total, "items": total,
"auto_matched": report["matched"], "auto_matched": report["matched"],
"auto_matched_pct": round(100 * report["matched"] / total, 1) if total else 0.0, "auto_matched_pct": round(100 * report["matched"] / total, 1) if total else 0.0,
"unmatched": total - report["matched"], "unmatched": total - report["matched"],
"by_method": { "by_method": {
method: report[f"method_{method}"] for method in ("code", "exact", "embedding", "fuzzy", None) method: report[f"method_{method}"]
for method in ("code", "exact", "embedding", "fuzzy", None)
}, },
} }
def run(
data_dir: str,
db_path: str,
dict_path: str,
embedder=None,
use_vision: bool = False,
today: date | None = None,
) -> dict:
"""Собрать базу с нуля по всему архиву и вернуть отчёт о качестве."""
today = today or date.today()
services = load_dictionary(dict_path)
matcher = Matcher(services, embedder=embedder)
# Кэшируем эмбеддинги справочника: при последующих догрузках их не пересчитываем.
if matcher.dict_emb is not None:
np.save(_emb_path(db_path), matcher.dict_emb)
conn = _open(db_path)
_load_services(conn, services)
partners: dict[str, str] = {}
report = _process_documents(conn, data_dir, matcher, partners, today, use_vision)
_apply_versioning(conn)
conn.commit()
summary = _summary(report)
conn.close()
return summary
def ingest(
data_dir: str,
db_path: str,
dict_path: str,
embedder=None,
use_vision: bool | None = None,
today: date | None = None,
) -> dict:
"""Догрузить новые прайсы из каталога в существующую базу (append, без пересборки).
На этом держится приём ZIP через интерфейс: справочник и его эмбеддинги уже готовы,
поэтому считается только новый файл. Партнёры дедуплицируются с уже загруженными,
версионирование вытесняет устаревшие прайсы той же клиники.
"""
today = today or date.today()
services = load_dictionary(dict_path)
emb_path = _emb_path(db_path)
dict_emb = np.load(emb_path) if (embedder is not None and emb_path.exists()) else None
matcher = Matcher(services, embedder=embedder, dict_emb=dict_emb)
if use_vision is None:
use_vision = embedder is not None # есть ключ Gemini — разрешаем распознавание сканов
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA)
_load_services(conn, services)
# Уже загруженные партнёры — чтобы повторная выгрузка той же клиники не задвоила её.
partners = {
row["name_norm"]: row["partner_id"]
for row in conn.execute("SELECT partner_id, name_norm FROM partner")
}
report = _process_documents(conn, data_dir, matcher, partners, today, use_vision)
_apply_versioning(conn)
conn.commit()
summary = _summary(report)
conn.close() conn.close()
return summary return summary
+1
View File
@@ -1,4 +1,5 @@
"""Валидация позиций прайса по правилам ТЗ §4.4.""" """Валидация позиций прайса по правилам ТЗ §4.4."""
from etl.validate.rules import Flag, to_kzt, validate_item from etl.validate.rules import Flag, to_kzt, validate_item
__all__ = ["Flag", "to_kzt", "validate_item"] __all__ = ["Flag", "to_kzt", "validate_item"]
+9 -2
View File
@@ -4,6 +4,7 @@
ошибка → error, предупреждение → needs_review, иначе → done. Конвертация валют и ошибка → error, предупреждение → needs_review, иначе → done. Конвертация валют и
сравнение с прошлой версией (детектор аномалий) тоже здесь. сравнение с прошлой версией (детектор аномалий) тоже здесь.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@@ -34,7 +35,9 @@ def validate_prices(prices: dict[str, float]) -> list[Flag]:
flags: list[Flag] = [] flags: list[Flag] = []
for tier, value in prices.items(): for tier, value in prices.items():
if value is None or value <= 0: if value is None or value <= 0:
flags.append(Flag("warning", "price_nonpositive", f"тариф «{tier}»: цена не положительна")) flags.append(
Flag("warning", "price_nonpositive", f"тариф «{tier}»: цена не положительна")
)
resident, nonresident = prices.get("resident"), prices.get("nonresident") resident, nonresident = prices.get("resident"), prices.get("nonresident")
if resident and nonresident and nonresident < resident: if resident and nonresident and nonresident < resident:
flags.append( flags.append(
@@ -50,7 +53,11 @@ def validate_date(effective_date: date | None, today: date | None = None) -> lis
def validate_anomaly(new_price: float | None, previous_price: float | None) -> list[Flag]: def validate_anomaly(new_price: float | None, previous_price: float | None) -> list[Flag]:
if previous_price and new_price and abs(new_price - previous_price) / previous_price > ANOMALY_RATIO: if (
previous_price
and new_price
and abs(new_price - previous_price) / previous_price > ANOMALY_RATIO
):
return [ return [
Flag( Flag(
"warning", "warning",
+24
View File
@@ -0,0 +1,24 @@
# Образ API MedArchive: зависимости ставятся один раз в слой образа, код проекта
# монтируется томом (/root/medarchive:/app) — правки кода не требуют пересборки.
# Тяжёлые зависимости (pdfplumber, pymupdf, google-genai) нужны для приёма ZIP через
# интерфейс (/ingest): распаковка, распознавание и нормализация идут прямо на сервере.
FROM python:3.12-slim
WORKDIR /app
# Зависимости рантайма. Версии — как в pyproject.toml.
RUN pip install --no-cache-dir \
"fastapi>=0.115" \
"uvicorn[standard]>=0.32" \
"pydantic>=2.9" \
"python-multipart>=0.0.12" \
"openpyxl>=3.1" \
"xlrd>=2.0" \
"python-docx>=1.1" \
"pdfplumber>=0.11" \
"pymupdf>=1.24" \
"rapidfuzz>=3.10" \
"numpy>=2.0" \
"google-genai>=1.0"
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
+30 -7
View File
@@ -1,19 +1,42 @@
# Деплой MedArchive # Деплой MedArchive
Лёгкая схема: контейнер API (FastAPI на готовой SQLite-базе) за общим Caddy на Selectel KZ. Контейнер API (FastAPI) за общим Caddy на Selectel KZ. Read-only поиск работает на
Рантайму не нужны ни Gemini, ни модель — эмбеддинги и Vision отработали на этапе сборки базы. готовой SQLite-базе; приём ZIP через интерфейс (`/ingest`) дораспаковывает и нормализует
новые прайсы прямо на сервере, поэтому в образ включены тяжёлые зависимости (pdfplumber,
pymupdf, google-genai), а контейнеру нужен ключ Gemini.
## Раскладка на сервере
- `/root/medarchive/` — код и данные, монтируется в контейнер как `/app`:
- `api/`, `contracts/`, `etl/` — код (правки кода не требуют пересборки образа);
- `web/` — статический фронт (его же отдаёт Caddy);
- `data/medarchive.db` — собранная база, `data/quality_report.json` — отчёт сборки;
- `data/dict_emb.npy` — кэш эмбеддингов справочника (чтобы догрузка не пересчитывала его);
- `data/reference/dictionary.xlsx` — эталонный справочник услуг для нормализации.
- `/root/med-hackathon/` — каталог Caddy: `docker-compose.yml`, `Dockerfile`, `Caddyfile`,
`site/` (презентации) и `.env` с `GEMINI_API_KEY` (в публичный репозиторий не попадает).
## Шаги ## Шаги
1. Залить на сервер: код (`api/`, `contracts/`), фронт (`web/`), базу (`data/medarchive.db`) и 1. Залить код и данные в `/root/medarchive/` (см. раскладку выше).
отчёт (`data/quality_report.json`) — в `/root/medarchive/`. 2. Положить `docker-compose.yml`, `Dockerfile`, `Caddyfile` в `/root/med-hackathon/`.
2. Положить `docker-compose.yml` и `Caddyfile` из этой папки в каталог с Caddy. 3. Создать `/root/med-hackathon/.env`:
3. `docker compose up -d` — поднимется `medarchive-api` (ставит зависимости и запускает uvicorn). ```
4. Маршруты Caddy: `/api/*` → контейнер API, `/` → фронт, `/demo` → презентация. GEMINI_API_KEY=…
```
4. Собрать образ и поднять стек:
```bash
cd /root/med-hackathon
docker compose build api
docker compose up -d
```
5. Маршруты Caddy: `/api/*` → контейнер API, `/` → фронт, `/demo` и `/day1` → презентации.
## Проверка ## Проверка
```bash ```bash
curl https://med.secondbrain.tools/api/health curl https://med.secondbrain.tools/api/health
curl https://med.secondbrain.tools/api/stats curl https://med.secondbrain.tools/api/stats
# приём архива через интерфейс:
curl -X POST https://med.secondbrain.tools/api/ingest -F "file=@archive.zip"
``` ```
+8 -2
View File
@@ -14,7 +14,10 @@ services:
- caddy_config:/config - caddy_config:/config
api: api:
image: python:3.12-slim build:
context: .
dockerfile: Dockerfile
image: medarchive-api:latest
container_name: medarchive-api container_name: medarchive-api
restart: unless-stopped restart: unless-stopped
working_dir: /app working_dir: /app
@@ -22,7 +25,10 @@ services:
- /root/medarchive:/app - /root/medarchive:/app
environment: environment:
MEDARCHIVE_DB: /app/data/medarchive.db MEDARCHIVE_DB: /app/data/medarchive.db
command: sh -c "pip install --quiet --no-cache-dir fastapi 'uvicorn[standard]' pydantic && uvicorn api.main:app --host 0.0.0.0 --port 8000" # Ключ Gemini для распознавания и эмбеддингов при загрузке архива (/ingest).
# Значение — из .env рядом с этим файлом (в публичный репозиторий не попадает).
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
command: ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
volumes: volumes:
caddy_data: caddy_data:
+9
View File
@@ -25,3 +25,12 @@ target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] # стиль, ошибки, импорты, апгрейды, баги select = ["E", "F", "I", "UP", "B"] # стиль, ошибки, импорты, апгрейды, баги
[tool.ruff.lint.flake8-bugbear]
# Вызовы-маркеры FastAPI в значениях по умолчанию — идиома фреймворка, не баг B008.
extend-immutable-calls = ["fastapi.File", "fastapi.Query", "fastapi.Depends"]
[tool.ruff.lint.per-file-ignores]
# Разведочные скрипты этапа разбора форматов — черновики, не часть продукта.
"scripts/proof/*" = ["E402", "E501", "E741", "B007"]
"scripts/gen_fixtures.py" = ["E402", "E501", "E741"]
+388 -95
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Генератор синтетических прайсов клиник (RU/KZ/EN) + эталонная нормализация + справочник канонов. """Генератор синтетических прайсов клиник (RU/KZ/EN) + эталонная нормализация + справочник канонов.
Выдаёт: Выдаёт:
@@ -8,7 +7,13 @@
contracts/service_canon.json — справочник канонических услуг (seed для нормализации) contracts/service_canon.json — справочник канонических услуг (seed для нормализации)
Детерминированно (seed=42). Детерминированно (seed=42).
""" """
import csv, json, os, random, subprocess, sys
import csv
import json
import os
import random
import subprocess
import sys
ROOT = sys.argv[1] if len(sys.argv) > 1 else "." ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
RAW = os.path.join(ROOT, "fixtures/raw") RAW = os.path.join(ROOT, "fixtures/raw")
@@ -18,145 +23,433 @@ for d in (RAW, NORM, CONTRACTS):
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
random.seed(42) random.seed(42)
def ensure(pkg, mod=None): def ensure(pkg, mod=None):
try: __import__(mod or pkg) try:
except ImportError: subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg]) __import__(mod or pkg)
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
# Справочник канонических медуслуг. lang-тегированные синонимы — материал для нормализации. # Справочник канонических медуслуг. lang-тегированные синонимы — материал для нормализации.
CANONS = [ CANONS = [
{"id":"cbc","ru":"Общий анализ крови","kz":"Қанның жалпы анализі","en":"Complete blood count (CBC)", {
"category":"Лаборатория","code":"B03.016.003","unit":"1 иссл.","price":(1200,4500), "id": "cbc",
"syn":[("ОАК","ru"),("Общий анализ крови (развернутый)","ru"),("Клинический анализ крови","ru"),("КАК","ru"),("CBC","en"),("Қанның жалпы талдауы","kz")]}, "ru": "Общий анализ крови",
{"id":"uzi_abd","ru":"УЗИ органов брюшной полости","kz":"Құрсақ қуысы ағзаларының УДЗ","en":"Abdominal ultrasound", "kz": "Қанның жалпы анализі",
"category":"Диагностика/УЗИ","code":"A04.16.001","unit":"1 иссл.","price":(6000,13000), "en": "Complete blood count (CBC)",
"syn":[("УЗИ ОБП","ru"),("УЗИ брюшной полости","ru"),("Ультразвуковое исследование брюшной полости","ru"),("Құрсақ қуысы УДЗ","kz"),("Abdominal US","en")]}, "category": "Лаборатория",
{"id":"therapist","ru":"Приём врача-терапевта","kz":"Терапевт дәрігердің қабылдауы","en":"General practitioner consultation", "code": "B03.016.003",
"category":"Приём специалистов","code":"B01.047.001","unit":"1 приём","price":(4000,9000), "unit": "1 иссл.",
"syn":[("Прием терапевта","ru"),("Консультация терапевта первичная","ru"),("Терапевт қабылдауы","kz"),("GP visit","en")]}, "price": (1200, 4500),
{"id":"ecg","ru":"Электрокардиография (ЭКГ)","kz":"Электрокардиография (ЭКГ)","en":"Electrocardiography (ECG)", "syn": [
"category":"Диагностика","code":"A05.10.006","unit":"1 иссл.","price":(2000,5000), ("ОАК", "ru"),
"syn":[("ЭКГ","ru"),("ЭКГ с расшифровкой","ru"),("Электрокардиография","ru"),("ECG","en"),("ЭКГ + описание","ru")]}, ("Общий анализ крови (развернутый)", "ru"),
{"id":"glucose","ru":"Глюкоза крови","kz":"Қандағы глюкоза","en":"Blood glucose", ("Клинический анализ крови", "ru"),
"category":"Лаборатория","code":"B03.016.006","unit":"1 иссл.","price":(800,2200), ("КАК", "ru"),
"syn":[("Глюкоза","ru"),("Сахар крови","ru"),("Қандағы глюкоза","kz"),("Glucose","en")]}, ("CBC", "en"),
{"id":"cardiologist","ru":"Консультация кардиолога","kz":"Кардиолог кеңесі","en":"Cardiologist consultation", ("Қанның жалпы талдауы", "kz"),
"category":"Приём специалистов","code":"B01.015.001","unit":"1 приём","price":(5000,12000), ],
"syn":[("Прием кардиолога","ru"),("Кардиолог консультация первичная","ru"),("Кардиолог қабылдауы","kz")]}, },
{"id":"mri_brain","ru":"МРТ головного мозга","kz":"Бас миының МРТ","en":"Brain MRI", {
"category":"Диагностика/МРТ","code":"A05.23.009","unit":"1 иссл.","price":(18000,35000), "id": "uzi_abd",
"syn":[("МРТ г","ru"),("Магнитно-резонансная томография головного мозга","ru"),("Бас миы МРТ","kz"),("Brain MRI","en")]}, "ru": "УЗИ органов брюшной полости",
{"id":"biochem","ru":"Биохимический анализ крови","kz":"Қанның биохимиялық анализі","en":"Blood chemistry panel", "kz": "Құрсақ қуысы ағзаларының УДЗ",
"category":"Лаборатория","code":"B03.016.004","unit":"1 иссл.","price":(3500,9000), "en": "Abdominal ultrasound",
"syn":[("Биохимия крови","ru"),("Б/х крови","ru"),("Биохимический анализ крови (базовый)","ru"),("Қан биохимиясы","kz")]}, "category": "Диагностика/УЗИ",
{"id":"tooth_ext","ru":"Удаление зуба","kz":"Тісті жұлу","en":"Tooth extraction", "code": "A04.16.001",
"category":"Стоматология","code":"A16.07.001","unit":"1 зуб","price":(6000,15000), "unit": "1 иссл.",
"syn":[("Удаление зуба простое","ru"),("Экстракция зуба","ru"),("Тіс жұлу","kz"),("Tooth removal","en")]}, "price": (6000, 13000),
{"id":"xray_chest","ru":"Рентген грудной клетки","kz":"Кеуде клеткасының рентгені","en":"Chest X-ray", "syn": [
"category":"Диагностика","code":"A06.09.007","unit":"1 иссл.","price":(2500,6000), ("УЗИ ОБП", "ru"),
"syn":[("Рентгенография ОГК","ru"),("Флюорография","ru"),("Кеуде рентгені","kz"),("Chest X-ray","en")]}, ("УЗИ брюшной полости", "ru"),
{"id":"tsh","ru":"Тиреотропный гормон (ТТГ)","kz":"Тиреотропты гормон (ТТГ)","en":"Thyroid-stimulating hormone (TSH)", ("Ультразвуковое исследование брюшной полости", "ru"),
"category":"Лаборатория","code":"B03.016.021","unit":"1 иссл.","price":(1500,4000), ("Құрсақ қуысы УДЗ", "kz"),
"syn":[("ТТГ","ru"),("Тиреотропный гормон","ru"),("TSH","en"),("ТТГ гормоны","kz")]}, ("Abdominal US", "en"),
{"id":"urinalysis","ru":"Общий анализ мочи","kz":"Зәрдің жалпы анализі","en":"Urinalysis", ],
"category":"Лаборатория","code":"B03.016.010","unit":"1 иссл.","price":(900,2500), },
"syn":[("ОАМ","ru"),("Общий анализ мочи","ru"),("Зәр анализі","kz"),("Urine test","en")]}, {
{"id":"gyn","ru":"Приём гинеколога","kz":"Гинеколог қабылдауы","en":"Gynecologist consultation", "id": "therapist",
"category":"Приём специалистов","code":"B01.001.001","unit":"1 приём","price":(5000,11000), "ru": "Приём врача-терапевта",
"syn":[("Прием гинеколога","ru"),("Консультация гинеколога первичная","ru"),("Гинеколог кеңесі","kz")]}, "kz": "Терапевт дәрігердің қабылдауы",
{"id":"covid_pcr","ru":"ПЦР на COVID-19","kz":"COVID-19 ПТР","en":"COVID-19 PCR test", "en": "General practitioner consultation",
"category":"Лаборатория","code":"A26.08.027","unit":"1 иссл.","price":(7000,16000), "category": "Приём специалистов",
"syn":[("ПЦР COVID-19","ru"),("ПЦР-тест на коронавирус","ru"),("COVID PCR","en"),("COVID ПТР","kz")]}, "code": "B01.047.001",
{"id":"vit_d","ru":"Витамин D (25-OH)","kz":"D дәрумені (25-OH)","en":"Vitamin D (25-OH)", "unit": "1 приём",
"category":"Лаборатория","code":"B03.016.115","unit":"1 иссл.","price":(6000,12000), "price": (4000, 9000),
"syn":[("25-OH витамин D","ru"),("Витамин Д","ru"),("Vitamin D","en"),("D витамині","kz")]}, "syn": [
("Прием терапевта", "ru"),
("Консультация терапевта первичная", "ru"),
("Терапевт қабылдауы", "kz"),
("GP visit", "en"),
],
},
{
"id": "ecg",
"ru": "Электрокардиография (ЭКГ)",
"kz": "Электрокардиография (ЭКГ)",
"en": "Electrocardiography (ECG)",
"category": "Диагностика",
"code": "A05.10.006",
"unit": "1 иссл.",
"price": (2000, 5000),
"syn": [
("ЭКГ", "ru"),
("ЭКГ с расшифровкой", "ru"),
("Электрокардиография", "ru"),
("ECG", "en"),
("ЭКГ + описание", "ru"),
],
},
{
"id": "glucose",
"ru": "Глюкоза крови",
"kz": "Қандағы глюкоза",
"en": "Blood glucose",
"category": "Лаборатория",
"code": "B03.016.006",
"unit": "1 иссл.",
"price": (800, 2200),
"syn": [
("Глюкоза", "ru"),
("Сахар крови", "ru"),
("Қандағы глюкоза", "kz"),
("Glucose", "en"),
],
},
{
"id": "cardiologist",
"ru": "Консультация кардиолога",
"kz": "Кардиолог кеңесі",
"en": "Cardiologist consultation",
"category": "Приём специалистов",
"code": "B01.015.001",
"unit": "1 приём",
"price": (5000, 12000),
"syn": [
("Прием кардиолога", "ru"),
("Кардиолог консультация первичная", "ru"),
("Кардиолог қабылдауы", "kz"),
],
},
{
"id": "mri_brain",
"ru": "МРТ головного мозга",
"kz": "Бас миының МРТ",
"en": "Brain MRI",
"category": "Диагностика/МРТ",
"code": "A05.23.009",
"unit": "1 иссл.",
"price": (18000, 35000),
"syn": [
("МРТ г", "ru"),
("Магнитно-резонансная томография головного мозга", "ru"),
("Бас миы МРТ", "kz"),
("Brain MRI", "en"),
],
},
{
"id": "biochem",
"ru": "Биохимический анализ крови",
"kz": "Қанның биохимиялық анализі",
"en": "Blood chemistry panel",
"category": "Лаборатория",
"code": "B03.016.004",
"unit": "1 иссл.",
"price": (3500, 9000),
"syn": [
("Биохимия крови", "ru"),
("Б/х крови", "ru"),
("Биохимический анализ крови (базовый)", "ru"),
("Қан биохимиясы", "kz"),
],
},
{
"id": "tooth_ext",
"ru": "Удаление зуба",
"kz": "Тісті жұлу",
"en": "Tooth extraction",
"category": "Стоматология",
"code": "A16.07.001",
"unit": "1 зуб",
"price": (6000, 15000),
"syn": [
("Удаление зуба простое", "ru"),
("Экстракция зуба", "ru"),
("Тіс жұлу", "kz"),
("Tooth removal", "en"),
],
},
{
"id": "xray_chest",
"ru": "Рентген грудной клетки",
"kz": "Кеуде клеткасының рентгені",
"en": "Chest X-ray",
"category": "Диагностика",
"code": "A06.09.007",
"unit": "1 иссл.",
"price": (2500, 6000),
"syn": [
("Рентгенография ОГК", "ru"),
("Флюорография", "ru"),
("Кеуде рентгені", "kz"),
("Chest X-ray", "en"),
],
},
{
"id": "tsh",
"ru": "Тиреотропный гормон (ТТГ)",
"kz": "Тиреотропты гормон (ТТГ)",
"en": "Thyroid-stimulating hormone (TSH)",
"category": "Лаборатория",
"code": "B03.016.021",
"unit": "1 иссл.",
"price": (1500, 4000),
"syn": [("ТТГ", "ru"), ("Тиреотропный гормон", "ru"), ("TSH", "en"), ("ТТГ гормоны", "kz")],
},
{
"id": "urinalysis",
"ru": "Общий анализ мочи",
"kz": "Зәрдің жалпы анализі",
"en": "Urinalysis",
"category": "Лаборатория",
"code": "B03.016.010",
"unit": "1 иссл.",
"price": (900, 2500),
"syn": [
("ОАМ", "ru"),
("Общий анализ мочи", "ru"),
("Зәр анализі", "kz"),
("Urine test", "en"),
],
},
{
"id": "gyn",
"ru": "Приём гинеколога",
"kz": "Гинеколог қабылдауы",
"en": "Gynecologist consultation",
"category": "Приём специалистов",
"code": "B01.001.001",
"unit": "1 приём",
"price": (5000, 11000),
"syn": [
("Прием гинеколога", "ru"),
("Консультация гинеколога первичная", "ru"),
("Гинеколог кеңесі", "kz"),
],
},
{
"id": "covid_pcr",
"ru": "ПЦР на COVID-19",
"kz": "COVID-19 ПТР",
"en": "COVID-19 PCR test",
"category": "Лаборатория",
"code": "A26.08.027",
"unit": "1 иссл.",
"price": (7000, 16000),
"syn": [
("ПЦР COVID-19", "ru"),
("ПЦР-тест на коронавирус", "ru"),
("COVID PCR", "en"),
("COVID ПТР", "kz"),
],
},
{
"id": "vit_d",
"ru": "Витамин D (25-OH)",
"kz": "D дәрумені (25-OH)",
"en": "Vitamin D (25-OH)",
"category": "Лаборатория",
"code": "B03.016.115",
"unit": "1 иссл.",
"price": (6000, 12000),
"syn": [
("25-OH витамин D", "ru"),
("Витамин Д", "ru"),
("Vitamin D", "en"),
("D витамині", "kz"),
],
},
]
CLINICS = [
"Клиника «Аль-Шифа»",
"Медцентр «Сункар»",
"Diagnostic Lab Astana",
"Клиника «Береке»",
"Медцентр «Тенгри»",
] ]
CLINICS = ["Клиника «Аль-Шифа»","Медцентр «Сункар»","Diagnostic Lab Astana","Клиника «Береке»","Медцентр «Тенгри»"]
def price(c): return random.randint(c["price"][0]//100, c["price"][1]//100) * 100
def variant(c): return random.choice([(c["ru"],"ru")] + c["syn"]) def price(c):
return random.randint(c["price"][0] // 100, c["price"][1] // 100) * 100
def variant(c):
return random.choice([(c["ru"], "ru")] + c["syn"])
GOLDEN = [] GOLDEN = []
def build(file_name, clinic, n): def build(file_name, clinic, n):
rows = [] rows = []
for c in random.sample(CANONS, k=min(n, len(CANONS))): for c in random.sample(CANONS, k=min(n, len(CANONS))):
raw_name, lang = variant(c); p = price(c) raw_name, lang = variant(c)
rows.append({"raw_name":raw_name,"price":p,"unit":c["unit"]}) p = price(c)
GOLDEN.append({"source_file":file_name,"clinic":clinic,"raw_name":raw_name,"lang":lang, rows.append({"raw_name": raw_name, "price": p, "unit": c["unit"]})
"canon_id":c["id"],"canonical_ru":c["ru"],"canonical_kz":c["kz"],"canonical_en":c["en"], GOLDEN.append(
"category":c["category"],"code":c["code"],"price":p,"currency":"KZT","unit":c["unit"]}) {
"source_file": file_name,
"clinic": clinic,
"raw_name": raw_name,
"lang": lang,
"canon_id": c["id"],
"canonical_ru": c["ru"],
"canonical_kz": c["kz"],
"canonical_en": c["en"],
"category": c["category"],
"code": c["code"],
"price": p,
"currency": "KZT",
"unit": c["unit"],
}
)
return rows return rows
ensure("openpyxl"); from openpyxl import Workbook
ensure("openpyxl")
from openpyxl import Workbook
# 1) чистый Excel # 1) чистый Excel
rows = build("clinic_alpha_clean.xlsx", CLINICS[0], 9) rows = build("clinic_alpha_clean.xlsx", CLINICS[0], 9)
wb=Workbook(); ws=wb.active; ws.title="Прайс"; ws.append(["Наименование услуги","Цена, тг"]) wb = Workbook()
for r in rows: ws.append([r["raw_name"], r["price"]]) ws = wb.active
ws.title = "Прайс"
ws.append(["Наименование услуги", "Цена, тг"])
for r in rows:
ws.append([r["raw_name"], r["price"]])
wb.save(f"{RAW}/clinic_alpha_clean.xlsx") wb.save(f"{RAW}/clinic_alpha_clean.xlsx")
# 2) грязный Excel # 2) грязный Excel
rows = build("clinic_beta_dirty.xlsx", CLINICS[1], 9) rows = build("clinic_beta_dirty.xlsx", CLINICS[1], 9)
wb=Workbook(); ws=wb.active; ws.title="PRICE 2026" wb = Workbook()
ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"]); ws.append([]) ws = wb.active
ws.append(["","Услуга / Қызмет","Стоимость (тенге)","Ед."]) ws.title = "PRICE 2026"
secs=["— Лаборатория / Зертхана —","— Диагностика —","— Приём специалистов —"]; i=1 ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"])
for k,r in enumerate(rows): ws.append([])
if k%3==0 and secs: ws.append([secs.pop(0),None,None,None]) ws.append(["", "Услуга / Қызмет", "Стоимость (тенге)", "Ед."])
val=f"от {r['price']}" if random.random()<0.3 else r["price"] secs = ["— Лаборатория / Зертхана —", "— Диагностика —", "— Приём специалистов —"]
ws.append([i, r["raw_name"], val, r["unit"]]); i+=1 i = 1
for k, r in enumerate(rows):
if k % 3 == 0 and secs:
ws.append([secs.pop(0), None, None, None])
val = f"от {r['price']}" if random.random() < 0.3 else r["price"]
ws.append([i, r["raw_name"], val, r["unit"]])
i += 1
wb.save(f"{RAW}/clinic_beta_dirty.xlsx") wb.save(f"{RAW}/clinic_beta_dirty.xlsx")
# 3) CSV ; # 3) CSV ;
rows = build("clinic_gamma.csv", CLINICS[2], 8) rows = build("clinic_gamma.csv", CLINICS[2], 8)
with open(f"{RAW}/clinic_gamma.csv","w",encoding="utf-8",newline="") as f: with open(f"{RAW}/clinic_gamma.csv", "w", encoding="utf-8", newline="") as f:
w=csv.writer(f,delimiter=";"); w.writerow(["service","price_kzt","unit"]) w = csv.writer(f, delimiter=";")
for r in rows: w.writerow([r["raw_name"], r["price"], r["unit"]]) w.writerow(["service", "price_kzt", "unit"])
for r in rows:
w.writerow([r["raw_name"], r["price"], r["unit"]])
# 4) грязный текст # 4) грязный текст
rows = build("clinic_delta_messy.txt", CLINICS[4], 7) rows = build("clinic_delta_messy.txt", CLINICS[4], 7)
with open(f"{RAW}/clinic_delta_messy.txt","w",encoding="utf-8") as f: with open(f"{RAW}/clinic_delta_messy.txt", "w", encoding="utf-8") as f:
f.write("МЕДЦЕНТР «ТЕНГРИ» — действующие цены\n\n") f.write("МЕДЦЕНТР «ТЕНГРИ» — действующие цены\n\n")
for r in rows: for r in rows:
sep=random.choice([""," ... ",": "," "]); cur=random.choice(["тг","тенге","","KZT"]) sep = random.choice(["", " ... ", ": ", " "])
cur = random.choice(["тг", "тенге", "", "KZT"])
f.write(f"{r['raw_name']}{sep}{r['price']} {cur} ({r['unit']})\n") f.write(f"{r['raw_name']}{sep}{r['price']} {cur} ({r['unit']})\n")
f.write("\n* Цены справочные, уточняйте в регистратуре. Бағалар анықтамалық.\n") f.write("\n* Цены справочные, уточняйте в регистратуре. Бағалар анықтамалық.\n")
# 5) PNG-скан # 5) PNG-скан
rows = build("clinic_epsilon_scan.png", CLINICS[3], 9) rows = build("clinic_epsilon_scan.png", CLINICS[3], 9)
ensure("Pillow","PIL"); from PIL import Image, ImageDraw, ImageFont ensure("Pillow", "PIL")
from PIL import Image, ImageDraw, ImageFont
def font(sz): def font(sz):
for p in ["/System/Library/Fonts/Supplemental/Arial.ttf","/Library/Fonts/Arial.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]: for p in [
if os.path.exists(p): return ImageFont.truetype(p,sz) "/System/Library/Fonts/Supplemental/Arial.ttf",
"/Library/Fonts/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]:
if os.path.exists(p):
return ImageFont.truetype(p, sz)
return ImageFont.load_default() return ImageFont.load_default()
img=Image.new("RGB",(900,720),(250,248,244)); d=ImageDraw.Draw(img)
d.text((40,30),"Клиника «Береке» — прайс-лист",fill=(20,20,20),font=font(34)); d.line((40,85,860,85),fill=(180,180,180),width=2)
y=110 img = Image.new("RGB", (900, 720), (250, 248, 244))
d = ImageDraw.Draw(img)
d.text((40, 30), "Клиника «Береке» — прайс-лист", fill=(20, 20, 20), font=font(34))
d.line((40, 85, 860, 85), fill=(180, 180, 180), width=2)
y = 110
for r in rows: for r in rows:
d.text((50,y),r["raw_name"][:42],fill=(30,30,30),font=font(23)); d.text((720,y),f"{r['price']} тг",fill=(30,30,30),font=font(23)); y+=62 d.text((50, y), r["raw_name"][:42], fill=(30, 30, 30), font=font(23))
img=img.rotate(-1.4,expand=False,fillcolor=(250,248,244)); img.save(f"{RAW}/clinic_epsilon_scan.png") d.text((720, y), f"{r['price']} тг", fill=(30, 30, 30), font=font(23))
y += 62
img = img.rotate(-1.4, expand=False, fillcolor=(250, 248, 244))
img.save(f"{RAW}/clinic_epsilon_scan.png")
# 6) PDF # 6) PDF
rows = build("clinic_zeta_prices.pdf", CLINICS[2], 10) rows = build("clinic_zeta_prices.pdf", CLINICS[2], 10)
pdf="skip" pdf = "skip"
try: try:
ensure("reportlab") ensure("reportlab")
from reportlab.lib.pagesizes import A4; from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics; from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase import pdfmetrics
fp=next((p for p in ["/Library/Fonts/Arial.ttf","/System/Library/Fonts/Supplemental/Arial.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"] if os.path.exists(p)),None) from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
fp = next(
(
p
for p in [
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
if os.path.exists(p)
),
None,
)
if fp: if fp:
pdfmetrics.registerFont(TTFont("U",fp)); c=canvas.Canvas(f"{RAW}/clinic_zeta_prices.pdf",pagesize=A4) pdfmetrics.registerFont(TTFont("U", fp))
c.setFont("U",18); c.drawString(60,790,"Diagnostic Lab Astana — Price list / Прайс"); c.setFont("U",12); y=750 c = canvas.Canvas(f"{RAW}/clinic_zeta_prices.pdf", pagesize=A4)
for r in rows: c.drawString(60,y,r["raw_name"][:55]); c.drawRightString(540,y,f"{r['price']} KZT"); y-=28 c.setFont("U", 18)
c.save(); pdf="ok" c.drawString(60, 790, "Diagnostic Lab Astana — Price list / Прайс")
except Exception as e: pdf=f"skip ({e})" c.setFont("U", 12)
y = 750
for r in rows:
c.drawString(60, y, r["raw_name"][:55])
c.drawRightString(540, y, f"{r['price']} KZT")
y -= 28
c.save()
pdf = "ok"
except Exception as e:
pdf = f"skip ({e})"
# эталон нормализации + справочник # эталон нормализации + справочник
with open(f"{NORM}/golden.json","w",encoding="utf-8") as f: with open(f"{NORM}/golden.json", "w", encoding="utf-8") as f:
json.dump(GOLDEN, f, ensure_ascii=False, indent=2) json.dump(GOLDEN, f, ensure_ascii=False, indent=2)
canon_out=[{"canon_id":c["id"],"canonical_ru":c["ru"],"canonical_kz":c["kz"],"canonical_en":c["en"], canon_out = [
"category":c["category"],"code":c["code"],"unit":c["unit"], {
"synonyms":[{"text":t,"lang":l} for t,l in c["syn"]]} for c in CANONS] "canon_id": c["id"],
with open(f"{CONTRACTS}/service_canon.json","w",encoding="utf-8") as f: "canonical_ru": c["ru"],
"canonical_kz": c["kz"],
"canonical_en": c["en"],
"category": c["category"],
"code": c["code"],
"unit": c["unit"],
"synonyms": [{"text": t, "lang": l} for t, l in c["syn"]],
}
for c in CANONS
]
with open(f"{CONTRACTS}/service_canon.json", "w", encoding="utf-8") as f:
json.dump(canon_out, f, ensure_ascii=False, indent=2) json.dump(canon_out, f, ensure_ascii=False, indent=2)
print("PDF:", pdf) print("PDF:", pdf)
+7 -2
View File
@@ -1,4 +1,5 @@
"""Прогон извлечения по всему архиву: сколько позиций берёт каждый формат.""" """Прогон извлечения по всему архиву: сколько позиций берёт каждый формат."""
import sys import sys
from pathlib import Path from pathlib import Path
@@ -14,11 +15,15 @@ for path in files:
total += len(doc.rows) total += len(doc.rows)
tiers = sorted({t for row in doc.rows for t in row.prices}) tiers = sorted({t for row in doc.rows for t in row.prices})
print(f"\n{'=' * 68}") print(f"\n{'=' * 68}")
print(f"{path.name} [{doc.file_format}] партнёр='{doc.partner_name}' дата={doc.effective_date}") print(
f"{path.name} [{doc.file_format}] партнёр='{doc.partner_name}' дата={doc.effective_date}"
)
print(f" позиций: {len(doc.rows)} | тарифы: {tiers}") print(f" позиций: {len(doc.rows)} | тарифы: {tiers}")
if doc.parse_log: if doc.parse_log:
print(f" лог: {doc.parse_log[:2]}") print(f" лог: {doc.parse_log[:2]}")
for row in doc.rows[:3]: for row in doc.rows[:3]:
print(f" код={row.service_code_source!r} «{row.service_name_raw[:40]}» {row.prices} секц={row.section!r}") print(
f" код={row.service_code_source!r} «{row.service_name_raw[:40]}» {row.prices} секц={row.section!r}"
)
print(f"\n{'=' * 68}\nИТОГО позиций по архиву: {total}") print(f"\n{'=' * 68}\nИТОГО позиций по архиву: {total}")
+7 -2
View File
@@ -1,4 +1,5 @@
"""Подбор порога нормализации: выборка позиций → каскад → покрытие при порогах.""" """Подбор порога нормализации: выборка позиций → каскад → покрытие при порогах."""
import random import random
import sys import sys
from pathlib import Path from pathlib import Path
@@ -63,7 +64,9 @@ for thr in (0.58, 0.60, 0.62, 0.64, 0.66, 0.70):
emb_matched = int((top_score >= thr).sum()) emb_matched = int((top_score >= thr).sum())
fuzzy_matched = int(((fuzzy_score >= 88) & (top_score < thr)).sum()) fuzzy_matched = int(((fuzzy_score >= 88) & (top_score < thr)).sum())
coverage = base + emb_matched + fuzzy_matched coverage = base + emb_matched + fuzzy_matched
print(f" порог {thr}: эмб {emb_matched} + fuzzy {fuzzy_matched} → покрытие {100 * coverage / len(sample):.0f}%") print(
f" порог {thr}: эмб {emb_matched} + fuzzy {fuzzy_matched} → покрытие {100 * coverage / len(sample):.0f}%"
)
print("\nпримеры эмбеддинг-совпадений (порог 0.62):") print("\nпримеры эмбеддинг-совпадений (порог 0.62):")
shown = 0 shown = 0
@@ -75,5 +78,7 @@ print("примеры unmatched (top<0.62 и fuzzy<88):")
shown = 0 shown = 0
for k, (name, _) in enumerate(pending): for k, (name, _) in enumerate(pending):
if top_score[k] < 0.62 and fuzzy_score[k] < 88 and shown < 6: if top_score[k] < 0.62 and fuzzy_score[k] < 88 and shown < 6:
print(f" «{name[:48]}» (лучший {services[int(top_idx[k])].name_ru[:22]} {top_score[k]:.2f})") print(
f" «{name[:48]}» (лучший {services[int(top_idx[k])].name_ru[:22]} {top_score[k]:.2f})"
)
shown += 1 shown += 1
+15 -7
View File
@@ -4,6 +4,7 @@
точное совпадение + RapidFuzz). Эмбеддинги в боевом каскаде поднимут этот процент — точное совпадение + RapidFuzz). Эмбеддинги в боевом каскаде поднимут этот процент —
здесь нужен нижний ориентир, чтобы убедиться, что цель 70% достижима. здесь нужен нижний ориентир, чтобы убедиться, что цель 70% достижима.
""" """
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
@@ -17,7 +18,9 @@ from rapidfuzz import fuzz, process
from etl.dictionary import load_dictionary, normalize_name from etl.dictionary import load_dictionary, normalize_name
svcs = load_dictionary(REPO / "data/reference/dictionary.xlsx") svcs = load_dictionary(REPO / "data/reference/dictionary.xlsx")
print(f"Справочник: {len(svcs)} услуг | с кодом тарификатора: {sum(1 for s in svcs if s.tarificator_code)}") print(
f"Справочник: {len(svcs)} услуг | с кодом тарификатора: {sum(1 for s in svcs if s.tarificator_code)}"
)
by_code = {s.tarificator_code: s for s in svcs if s.tarificator_code} by_code = {s.tarificator_code: s for s in svcs if s.tarificator_code}
by_norm: dict[str, object] = {} by_norm: dict[str, object] = {}
@@ -35,11 +38,11 @@ for row in table.rows:
if len(cells) < 3: if len(cells) < 3:
continue continue
code, name, price = cells[0], cells[1], cells[2] code, name, price = cells[0], cells[1], cells[2]
if code == name == price: # строка-заголовок секции (объединённые ячейки) if code == name == price: # строка-заголовок секции (объединённые ячейки)
continue continue
if name.lower() == "наименование услуги": if name.lower() == "наименование услуги":
continue continue
if not re.search(r"\d", price): # строка без цены — не позиция if not re.search(r"\d", price): # строка без цены — не позиция
continue continue
items.append((code, name, price)) items.append((code, name, price))
@@ -52,14 +55,17 @@ examples_ok, examples_miss = [], []
for code, name, price in items: for code, name, price in items:
nn = normalize_name(name) nn = normalize_name(name)
if code in by_code: if code in by_code:
matched += 1; by_code_n += 1 matched += 1
by_code_n += 1
continue continue
if nn in by_norm: if nn in by_norm:
matched += 1; by_exact_n += 1 matched += 1
by_exact_n += 1
continue continue
best = process.extractOne(nn, names_norm, scorer=fuzz.token_set_ratio) best = process.extractOne(nn, names_norm, scorer=fuzz.token_set_ratio)
if best and best[1] >= THRESHOLD: if best and best[1] >= THRESHOLD:
matched += 1; by_fuzzy_n += 1 matched += 1
by_fuzzy_n += 1
if len(examples_ok) < 6: if len(examples_ok) < 6:
examples_ok.append((name, svcs[best[2]].name_ru, round(best[1]))) examples_ok.append((name, svcs[best[2]].name_ru, round(best[1])))
elif len(examples_miss) < 6 and best: elif len(examples_miss) < 6 and best:
@@ -67,7 +73,9 @@ for code, name, price in items:
pct = 100 * matched / max(1, len(items)) pct = 100 * matched / max(1, len(items))
print(f"\nАВТО-СОПОСТАВЛЕНО (без эмбеддингов): {matched}/{len(items)} = {pct:.0f}%") print(f"\nАВТО-СОПОСТАВЛЕНО (без эмбеддингов): {matched}/{len(items)} = {pct:.0f}%")
print(f" код тарификатора: {by_code_n} | точное имя: {by_exact_n} | fuzzy≥{THRESHOLD}: {by_fuzzy_n}") print(
f" код тарификатора: {by_code_n} | точное имя: {by_exact_n} | fuzzy≥{THRESHOLD}: {by_fuzzy_n}"
)
print("\nпримеры совпадений:") print("\nпримеры совпадений:")
for raw, canon, sc in examples_ok: for raw, canon, sc in examples_ok:
print(f" «{raw[:46]}» → «{canon[:46]}» ({sc})") print(f" «{raw[:46]}» → «{canon[:46]}» ({sc})")
+3 -2
View File
@@ -2,6 +2,7 @@
Флаг --vision включает добор трудных PDF через Gemini Vision (медленно). Флаг --vision включает добор трудных PDF через Gemini Vision (медленно).
""" """
import json import json
import sys import sys
from datetime import date from datetime import date
@@ -10,8 +11,8 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[1] REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO)) sys.path.insert(0, str(REPO))
from etl.normalize.embedding import GeminiEmbedder from etl.normalize.embedding import GeminiEmbedder # noqa: E402
from etl.pipeline import run from etl.pipeline import run # noqa: E402
summary = run( summary = run(
data_dir=str(REPO / "data/raw"), data_dir=str(REPO / "data/raw"),
Generated
+1319
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -51,6 +51,21 @@
<h2 class="text-2xl font-bold mt-6">Дашборд обработки</h2> <h2 class="text-2xl font-bold mt-6">Дашборд обработки</h2>
<div id="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"></div> <div id="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"></div>
<!-- Загрузка нового архива (ТЗ §4.1) -->
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5 mt-6">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 class="text-lg font-semibold">Загрузить архив прайсов</h3>
<p class="text-sm text-tg mt-1">ZIP с прайсами клиник (pdf, xlsx, xls, docx). Система распакует, распознает и догрузит позиции в базу.</p>
</div>
<div class="shrink-0">
<input id="zip" type="file" accept=".zip" class="hidden" onchange="uploadArchive(this)">
<button onclick="$('zip').click()" class="bg-nomad text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-nomad-dark">Выбрать ZIP</button>
</div>
</div>
<div id="ingest-result" class="mt-3 text-sm hidden"></div>
</div>
<div class="flex items-center justify-between mt-8"> <div class="flex items-center justify-between mt-8">
<h3 class="text-lg font-semibold">Очередь верификации</h3> <h3 class="text-lg font-semibold">Очередь верификации</h3>
<div id="vprogress" class="text-sm text-tg"></div> <div id="vprogress" class="text-sm text-tg"></div>
@@ -145,6 +160,33 @@ async function loadAdmin() {
loadQueue(); loadQueue();
} }
async function uploadArchive(input) {
const f = input.files[0];
if (!f) return;
const box = $('ingest-result');
box.classList.remove('hidden');
box.innerHTML = `<span class="text-tg">⏳ Обрабатываю <b>${f.name}</b> — распаковка, распознавание и нормализация. Может занять до минуты…</span>`;
const fd = new FormData();
fd.append('file', f);
try {
const res = await fetch(`${API}/ingest`, { method: 'POST', body: fd });
const r = await res.json().catch(() => ({}));
if (!res.ok) {
box.innerHTML = `<span class="text-[#dc2626]">Ошибка: ${r.detail || res.status}</span>`;
} else {
const note = r.embeddings ? '' : ' <span class="text-tg">(нормализация по кодам и нечёткому совпадению — без эмбеддингов)</span>';
box.innerHTML = `<div class="bg-nomad-soft border border-[#ffd9cc] rounded-lg p-3">
✓ Архив обработан: <b>${r.documents}</b> прайс(ов), <b>${r.items}</b> позиций,
автонормализация <b class="text-nomad">${r.auto_matched_pct}%</b>${r.new_partners ? ', новых клиник ' + r.new_partners : ''}.${note}
<div class="text-tg mt-1">Новые позиции уже доступны в поиске и в очереди верификации.</div></div>`;
loadAdmin(); // обновить дашборд и очередь под новые данные
}
} catch (e) {
box.innerHTML = `<span class="text-[#dc2626]">Сбой загрузки: ${e}</span>`;
}
input.value = '';
}
async function loadQueue() { async function loadQueue() {
const items = await fetch(`${API}/unmatched?limit=25`).then(r => r.json()); const items = await fetch(`${API}/unmatched?limit=25`).then(r => r.json());
$('queue').innerHTML = items.map(renderCard).join('') || '<div class="text-tg p-4">Очередь пуста 🎉</div>'; $('queue').innerHTML = items.map(renderCard).join('') || '<div class="text-tg p-4">Очередь пуста 🎉</div>';