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:
«кто оказывает услугу и по какой цене», отсортировано от выгодной.
"""
from __future__ import annotations
import json
@@ -13,13 +14,15 @@ import sqlite3
import sys
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
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(
title="MedArchive API",
@@ -28,9 +31,7 @@ app = FastAPI(
# За Caddy сервис живёт на /api — без этого Swagger ищет спеку в корне и падает.
root_path="/api",
)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
def db() -> sqlite3.Connection:
@@ -68,16 +69,21 @@ def list_services(specialty: str | None = None, q: str | None = None, limit: int
sql += " AND specialty = ?"
args.append(specialty)
if q:
sql += " AND name_ru LIKE ?"
args.append(f"%{q}%")
# Поиск по нормализованной колонке: SQLite LIKE сворачивает регистр только для
# латиницы, поэтому «УЗИ» и «узи» совпадут лишь при сравнении lowercase-форм.
sql += " AND name_norm LIKE ?"
args.append(f"%{_norm(q)}%")
sql += " ORDER BY name_ru LIMIT ?"
args.append(limit)
with db() as conn:
return [ServiceOut(**dict(r)) for r in conn.execute(sql, args)]
@app.get("/services/{service_id}/partners", response_model=list[PriceOut],
summary="Кто оказывает услугу и по какой цене")
@app.get(
"/services/{service_id}/partners",
response_model=list[PriceOut],
summary="Кто оказывает услугу и по какой цене",
)
def service_partners(service_id: str):
"""Одна строка на клинику с показательной ценой (резидент или минимальный тариф)."""
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)
sql += " ORDER BY name"
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],
summary="Все услуги партнёра с ценами")
@app.get(
"/partners/{partner_id}/services",
response_model=list[PriceOut],
summary="Все услуги партнёра с ценами",
)
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,
pi.prices, pi.effective_date
@@ -130,18 +142,22 @@ def partner_services(partner_id: str, limit: int = 500):
@app.get("/search", summary="Полнотекстовый поиск по услугам и партнёрам")
def search(q: str = Query(..., min_length=1), limit: int = 20):
# Сравниваем по name_norm (lowercase): иначе кириллический регистр («УЗИ» vs «узи»)
# не сворачивается и часть запросов даёт «ничего не найдено».
like = f"%{_norm(q)}%"
with db() as conn:
services = [
dict(r)
for r in conn.execute(
"SELECT service_id, specialty, name_ru FROM service WHERE name_ru LIKE ? LIMIT ?",
[f"%{q}%", limit],
"SELECT service_id, specialty, name_ru FROM service WHERE name_norm LIKE ? LIMIT ?",
[like, limit],
)
]
partners = [
dict(r)
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}
@@ -203,6 +219,102 @@ def skip_item(item_id: int):
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="Ручное сопоставление позиции (с дообучением)")
def manual_match(item_id: int, service_id: str):
with db() as conn:
@@ -214,7 +326,8 @@ def manual_match(item_id: int, service_id: str):
if not row:
raise HTTPException(404, "позиция не найдена")
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],
)
# Дообучение: запоминаем синоним — следующие прогоны сопоставят его автоматически.
@@ -228,6 +341,22 @@ def manual_match(item_id: int, service_id: str):
@app.get("/stats", response_model=Stats, summary="Сводка качества обработки")
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"
if report.exists():
data = json.loads(report.read_text(encoding="utf-8"))
+1
View File
@@ -4,6 +4,7 @@
`MatchResult` — выход сопоставления (агент C). `*Out`-модели — выдача API (агент D),
её же потребляет фронт (агент E). Любая правка этих типов проходит через оркестратора.
"""
from __future__ import annotations
from datetime import date
+1
View File
@@ -10,6 +10,7 @@
отвечает только за загрузку и подготовку индексов; само сопоставление — в
`etl/normalize`.
"""
from __future__ import annotations
import re
+4 -1
View File
@@ -4,6 +4,7 @@
PDF с битым текстовым слоем помечается как scan_pdf — его добирает путь через Vision.
Любой сбой чтения логируется, но не роняет обработку остального архива.
"""
from __future__ import annotations
import os
@@ -26,7 +27,9 @@ def extract(path: str, use_vision: bool = True) -> ParsedDocument:
"""
name = Path(path).name
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)
if year:
+7 -1
View File
@@ -6,6 +6,7 @@
в числах, многотарифные колонки цен, строки-заголовки секций и шапку не в первой
строке таблицы.
"""
from __future__ import annotations
import re
@@ -85,7 +86,12 @@ def is_real_name(text) -> bool:
"""
s = clean(text)
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:
+22 -5
View File
@@ -9,6 +9,7 @@
- настоящая цена медуслуги — сотни и тысячи тенге, а не мелкое число;
- название услуги — самая «кириллическая» колонка, код — цифро-точечная.
"""
from __future__ import annotations
import statistics
@@ -97,7 +98,11 @@ def _assign_columns(grid: Grid, header_idx: int):
# --- колонка названия: явный заголовок, иначе самая кириллическая ---
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,
)
if name_col is None:
@@ -109,7 +114,9 @@ def _assign_columns(grid: Grid, header_idx: int):
(
c
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,
)
@@ -123,7 +130,11 @@ def _assign_columns(grid: Grid, header_idx: int):
break
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,
)
return name_col, code_col, unit_col, price_cols
@@ -165,10 +176,16 @@ def extract_rows_from_grid(grid: Grid) -> list[RawRow]:
RawRow(
service_name_raw=name,
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,
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,
)
)
+8 -2
View File
@@ -4,6 +4,7 @@
многолистовых книг и многотабличных PDF. Сырой текст нужен для аудита и для
детектора битого текстового слоя.
"""
from __future__ import annotations
import re
@@ -29,14 +30,19 @@ def partner_from_name(name: str) -> str:
def detect_format(path: str) -> str:
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]:
import docx
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)
return grids, text
+1
View File
@@ -5,6 +5,7 @@
вернуть позиции прайса строго по JSON-схеме. Требует переменную окружения
GEMINI_API_KEY; модель задаётся через GEMINI_MODEL (по умолчанию gemini-2.0-flash).
"""
from __future__ import annotations
import json
+1
View File
@@ -1,4 +1,5 @@
"""Нормализация извлечённых позиций к справочнику услуг."""
from etl.normalize.matcher import Matcher
__all__ = ["Matcher"]
+4 -1
View File
@@ -4,6 +4,7 @@
раздувается и не держит модель в RAM. Размерность 768 выбрана, чтобы вектор лез в
HNSW-индекс pgvector. Требуется переменная окружения GEMINI_API_KEY.
"""
from __future__ import annotations
import os
@@ -54,7 +55,9 @@ class GeminiEmbedder:
chunk = items[start : start + self.batch]
for attempt in range(8):
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)
break
except Exception as exc: # пауза и повтор при превышении квоты (429)
+15 -5
View File
@@ -11,6 +11,7 @@
клиник встречаются с лишним хвостом («A02.020.000.2»), поэтому сравниваем по
канонической части кода тарификатора.
"""
from __future__ import annotations
import re
@@ -37,6 +38,7 @@ class Matcher:
embedder=None,
emb_threshold: float = 0.70,
fuzzy_threshold: int = 88,
dict_emb=None,
):
self.services = services
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.fuzzy_threshold = fuzzy_threshold
self.embedder = embedder
self.dict_emb = None
if embedder is not None:
# Готовые эмбеддинги справочника (dict_emb) переиспользуются между прогонами:
# при догрузке нового прайса не нужно заново векторизовать тысячи услуг.
self.dict_emb = dict_emb
if embedder is not None and self.dict_emb is None:
self.dict_emb = embedder.encode(
[s.name_ru for s in services],
normalize_embeddings=True,
@@ -65,7 +69,9 @@ class Matcher:
best = process.extractOne(name_norm, self.names_norm, scorer=fuzz.token_set_ratio)
if best and best[1] >= self.fuzzy_threshold:
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()
@@ -78,12 +84,16 @@ class Matcher:
for i, (name, code) in enumerate(zip(names, codes, strict=True)):
service = self._match_by_code(code)
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
name_norm = normalize_name(name)
exact = self.by_norm.get(name_norm)
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
pending_idx.append(i)
pending_norm.append(name_norm)
+143 -29
View File
@@ -4,7 +4,11 @@
PostgreSQL + pgvector лежит в db/migrations. Здесь же — дедупликация партнёров,
версионирование цен (последний прайс активен, старые архивируются) и сбор отчёта о
качестве для дашборда и сдачи.
Два входа: `run()` собирает базу с нуля (полная пересборка), `ingest()` догружает
новые прайсы в уже собранную базу (append) — на нём держится приём ZIP через интерфейс.
"""
from __future__ import annotations
import json
@@ -15,6 +19,8 @@ from collections import Counter
from datetime import date
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
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);
"""
_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:
# Полная пересборка: удаляем файл, чтобы схема всегда создавалась свежей.
Path(db_path).unlink(missing_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA)
return conn
def run(data_dir: str, db_path: str, dict_path: str, embedder=None, use_vision: bool = False,
today: date | None = None) -> dict:
"""Прогнать весь архив в SQLite и вернуть отчёт о качестве."""
today = today or date.today()
services = load_dictionary(dict_path)
matcher = Matcher(services, embedder=embedder)
conn = _open(db_path)
def _load_services(conn, services) -> None:
"""Залить справочник услуг, если таблица ещё пуста."""
if conn.execute("SELECT 1 FROM service LIMIT 1").fetchone():
return
conn.executemany(
"INSERT INTO service VALUES (?,?,?,?,?)",
[(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()
staged: list[tuple] = [] # (doc_id, partner_id, raw, code, prices, unit, eff_iso)
for path in sorted(Path(data_dir).glob("*")):
if path.is_dir():
continue
doc = extract(str(path), use_vision=use_vision)
partner_name = doc.partner_name or path.stem
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:
partner_id = str(uuid.uuid4())
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())
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
for row in doc.rows:
staged.append((doc_id, partner_id, row.service_name_raw, row.service_code_source,
row.prices, row.unit, eff_iso))
staged.append(
(
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])
for (doc_id, partner_id, raw, code, prices, unit, eff_iso), match in zip(staged, matches, strict=True):
for (doc_id, partner_id, raw, code, prices, unit, eff_iso), match in zip(
staged, matches, strict=True
):
status, _flags = validate_item(raw, prices, effective_date=None, today=today)
if match.service_id:
report["matched"] += 1
report[f"method_{match.method}"] += 1
conn.execute(
"""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,?,?)""",
(doc_id, partner_id, raw, code, match.service_id, json.dumps(prices, ensure_ascii=False),
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),
_INSERT_ITEM,
(
doc_id,
partner_id,
raw,
code,
match.service_id,
json.dumps(prices, ensure_ascii=False),
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
return report
# Версионирование: если у партнёра по той же услуге есть более свежий прайс — старые архивируем.
def _apply_versioning(conn) -> None:
"""У партнёра по той же услуге более свежий прайс вытесняет старые (is_active = 0)."""
conn.execute(
"""UPDATE price_item SET is_active = 0
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.effective_date > price_item.effective_date)"""
)
conn.commit()
def _summary(report: Counter) -> dict:
total = report["items"]
summary = {
return {
"documents": report["documents"],
"partners": len(partners),
"partners": report["new_partners"],
"items": total,
"auto_matched": report["matched"],
"auto_matched_pct": round(100 * report["matched"] / total, 1) if total else 0.0,
"unmatched": total - report["matched"],
"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()
return summary
+1
View File
@@ -1,4 +1,5 @@
"""Валидация позиций прайса по правилам ТЗ §4.4."""
from etl.validate.rules import Flag, to_kzt, validate_item
__all__ = ["Flag", "to_kzt", "validate_item"]
+9 -2
View File
@@ -4,6 +4,7 @@
ошибка → error, предупреждение → needs_review, иначе → done. Конвертация валют и
сравнение с прошлой версией (детектор аномалий) тоже здесь.
"""
from __future__ import annotations
from dataclasses import dataclass
@@ -34,7 +35,9 @@ def validate_prices(prices: dict[str, float]) -> list[Flag]:
flags: list[Flag] = []
for tier, value in prices.items():
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")
if resident and nonresident and nonresident < resident:
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]:
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 [
Flag(
"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
Лёгкая схема: контейнер API (FastAPI на готовой SQLite-базе) за общим Caddy на Selectel KZ.
Рантайму не нужны ни Gemini, ни модель — эмбеддинги и Vision отработали на этапе сборки базы.
Контейнер API (FastAPI) за общим Caddy на Selectel KZ. Read-only поиск работает на
готовой 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`) и
отчёт (`data/quality_report.json`) — в `/root/medarchive/`.
2. Положить `docker-compose.yml` и `Caddyfile` из этой папки в каталог с Caddy.
3. `docker compose up -d` — поднимется `medarchive-api` (ставит зависимости и запускает uvicorn).
4. Маршруты Caddy: `/api/*` → контейнер API, `/` → фронт, `/demo` → презентация.
1. Залить код и данные в `/root/medarchive/` (см. раскладку выше).
2. Положить `docker-compose.yml`, `Dockerfile`, `Caddyfile` в `/root/med-hackathon/`.
3. Создать `/root/med-hackathon/.env`:
```
GEMINI_API_KEY=…
```
4. Собрать образ и поднять стек:
```bash
cd /root/med-hackathon
docker compose build api
docker compose up -d
```
5. Маршруты Caddy: `/api/*` → контейнер API, `/` → фронт, `/demo` и `/day1` → презентации.
## Проверка
```bash
curl https://med.secondbrain.tools/api/health
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
api:
image: python:3.12-slim
build:
context: .
dockerfile: Dockerfile
image: medarchive-api:latest
container_name: medarchive-api
restart: unless-stopped
working_dir: /app
@@ -22,7 +25,10 @@ services:
- /root/medarchive:/app
environment:
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:
caddy_data:
+9
View File
@@ -25,3 +25,12 @@ target-version = "py312"
[tool.ruff.lint]
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"]
+378 -85
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Генератор синтетических прайсов клиник (RU/KZ/EN) + эталонная нормализация + справочник канонов.
Выдаёт:
@@ -8,7 +7,13 @@
contracts/service_canon.json — справочник канонических услуг (seed для нормализации)
Детерминированно (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 "."
RAW = os.path.join(ROOT, "fixtures/raw")
@@ -18,144 +23,432 @@ for d in (RAW, NORM, CONTRACTS):
os.makedirs(d, exist_ok=True)
random.seed(42)
def ensure(pkg, mod=None):
try: __import__(mod or pkg)
except ImportError: subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
try:
__import__(mod or pkg)
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
# Справочник канонических медуслуг. lang-тегированные синонимы — материал для нормализации.
CANONS = [
{"id":"cbc","ru":"Общий анализ крови","kz":"Қанның жалпы анализі","en":"Complete blood count (CBC)",
"category":"Лаборатория","code":"B03.016.003","unit":"1 иссл.","price":(1200,4500),
"syn":[("ОАК","ru"),("Общий анализ крови (развернутый)","ru"),("Клинический анализ крови","ru"),("КАК","ru"),("CBC","en"),("Қанның жалпы талдауы","kz")]},
{"id":"uzi_abd","ru":"УЗИ органов брюшной полости","kz":"Құрсақ қуысы ағзаларының УДЗ","en":"Abdominal ultrasound",
"category":"Диагностика/УЗИ","code":"A04.16.001","unit":"1 иссл.","price":(6000,13000),
"syn":[("УЗИ ОБП","ru"),("УЗИ брюшной полости","ru"),("Ультразвуковое исследование брюшной полости","ru"),("Құрсақ қуысы УДЗ","kz"),("Abdominal US","en")]},
{"id":"therapist","ru":"Приём врача-терапевта","kz":"Терапевт дәрігердің қабылдауы","en":"General practitioner consultation",
"category":"Приём специалистов","code":"B01.047.001","unit":"1 приём","price":(4000,9000),
"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")]},
{
"id": "cbc",
"ru": "Общий анализ крови",
"kz": "Қанның жалпы анализі",
"en": "Complete blood count (CBC)",
"category": "Лаборатория",
"code": "B03.016.003",
"unit": "1 иссл.",
"price": (1200, 4500),
"syn": [
("ОАК", "ru"),
("Общий анализ крови (развернутый)", "ru"),
("Клинический анализ крови", "ru"),
("КАК", "ru"),
("CBC", "en"),
("Қанның жалпы талдауы", "kz"),
],
},
{
"id": "uzi_abd",
"ru": "УЗИ органов брюшной полости",
"kz": "Құрсақ қуысы ағзаларының УДЗ",
"en": "Abdominal ultrasound",
"category": "Диагностика/УЗИ",
"code": "A04.16.001",
"unit": "1 иссл.",
"price": (6000, 13000),
"syn": [
("УЗИ ОБП", "ru"),
("УЗИ брюшной полости", "ru"),
("Ультразвуковое исследование брюшной полости", "ru"),
("Құрсақ қуысы УДЗ", "kz"),
("Abdominal US", "en"),
],
},
{
"id": "therapist",
"ru": "Приём врача-терапевта",
"kz": "Терапевт дәрігердің қабылдауы",
"en": "General practitioner consultation",
"category": "Приём специалистов",
"code": "B01.047.001",
"unit": "1 приём",
"price": (4000, 9000),
"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 = []
def build(file_name, clinic, n):
rows = []
for c in random.sample(CANONS, k=min(n, len(CANONS))):
raw_name, lang = variant(c); p = price(c)
raw_name, lang = variant(c)
p = price(c)
rows.append({"raw_name": raw_name, "price": p, "unit": c["unit"]})
GOLDEN.append({"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"]})
GOLDEN.append(
{
"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
ensure("openpyxl"); from openpyxl import Workbook
ensure("openpyxl")
from openpyxl import Workbook
# 1) чистый Excel
rows = build("clinic_alpha_clean.xlsx", CLINICS[0], 9)
wb=Workbook(); ws=wb.active; ws.title="Прайс"; ws.append(["Наименование услуги","Цена, тг"])
for r in rows: ws.append([r["raw_name"], r["price"]])
wb = Workbook()
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")
# 2) грязный Excel
rows = build("clinic_beta_dirty.xlsx", CLINICS[1], 9)
wb=Workbook(); ws=wb.active; ws.title="PRICE 2026"
ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"]); ws.append([])
wb = Workbook()
ws = wb.active
ws.title = "PRICE 2026"
ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"])
ws.append([])
ws.append(["", "Услуга / Қызмет", "Стоимость (тенге)", "Ед."])
secs=["— Лаборатория / Зертхана —","— Диагностика —","— Приём специалистов —"]; i=1
secs = ["— Лаборатория / Зертхана —", "— Диагностика —", "— Приём специалистов —"]
i = 1
for k, r in enumerate(rows):
if k%3==0 and secs: ws.append([secs.pop(0),None,None,None])
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
ws.append([i, r["raw_name"], val, r["unit"]])
i += 1
wb.save(f"{RAW}/clinic_beta_dirty.xlsx")
# 3) CSV ;
rows = build("clinic_gamma.csv", CLINICS[2], 8)
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"])
for r in rows: w.writerow([r["raw_name"], r["price"], r["unit"]])
w = csv.writer(f, delimiter=";")
w.writerow(["service", "price_kzt", "unit"])
for r in rows:
w.writerow([r["raw_name"], r["price"], r["unit"]])
# 4) грязный текст
rows = build("clinic_delta_messy.txt", CLINICS[4], 7)
with open(f"{RAW}/clinic_delta_messy.txt", "w", encoding="utf-8") as f:
f.write("МЕДЦЕНТР «ТЕНГРИ» — действующие цены\n\n")
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("\n* Цены справочные, уточняйте в регистратуре. Бағалар анықтамалық.\n")
# 5) PNG-скан
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):
for p in ["/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)
for p in [
"/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()
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)
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:
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
img=img.rotate(-1.4,expand=False,fillcolor=(250,248,244)); img.save(f"{RAW}/clinic_epsilon_scan.png")
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
img = img.rotate(-1.4, expand=False, fillcolor=(250, 248, 244))
img.save(f"{RAW}/clinic_epsilon_scan.png")
# 6) PDF
rows = build("clinic_zeta_prices.pdf", CLINICS[2], 10)
pdf = "skip"
try:
ensure("reportlab")
from reportlab.lib.pagesizes import A4; from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics; from reportlab.pdfbase.ttfonts import TTFont
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.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
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:
pdfmetrics.registerFont(TTFont("U",fp)); c=canvas.Canvas(f"{RAW}/clinic_zeta_prices.pdf",pagesize=A4)
c.setFont("U",18); c.drawString(60,790,"Diagnostic Lab Astana — Price list / Прайс"); 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})"
pdfmetrics.registerFont(TTFont("U", fp))
c = canvas.Canvas(f"{RAW}/clinic_zeta_prices.pdf", pagesize=A4)
c.setFont("U", 18)
c.drawString(60, 790, "Diagnostic Lab Astana — Price list / Прайс")
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:
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"],
"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_out = [
{
"canon_id": c["id"],
"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)
+7 -2
View File
@@ -1,4 +1,5 @@
"""Прогон извлечения по всему архиву: сколько позиций берёт каждый формат."""
import sys
from pathlib import Path
@@ -14,11 +15,15 @@ for path in files:
total += len(doc.rows)
tiers = sorted({t for row in doc.rows for t in row.prices})
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}")
if doc.parse_log:
print(f" лог: {doc.parse_log[:2]}")
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}")
+7 -2
View File
@@ -1,4 +1,5 @@
"""Подбор порога нормализации: выборка позиций → каскад → покрытие при порогах."""
import random
import sys
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())
fuzzy_matched = int(((fuzzy_score >= 88) & (top_score < thr)).sum())
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):")
shown = 0
@@ -75,5 +78,7 @@ print("примеры unmatched (top<0.62 и fuzzy<88):")
shown = 0
for k, (name, _) in enumerate(pending):
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
+13 -5
View File
@@ -4,6 +4,7 @@
точное совпадение + RapidFuzz). Эмбеддинги в боевом каскаде поднимут этот процент —
здесь нужен нижний ориентир, чтобы убедиться, что цель 70% достижима.
"""
import re
import sys
from pathlib import Path
@@ -17,7 +18,9 @@ from rapidfuzz import fuzz, process
from etl.dictionary import load_dictionary, normalize_name
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_norm: dict[str, object] = {}
@@ -52,14 +55,17 @@ examples_ok, examples_miss = [], []
for code, name, price in items:
nn = normalize_name(name)
if code in by_code:
matched += 1; by_code_n += 1
matched += 1
by_code_n += 1
continue
if nn in by_norm:
matched += 1; by_exact_n += 1
matched += 1
by_exact_n += 1
continue
best = process.extractOne(nn, names_norm, scorer=fuzz.token_set_ratio)
if best and best[1] >= THRESHOLD:
matched += 1; by_fuzzy_n += 1
matched += 1
by_fuzzy_n += 1
if len(examples_ok) < 6:
examples_ok.append((name, svcs[best[2]].name_ru, round(best[1])))
elif len(examples_miss) < 6 and best:
@@ -67,7 +73,9 @@ for code, name, price in items:
pct = 100 * matched / max(1, len(items))
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примеры совпадений:")
for raw, canon, sc in examples_ok:
print(f" «{raw[:46]}» → «{canon[:46]}» ({sc})")
+3 -2
View File
@@ -2,6 +2,7 @@
Флаг --vision включает добор трудных PDF через Gemini Vision (медленно).
"""
import json
import sys
from datetime import date
@@ -10,8 +11,8 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO))
from etl.normalize.embedding import GeminiEmbedder
from etl.pipeline import run
from etl.normalize.embedding import GeminiEmbedder # noqa: E402
from etl.pipeline import run # noqa: E402
summary = run(
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>
<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">
<h3 class="text-lg font-semibold">Очередь верификации</h3>
<div id="vprogress" class="text-sm text-tg"></div>
@@ -145,6 +160,33 @@ async function loadAdmin() {
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() {
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>';