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:
parent
fb99e92a60
commit
677065de76
@ -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
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user