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:
root
2026-04-28 06:01:04 -05:00
parent 677065de76
commit 5f0beffe80
2 changed files with 139 additions and 8 deletions
+50 -6
View File
@@ -302,11 +302,18 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
</p>
<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">
<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="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>
</div>
<div id="sstaffer-greeting" style="font-size:11px;color:#58a6ff;margin-bottom:8px;min-height:14px"></div>
<div id="sresults"></div>
</div>
@@ -363,7 +370,38 @@ var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
var A=location.origin+P;
var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a'];
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-
// 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;
lastQuery=q;
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
// ' in '+st to the message, which the server misparsed: the
// preposition "in" matched the regex for state code "IN" (Indiana)
// and every search returned Indiana workers regardless of dropdown.
// Sending structured state/role lets the server skip NL parsing
// for those fields entirely.
// Sending structured state/role + staffer_id lets the server skip
// NL parsing for those fields and apply per-staffer scoping.
var out=document.getElementById('sresults');out.textContent='Finding the best matches...';
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){
out.textContent='';
// Type-specific renderers — added 2026-04-27 for the persona-driven
@@ -2457,7 +2497,11 @@ function doSearch(){
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';
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);
var pattern = d.discovered_pattern || '';
if(!pattern || pattern.indexOf('No similar')>=0 || pattern.indexOf('0 workers')>=0){