feat(день2): экран верификации с предложениями + обучение, детектор переплаты
- очередь верификации: предложенный кандидат (топ эмбеддинг), подтвердить/другое/пропустить - POST /match пополняет синонимы (learned_synonym) — обучающаяся нормализация - сравнение: медиана + разброс цен + отклонение клиники (переплата/выгода) - эмбеддер: дедуп названий + ретрай при 429 (квота Gemini) - развёрнуто на med.secondbrain.tools Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+84
-11
@@ -46,12 +46,17 @@
|
||||
<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>
|
||||
<h3 class="text-lg font-semibold mt-8">Очередь ручной разметки (unmatched)</h3>
|
||||
<div id="unmatched" class="mt-3 bg-white border border-[#ebedf0] rounded-xl overflow-hidden"></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>
|
||||
@@ -88,26 +93,37 @@ async function search() {
|
||||
async function compare(serviceId, name) {
|
||||
$('suggest').classList.add('hidden');
|
||||
const rows = await fetch(`${API}/services/${encodeURIComponent(serviceId)}/partners`).then(r => r.json());
|
||||
const best = rows.reduce((m, r) => (r.price_resident_kzt != null && (m == null || r.price_resident_kzt < m)) ? r.price_resident_kzt : m, null);
|
||||
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} клиник(и) оказывают услугу</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('')}
|
||||
@@ -125,12 +141,69 @@ async function loadAdmin() {
|
||||
<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('');
|
||||
const u = await fetch(`${API}/unmatched?limit=25`).then(r => r.json());
|
||||
$('unmatched').innerHTML = `<table class="w-full text-sm"><tbody class="divide-y divide-[#f0f0f0]">
|
||||
${u.map(x => `<tr><td class="px-5 py-2.5">${x.service_name_raw}</td>
|
||||
<td class="px-5 py-2.5 text-tg text-xs">${x.partner_name}</td>
|
||||
<td class="px-5 py-2.5 text-right tabular-nums text-tg">${Object.values(x.prices)[0] != null ? money(Object.values(x.prices)[0]) : '—'}</td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user