o2o-site-ontology/public/demo.html
hbyang edde23f15e 고정 데이터셋 1,000건 적재 + 로컬 임베딩 + 매칭 콘솔
정책 변경: 주기 수집 없이 고정 데이터셋을 1회 적재한다.

- data/gunsan-pension-keywords.json — "군산 펜션" 키워드·태그 1,000건
  실제 군산 지명·관광지·숙박 시설 어휘 × 로컬 검색 패턴으로 작성
- 임베딩을 LlmProvider 에서 EmbeddingProvider 로 분리
  (mock | local:multilingual-e5-small | openai), e5 의 query/passage 비대칭 반영
- vector(1536) → vector(384) 마이그레이션, keyword 에 source/kind/category 추가
- scripts/ingest-dataset.ts — 어휘 중복만 자동 병합, 벡터 근접쌍은 검토 목록만 출력
- POST /v1/match — 업체명(띄어쓰기 무관) 또는 문장 → 사전에서 매칭
  업체는 프로필 전체를 질의문으로 조립해 임베딩
- GET /demo — 매칭 콘솔 (public/demo.html)

실측으로 코사인 자동 병합 임계값을 0.92 → 0.99 로 정정.
짧은 한글 키워드는 같은 도메인이면 0.93+ 가 기본이라 0.92 는 오병합을 부른다.

적재 결과: 1,000건 → 어휘 중복 27 병합, 금칙어 2 차단 → 971건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:11:45 +09:00

