demo: P2 — staffer-language routes (zip, headcount, name, late-triage, ingest log)

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: <Name> (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).
This commit is contained in:
root 2026-04-27 21:05:40 -05:00
parent fb99e92a60
commit 677065de76
2 changed files with 426 additions and 15 deletions

View File

@ -1693,6 +1693,163 @@ async function main() {
queries_run: queries, duration_ms: Date.now() - start }); 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 // Default: smart search — extract role, location, availability from natural language
{ {
const filters: string[] = ["CAST(reliability AS DOUBLE) >= 0.5"]; const filters: string[] = ["CAST(reliability AS DOUBLE) >= 0.5"];
@ -1705,6 +1862,96 @@ async function main() {
const explicitState = String(b.state || "").trim().toUpperCase(); const explicitState = String(b.state || "").trim().toUpperCase();
const explicitRole = String(b.role || "").trim(); 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) // Extract role keywords (skip if dropdown picked one)
const roleKeywords: Record<string, string> = { const roleKeywords: Record<string, string> = {
"warehouse": "warehouse", "forklift": "forklift", "welder": "weld", "assembler": "assembl", "warehouse": "warehouse", "forklift": "forklift", "welder": "weld", "assembler": "assembl",
@ -1724,6 +1971,12 @@ async function main() {
} }
// Extract city // Extract city
// 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", const cities = ["chicago","springfield","rockford","peoria","joliet","indianapolis","fort wayne",
"evansville","south bend","columbus","cleveland","cincinnati","dayton","akron","toledo", "evansville","south bend","columbus","cleveland","cincinnati","dayton","akron","toledo",
"st. louis","st louis","kansas city","nashville","memphis","knoxville","louisville","lexington", "st. louis","st louis","kansas city","nashville","memphis","knoxville","louisville","lexington",
@ -1737,6 +1990,7 @@ async function main() {
break; break;
} }
} }
}
// Extract state — dropdown wins; otherwise NL parse, but // Extract state — dropdown wins; otherwise NL parse, but
// require either an explicit "in/from <STATE>" preposition // require either an explicit "in/from <STATE>" preposition
@ -1785,9 +2039,12 @@ async function main() {
queries.push("SQL filter: " + filterStr); queries.push("SQL filter: " + filterStr);
queries.push("Vector: semantic search for best skill match"); 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 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 // Derive role+geo for the pattern query so the meta-index
// surface lines up with what the user actually asked for. // surface lines up with what the user actually asked for.
@ -1798,7 +2055,10 @@ async function main() {
const [searchR, directR, patternR] = await Promise.all([ const [searchR, directR, patternR] = await Promise.all([
api("POST", "/vectors/hybrid", { api("POST", "/vectors/hybrid", {
question: q, index_name: "workers_500k_v1", sql_filter: filterStr, 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 // k=200 to catch compounding — direct measurement shows
// boost reliably fires only when ~all memory is scanned // boost reliably fires only when ~all memory is scanned
// due to the narrow 0.55-0.67 cosine band in the 768d // due to the narrow 0.55-0.67 cosine band in the 768d

View File

@ -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'; 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'; ebLabel.textContent='PROJECT INDEX — Build Signals';
var ebTags=document.createElement('span');ebTags.style.cssText='color:#e6edf3;font-size:11px;flex:1;font-weight:500'; 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=[]; 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_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); 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'; var ebMeta=document.createElement('span');ebMeta.style.cssText='color:#545d68;font-size:10px';
ebMeta.textContent='click → fetch OSHA + ILSOS'; ebMeta.textContent='click → fetch OSHA + ILSOS';
ebSum.appendChild(ebCaret);ebSum.appendChild(ebLabel);ebSum.appendChild(ebTags);ebSum.appendChild(ebMeta); 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} 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(){ function doSearch(){
var q=document.getElementById('sq').value.trim();if(!q)return; var q=document.getElementById('sq').value.trim();if(!q)return;
lastQuery=q; lastQuery=q;
@ -2285,6 +2428,14 @@ function doSearch(){
body:JSON.stringify({message:q,state:st||undefined,role:rl||undefined}) body:JSON.stringify({message:q,state:st||undefined,role:rl||undefined})
}).then(function(r){return r.json()}).then(function(d){ }).then(function(r){return r.json()}).then(function(d){
out.textContent=''; 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 // Show what the system understood
if(d.understood&&d.understood.length){ if(d.understood&&d.understood.length){
var tags=document.createElement('div');tags.style.cssText='display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px'; var tags=document.createElement('div');tags.style.cssText='display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px';