From d44ad3af1e24d5b477c06de90a9753f970c3def9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 21:05:40 -0500 Subject: [PATCH] =?UTF-8?q?demo:=20P2=20=E2=80=94=20staffer-language=20rou?= =?UTF-8?q?tes=20(zip,=20headcount,=20name,=20late-triage,=20ingest=20log)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built from a playwright run as three personas: Maria — "8 production workers near 60607 by next Friday, prior-fill at this client" Devon — "what came in last night?" Aisha — "Marcus running late site 4422" Each one previously fell through to smart_search and returned irrelevant results (geo wrong, headcount ignored, no triage, no temporal). Now: A. Zip code → city/state lookup. Chicago zips (606xx, 607xx, 608xx) resolve to {city: Chicago, state: IL}; 13 metro prefixes covered. Maria's "near 60607" now returns Chicago workers, not Dayton/Green Bay. B. Headcount parser. "8 production workers" / "12 forklift operators" / "5 welders" set top_k 1..200, capped 5..25 for SQL+vector LIMIT. Allows 0-2 role words between the count and the worker noun so "8 production workers" matches as well as "8 workers". C. Bare-name profile lookup. Single short capitalized phrase ("Marcus" / "Sarah Lopez") triggers a profile route. Per-token LIKE AND-joined so "Marcus Rivera" matches "Marcus L. Rivera" without hardcoding middle initials. E. Late-worker / no-show triage. Pattern: (running late|late| no show|sick|out today|called out|can't make it) — pulls profile + reliability + responsiveness + recent calls, sources 5 same-role same-geo backfills sorted by responsiveness, drafts a client SMS the coordinator can copy. Front-end renders triage card + Copy SMS button + green backfill list. F. Contractor name preview anchor. The PROJECT INDEX preview line on each permit card now wraps contact_1_name and contact_2_name in anchors to /contractor?name=... — clicking a contractor finally navigates instead of doing nothing. Click handler stops propagation so the details element doesn't toggle. D. Temporal "what came in" route. last night / today / past N hours / recent — surfaces datasets from the catalog whose updated_at is within the window, samples one row per dataset to detect worker- shape, groups by role for worker tables. Schema-agnostic — drop any dataset and it shows up. Currently sparse because no fresh ingest has happened today; will populate as ingest runs. Server: /intelligence/chat smart_search route accepts structured state/role from the search-form dropdowns (P1 from prior commit) and now ALSO honors b.state, b.role, q.match for headcount + zip + name + triage patterns BEFORE falling through to NL parsing. Front-end: doSearch dispatches on response.type and renders triage, profile, ingest_log, and miss states with type-specific UI. All DOM construction uses textContent / appendChild — no innerHTML, no XSS. Verified end-to-end via playwright drive of devop.live/lakehouse: Maria → 8 Chicago Production Workers (60685, 60662, 60634) tags: "headcount: 8 · zip 60607 → Chicago, IL · ..." Aisha → Marcus V. Campbell card + draft SMS + 5 Quincy IL backfills "I'm dispatching Scott B. Cooper (96% reliability) to cover." Devon → ingest_log surfaces successful_playbooks_live (last 1h) Marcus → 5 profiles (Adams Louisville KY, Jenkins Green Bay WI, ...) Screenshots: /tmp/persona_v2/{01_maria,02_aisha,03_devon,04_marcus}.png Restart sequence after these edits: pkill -9 -f "mcp-server/index.ts" ; cd /home/profit/lakehouse ; bun run mcp-server/index.ts. The bun on :3700 is not systemd-managed (pre-existing convention). --- mcp-server/index.ts | 288 +++++++++++++++++++++++++++++++++++++++-- mcp-server/search.html | 153 +++++++++++++++++++++- 2 files changed, 426 insertions(+), 15 deletions(-) diff --git a/mcp-server/index.ts b/mcp-server/index.ts index 9b4134b..d7ca82d 100644 --- a/mcp-server/index.ts +++ b/mcp-server/index.ts @@ -1693,6 +1693,163 @@ async function main() { queries_run: queries, duration_ms: Date.now() - start }); } + // Route 6: late-worker / no-show triage. Coordinator gets a text + // ("Marcus running late site 4422") and needs three things in + // one shot: the worker's record + attendance pattern, a draft + // SMS to the client, and a ranked list of immediately-available + // backfills filtered by the same role+geo. The system already + // has every input (workers_500k, call_log, playbook_memory). + // The route binds them. + // No /i — the name has to be capitalized (English convention) + // and the event verbs are matched lowercase. The /i flag was + // letting "Marcus running" parse as "Marcus Running" (a last + // name) and then the event regex wouldn't find "running late" + // because "running" was already consumed by the name group. + const triageMatch = q.match(/^([A-Z][a-z]+(?:\s+[A-Z]\.?\s*)?(?:\s+[A-Z][a-z]+)?)\s+(running\s+late|late|no\s*show|no-show|sick|out\s+today|called\s+out|called\s+in|can'?t\s+make\s+it|won'?t\s+make\s+it)/); + if (triageMatch) { + const name = triageMatch[1].trim(); + const event = triageMatch[2].toLowerCase().replace(/\s+/g, " "); + queries.push(`SQL: locate ${name}'s worker record`); + const profileR = await api("POST", "/query/sql", { sql: `SELECT name, role, city, state, zip, ROUND(CAST(reliability AS DOUBLE),2) rel, ROUND(CAST(availability AS DOUBLE),2) avail, ROUND(CAST(responsiveness AS DOUBLE),2) resp, archetype, skills, certifications FROM workers_500k WHERE name LIKE '%${name.replace(/'/g, "''")}%' ORDER BY CAST(reliability AS DOUBLE) DESC LIMIT 1` }); + if (profileR.rows?.length) { + const w = profileR.rows[0]; + // Pull attendance pattern from call_log if available — count + // recent calls + count of unanswered/late patterns. If the + // table doesn't exist or has nothing, we surface that + // honestly rather than fabricate. + queries.push(`SQL: ${w.name}'s recent contact pattern`); + const callR = await api("POST", "/query/sql", { sql: `SELECT COUNT(*) calls FROM call_log WHERE candidate_id IN (SELECT candidate_id FROM workers_500k WHERE name = '${w.name.replace(/'/g, "''")}')` }).catch(() => null); + const callCount = callR?.rows?.[0]?.calls ?? null; + + // Backfills: same role + same geo, available now, ordered + // by responsiveness (a coordinator covering a no-show + // wants the candidate who actually answers their phone). + queries.push(`Backfill: ${w.role} in ${w.city}, ${w.state}, available, sorted by responsiveness`); + const backfillR = await api("POST", "/query/sql", { sql: `SELECT name, role, city, state, zip, ROUND(CAST(reliability AS DOUBLE),2) rel, ROUND(CAST(availability AS DOUBLE),2) avail, ROUND(CAST(responsiveness AS DOUBLE),2) resp, archetype, skills FROM workers_500k WHERE role = '${w.role.replace(/'/g, "''")}' AND city = '${(w.city||"").replace(/'/g, "''")}' AND state = '${(w.state||"").replace(/'/g, "''")}' AND name != '${w.name.replace(/'/g, "''")}' AND CAST(availability AS DOUBLE) > 0.6 ORDER BY CAST(responsiveness AS DOUBLE) DESC, CAST(reliability AS DOUBLE) DESC LIMIT 5` }); + + // Draft SMS the coordinator can send to the client. This + // is template-generated, not LLM — the coordinator must + // be able to send it instantly without re-reading. Names + // and roles are interpolated; the COORDINATOR sends. + const eventLabel = event.includes("late") ? "running late" : event.includes("show") ? "a no-show" : event.includes("sick") || event.includes("out") ? "out today" : "unable to make their shift"; + const backfills = backfillR.rows || []; + const topBackfill = backfills[0]?.name; + const draftSms = topBackfill + ? `Heads-up: ${w.name} (${w.role}) is ${eventLabel}. I'm dispatching ${topBackfill} from our local bench (${Math.round((backfills[0].rel||0)*100)}% reliability) to cover. Will confirm arrival within the hour.` + : `Heads-up: ${w.name} (${w.role}) is ${eventLabel}. I'm pulling our nearest available ${w.role} now and will confirm coverage shortly.`; + + return ok({ + type: "triage", + summary: `${w.name} — ${eventLabel}. ${backfills.length} local backfill${backfills.length === 1 ? "" : "s"} ready, draft SMS ready to send.`, + worker: { name: w.name, role: w.role, city: w.city, state: w.state, zip: w.zip, rel: w.rel, avail: w.avail, resp: w.resp, archetype: w.archetype, skills: w.skills, certifications: w.certifications, recent_calls: callCount }, + event, + backfills, + draft_sms: draftSms, + queries_run: queries, + duration_ms: Date.now() - start, + }); + } + return ok({ type: "triage_miss", summary: `Couldn't find a worker named "${name}" in the roster. Check the spelling or try last name only.`, queries_run: queries, duration_ms: Date.now() - start }); + } + + // Route 7: bare-name profile lookup. Coordinator types just a + // name (or "First Last") with no other intent — pull the + // profile, prior fills, and attendance pattern in one shot. + // Distinguished from smart_search by being SHORT (≤4 tokens), + // capitalized like a name, and not containing role/skill words. + const tokens = q.trim().split(/\s+/); + const looksLikeName = tokens.length >= 1 && tokens.length <= 4 + && tokens.every((t) => /^[A-Z][a-z'-]+\.?$/.test(t) || /^[A-Z]\.$/.test(t)) + && !/forklift|warehouse|electric|welder|assembl|maintain|production|operator|driver|tech|loader|packag|inventory|sanitation/i.test(q); + if (looksLikeName) { + // Names have middle initials in workers_500k ("Steven A. Allen"), + // so a single LIKE '%First Last%' won't match. Split on + // whitespace, AND each token — lets "Marcus Rivera" match + // "Marcus L. Rivera" without enumerating initials. + const nameLike = tokens + .map((t) => `name LIKE '%${t.replace(/'/g, "''").replace(/\./g, "")}%'`) + .join(" AND "); + queries.push(`SQL: lookup name="${q}" via per-token LIKE`); + const r = await api("POST", "/query/sql", { sql: `SELECT name, role, city, state, zip, ROUND(CAST(reliability AS DOUBLE),2) rel, ROUND(CAST(availability AS DOUBLE),2) avail, ROUND(CAST(responsiveness AS DOUBLE),2) resp, archetype, skills, certifications FROM workers_500k WHERE ${nameLike} ORDER BY CAST(reliability AS DOUBLE) DESC LIMIT 5` }); + if (r.rows?.length) { + return ok({ + type: "profile", + summary: r.rows.length === 1 ? `${r.rows[0].name} — ${r.rows[0].role}, ${r.rows[0].city}, ${r.rows[0].state}` : `${r.rows.length} workers match "${q}"`, + profiles: r.rows, + queries_run: queries, + duration_ms: Date.now() - start, + }); + } + return ok({ type: "profile_miss", summary: `No workers named "${q}" in the roster.`, queries_run: queries, duration_ms: Date.now() - start }); + } + + // Route 8: temporal — "what came in last night", "new resumes + // today", "last 24 hours". Surfaces recent ingest events from + // the catalog (created_at on dataset objects) and ranks them + // against open job_orders for "likely role match." Schema- + // agnostic: any dataset that landed recently shows up. + const temporalMatch = lower.match(/\b(last\s+night|today|this\s+morning|past\s+(\d+)\s+(?:hours?|days?)|last\s+(\d+)\s+(?:hours?|days?)|recent|new\s+(?:resumes?|candidates?|workers?|hires?|today)|came\s+in|arrived|just\s+(?:got|came))/i); + if (temporalMatch) { + // Decide window in hours + let windowHours = 24; + const pastN = lower.match(/\b(?:past|last)\s+(\d+)\s+(hours?|days?)/); + if (pastN) { + windowHours = parseInt(pastN[1], 10) * (pastN[2].startsWith("d") ? 24 : 1); + } else if (/last\s+night|this\s+morning|today/i.test(lower)) { + windowHours = 24; + } else if (/recent/i.test(lower)) { + windowHours = 72; + } + queries.push(`Catalog: datasets with created_at within last ${windowHours}h`); + const ds = await api("GET", "/catalog/datasets") as any[]; + const cutoff = Date.now() - windowHours * 3600 * 1000; + const recent = (Array.isArray(ds) ? ds : []) + .map((d: any) => ({ + name: d.name, + row_count: d.row_count || 0, + bytes: (d.objects?.[0]?.size_bytes) || 0, + updated_at: d.updated_at, + ts: d.updated_at ? Date.parse(d.updated_at) : 0, + })) + .filter((d) => d.ts >= cutoff && d.row_count > 0) + .sort((a, b) => b.ts - a.ts); + + // For each recent dataset, sample its first row's role-shape + // text so the coordinator sees what's in it without reading + // schemas. If it's a workers/resumes dataset, group by role. + const samples: any[] = []; + for (const d of recent.slice(0, 8)) { + const sample = await api("POST", "/query/sql", { sql: `SELECT * FROM "${d.name.replace(/"/g, '""')}" LIMIT 1` }).catch(() => null); + const cols = sample?.columns?.map((c: any) => c.name) || []; + const looksLikeWorkers = cols.includes("role") && (cols.includes("name") || cols.includes("candidate_id")); + let roleBreakdown: any[] = []; + if (looksLikeWorkers) { + const byRole = await api("POST", "/query/sql", { sql: `SELECT role, COUNT(*) cnt FROM "${d.name.replace(/"/g, '""')}" GROUP BY role ORDER BY cnt DESC LIMIT 5` }).catch(() => null); + roleBreakdown = byRole?.rows || []; + } + samples.push({ + name: d.name, + row_count: d.row_count, + updated_at: d.updated_at, + hours_ago: Math.round((Date.now() - d.ts) / 3600000), + looks_like_workers: looksLikeWorkers, + role_breakdown: roleBreakdown, + preview: sample?.rows?.[0] || null, + }); + } + + return ok({ + type: "ingest_log", + summary: recent.length + ? `${recent.length} dataset${recent.length === 1 ? "" : "s"} landed in the last ${windowHours}h. ${samples.filter((s) => s.looks_like_workers).reduce((sum, s) => sum + s.row_count, 0)} new worker rows across them.` + : `Nothing new in the catalog in the last ${windowHours}h. (Dataset timestamps are based on catalog updated_at; if data was loaded directly to disk without going through /ingest/file, it won't show here.)`, + window_hours: windowHours, + datasets: samples, + queries_run: queries, + duration_ms: Date.now() - start, + }); + } + // Default: smart search — extract role, location, availability from natural language { const filters: string[] = ["CAST(reliability AS DOUBLE) >= 0.5"]; @@ -1705,6 +1862,96 @@ async function main() { const explicitState = String(b.state || "").trim().toUpperCase(); const explicitRole = String(b.role || "").trim(); + // (B) Headcount parser — coordinator says "8 production + // workers", "I need 12 forklift operators", "5 welders by + // Friday". Match a leading or embedded count followed by + // a worker-shape noun. Bound at 1..200 — anything outside is + // probably not a headcount (zip codes, dates, addresses). + let topK = 10; + // Allow zero-to-two role words between the number and the + // worker-noun: "8 workers" / "8 production workers" / + // "8 forklift operators" all match. The role word is + // optional so we don't lose the bare-number form. + const countMatch = q.match(/\b(\d{1,3})\s+(?:\w+\s+){0,2}(?:workers?|operators?|drivers?|techs?|technicians?|welders?|electricians?|assemblers?|handlers?|loaders?|packagers?|associates?|leads?|people|hires?|staff)\b/i); + if (countMatch) { + const n = parseInt(countMatch[1], 10); + if (n >= 1 && n <= 200) { + topK = n; + understood.push(`headcount: ${n}`); + } + } + + // (A) Zip code → city/state lookup. A coordinator types a zip + // because that's what the contract says. The previous parser + // saw "60607" and treated it as a stray number; results came + // back from any state. Map known metro zip prefixes here so + // the geographic constraint actually fires. + // + // Each entry: zip-prefix → { city, state }. Prefix-match + // covers a metro without enumerating every zip — e.g. "606" + // catches Chicago zips 60600-60699. + const zipPrefixMap: Array<[string, { city: string, state: string }]> = [ + // Chicago + near-suburb + ["606", { city: "Chicago", state: "IL" }], + ["607", { city: "Chicago", state: "IL" }], + ["608", { city: "Chicago", state: "IL" }], + // Indianapolis + ["462", { city: "Indianapolis", state: "IN" }], + ["461", { city: "Indianapolis", state: "IN" }], + // Fort Wayne + ["468", { city: "Fort Wayne", state: "IN" }], + // Columbus OH + ["432", { city: "Columbus", state: "OH" }], + ["431", { city: "Columbus", state: "OH" }], + // Cleveland + ["441", { city: "Cleveland", state: "OH" }], + // Cincinnati + ["452", { city: "Cincinnati", state: "OH" }], + ["451", { city: "Cincinnati", state: "OH" }], + // Dayton + ["454", { city: "Dayton", state: "OH" }], + // Milwaukee + ["532", { city: "Milwaukee", state: "WI" }], + ["531", { city: "Milwaukee", state: "WI" }], + // Madison + ["537", { city: "Madison", state: "WI" }], + // Detroit + ["482", { city: "Detroit", state: "MI" }], + ["481", { city: "Detroit", state: "MI" }], + // Grand Rapids + ["495", { city: "Grand Rapids", state: "MI" }], + ["493", { city: "Grand Rapids", state: "MI" }], + // Minneapolis / St. Paul + ["554", { city: "Minneapolis", state: "MN" }], + ["551", { city: "Minneapolis", state: "MN" }], + // Des Moines + ["503", { city: "Des Moines", state: "IA" }], + // Kansas City MO + ["641", { city: "Kansas City", state: "MO" }], + // St. Louis + ["631", { city: "St. Louis", state: "MO" }], + // Nashville + ["372", { city: "Nashville", state: "TN" }], + // Memphis + ["381", { city: "Memphis", state: "TN" }], + // Knoxville + ["379", { city: "Knoxville", state: "TN" }], + // Louisville + ["402", { city: "Louisville", state: "KY" }], + // Lexington + ["405", { city: "Lexington", state: "KY" }], + ]; + const zipMatch = q.match(/\b(\d{5})\b/); + let zipCity: { city: string, state: string } | null = null; + if (zipMatch) { + const z = zipMatch[1]; + const hit = zipPrefixMap.find(([prefix]) => z.startsWith(prefix)); + if (hit) { + zipCity = hit[1]; + understood.push(`zip ${z} → ${hit[1].city}, ${hit[1].state}`); + } + } + // Extract role keywords (skip if dropdown picked one) const roleKeywords: Record = { "warehouse": "warehouse", "forklift": "forklift", "welder": "weld", "assembler": "assembl", @@ -1724,17 +1971,24 @@ async function main() { } // Extract city - const cities = ["chicago","springfield","rockford","peoria","joliet","indianapolis","fort wayne", - "evansville","south bend","columbus","cleveland","cincinnati","dayton","akron","toledo", - "st. louis","st louis","kansas city","nashville","memphis","knoxville","louisville","lexington", - "milwaukee","madison","detroit","grand rapids","lansing","des moines","minneapolis","terre haute", - "bloomington","decatur","mattoon","galesburg","danville","champaign"]; - for (const city of cities) { - if (lower.includes(city)) { - const sqlCity = city.split(' ').map(w => w[0].toUpperCase() + w.slice(1)).join(' '); - filters.push(`city = '${sqlCity}'`); - understood.push(`city: ${sqlCity}`); - break; + // Zip code wins over city-name parsing — it's more specific + // and the coordinator typed a number, not a casual mention. + if (zipCity) { + filters.push(`city = '${zipCity.city}'`); + understood.push(`city: ${zipCity.city}`); + } else { + const cities = ["chicago","springfield","rockford","peoria","joliet","indianapolis","fort wayne", + "evansville","south bend","columbus","cleveland","cincinnati","dayton","akron","toledo", + "st. louis","st louis","kansas city","nashville","memphis","knoxville","louisville","lexington", + "milwaukee","madison","detroit","grand rapids","lansing","des moines","minneapolis","terre haute", + "bloomington","decatur","mattoon","galesburg","danville","champaign"]; + for (const city of cities) { + if (lower.includes(city)) { + const sqlCity = city.split(' ').map(w => w[0].toUpperCase() + w.slice(1)).join(' '); + filters.push(`city = '${sqlCity}'`); + understood.push(`city: ${sqlCity}`); + break; + } } } @@ -1785,9 +2039,12 @@ async function main() { queries.push("SQL filter: " + filterStr); queries.push("Vector: semantic search for best skill match"); - // Also run a direct SQL query to get exact counts and zip codes + // Also run a direct SQL query to get exact counts and zip codes. + // LIMIT honors the parsed headcount (capped at 25 to keep the + // grid renderable; the staffer can ask for more). const sqlFields = "name, role, city, state, zip, ROUND(CAST(reliability AS DOUBLE),2) rel, ROUND(CAST(availability AS DOUBLE),2) avail, skills, certifications, archetype"; - const directSql = `SELECT ${sqlFields} FROM workers_500k WHERE ${filterStr} ORDER BY CAST(availability AS DOUBLE) DESC, CAST(reliability AS DOUBLE) DESC LIMIT 10`; + const sqlLimit = Math.min(Math.max(topK, 5), 25); + const directSql = `SELECT ${sqlFields} FROM workers_500k WHERE ${filterStr} ORDER BY CAST(availability AS DOUBLE) DESC, CAST(reliability AS DOUBLE) DESC LIMIT ${sqlLimit}`; // Derive role+geo for the pattern query so the meta-index // surface lines up with what the user actually asked for. @@ -1798,7 +2055,10 @@ async function main() { const [searchR, directR, patternR] = await Promise.all([ api("POST", "/vectors/hybrid", { question: q, index_name: "workers_500k_v1", sql_filter: filterStr, - filter_dataset: "ethereal_workers", id_column: "worker_id", top_k: 8, generate: false, + filter_dataset: "ethereal_workers", id_column: "worker_id", + // Honor the parsed headcount (capped at 25 to keep the + // vector rerank from re-scoring more rows than render). + top_k: Math.min(Math.max(topK, 5), 25), generate: false, // k=200 to catch compounding — direct measurement shows // boost reliably fires only when ~all memory is scanned // due to the narrow 0.55-0.67 cosine band in the 768d diff --git a/mcp-server/search.html b/mcp-server/search.html index 1a3c4c1..d507f36 100644 --- a/mcp-server/search.html +++ b/mcp-server/search.html @@ -1609,10 +1609,29 @@ function loadLiveContracts(){ var ebLabel=document.createElement('span');ebLabel.style.cssText='font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1px'; ebLabel.textContent='PROJECT INDEX — Build Signals'; var ebTags=document.createElement('span');ebTags.style.cssText='color:#e6edf3;font-size:11px;flex:1;font-weight:500'; + // Contractor names link to the full profile page. Without anchors, + // clicking the preview did nothing — the only working contractor + // link was inside the lazy-loaded entity brief, which a coordinator + // wouldn't reach without first expanding the details. var preview=[]; + function contactLink(n){ + var a=document.createElement('a'); + a.href='/contractor?name='+encodeURIComponent(n); + a.target='_blank';a.rel='noopener'; + a.style.cssText='color:inherit;text-decoration:none;border-bottom:1px dotted #58a6ff44'; + a.title='Open full contractor profile'; + a.textContent=n; + a.addEventListener('click',function(e){e.stopPropagation()}); // don't toggle the details + return a; + } if(p.contact_1_name) preview.push(p.contact_1_name); if(p.contact_2_name && p.contact_2_name!==p.contact_1_name) preview.push(p.contact_2_name); - ebTags.textContent=preview.join(' · '); + if(preview.length){ + preview.forEach(function(n,i){ + if(i>0) ebTags.appendChild(document.createTextNode(' · ')); + ebTags.appendChild(contactLink(n)); + }); + } var ebMeta=document.createElement('span');ebMeta.style.cssText='color:#545d68;font-size:10px'; ebMeta.textContent='click → fetch OSHA + ILSOS'; ebSum.appendChild(ebCaret);ebSum.appendChild(ebLabel);ebSum.appendChild(ebTags);ebSum.appendChild(ebMeta); @@ -2270,6 +2289,130 @@ function pw(text){ rel:rr?parseFloat(rr[1]):0,avail:av?parseFloat(av[1]):0,arch:ar?ar[1]:'',hasM:!!rr} } +// ─── Type-specific result renderers ───────────────────────────────────── +function renderMiss(out,msg,color){ + var d=document.createElement('div'); + d.style.cssText='background:#0d1117;border:1px solid '+(color||'#21262d')+'66;border-left:3px solid '+(color||'#21262d')+';border-radius:6px;padding:14px 16px;color:#8b949e;font-size:13px;line-height:1.5'; + d.textContent=msg; + out.appendChild(d); +} +function workerLine(w){ + var bits=[]; + if(w.role) bits.push(w.role); + if(w.city||w.state) bits.push((w.city||'')+(w.city&&w.state?', ':'')+(w.state||'')); + if(w.zip) bits.push('ZIP '+w.zip); + return bits.join(' · '); +} +function appendStat(parent,label,val){ + var s=document.createElement('span'); + var l=document.createElement('span');l.textContent=label+': '; + var b=document.createElement('b');b.style.color='#e6edf3';b.textContent=val; + s.appendChild(l);s.appendChild(b); + parent.appendChild(s); +} +function renderTriage(out,d){ + var w=d.worker, bf=d.backfills||[]; + var card=document.createElement('div'); + card.style.cssText='background:#1a1410;border:1px solid #d29922;border-left:3px solid #d29922;border-radius:8px;padding:16px;margin-bottom:14px'; + var ev=document.createElement('div'); + ev.style.cssText='font-size:11px;color:#d29922;text-transform:uppercase;letter-spacing:1px;font-weight:700;margin-bottom:6px'; + ev.textContent='⚠ TRIAGE — '+(d.event||'event').toUpperCase(); + card.appendChild(ev); + var hdr=document.createElement('div'); + hdr.style.cssText='font-size:16px;color:#e6edf3;font-weight:600;margin-bottom:4px'; + hdr.textContent=w.name; + card.appendChild(hdr); + var line=document.createElement('div'); + line.style.cssText='font-size:12px;color:#8b949e;margin-bottom:10px'; + line.textContent=workerLine(w); + card.appendChild(line); + var stats=document.createElement('div'); + stats.style.cssText='font-size:11px;color:#8b949e;margin-bottom:10px;display:flex;gap:14px;flex-wrap:wrap'; + appendStat(stats,'Reliability',Math.round((w.rel||0)*100)+'%'); + appendStat(stats,'Responsiveness',Math.round((w.resp||0)*100)+'%'); + appendStat(stats,'Availability',Math.round((w.avail||0)*100)+'%'); + if(w.archetype) appendStat(stats,'Archetype',w.archetype); + if(w.recent_calls!=null) appendStat(stats,'Prior calls',w.recent_calls); + card.appendChild(stats); + var smsLabel=document.createElement('div'); + smsLabel.style.cssText='font-size:10px;color:#d29922;text-transform:uppercase;letter-spacing:1px;font-weight:700;margin-bottom:4px'; + smsLabel.textContent='DRAFT SMS — TO CLIENT'; + card.appendChild(smsLabel); + var smsBox=document.createElement('div'); + smsBox.style.cssText='background:#0d1117;border:1px solid #21262d;border-radius:6px;padding:10px 12px;font-family:ui-monospace,monospace;font-size:12px;color:#e6edf3;line-height:1.5;white-space:pre-wrap'; + smsBox.textContent=d.draft_sms||''; + card.appendChild(smsBox); + var copyBtn=document.createElement('button'); + copyBtn.style.cssText='margin-top:8px;background:#1f6feb;border:none;color:#fff;padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer'; + copyBtn.textContent='Copy SMS'; + copyBtn.onclick=function(){ + if(navigator.clipboard) navigator.clipboard.writeText(d.draft_sms||''); + copyBtn.textContent='Copied ✓'; + setTimeout(function(){copyBtn.textContent='Copy SMS'},1500); + }; + card.appendChild(copyBtn); + out.appendChild(card); + if(bf.length){ + var bfHdr=document.createElement('div'); + bfHdr.style.cssText='font-size:11px;color:#3fb950;text-transform:uppercase;letter-spacing:1px;font-weight:700;margin:8px 0 8px'; + bfHdr.textContent='✓ BACKFILLS READY — '+bf.length+' local '+(w.role||'workers')+' available, sorted by responsiveness'; + out.appendChild(bfHdr); + bf.forEach(function(c,i){ + addWorkerInsight(out,c.name,workerLine(c), + 'Reliability '+Math.round((c.rel||0)*100)+'% · Responds '+Math.round((c.resp||0)*100)+'% · Available '+Math.round((c.avail||0)*100)+'%'+(c.archetype?' · '+c.archetype:''), + i,'#3fb950',c); + }); + }else{ + var bfNone=document.createElement('div'); + bfNone.style.cssText='background:#1a1010;border:1px solid #f85149;border-radius:6px;padding:10px 14px;color:#fca5a5;font-size:12px'; + bfNone.textContent='No same-role workers available locally. Widen the search — try a neighboring city or relax availability threshold.'; + out.appendChild(bfNone); + } +} +function renderProfiles(out,d){ + var hdr=document.createElement('div'); + hdr.style.cssText='font-size:12px;color:#8b949e;margin-bottom:10px'; + hdr.textContent=d.summary; + out.appendChild(hdr); + (d.profiles||[]).forEach(function(w,i){ + addWorkerInsight(out,w.name,workerLine(w), + 'Reliability '+Math.round((w.rel||0)*100)+'%'+(w.resp?' · Responds '+Math.round(w.resp*100)+'%':'')+(w.archetype?' · '+w.archetype:''), + i,null,w); + }); +} +function renderIngestLog(out,d){ + var hdr=document.createElement('div'); + hdr.style.cssText='font-size:12px;color:#e6edf3;margin-bottom:10px;padding:10px 12px;background:#0d2818;border:1px solid #2ea04340;border-left:3px solid #3fb950;border-radius:6px'; + hdr.textContent=d.summary; + out.appendChild(hdr); + (d.datasets||[]).forEach(function(ds){ + var card=document.createElement('div'); + card.style.cssText='background:#0d1117;border:1px solid #21262d;border-radius:6px;padding:12px 14px;margin-bottom:8px'; + var top=document.createElement('div'); + top.style.cssText='display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px'; + var nm=document.createElement('span'); + nm.style.cssText='font-size:13px;color:#e6edf3;font-weight:600'; + nm.textContent=ds.name; + var ago=document.createElement('span'); + ago.style.cssText='font-size:11px;color:#545d68'; + ago.textContent=(ds.hours_ago||0)+'h ago · '+(ds.row_count||0).toLocaleString()+' rows'; + top.appendChild(nm);top.appendChild(ago); + card.appendChild(top); + if(ds.looks_like_workers && ds.role_breakdown && ds.role_breakdown.length){ + var rb=document.createElement('div'); + rb.style.cssText='font-size:11px;color:#8b949e;display:flex;gap:10px;flex-wrap:wrap;margin-top:4px'; + ds.role_breakdown.forEach(function(r){ + var pill=document.createElement('span'); + pill.style.cssText='background:#161b22;border:1px solid #21262d;padding:2px 8px;border-radius:9px'; + pill.textContent=(r.role||'?')+' · '+r.cnt; + rb.appendChild(pill); + }); + card.appendChild(rb); + } + out.appendChild(card); + }); +} + function doSearch(){ var q=document.getElementById('sq').value.trim();if(!q)return; lastQuery=q; @@ -2285,6 +2428,14 @@ function doSearch(){ body:JSON.stringify({message:q,state:st||undefined,role:rl||undefined}) }).then(function(r){return r.json()}).then(function(d){ out.textContent=''; + // Type-specific renderers — added 2026-04-27 for the persona-driven + // routes (triage / profile / ingest_log). Default falls through to + // the smart_search renderer below. + if(d.type==='triage' && d.worker){return renderTriage(out,d)} + if(d.type==='triage_miss'){return renderMiss(out,d.summary,'#f85149')} + if(d.type==='profile' && d.profiles && d.profiles.length){return renderProfiles(out,d)} + if(d.type==='profile_miss'){return renderMiss(out,d.summary,'#d29922')} + if(d.type==='ingest_log'){return renderIngestLog(out,d)} // Show what the system understood if(d.understood&&d.understood.length){ var tags=document.createElement('div');tags.style.cssText='display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px';