236 lines
12 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>키워드 매칭 콘솔 · o2o-site-ontology</title>
<style>
:root{
--bg:#f4f6f5; --surface:#fff; --surface2:#eceff0; --ink:#101819; --ink-soft:#3d4c4e;
--muted:#63757a; --line:#d5dcdb; --line-soft:#e4e9e8;
--accent:#0d6a60; --accent-bg:#dff0ec; --warn:#8a5a06; --warn-bg:#f6ead2;
--stop:#9d3a30; --info:#1d5b7a; --info-bg:#dceaf2;
--sans:'Apple SD Gothic Neo',system-ui,-apple-system,'Malgun Gothic',sans-serif;
--mono:'Menlo','SFMono-Regular',Consolas,monospace;
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--bg:#0d1213; --surface:#141b1c; --surface2:#1b2425; --ink:#e7edeb; --ink-soft:#c2cecd;
--muted:#8d9d9f; --line:#263130; --line-soft:#1e2728;
--accent:#56c2b1; --accent-bg:#12312e; --warn:#d7a34a; --warn-bg:#33270f;
--stop:#e28a80; --info:#7fb6d4; --info-bg:#142c3a;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.6}
.wrap{max-width:1180px;margin:0 auto;padding:32px 24px 80px}
header{border-bottom:1px solid var(--line);padding-bottom:20px;margin-bottom:24px}
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.12em;color:var(--accent);margin:0 0 8px}
h1{margin:0 0 6px;font-size:26px;letter-spacing:-.01em}
.sub{margin:0;color:var(--muted);font-size:14px}
.status{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px;font-family:var(--mono);font-size:11px;color:var(--muted)}
.status span{border:1px solid var(--line);background:var(--surface);border-radius:3px;padding:3px 9px}
form{display:flex;gap:10px;margin:0 0 20px;flex-wrap:wrap}
input[type=text]{flex:1;min-width:260px;padding:12px 14px;font-size:15px;font-family:var(--sans);
background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:5px}
input[type=text]:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
select,button{padding:12px 16px;font-size:14px;font-family:var(--sans);border-radius:5px;border:1px solid var(--line);
background:var(--surface);color:var(--ink)}
button{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;cursor:pointer}
button:disabled{opacity:.5;cursor:progress}
.examples{display:flex;gap:6px;flex-wrap:wrap;margin:-8px 0 22px}
.examples button{background:var(--surface);color:var(--muted);border:1px solid var(--line);
font-weight:400;font-size:12px;padding:5px 11px;border-radius:20px}
.examples button:hover{color:var(--accent);border-color:var(--accent)}
.grid{display:grid;grid-template-columns:320px 1fr;gap:22px;align-items:start}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
.card{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:18px}
.card h2{margin:0 0 12px;font-size:14px;letter-spacing:.02em}
.kv{display:grid;grid-template-columns:72px 1fr;gap:4px 10px;font-size:13px}
.kv dt{color:var(--muted)}
.kv dd{margin:0;color:var(--ink-soft)}
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:4px}
.chip{font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--ink-soft);
padding:2px 7px;border-radius:3px}
.qtext{margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);
font-family:var(--mono);font-size:11.5px;color:var(--muted);word-break:break-all;line-height:1.7}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.toolbar .count{font-family:var(--mono);font-size:12px;color:var(--muted);margin-left:auto}
.filter{font-size:12px;font-family:var(--mono);padding:4px 10px;border-radius:20px;
border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
.filter[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
table{width:100%;border-collapse:collapse;font-size:14px}
th{text-align:left;font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;color:var(--muted);
text-transform:uppercase;padding:8px 10px;border-bottom:1px solid var(--line);font-weight:600}
td{padding:9px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
tr:hover td{background:var(--surface2)}
.rank{font-family:var(--mono);font-size:11px;color:var(--muted);width:34px;text-align:right;
font-variant-numeric:tabular-nums}
.kw{font-weight:500}
.kw small{display:block;font-family:var(--mono);font-size:10.5px;color:var(--muted);font-weight:400}
.bar{position:relative;width:120px;height:7px;background:var(--surface2);border-radius:4px;overflow:hidden}
.bar i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:4px}
.score{font-family:var(--mono);font-size:11.5px;color:var(--ink-soft);width:48px;
font-variant-numeric:tabular-nums;text-align:right}
.tag{font-size:10.5px;font-family:var(--mono);padding:2px 7px;border-radius:3px;white-space:nowrap}
.t-local{background:var(--accent-bg);color:var(--accent)}
.t-transactional{background:var(--warn-bg);color:var(--warn)}
.t-informational{background:var(--info-bg);color:var(--info)}
.t-brand{background:var(--surface2);color:var(--ink-soft)}
.cat{font-size:11px;color:var(--muted);font-family:var(--mono)}
.linked{font-size:10.5px;font-family:var(--mono);color:var(--accent)}
.empty{padding:40px;text-align:center;color:var(--muted);font-size:14px}
.err{background:var(--surface);border:1px solid var(--stop);color:var(--stop);
border-radius:6px;padding:14px 16px;font-size:13.5px;margin-bottom:16px}
.tblwrap{overflow-x:auto}
</style>
</head>
<body>
<div class="wrap">
<header>
<p class="eyebrow">O2O-SITE-ONTOLOGY</p>
<h1>키워드 매칭 콘솔</h1>
<p class="sub">업체명이나 문장을 넣으면 적재된 “군산 펜션” 키워드 사전에서 잘 맞는 것을 골라 보여줍니다.</p>
<div class="status" id="status"><span>연결 확인 중…</span></div>
</header>
<form id="f">
<input type="text" id="q" value="스테이 머뭄" placeholder="업체명 또는 문장" autocomplete="off">
<select id="limit">
<option value="30">상위 30</option>
<option value="50" selected>상위 50</option>
<option value="100">상위 100</option>
</select>
<button type="submit" id="go">매칭</button>
</form>
<div class="examples" id="ex"></div>
<div id="err"></div>
<div class="grid">
<aside class="card" id="side"><div class="empty">업체 정보</div></aside>
<section>
<div class="toolbar" id="filters"></div>
<div class="card" style="padding:0">
<div class="tblwrap"><table id="tbl">
<thead><tr><th class="rank">#</th><th>키워드</th><th>유사도</th><th>의도</th><th>카테고리</th></tr></thead>
<tbody><tr><td colspan="5"><div class="empty">매칭 버튼을 눌러 시작하세요</div></td></tr></tbody>
</table></div>
</div>
</section>
</div>
</div>
<script>
const API = location.origin;
const $ = (s) => document.querySelector(s);
let LAST = null, FILTER = null;
const EXAMPLES = ['스테이 머뭄', '스테이머뭄', '군산 애견동반 펜션', '강아지랑 갈 수 있는 바다 근처 숙소',
'아이랑 물놀이 하기 좋은 곳', '선유도 근처에서 바베큐 되는 독채'];
$('#ex').innerHTML = EXAMPLES.map(e => `<button type="button" data-q="${e}">${e}</button>`).join('');
$('#ex').addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
$('#q').value = b.dataset.q; run();
});
async function health() {
try {
const h = await (await fetch(API + '/health')).json();
$('#status').innerHTML =
`<span>API ${h.status}</span><span>임베딩 ${h.embeddingProvider}</span><span>생성 ${h.llmProvider}</span>`;
} catch { $('#status').innerHTML = '<span>API 연결 실패 — npm start 실행 중인지 확인</span>'; }
}
async function run() {
const query = $('#q').value.trim();
if (!query) return;
$('#go').disabled = true; $('#err').innerHTML = '';
try {
const res = await fetch(API + '/v1/match', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, limit: Number($('#limit').value) }),
});
if (!res.ok) throw new Error(await res.text());
LAST = await res.json(); FILTER = null;
renderSide(); renderFilters(); renderTable();
} catch (e) {
$('#err').innerHTML = `<div class="err">요청 실패 — ${String(e.message || e).slice(0, 300)}</div>`;
} finally { $('#go').disabled = false; }
}
function renderSide() {
const m = LAST.resolved;
if (!m) {
$('#side').innerHTML = `<h2>업체 미해석</h2>
<p style="font-size:13px;color:var(--muted);margin:0">
일치하는 업체가 없어 입력 문장을 그대로 질의로 사용했습니다.</p>
<div class="qtext"><b>질의문</b><br>${esc(LAST.queryText)}</div>`;
return;
}
const p = m.profile || {};
const arr = (k) => Array.isArray(p[k]) ? p[k] : [];
const chips = (k, label) => arr(k).length
? `<dt>${label}</dt><dd><div class="chips">${arr(k).map(v => `<span class="chip">${esc(v)}</span>`).join('')}</div></dd>` : '';
$('#side').innerHTML = `
<h2>해석된 업체</h2>
<dl class="kv">
<dt>상호</dt><dd><b>${esc(m.name)}</b></dd>
<dt>지역</dt><dd>${esc(m.region || '-')}</dd>
<dt>업종</dt><dd>${esc(m.industry || '-')}</dd>
<dt>ID</dt><dd style="font-family:var(--mono);font-size:11.5px">${esc(m.externalId)}</dd>
<dt>소개</dt><dd>${esc(m.description || '-')}</dd>
${chips('services', '서비스')}${chips('features', '시설')}
${chips('audiences', '동반자')}${chips('nearby', '인근')}
</dl>
<div class="qtext"><b>임베딩에 사용한 질의문</b><br>${esc(LAST.queryText)}</div>`;
}
function renderFilters() {
const cats = [...new Set(LAST.matches.map(m => m.category).filter(Boolean))];
$('#filters').innerHTML =
`<button class="filter" data-c="" aria-pressed="true">전체</button>` +
cats.map(c => `<button class="filter" data-c="${esc(c)}" aria-pressed="false">${esc(c)}</button>`).join('') +
`<span class="count">사전 ${LAST.total.toLocaleString()}건 중 상위 ${LAST.matches.length}건</span>`;
$('#filters').onclick = (e) => {
const b = e.target.closest('.filter'); if (!b) return;
FILTER = b.dataset.c || null;
[...$('#filters').querySelectorAll('.filter')]
.forEach(x => x.setAttribute('aria-pressed', String((x.dataset.c || null) === FILTER)));
renderTable();
};
}
function renderTable() {
const rows = LAST.matches.filter(m => !FILTER || m.category === FILTER);
const tb = $('#tbl tbody');
if (!rows.length) { tb.innerHTML = '<tr><td colspan="5"><div class="empty">결과 없음</div></td></tr>'; return; }
const max = Math.max(...rows.map(r => r.score)), min = Math.min(...rows.map(r => r.score));
const norm = (s) => max === min ? 100 : 6 + 94 * (s - min) / (max - min);
tb.innerHTML = rows.map((r, i) => `
<tr>
<td class="rank">${i + 1}</td>
<td class="kw">${esc(r.canonical)}
${r.kind === 'tag' ? '<span class="chip">태그</span>' : ''}
${r.linked ? '<span class="linked">· 연결됨</span>' : ''}
${r.aliases?.length ? `<small>흡수: ${r.aliases.map(esc).join(', ')}</small>` : ''}
</td>
<td><div style="display:flex;align-items:center;gap:8px">
<div class="bar"><i style="width:${norm(r.score).toFixed(1)}%"></i></div>
<span class="score">${r.score.toFixed(3)}</span></div></td>
<td><span class="tag t-${esc(r.intent)}">${esc(r.intent)}</span></td>
<td class="cat">${esc(r.category || '-')}</td>
</tr>`).join('');
}
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
$('#f').addEventListener('submit', (e) => { e.preventDefault(); run(); });
health(); run();
</script>
</body>
</html>