commit fa2fbc59b03d25e2a6af09b468b89501fffaf88b Author: dmitriylaukhin Date: Fri Jun 26 12:53:04 2026 +0500 chore: seed repo — README, .gitignore, synthetic price fixtures (6 форматов) Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5ae763 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +.env.* +!.env.example + +# Node / Next.js +node_modules/ +.next/ +out/ +dist/ +*.log + +# Claude Code worktrees (изоляция агентов) +.claude/worktrees/ + +# OS +.DS_Store +Thumbs.db + +# Данные (реальные прайсы НЕ коммитим; синтетика в fixtures/raw — коммитим) +data/raw/ +data/uploads/ +*.sqlite diff --git a/README.md b/README.md new file mode 100644 index 0000000..daa906b --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# medtech-hackathon + +Terricon Valley · Medtech Hackathon (26–28.06.2026) · **Кейс 1 — автоматическая обработка архива прайсов клиник-партнёров.** + +Превращаем архив разнородных прайс-листов клиник (Excel / PDF / скан / фото / .doc, RU/KZ/EN, грязные данные) в единый сравнимый каталог **услуга → клиника → цена** с поиском, сравнением цен и экспортом. + +## Стек + +- **ETL/бэкенд:** Python + FastAPI (pandas для Excel, LLM-vision для грязного, pdfplumber, Tesseract `kaz+rus` fallback) +- **Канонизация услуг:** sentence-transformers + pgvector + детерминированная матрица конфликтов (см. словарик) +- **БД:** PostgreSQL + pgvector +- **Фронт:** Next.js + Tailwind + shadcn/ui (дизайн на палитре Nomad) +- **Деплой:** Docker Compose + Caddy → `med.secondbrain.tools` (Selectel KZ) + +## Языки + +KAZ / RUS / ENG — сквозное требование (данные и UI). + +## Структура (план) + +``` +contracts/ — модель данных + Zod-схемы + openapi.yaml (агент A) +db/ — миграции (агент A) +etl/ — парсинг и нормализация (агент B) +api/ — REST API поиска/сравнения (агент C) +web/ — Next.js UI (агент D) +infra/ — docker-compose, Caddyfile (агент E) +docs/ — документация, ADR (агент F) +pitch/ — слайды и демо-сценарий (агент F) +fixtures/ — синтетические + эталонные данные +``` + +Скелет кода поднимается в Фазе 0 хакатона после заморозки контрактов. + +## Полный план + +В хранилище Obsidian: `Base/_Inbox/Хакатон Medtech/` (план, словарик нормализации, дизайн-система, памятка). diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..7a180fe --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,20 @@ +# Fixtures — синтетические прайсы клиник + +Сгенерированы скриптом `scripts/gen_fixtures.py` (seed=42, детерминированно) для отладки pipeline **до** выдачи реальных данных от организатора. Намеренно разнородные и «грязные». + +## `raw/` — входные прайсы (6 форматов) + +| Файл | Формат | Особенности (что тестирует) | +|---|---|---| +| `clinic_alpha_clean.xlsx` | Excel | Чистая таблица — быстрый путь pandas, без LLM | +| `clinic_beta_dirty.xlsx` | Excel | Секции-заголовки, «от 12000», KZ-названия, лишние колонки | +| `clinic_gamma.csv` | CSV (`;`) | Смешанные RU/KZ, единицы измерения | +| `clinic_delta_messy.txt` | Текст | Прайс абзацем, разные разделители и валюты (тг/тенге/₸/KZT) | +| `clinic_epsilon_scan.png` | Картинка | Имитация фото/скана (наклон) → путь LLM-vision / OCR | +| `clinic_zeta_prices.pdf` | PDF | Текстовый PDF, RU/EN заголовок | + +Одни и те же услуги названы по-разному между файлами (ОАК / Общий анализ крови / Қанның жалпы анализі) — это материал для канонизации и дедупликации. + +## TODO (Фаза 0 хакатона) +- `normalized/*.json` — эталонные нормализованные записи (для TDD ETL) +- `api/*.json` — эталонные ответы API (для MSW-моков фронта) diff --git a/fixtures/raw/clinic_alpha_clean.xlsx b/fixtures/raw/clinic_alpha_clean.xlsx new file mode 100644 index 0000000..f525594 Binary files /dev/null and b/fixtures/raw/clinic_alpha_clean.xlsx differ diff --git a/fixtures/raw/clinic_beta_dirty.xlsx b/fixtures/raw/clinic_beta_dirty.xlsx new file mode 100644 index 0000000..dd032cf Binary files /dev/null and b/fixtures/raw/clinic_beta_dirty.xlsx differ diff --git a/fixtures/raw/clinic_delta_messy.txt b/fixtures/raw/clinic_delta_messy.txt new file mode 100644 index 0000000..a1ec6a8 --- /dev/null +++ b/fixtures/raw/clinic_delta_messy.txt @@ -0,0 +1,11 @@ +МЕДЦЕНТР «ТЕНГРИ» — действующие цены + +Қан биохимиясы — 8300 KZT (1 иссл.) +Терапевт қабылдауы 7000 KZT (1 приём) +Глюкоза крови: 1100 тг (1 иссл.) +Құрсақ қуысы УДЗ — 6700 ₸ (1 иссл.) +МРТ головного мозга: 21600 тг (1 иссл.) +Рентгенография ОГК: 5100 KZT (1 иссл.) +Удаление зуба простое ... 12900 KZT (1 зуб) + +* Цены справочные, уточняйте в регистратуре. Бағалар анықтамалық. diff --git a/fixtures/raw/clinic_epsilon_scan.png b/fixtures/raw/clinic_epsilon_scan.png new file mode 100644 index 0000000..4d929c5 Binary files /dev/null and b/fixtures/raw/clinic_epsilon_scan.png differ diff --git a/fixtures/raw/clinic_gamma.csv b/fixtures/raw/clinic_gamma.csv new file mode 100644 index 0000000..524c01f --- /dev/null +++ b/fixtures/raw/clinic_gamma.csv @@ -0,0 +1,9 @@ +service;price_kzt;unit +УЗИ ОБП;11600;1 иссл. +Приём врача-терапевта;4500;1 приём +Электрокардиография;2100;1 иссл. +Флюорография;4100;1 иссл. +Б/х крови;4000;1 иссл. +Консультация кардиолога;9600;1 приём +Экстракция зуба;9500;1 зуб +Қандағы глюкоза;1600;1 иссл. diff --git a/fixtures/raw/clinic_zeta_prices.pdf b/fixtures/raw/clinic_zeta_prices.pdf new file mode 100644 index 0000000..2d35dca Binary files /dev/null and b/fixtures/raw/clinic_zeta_prices.pdf differ diff --git a/scripts/gen_fixtures.py b/scripts/gen_fixtures.py new file mode 100644 index 0000000..6e7a349 --- /dev/null +++ b/scripts/gen_fixtures.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Генератор синтетических прайсов клиник (RU/KZ) в разных форматах для отладки pipeline.""" +import csv, os, random, subprocess, sys + +OUT = sys.argv[1] if len(sys.argv) > 1 else "fixtures/raw" +os.makedirs(OUT, 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]) + +# Базовый каталог: (канон RU, канон KZ, [варианты названий], мин, макс, единица) +CATALOG = [ + ("Общий анализ крови", "Қанның жалпы анализі", + ["Общий анализ крови", "ОАК", "Общий анализ крови (развернутый)", "Клинический анализ крови", "Қанның жалпы анализі"], + 1200, 4500, "1 иссл."), + ("УЗИ органов брюшной полости", "Құрсақ қуысы ағзаларының УДЗ", + ["УЗИ органов брюшной полости", "УЗИ ОБП", "УЗИ брюшной полости", "Құрсақ қуысы УДЗ"], + 6000, 13000, "1 иссл."), + ("Приём врача-терапевта", "Терапевт дәрігердің қабылдауы", + ["Прием терапевта", "Консультация терапевта первичная", "Приём врача-терапевта", "Терапевт қабылдауы"], + 4000, 9000, "1 приём"), + ("Электрокардиография (ЭКГ)", "Электрокардиография (ЭКГ)", + ["ЭКГ", "Электрокардиография", "ЭКГ с расшифровкой", "ЭКГ + описание"], + 2000, 5000, "1 иссл."), + ("Глюкоза крови", "Қандағы глюкоза", + ["Глюкоза", "Глюкоза крови", "Сахар крови", "Қандағы глюкоза"], + 800, 2200, "1 иссл."), + ("Консультация кардиолога", "Кардиолог кеңесі", + ["Консультация кардиолога", "Прием кардиолога", "Кардиолог консультация первичная"], + 5000, 12000, "1 приём"), + ("МРТ головного мозга", "Бас миының МРТ", + ["МРТ головного мозга", "Магнитно-резонансная томография головного мозга", "МРТ г/м", "Бас миы МРТ"], + 18000, 35000, "1 иссл."), + ("Биохимический анализ крови", "Қанның биохимиялық анализі", + ["Биохимия крови", "Биохимический анализ крови (базовый)", "Б/х крови", "Қан биохимиясы"], + 3500, 9000, "1 иссл."), + ("Удаление зуба", "Тісті жұлу", + ["Удаление зуба", "Удаление зуба простое", "Экстракция зуба", "Тіс жұлу"], + 6000, 15000, "1 зуб"), + ("Рентген грудной клетки", "Кеуде клеткасының рентгені", + ["Рентген грудной клетки", "Рентгенография ОГК", "Флюорография", "Кеуде рентгені"], + 2500, 6000, "1 иссл."), +] + +CLINICS = ["Клиника «Аль-Шифа»", "Медцентр «Сункар»", "Diagnostic Lab Astana", "Клиника «Береке»", "Медцентр «Тенгри»"] + +def price(lo, hi): + return round(random.randint(lo, hi), -2) # круглые сотни + +def variants_for(clinic_idx, n): + """Выбрать n услуг с разными вариантами написания и ценами.""" + rows = [] + chosen = random.sample(CATALOG, k=min(n, len(CATALOG))) + for ru, kz, names, lo, hi, unit in chosen: + name = random.choice(names) + rows.append((name, price(lo, hi), unit)) + return rows + +ensure("openpyxl") +from openpyxl import Workbook + +# 1) Чистый Excel +wb = Workbook(); ws = wb.active; ws.title = "Прайс" +ws.append(["Наименование услуги", "Цена, тг"]) +for name, p, _ in variants_for(0, 9): + ws.append([name, p]) +wb.save(f"{OUT}/clinic_alpha_clean.xlsx") + +# 2) Грязный Excel: секции-заголовки, "от", разные колонки, KZ +wb = Workbook(); ws = wb.active; ws.title = "PRICE 2026" +ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"]) +ws.append([]) +ws.append(["№", "Услуга / Қызмет", "Стоимость (тенге)", "Ед."]) +section = ["— Лаборатория / Зертхана —", "— Диагностика —", "— Приём специалистов —"] +i = 1 +for s in section: + ws.append([s, None, None, None]) + for name, p, unit in variants_for(1, 3): + val = f"от {p}" if random.random() < 0.3 else p + ws.append([i, name, val, unit]); i += 1 +wb.save(f"{OUT}/clinic_beta_dirty.xlsx") + +# 3) CSV с разделителем ; и смешанными языками +with open(f"{OUT}/clinic_gamma.csv", "w", encoding="utf-8", newline="") as f: + w = csv.writer(f, delimiter=";") + w.writerow(["service", "price_kzt", "unit"]) + for name, p, unit in variants_for(2, 8): + w.writerow([name, p, unit]) + +# 4) Грязный текст (прайс абзацем) +with open(f"{OUT}/clinic_delta_messy.txt", "w", encoding="utf-8") as f: + f.write("МЕДЦЕНТР «ТЕНГРИ» — действующие цены\n\n") + for name, p, unit in variants_for(3, 7): + sep = random.choice([" — ", " ... ", ": ", " "]) + cur = random.choice(["тг", "тенге", "₸", "KZT"]) + f.write(f"{name}{sep}{p} {cur} ({unit})\n") + f.write("\n* Цены справочные, уточняйте в регистратуре. Бағалар анықтамалық.\n") + +# 5) Картинка-«скан» прайса (имитация фото) +ensure("Pillow", "PIL") +from PIL import Image, ImageDraw, ImageFont +img = Image.new("RGB", (900, 700), (250, 248, 244)) +d = ImageDraw.Draw(img) +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) + return ImageFont.load_default() +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 name, p, unit in variants_for(4, 9): + d.text((50, y), name, fill=(30, 30, 30), font=font(24)) + d.text((720, y), f"{p} тг", fill=(30, 30, 30), font=font(24)) + y += 58 +img = img.rotate(-1.4, expand=False, fillcolor=(250, 248, 244)) # лёгкий наклон как у фото +img.save(f"{OUT}/clinic_epsilon_scan.png") + +# 6) PDF (если есть reportlab) +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) + if fp: + pdfmetrics.registerFont(TTFont("U", fp)) + c = canvas.Canvas(f"{OUT}/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 name, p, unit in variants_for(2, 10): + c.drawString(60, y, name[:55]); c.drawRightString(540, y, f"{p} KZT"); y -= 28 + c.save() + pdf = "ok" + else: + pdf = "no font" +except Exception as e: + pdf = f"skip ({e})" + +print("PDF:", pdf) +print("Files:", sorted(os.listdir(OUT)))