demo: G — per-staffer hot-swap index (synthetic coordinator personas)
Same corpus, different relevance gradient per staffer. Three personas defined in mcp-server/index.ts STAFFERS roster (Maria/IL, Devon/IN, Aisha/WI), each with a primary state + secondary cities. Server-side: /intelligence/chat smart_search accepts a staffer_id body field; when set, defaults state to the staffer's territory and labels the playbook context as theirs. The playbook patterns query also defaults its geo to the staffer's primary city/state, so the recurring-skills/cert breakdowns reflect what they actually fill, not the global IL prior. Front-end: a staffer selector dropdown beside the existing state/role filters. Picking a staffer auto-pins state to their territory, shows a greeting line, relabels the MEMORY panel as MARIA'S/DEVON'S/AISHA'S MEMORY, and sends staffer_id to chat for scoping. Dropdown is populated from /staffers (NOT /api/staffers — the generic /api/* passthrough sends everything under /api/ to the Rust gateway, which doesn't own the roster). loadStaffers runs at window-load independently of loadDay's Promise.all so the dropdown populates even if simulation/SQL inits error out. Verified end-to-end via playwright. Same q="forklift operators": no staffer → 509 workers across MI/OH/IA, MEMORY label as Devon → 89 IN-only (Fort Wayne, Terre Haute), DEVON'S MEMORY as Aisha → 16 WI-only (Milwaukee, Madison, Green Bay), AISHA'S MEMORY As Maria with q="8 production workers near 60607": tags: headcount: 8 · zip 60607 → Chicago, IL · role: production · city: Chicago 20 workers, MARIA'S MEMORY label, top results in Chicago zips Closes the demo-side build of A-G from the persona plan: A. zip → city/state, B. headcount, C. bare-name, D. temporal, E. late-worker triage, F. contractor anchor, G. per-staffer index.
This commit is contained in:
parent
677065de76
commit
5f0beffe80
@ -22,6 +22,58 @@ import { buildPermitBrief } from "./entity.js";
|
|||||||
|
|
||||||
const BASE = process.env.LAKEHOUSE_URL || "http://localhost:3100";
|
const BASE = process.env.LAKEHOUSE_URL || "http://localhost:3100";
|
||||||
const PORT = parseInt(process.env.MCP_PORT || "3700");
|
const PORT = parseInt(process.env.MCP_PORT || "3700");
|
||||||
|
|
||||||
|
// ─── Staffer roster — used by the per-staffer hot-swap index (G). ────
|
||||||
|
//
|
||||||
|
// J's vision: each staffer has their own molded view of the corpus.
|
||||||
|
// When Maria searches, the system surfaces *Maria's* prior fills and
|
||||||
|
// her territory's playbooks first. When Aisha searches, the same
|
||||||
|
// corpus gets re-shaped to her geo and recent activity. This is what
|
||||||
|
// generic CRM fast-search can't do: a relevance gradient that
|
||||||
|
// compounds with each staffer's own signal.
|
||||||
|
//
|
||||||
|
// First implementation is geography-based — each staffer has a primary
|
||||||
|
// state and a list of cities they recruit for. Playbook queries get
|
||||||
|
// scoped to that territory when staffer_id is provided. As the system
|
||||||
|
// accumulates per-staffer signal (call_log assignments, email threads,
|
||||||
|
// SMS history), the scope expands beyond geography.
|
||||||
|
//
|
||||||
|
// Adding a staffer: append to this list. The /api/staffers endpoint
|
||||||
|
// exposes the public-safe fields to the UI dropdown.
|
||||||
|
const STAFFERS: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
display: string;
|
||||||
|
territory: { state: string; cities: string[] };
|
||||||
|
greeting: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "maria",
|
||||||
|
name: "Maria",
|
||||||
|
display: "Maria · Chicago coordinator",
|
||||||
|
territory: { state: "IL", cities: ["Chicago", "Joliet", "Rockford", "Peoria", "Springfield", "Decatur"] },
|
||||||
|
greeting: "Maria's territory: Illinois warehouse + manufacturing fills",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "devon",
|
||||||
|
name: "Devon",
|
||||||
|
display: "Devon · Indiana coordinator",
|
||||||
|
territory: { state: "IN", cities: ["Indianapolis", "Fort Wayne", "South Bend", "Evansville", "Bloomington", "Terre Haute"] },
|
||||||
|
greeting: "Devon's territory: Indiana production + assembly fills",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "aisha",
|
||||||
|
name: "Aisha",
|
||||||
|
display: "Aisha · Wisconsin/Michigan coordinator",
|
||||||
|
territory: { state: "WI", cities: ["Milwaukee", "Madison", "Green Bay", "Detroit", "Grand Rapids", "Lansing"] },
|
||||||
|
greeting: "Aisha's territory: Wisconsin + Michigan logistics",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function lookupStaffer(id: string | undefined): typeof STAFFERS[number] | null {
|
||||||
|
if (!id) return null;
|
||||||
|
return STAFFERS.find((s) => s.id === id) || null;
|
||||||
|
}
|
||||||
const MODE = process.env.MCP_TRANSPORT || "http"; // "stdio" or "http"
|
const MODE = process.env.MCP_TRANSPORT || "http"; // "stdio" or "http"
|
||||||
|
|
||||||
// Active trace for the current request — set per-request in the HTTP handler
|
// Active trace for the current request — set per-request in the HTTP handler
|
||||||
@ -824,6 +876,20 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Staffer roster — read by the UI dropdown so each coordinator
|
||||||
|
// can act under their own identity (per-staffer hot-swap index).
|
||||||
|
if (url.pathname === "/api/staffers" || url.pathname === "/staffers") {
|
||||||
|
return ok({
|
||||||
|
staffers: STAFFERS.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
display: s.display,
|
||||||
|
territory: s.territory,
|
||||||
|
greeting: s.greeting,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/system/summary") {
|
if (url.pathname === "/system/summary") {
|
||||||
const [ds, indexes, workersCount, candsCount] = await Promise.all([
|
const [ds, indexes, workersCount, candsCount] = await Promise.all([
|
||||||
api("GET", "/catalog/datasets").catch(() => [] as any),
|
api("GET", "/catalog/datasets").catch(() => [] as any),
|
||||||
@ -1862,6 +1928,21 @@ 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();
|
||||||
|
|
||||||
|
// (G) Per-staffer context. When the UI sends a staffer_id,
|
||||||
|
// playbook queries scope to that staffer's territory — their
|
||||||
|
// recent fills, their geo's recurring patterns. The corpus is
|
||||||
|
// the same for everyone; the relevance gradient is unique to
|
||||||
|
// each staffer because each pulls a different shape from it.
|
||||||
|
const staffer = lookupStaffer(String(b.staffer_id || "").trim());
|
||||||
|
// If the staffer has a territory and the user hasn't already
|
||||||
|
// pinned a state/city via dropdown or NL, default the search
|
||||||
|
// to their territory. They can override by typing a different
|
||||||
|
// city or selecting a different state.
|
||||||
|
if (staffer && !explicitState) {
|
||||||
|
filters.push(`state = '${staffer.territory.state}'`);
|
||||||
|
understood.push(`as ${staffer.name}: ${staffer.territory.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
// (B) Headcount parser — coordinator says "8 production
|
// (B) Headcount parser — coordinator says "8 production
|
||||||
// workers", "I need 12 forklift operators", "5 welders by
|
// workers", "I need 12 forklift operators", "5 welders by
|
||||||
// Friday". Match a leading or embedded count followed by
|
// Friday". Match a leading or embedded count followed by
|
||||||
@ -2048,9 +2129,14 @@ async function main() {
|
|||||||
|
|
||||||
// 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.
|
||||||
|
// (G) When a staffer is acting, default the geo to their
|
||||||
|
// primary territory — their playbook view is shaped by
|
||||||
|
// where they actually fill, not the global Chicago/IL prior.
|
||||||
const roleForPatterns = understood.find(u => u.startsWith('role:'))?.split(': ')[1] || q;
|
const roleForPatterns = understood.find(u => u.startsWith('role:'))?.split(': ')[1] || q;
|
||||||
const cityForPatterns = understood.find(u => u.startsWith('city:'))?.split(': ')[1] || 'Chicago';
|
const cityForPatterns = understood.find(u => u.startsWith('city:'))?.split(': ')[1]
|
||||||
const stateForPatterns = understood.find(u => u.startsWith('state:'))?.split(': ')[1] || 'IL';
|
|| staffer?.territory.cities[0] || 'Chicago';
|
||||||
|
const stateForPatterns = understood.find(u => u.startsWith('state:'))?.split(': ')[1]
|
||||||
|
|| staffer?.territory.state || 'IL';
|
||||||
|
|
||||||
const [searchR, directR, patternR] = await Promise.all([
|
const [searchR, directR, patternR] = await Promise.all([
|
||||||
api("POST", "/vectors/hybrid", {
|
api("POST", "/vectors/hybrid", {
|
||||||
@ -2084,6 +2170,7 @@ async function main() {
|
|||||||
return ok({
|
return ok({
|
||||||
type: "smart_search",
|
type: "smart_search",
|
||||||
summary: `Found ${searchR.sql_matches || 0} workers matching your criteria${understood.length ? ' (' + understood.join(', ') + ')' : ''}`,
|
summary: `Found ${searchR.sql_matches || 0} workers matching your criteria${understood.length ? ' (' + understood.join(', ') + ')' : ''}`,
|
||||||
|
staffer: staffer ? { id: staffer.id, name: staffer.name, display: staffer.display, territory: staffer.territory } : null,
|
||||||
understood,
|
understood,
|
||||||
sql_results: sqlWorkers,
|
sql_results: sqlWorkers,
|
||||||
vector_results: vectorWorkers,
|
vector_results: vectorWorkers,
|
||||||
|
|||||||
@ -302,11 +302,18 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
|
|||||||
</p>
|
</p>
|
||||||
<div id="ws-samples" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;font-size:11px"></div>
|
<div id="ws-samples" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;font-size:11px"></div>
|
||||||
<input type="text" id="sq" placeholder="Try: reliable forklift operator available in Nashville" onkeydown="if(event.key==='Enter')doSearch()" style="width:100%;box-sizing:border-box;padding:10px 12px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3;font-size:13px;margin-bottom:8px">
|
<input type="text" id="sq" placeholder="Try: reliable forklift operator available in Nashville" onkeydown="if(event.key==='Enter')doSearch()" style="width:100%;box-sizing:border-box;padding:10px 12px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3;font-size:13px;margin-bottom:8px">
|
||||||
<div class="srow" style="display:flex;gap:8px;margin-bottom:8px">
|
<div class="srow" style="display:flex;gap:8px;margin-bottom:8px;align-items:center">
|
||||||
|
<!-- Per-staffer hot-swap selector. When a staffer is chosen, every
|
||||||
|
search and triage scopes to their territory; the playbook MEMORY
|
||||||
|
panel labels itself "Maria's recent fills" instead of generic. -->
|
||||||
|
<select id="sstaffer" style="flex:0 0 auto;padding:8px 10px;background:#0d1117;border:1px solid #58a6ff66;border-radius:6px;color:#e6edf3;font-weight:600;cursor:pointer" title="Act as this coordinator — playbook context will scope to their territory">
|
||||||
|
<option value="">All staffers</option>
|
||||||
|
</select>
|
||||||
<select id="sst" style="flex:1;padding:8px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3"><option value="">Any State</option></select>
|
<select id="sst" style="flex:1;padding:8px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3"><option value="">Any State</option></select>
|
||||||
<select id="srl" style="flex:1;padding:8px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3"><option value="">Any Role</option></select>
|
<select id="srl" style="flex:1;padding:8px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3"><option value="">Any Role</option></select>
|
||||||
<button class="sbtn" onclick="doSearch()" style="padding:8px 16px;background:#238636;border:none;border-radius:6px;color:#fff;font-weight:600;cursor:pointer">Find Workers</button>
|
<button class="sbtn" onclick="doSearch()" style="padding:8px 16px;background:#238636;border:none;border-radius:6px;color:#fff;font-weight:600;cursor:pointer">Find Workers</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="sstaffer-greeting" style="font-size:11px;color:#58a6ff;margin-bottom:8px;min-height:14px"></div>
|
||||||
<div id="sresults"></div>
|
<div id="sresults"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -363,7 +370,38 @@ var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
|||||||
var A=location.origin+P;
|
var A=location.origin+P;
|
||||||
var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a'];
|
var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a'];
|
||||||
var lastQuery='';
|
var lastQuery='';
|
||||||
window.addEventListener('load',function(){loadSystemSummary();loadLegacyBridge();loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning();loadWorkerSearchSamples();loadArchSignals()});
|
window.addEventListener('load',function(){loadSystemSummary();loadLegacyBridge();loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning();loadWorkerSearchSamples();loadArchSignals();loadStaffers()});
|
||||||
|
|
||||||
|
// Per-staffer hot-swap dropdown — runs independently of the simulation
|
||||||
|
// fetch so the staffer selector populates even if any other init step
|
||||||
|
// errors out. /api/staffers returns the synthetic coordinator roster.
|
||||||
|
function loadStaffers(){
|
||||||
|
var sel=document.getElementById('sstaffer');
|
||||||
|
var greeting=document.getElementById('sstaffer-greeting');
|
||||||
|
if(!sel) return;
|
||||||
|
// /staffers (not /api/staffers) — the /api/* generic passthrough
|
||||||
|
// forwards anything under /api/ to the Rust gateway on :3100 and the
|
||||||
|
// gateway doesn't know the staffer roster (it lives in the mcp-server
|
||||||
|
// module). The bare /staffers route serves directly.
|
||||||
|
fetch(A+'/staffers').then(function(r){return r.json()}).then(function(d){
|
||||||
|
(d.staffers||[]).forEach(function(s){
|
||||||
|
var o=document.createElement('option');o.value=s.id;o.textContent=s.display||s.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
sel._roster=d.staffers||[];
|
||||||
|
}).catch(function(){});
|
||||||
|
sel.addEventListener('change',function(){
|
||||||
|
var roster=sel._roster||[];
|
||||||
|
var s=roster.find(function(x){return x.id===sel.value});
|
||||||
|
if(s){
|
||||||
|
greeting.textContent='Acting as '+s.name+' — '+(s.greeting||'')+' · territory: '+s.territory.cities.slice(0,3).join(', ')+'…';
|
||||||
|
var stSel=document.getElementById('sst');
|
||||||
|
if(stSel && !stSel.value){stSel.value=s.territory.state}
|
||||||
|
}else{
|
||||||
|
greeting.textContent='';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Deep-link: visiting the dashboard with #open-briefs in the URL auto-
|
// Deep-link: visiting the dashboard with #open-briefs in the URL auto-
|
||||||
// expands every Entity Brief panel once the contract cards finish
|
// expands every Entity Brief panel once the contract cards finish
|
||||||
@ -2417,15 +2455,17 @@ 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;
|
||||||
var st=document.getElementById('sst').value,rl=document.getElementById('srl').value;
|
var st=document.getElementById('sst').value,rl=document.getElementById('srl').value;
|
||||||
|
var stafferEl=document.getElementById('sstaffer');
|
||||||
|
var stafferId=stafferEl?stafferEl.value:'';
|
||||||
// Pass dropdown filters as structured fields. Old code appended
|
// Pass dropdown filters as structured fields. Old code appended
|
||||||
// ' in '+st to the message, which the server misparsed: the
|
// ' in '+st to the message, which the server misparsed: the
|
||||||
// preposition "in" matched the regex for state code "IN" (Indiana)
|
// preposition "in" matched the regex for state code "IN" (Indiana)
|
||||||
// and every search returned Indiana workers regardless of dropdown.
|
// and every search returned Indiana workers regardless of dropdown.
|
||||||
// Sending structured state/role lets the server skip NL parsing
|
// Sending structured state/role + staffer_id lets the server skip
|
||||||
// for those fields entirely.
|
// NL parsing for those fields and apply per-staffer scoping.
|
||||||
var out=document.getElementById('sresults');out.textContent='Finding the best matches...';
|
var out=document.getElementById('sresults');out.textContent='Finding the best matches...';
|
||||||
fetch(A+'/intelligence/chat',{method:'POST',headers:{'Content-Type':'application/json'},
|
fetch(A+'/intelligence/chat',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({message:q,state:st||undefined,role:rl||undefined})
|
body:JSON.stringify({message:q,state:st||undefined,role:rl||undefined,staffer_id:stafferId||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
|
// Type-specific renderers — added 2026-04-27 for the persona-driven
|
||||||
@ -2457,7 +2497,11 @@ function doSearch(){
|
|||||||
var mem=document.createElement('div');
|
var mem=document.createElement('div');
|
||||||
mem.style.cssText='background:#0d2818;border:1px solid #2ea04360;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-size:11px;color:#86efac;line-height:1.5';
|
mem.style.cssText='background:#0d2818;border:1px solid #2ea04360;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-size:11px;color:#86efac;line-height:1.5';
|
||||||
var label=document.createElement('span');label.style.cssText='color:#3fb950;font-weight:600;margin-right:6px';
|
var label=document.createElement('span');label.style.cssText='color:#3fb950;font-weight:600;margin-right:6px';
|
||||||
label.textContent='MEMORY ('+(d.pattern_playbooks_matched||0)+' playbook'+(d.pattern_playbooks_matched===1?'':'s')+'):';
|
// When a staffer is acting, label the panel with their name —
|
||||||
|
// "MARIA'S MEMORY (12 playbooks)" makes the per-user shaping
|
||||||
|
// visible in the UI, not just the response data.
|
||||||
|
var memOwner=d.staffer&&d.staffer.name?d.staffer.name.toUpperCase()+"'S MEMORY":'MEMORY';
|
||||||
|
label.textContent=memOwner+' ('+(d.pattern_playbooks_matched||0)+' playbook'+(d.pattern_playbooks_matched===1?'':'s')+'):';
|
||||||
mem.appendChild(label);
|
mem.appendChild(label);
|
||||||
var pattern = d.discovered_pattern || '';
|
var pattern = d.discovered_pattern || '';
|
||||||
if(!pattern || pattern.indexOf('No similar')>=0 || pattern.indexOf('0 workers')>=0){
|
if(!pattern || pattern.indexOf('No similar')>=0 || pattern.indexOf('0 workers')>=0){
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user