lakehouse/mcp-server/dashboard.html
root e7e988dcc0 Fix dashboard: always use hybrid (no HNSW dependency), 15s timeout, error display
The search hung because pure AI mode calls HNSW which is RAM-only —
gone after every lakehouse restart. Now ALL AI/hybrid searches go
through the /search endpoint which uses brute-force when HNSW isn't
loaded. Added 15s AbortController timeout so fetch never hangs.
Added window.onerror handler to show JS errors on page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 13:23:29 -05:00

356 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lakehouse — Staffing Search</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter','SF Pro',system-ui,sans-serif;background:#0a0a0f;color:#d4d4d8;line-height:1.6}
.hero{background:linear-gradient(135deg,#0f172a 0%,#1e1b4b 50%,#0f172a 100%);padding:30px;border-bottom:1px solid #1e293b;text-align:center}
.hero h1{font-size:24px;font-weight:700;background:linear-gradient(to right,#f472b6,#818cf8);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:6px}
.hero p{color:#94a3b8;font-size:13px}
.container{max-width:900px;margin:0 auto;padding:24px 16px}
.search-box{background:#111827;border:2px solid #334155;border-radius:12px;padding:20px;margin-bottom:24px;transition:border-color .2s}
.search-box:focus-within{border-color:#818cf8}
.search-row{display:flex;gap:10px}
.search-input{flex:1;background:#0a0a0f;border:1px solid #1e293b;border-radius:8px;padding:14px 18px;color:#e2e8f0;font-size:15px;outline:none}
.search-input::placeholder{color:#475569}
.search-btn{background:#7c3aed;border:none;color:#fff;padding:14px 28px;border-radius:8px;cursor:pointer;font-size:14px;font-weight:600;white-space:nowrap}
.search-btn:hover{background:#6d28d9}
.search-btn:disabled{background:#334155;cursor:wait}
.filters{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.filter{background:#1e293b;border:1px solid #334155;border-radius:6px;padding:6px 12px;font-size:12px;color:#94a3b8;outline:none}
.filter select,.filter input{background:transparent;border:none;color:#e2e8f0;font-size:12px;outline:none;width:auto}
.filter select{cursor:pointer}
.filter label{color:#64748b;margin-right:4px}
.examples{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.example{background:#0d1117;border:1px solid #1e293b;border-radius:20px;padding:4px 14px;font-size:11px;color:#818cf8;cursor:pointer;transition:all .15s}
.example:hover{background:#1e1b4b;border-color:#818cf8}
.results{margin-top:8px}
.result-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px}
.result-header .count{font-size:14px;color:#e2e8f0;font-weight:600}
.result-header .meta{font-size:11px;color:#64748b}
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:10px;font-weight:600}
.badge.green{background:#052e16;color:#34d399;border:1px solid #166534}
.badge.purple{background:#1e1047;color:#a78bfa;border:1px solid #5b21b6}
.badge.blue{background:#0c1a3d;color:#60a5fa;border:1px solid #1e40af}
.worker{background:#111827;border:1px solid #1e293b;border-radius:10px;padding:18px;margin-bottom:12px;transition:border-color .2s}
.worker:hover{border-color:#334155}
.worker .top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
.worker .name{font-size:16px;font-weight:700;color:#e2e8f0}
.worker .role{font-size:13px;color:#818cf8;margin-top:2px}
.worker .location{font-size:12px;color:#64748b;margin-top:2px}
.worker .score{text-align:right}
.worker .score .num{font-size:24px;font-weight:800;color:#34d399}
.worker .score .label{font-size:10px;color:#64748b;text-transform:uppercase}
.worker .details{display:flex;gap:16px;margin-top:12px;flex-wrap:wrap}
.worker .detail{font-size:11px}
.worker .detail .dt{color:#64748b;text-transform:uppercase;font-size:10px;letter-spacing:0.5px}
.worker .detail .dd{color:#d4d4d8;margin-top:2px}
.worker .verified{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#34d399;margin-top:8px}
.empty{text-align:center;color:#475569;padding:40px;font-size:14px}
.loading{text-align:center;padding:30px;color:#818cf8}
.mode-toggle{display:flex;gap:4px;background:#0d1117;border-radius:8px;padding:3px;margin-bottom:16px}
.mode-btn{padding:8px 16px;border-radius:6px;border:none;cursor:pointer;font-size:12px;font-weight:500;color:#94a3b8;background:transparent}
.mode-btn.active{background:#7c3aed;color:#fff}
.footer{text-align:center;padding:20px;color:#475569;font-size:11px;border-top:1px solid #1e293b;margin-top:30px}
.footer a{color:#818cf8;text-decoration:none}
@media(max-width:768px){
.search-row{flex-direction:column}
.worker .top{flex-direction:column}
.worker .score{text-align:left}
.worker .details{flex-direction:column;gap:8px}
.filters{flex-direction:column}
}
</style>
</head>
<body>
<div class="hero">
<h1>Search 500,000 Workers</h1>
<p>Type what you need in plain English — the AI understands meaning, not just keywords</p>
</div>
<div class="container">
<div class="search-box">
<div class="search-row">
<input class="search-input" id="query" type="text" placeholder="e.g. reliable forklift operator for warehouse in Illinois" autofocus>
<button class="search-btn" id="search-btn" onclick="doSearch()">Search</button>
</div>
<div class="filters">
<div class="filter"><label>State:</label><select id="f-state"><option value="">Any</option><option>IL</option><option>IN</option><option>OH</option><option>MO</option><option>TN</option><option>KY</option><option>WI</option><option>MI</option></select></div>
<div class="filter"><label>Role:</label><select id="f-role"><option value="">Any</option><option>Forklift Operator</option><option>Machine Operator</option><option>Assembler</option><option>Loader</option><option>Quality Tech</option><option>Welder</option><option>Sanitation Worker</option><option>Shipping Clerk</option><option>Production Worker</option><option>Maintenance Tech</option></select></div>
<div class="filter"><label>Min Reliability:</label><input id="f-rel" type="number" min="0" max="1" step="0.1" value="0.5" style="width:50px"></div>
</div>
<div class="examples">
<span class="example" onclick="tryExample(this)">warehouse help in Illinois</span>
<span class="example" onclick="tryExample(this)">dependable machine operator with CNC</span>
<span class="example" onclick="tryExample(this)">safety trained for chemical plant</span>
<span class="example" onclick="tryExample(this)">bilingual shipping clerk</span>
<span class="example" onclick="tryExample(this)">experienced welder Ohio</span>
</div>
</div>
<div class="mode-toggle">
<button class="mode-btn active" id="mode-ai" onclick="setMode('ai')">AI Search</button>
<button class="mode-btn" id="mode-crm" onclick="setMode('crm')">CRM Keyword</button>
<button class="mode-btn" id="mode-hybrid" onclick="setMode('hybrid')">Hybrid (best)</button>
</div>
<div id="results"></div>
<div class="footer">
<a href="proof">View Proof of Work</a> · Powered by Lakehouse · 500K workers · 673K AI-indexed chunks
</div>
</div>
<script>
window.onerror = function(msg, url, line) {
document.body.insertAdjacentHTML('afterbegin',
'<div style="background:#7f1d1d;color:#fca5a5;padding:12px;font-size:12px">JS Error: ' + msg + ' (line ' + line + ')</div>');
};
const base = window.location.pathname.replace(/\/+$/, '');
const GW = window.location.origin + base;
let mode = 'ai';
function setMode(m) {
mode = m;
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
document.getElementById('mode-' + m).classList.add('active');
}
function tryExample(el) {
document.getElementById('query').value = el.textContent;
doSearch();
}
document.getElementById('query').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doSearch();
});
async function doSearch() {
const query = document.getElementById('query').value.trim();
if (!query) return;
const state = document.getElementById('f-state').value;
const role = document.getElementById('f-role').value;
const rel = parseFloat(document.getElementById('f-rel').value) || 0.5;
const btn = document.getElementById('search-btn');
btn.disabled = true;
btn.textContent = 'Searching...';
const searchLabel = effectiveMode === 'crm' ? 'by keyword' : effectiveMode === 'hybrid' ? 'with AI + filters' : 'with AI';
document.getElementById('results').innerHTML = '<div class="loading">Searching ' + searchLabel + '...</div>';
const t0 = Date.now();
// ALWAYS use hybrid for AI modes — it handles filters AND works even
// when HNSW isn't loaded (falls back to brute-force). Pure HNSW mode
// hangs when the RAM index isn't built.
const effectiveMode = (mode === 'crm') ? 'crm' : 'hybrid';
try {
let data;
if (effectiveMode === 'crm') {
// CRM keyword search — exact LIKE match
let where = "resume_text LIKE '%" + query.replace(/'/g, "''") + "%'";
if (state) where += " AND state = '" + state + "'";
if (role) where += " AND role = '" + role + "'";
where += " AND CAST(reliability AS DOUBLE) >= " + rel;
const r = await fetch(GW + '/sql', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({sql: "SELECT worker_id, name, role, city, state, skills, certifications, archetype, ROUND(CAST(reliability AS DOUBLE),2) as reliability, ROUND(CAST(availability AS DOUBLE),2) as availability FROM workers_500k WHERE " + where + " ORDER BY CAST(reliability AS DOUBLE) DESC LIMIT 10"})
});
data = await r.json();
renderCRMResults(data, query, Date.now() - t0);
} else {
// Hybrid — SQL filters enforce structure, AI ranks by relevance
// Always works — uses brute-force when HNSW isn't loaded
let filter = "CAST(reliability AS DOUBLE) >= " + rel;
if (state) filter += " AND state = '" + state + "'";
if (role) filter += " AND role = '" + role + "'";
const controller = new AbortController();
const timeout = setTimeout(function() { controller.abort(); }, 15000);
const r = await fetch(GW + '/search', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
question: query, index_name: 'workers_500k_v1',
sql_filter: filter, dataset: 'workers_500k',
id_column: 'worker_id', top_k: 10, generate: false
}),
signal: controller.signal
});
clearTimeout(timeout);
data = await r.json();
renderHybridResults(data, query, Date.now() - t0);
}
} catch (e) {
document.getElementById('results').innerHTML = '<div class="empty">Search error: ' + e.message + '<br><br>If filters were set, try Hybrid mode. If the search is slow, the AI index may be loading — try again in 10 seconds.</div>';
}
btn.disabled = false;
btn.textContent = 'Search';
}
function renderCRMResults(data, query, ms) {
const el = document.getElementById('results');
const rows = data.rows || [];
el.innerHTML = '';
const header = document.createElement('div');
header.className = 'result-header';
header.innerHTML = '<span class="count">' + rows.length + ' results</span>' +
'<span class="meta">CRM keyword match · "' + query + '" · ' + ms + 'ms</span>';
el.appendChild(header);
if (!rows.length) {
el.innerHTML += '<div class="empty">No exact keyword match found.<br>Try <b>AI Search</b> — it understands what you mean, not just the exact words.</div>';
return;
}
rows.forEach(function(w) { el.appendChild(makeWorkerCard(w, null)); });
}
function renderAIResults(data, query, ms) {
const el = document.getElementById('results');
const hits = data.results || [];
el.innerHTML = '';
const header = document.createElement('div');
header.className = 'result-header';
header.innerHTML = '<span class="count">' + hits.length + ' results</span>' +
'<span class="meta">AI vector search · understood meaning · ' + ms + 'ms</span>';
el.appendChild(header);
if (!hits.length) {
el.innerHTML += '<div class="empty">No results found.</div>';
return;
}
hits.forEach(function(h) {
const parts = (h.chunk_text || '').split('—');
const name = (parts[0] || '').trim();
const rest = (parts[1] || '').trim();
const roleMatch = rest.match(/^(.+?) in (.+?)\./);
const skillsMatch = rest.match(/Skills: ([^.]+)/);
const certsMatch = rest.match(/Certs?: ([^.]+)/);
const relMatch = rest.match(/Reliability: ([\d.]+)/);
const availMatch = rest.match(/Availability: ([\d.]+)/);
const archMatch = rest.match(/Archetype: (\w+)/);
const w = {
name: name, role: roleMatch ? roleMatch[1].trim() : '',
city: roleMatch ? roleMatch[2].split(',')[0].trim() : '',
state: roleMatch ? (roleMatch[2].split(',')[1] || '').trim() : '',
skills: skillsMatch ? skillsMatch[1] : '', certifications: certsMatch ? certsMatch[1] : '',
reliability: relMatch ? relMatch[1] : '', availability: availMatch ? availMatch[1] : '',
archetype: archMatch ? archMatch[1] : '', worker_id: h.doc_id
};
el.appendChild(makeWorkerCard(w, h.score));
});
}
function renderHybridResults(data, query, ms) {
const el = document.getElementById('results');
const sources = data.sources || [];
el.innerHTML = '';
const header = document.createElement('div');
header.className = 'result-header';
const badges = '<span class="badge green">' + (data.sql_matches || 0) + ' SQL matches</span> ' +
'<span class="badge purple">AI ranked top ' + sources.length + '</span> ' +
'<span class="badge blue">' + ms + 'ms</span>';
header.innerHTML = '<span class="count">' + sources.length + ' results</span><span class="meta">' + badges + '</span>';
el.appendChild(header);
if (!sources.length) {
el.innerHTML += '<div class="empty">No matches with current filters. Try broadening the state or role filter.</div>';
return;
}
sources.forEach(function(s) {
const parts = (s.chunk_text || '').split('—');
const name = (parts[0] || '').trim();
const rest = (parts[1] || '').trim();
const roleMatch = rest.match(/^(.+?) in (.+?)\./);
const skillsMatch = rest.match(/Skills: ([^.]+)/);
const certsMatch = rest.match(/Certs?: ([^.]+)/);
const relMatch = rest.match(/Reliability: ([\d.]+)/);
const availMatch = rest.match(/Availability: ([\d.]+)/);
const w = {
name: name, role: roleMatch ? roleMatch[1].trim() : '',
city: roleMatch ? roleMatch[2].split(',')[0].trim() : '',
state: roleMatch ? (roleMatch[2].split(',')[1] || '').trim() : '',
skills: skillsMatch ? skillsMatch[1] : '', certifications: certsMatch ? certsMatch[1] : '',
reliability: relMatch ? relMatch[1] : '', availability: availMatch ? availMatch[1] : '',
worker_id: s.doc_id
};
const card = makeWorkerCard(w, s.score);
if (s.sql_verified) {
const v = document.createElement('div');
v.className = 'verified';
v.textContent = '✓ SQL verified against database';
card.appendChild(v);
}
el.appendChild(card);
});
}
function makeWorkerCard(w, score) {
const card = document.createElement('div');
card.className = 'worker';
const top = document.createElement('div');
top.className = 'top';
const info = document.createElement('div');
const nameEl = document.createElement('div');
nameEl.className = 'name';
nameEl.textContent = w.name || w.worker_id || '—';
info.appendChild(nameEl);
if (w.role) { const r = document.createElement('div'); r.className = 'role'; r.textContent = w.role; info.appendChild(r); }
if (w.city || w.state) { const l = document.createElement('div'); l.className = 'location'; l.textContent = [w.city, w.state].filter(Boolean).join(', '); info.appendChild(l); }
top.appendChild(info);
if (score !== null && score !== undefined) {
const sc = document.createElement('div');
sc.className = 'score';
const num = document.createElement('div');
num.className = 'num';
num.textContent = (score * 100).toFixed(0) + '%';
sc.appendChild(num);
const label = document.createElement('div');
label.className = 'label';
label.textContent = 'AI match';
sc.appendChild(label);
top.appendChild(sc);
}
card.appendChild(top);
const details = document.createElement('div');
details.className = 'details';
if (w.skills) addDetail(details, 'Skills', w.skills.replace(/\|/g, ', '));
if (w.certifications && w.certifications !== 'none') addDetail(details, 'Certs', w.certifications.replace(/\|/g, ', '));
if (w.reliability) addDetail(details, 'Reliability', w.reliability);
if (w.availability) addDetail(details, 'Availability', w.availability);
if (w.archetype) addDetail(details, 'Type', w.archetype);
card.appendChild(details);
return card;
}
function addDetail(parent, label, value) {
const d = document.createElement('div');
d.className = 'detail';
const dt = document.createElement('div');
dt.className = 'dt';
dt.textContent = label;
d.appendChild(dt);
const dd = document.createElement('div');
dd.className = 'dd';
dd.textContent = value;
d.appendChild(dd);
parent.appendChild(d);
}
</script>
</body>
</html>