Files
medtech-hackathon/web/index.html
T
admins 1ff5d93cd8 feat(день2): экран верификации с предложениями + обучение, детектор переплаты
- очередь верификации: предложенный кандидат (топ эмбеддинг), подтвердить/другое/пропустить
- POST /match пополняет синонимы (learned_synonym) — обучающаяся нормализация
- сравнение: медиана + разброс цен + отклонение клиники (переплата/выгода)
- эмбеддер: дедуп названий + ретрай при 429 (квота Gemini)
- развёрнуто на med.secondbrain.tools

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:47:57 +05:00

211 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MedArchive — сравнение цен на медуслуги</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=geologica:300,400,500,600,700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { theme: { extend: {
colors: { nomad: { DEFAULT: '#ff4713', soft: '#ffede7', dark: '#e63e0f' },
tg: '#6b788e', lg: '#f5f7f9', ink: '#111827' },
fontFamily: { sans: ['Geologica', 'ui-sans-serif', 'system-ui', 'sans-serif'] } } } };
</script>
<style>body{font-family:'Geologica',system-ui,sans-serif}</style>
</head>
<body class="bg-[#f3f4f6] text-ink min-h-screen">
<div class="h-1 bg-nomad w-full"></div>
<header class="max-w-5xl mx-auto px-5 pt-8 pb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-2xl font-bold">Med<span class="text-nomad">Archive</span></span>
<span class="text-xs text-tg bg-lg border border-[#ebedf0] rounded-full px-2 py-0.5">прайсы клиник-партнёров</span>
</div>
<nav class="text-sm text-tg flex gap-4">
<button onclick="show('search')" class="hover:text-ink">Поиск</button>
<button onclick="show('admin')" class="hover:text-ink">Админка</button>
</nav>
</header>
<main class="max-w-5xl mx-auto px-5 pb-16">
<!-- Поиск + сравнение -->
<section id="view-search">
<h1 class="text-3xl sm:text-4xl font-bold mt-6 leading-tight">Найдите услугу — <span class="text-nomad">сравните цены</span> в клиниках</h1>
<p class="text-tg mt-2">Единый каталог услуг и цен из архива прайсов клиник-партнёров.</p>
<div class="mt-5 flex gap-2">
<input id="q" type="text" placeholder="например: УЗИ брюшной полости, приём кардиолога, общий анализ крови"
class="flex-1 bg-white border border-[#d1d5db] rounded-lg px-4 py-3 outline-none focus:border-nomad focus:ring-2 focus:ring-nomad/40"
oninput="debouncedSearch()" autocomplete="off">
</div>
<div id="suggest" class="mt-2 bg-white border border-[#ebedf0] rounded-xl divide-y divide-[#f0f0f0] hidden shadow-sm"></div>
<div id="compare" class="mt-6"></div>
</section>
<!-- Админка + верификация -->
<section id="view-admin" class="hidden">
<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 class="flex items-center justify-between mt-8">
<h3 class="text-lg font-semibold">Очередь верификации</h3>
<div id="vprogress" class="text-sm text-tg"></div>
</div>
<p class="text-sm text-tg mt-1">Система предлагает кандидата из справочника — подтвердите, выберите другой или пропустите. Подтверждение обучает систему.</p>
<div id="queue" class="mt-3 space-y-2"></div>
</section>
</main>
<script>
const API = new URLSearchParams(location.search).get('api') || '/api';
const $ = (id) => document.getElementById(id);
const money = (v) => v == null ? '—' : new Intl.NumberFormat('ru-RU').format(v) + ' ₸';
function show(view) {
$('view-search').classList.toggle('hidden', view !== 'search');
$('view-admin').classList.toggle('hidden', view !== 'admin');
if (view === 'admin') loadAdmin();
}
let timer;
function debouncedSearch() { clearTimeout(timer); timer = setTimeout(search, 250); }
async function search() {
const q = $('q').value.trim();
const box = $('suggest');
if (q.length < 2) { box.classList.add('hidden'); return; }
const data = await fetch(`${API}/search?q=${encodeURIComponent(q)}&limit=8`).then(r => r.json());
const items = (data.services || []);
box.innerHTML = items.length
? items.map(s => `<button class="block w-full text-left px-4 py-2.5 hover:bg-nomad-soft"
onclick="compare('${s.service_id}', ${JSON.stringify(s.name_ru).replace(/"/g, '&quot;')})">
<span class="font-medium">${s.name_ru}</span>
<span class="text-xs text-tg ml-2">${s.specialty || ''}</span></button>`).join('')
: `<div class="px-4 py-3 text-tg text-sm">Ничего не найдено</div>`;
box.classList.remove('hidden');
}
async function compare(serviceId, name) {
$('suggest').classList.add('hidden');
const rows = await fetch(`${API}/services/${encodeURIComponent(serviceId)}/partners`).then(r => r.json());
const vals = rows.map(r => r.price_resident_kzt).filter(v => v != null).sort((a, b) => a - b);
const best = vals.length ? vals[0] : null;
const worst = vals.length ? vals[vals.length - 1] : null;
const median = vals.length ? (vals.length % 2 ? vals[(vals.length - 1) / 2] : (vals[vals.length / 2 - 1] + vals[vals.length / 2]) / 2) : null;
const dev = (v) => (median && v != null) ? Math.round((v - median) / median * 100) : null;
const spread = (best != null && worst != null) ? worst - best : null;
$('compare').innerHTML = `
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm overflow-hidden">
<div class="px-5 py-4 border-b border-[#ebedf0]">
<div class="text-lg font-semibold">${name}</div>
<div class="text-sm text-tg">${rows.length} клиник(и) · медиана ${money(median)}${spread ? ' · разброс цен до <b class="text-nomad">' + money(spread) + '</b>' : ''}</div>
</div>
<table class="w-full text-sm">
<thead class="bg-lg text-tg text-left">
<tr><th class="px-5 py-2.5 font-semibold">Клиника</th>
<th class="px-5 py-2.5 font-semibold text-right">Резидент</th>
<th class="px-5 py-2.5 font-semibold text-right">vs медиана</th>
<th class="px-5 py-2.5 font-semibold text-right">Нерезидент</th>
<th class="px-5 py-2.5 font-semibold text-right">Прайс от</th></tr>
</thead>
<tbody class="divide-y divide-[#f0f0f0]">
${rows.map(r => {
const cheapest = r.price_resident_kzt != null && r.price_resident_kzt === best;
const d = dev(r.price_resident_kzt);
const badge = d == null ? '—' : (d > 0
? `<span class="text-[#dc2626] font-medium">+${d}%</span>`
: `<span class="text-[#0f973d] font-medium">${d}%</span>`);
return `<tr class="${cheapest ? 'bg-nomad-soft' : ''}">
<td class="px-5 py-3">${r.partner_name}${cheapest ? ' <span class="text-nomad text-xs font-semibold">· выгодно</span>' : ''}</td>
<td class="px-5 py-3 text-right tabular-nums font-medium">${money(r.price_resident_kzt)}</td>
<td class="px-5 py-3 text-right tabular-nums text-xs">${badge}</td>
<td class="px-5 py-3 text-right tabular-nums text-tg">${money(r.price_nonresident_kzt)}</td>
<td class="px-5 py-3 text-right text-tg text-xs">${r.effective_date || '—'}</td></tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
async function loadAdmin() {
const s = await fetch(`${API}/stats`).then(r => r.json());
const cards = [
['Документов', s.documents_total], ['Позиций', s.items_total],
['Автонормализация', s.auto_matched_pct + '%'], ['В очереди', s.unmatched_total]];
$('stats').innerHTML = cards.map(([l, v]) => `
<div class="bg-white border border-[#ebedf0] rounded-xl p-4">
<div class="text-2xl font-bold text-nomad">${v}</div>
<div class="text-xs text-tg mt-1">${l}</div></div>`).join('');
loadQueue();
}
async function loadQueue() {
const items = await fetch(`${API}/unmatched?limit=40`).then(r => r.json());
$('queue').innerHTML = items.map(renderCard).join('') || '<div class="text-tg p-4">Очередь пуста 🎉</div>';
loadCounts();
}
async function loadCounts() {
const c = await fetch(`${API}/unmatched/count`).then(r => r.json());
$('vprogress').innerHTML = `осталось <b>${c.remaining}</b> · обучено синонимов <b class="text-nomad">${c.learned_synonyms}</b>`;
}
function renderCard(it) {
const price = Object.values(it.prices)[0];
const sug = it.suggested;
const sugHtml = sug
? `<div class="text-sm mt-1">предложение: <b>${sug.name_ru}</b> <span class="text-tg">${sug.specialty || ''} · ${(sug.score || 0).toFixed(2)}</span></div>`
: `<div class="text-sm text-tg mt-1">кандидата нет</div>`;
const confirm = sug
? `<button onclick="doMatch(${it.item_id}, '${sug.service_id}', this)" class="bg-nomad text-white text-sm rounded-lg px-3 py-1.5 hover:bg-nomad-dark">✓ Подтвердить</button>`
: '';
return `<div id="card-${it.item_id}" class="bg-white border border-[#ebedf0] rounded-xl p-4">
<div class="flex justify-between items-start gap-3">
<div>
<div>извлечено: <b>${it.service_name_raw}</b></div>
<div class="text-sm text-tg">${it.partner_name}${price != null ? ' · ' + money(price) : ''}</div>
${sugHtml}
</div>
<div class="flex gap-2 shrink-0">
${confirm}
<button onclick="togglePick(${it.item_id})" class="border border-[#e5e7eb] text-sm rounded-lg px-3 py-1.5 hover:bg-lg">Другое</button>
<button onclick="skipCard(${it.item_id})" class="text-tg text-sm rounded-lg px-3 py-1.5 hover:bg-lg">Пропустить</button>
</div>
</div>
<div id="pick-${it.item_id}" class="hidden mt-3">
<input oninput="searchDict(${it.item_id}, this.value)" placeholder="найти услугу в справочнике…"
class="w-full bg-white border border-[#d1d5db] rounded-lg px-3 py-2 text-sm outline-none focus:border-nomad">
<div id="dict-${it.item_id}" class="mt-1 bg-white border border-[#ebedf0] rounded-lg divide-y divide-[#f0f0f0] max-h-44 overflow-auto"></div>
</div>
</div>`;
}
async function doMatch(itemId, serviceId, btn) {
if (btn) btn.disabled = true;
await fetch(`${API}/match?item_id=${itemId}&service_id=${encodeURIComponent(serviceId)}`, { method: 'POST' });
const card = $(`card-${itemId}`); if (card) card.remove();
loadCounts();
}
function togglePick(id) { $(`pick-${id}`).classList.toggle('hidden'); }
function skipCard(id) { const c = $(`card-${id}`); if (c) c.remove(); }
let dictTimer;
function searchDict(itemId, q) {
clearTimeout(dictTimer);
dictTimer = setTimeout(async () => {
if (q.trim().length < 2) return;
const items = await fetch(`${API}/services?q=${encodeURIComponent(q)}&limit=8`).then(r => r.json());
$(`dict-${itemId}`).innerHTML = items.map(s =>
`<button class="block w-full text-left px-3 py-2 text-sm hover:bg-nomad-soft" onclick="doMatch(${itemId}, '${s.service_id}', null)">${s.name_ru} <span class="text-tg text-xs">${s.specialty || ''}</span></button>`).join('');
}, 250);
}
</script>
</body>
</html>