정책 변경: 주기 수집 없이 고정 데이터셋을 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>
236 lines
12 KiB
HTML
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 =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
|
|
$('#f').addEventListener('submit', (e) => { e.preventDefault(); run(); });
|
|
health(); run();
|
|
</script>
|
|
</body>
|
|
</html>
|