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>
356 lines
17 KiB
HTML
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>
|