Rate/margin awareness: implied pay rate per worker, bill rate per contract
Closes one of the Path 1 trust-break gaps. The scenario we kept flagging:
recruiter calls the system's top pick, worker quotes $35/hr, contract
pays $28/hr. First broken call kills the demo. This fixes it.
Heuristic (no schema change, derived at query time):
- Per worker: implied_pay_rate = role_base + (reliability × 4) + archetype_bump
role_base: Electrician $28, Welder $26, Machine Op $24, Maint $26,
Forklift Op $20, Loader $17, Warehouse Assoc $17, Quality Tech $23,
Production Worker $18 ...
archetype bump: specialist +4, leader +3, reliable +1, else 0
- Per contract: implied_bill_rate = role_base × 1.4
(40% markup — industry norm: pay + overhead + insurance + margin)
- Worker is 'over_bill_rate' when implied_pay_rate > contract's bill_rate
on a candidate-by-candidate basis
Backend (mcp-server/index.ts):
- ROLE_BASE_PAY_RATE + BILL_MARKUP constants
- impliedPayRate(worker), impliedBillRate(role) functions
- parseWorkerChunk() extracts role/reliability/archetype from vector text
- enrichWithRates() attaches implied_pay_rate on every /vectors/hybrid
source response. Called from /search and /intelligence/permit_contracts.
- /search accepts optional max_pay_rate number — if set, filters out
workers above that rate and reports pay_rate_filtered_out count.
- /intelligence/permit_contracts returns implied_bill_rate per contract
AND over_bill_rate boolean per candidate.
Frontend (search.html):
- Live Contracts cards show 'bill rate: $X/hr' under the headcount line
- Each candidate shows 'pay $X/hr' in the sub-line; red 'Over bill rate'
chip next to name when their pay exceeds the contract's bill rate
(hover reveals the exact numbers and why it's flagged)
- Main 'Search all workers' results now include 'pay $X/hr' in the
why-text (computeImpliedPayRate mirrored client-side to match Bun)
End-to-end verified live:
- Masonry Work permit, bill_rate $25.20/hr
Kathleen M. Gutierrez pay $25.56/hr → 🔴 OVER
Melissa C. Rivera pay $20.88/hr → 🟢 OK
- /search with max_pay_rate:32 filtered out 1 Toledo Welder above $32
- Main search shows 'pay $28.64/hr' in each result row
When real ATS data replaces synthetic workers_500k, same UI — the
client's real pay_rate column substitutes for the heuristic.
This commit is contained in:
parent
a117ae8b38
commit
af3856b103
@ -442,13 +442,24 @@ async function main() {
|
||||
filter = `(${filter}) AND worker_id NOT IN (${ids.join(",")})`;
|
||||
}
|
||||
}
|
||||
return ok(await api("POST", "/vectors/hybrid", {
|
||||
const hybridRes = await api("POST", "/vectors/hybrid", {
|
||||
question: b.question, index_name: b.index || "workers_500k_v1",
|
||||
sql_filter: filter, filter_dataset: b.dataset || "ethereal_workers",
|
||||
id_column: b.id_column || "worker_id", top_k: b.top_k || 5, generate: b.generate !== false,
|
||||
use_playbook_memory: b.use_playbook_memory !== false,
|
||||
playbook_memory_k: b.playbook_memory_k ?? 200,
|
||||
}));
|
||||
});
|
||||
// Rate enrichment + optional max_pay_rate filter (soft filter,
|
||||
// preserves result shape). Operator can opt out by omitting.
|
||||
if (hybridRes && Array.isArray(hybridRes.sources)) {
|
||||
enrichWithRates(hybridRes.sources);
|
||||
if (typeof b.max_pay_rate === "number" && b.max_pay_rate > 0) {
|
||||
const before = hybridRes.sources.length;
|
||||
hybridRes.sources = hybridRes.sources.filter((s: any) => s.implied_pay_rate <= b.max_pay_rate);
|
||||
(hybridRes as any).pay_rate_filtered_out = before - hybridRes.sources.length;
|
||||
}
|
||||
}
|
||||
return ok(hybridRes);
|
||||
}
|
||||
|
||||
// Tool: SQL
|
||||
@ -1080,6 +1091,9 @@ async function main() {
|
||||
min_trait_frequency: 0.3,
|
||||
}).catch(() => ({} as any));
|
||||
|
||||
// Enrich with implied pay rate before taking the top-5
|
||||
enrichWithRates(searchRes.sources || []);
|
||||
const contractBillRate = impliedBillRate(role);
|
||||
const sources = (searchRes.sources || []).slice(0, 5).map((s: any) => {
|
||||
const name = String(s.chunk_text || "").split("—")[0]?.trim() || s.doc_id;
|
||||
return {
|
||||
@ -1088,6 +1102,8 @@ async function main() {
|
||||
score: s.score,
|
||||
playbook_boost: s.playbook_boost || 0,
|
||||
playbook_citations: s.playbook_citations || [],
|
||||
implied_pay_rate: s.implied_pay_rate ?? null,
|
||||
over_bill_rate: (s.implied_pay_rate ?? 0) > contractBillRate,
|
||||
};
|
||||
});
|
||||
|
||||
@ -1114,6 +1130,7 @@ async function main() {
|
||||
community_area: p.community_area,
|
||||
issue_date: (p.issue_date || "").substring(0, 10),
|
||||
},
|
||||
implied_bill_rate: contractBillRate,
|
||||
timeline: {
|
||||
estimated_construction_start: estStart.toISOString().slice(0, 10),
|
||||
staffing_window_opens: stagingDate.toISOString().slice(0, 10),
|
||||
@ -1789,6 +1806,79 @@ async function runAlertsOnce() {
|
||||
// Seed playbook_memory from a filled contract so the next hybrid query
|
||||
// ranks against it. Used by both runWeekSimulation (per-day) and the /log
|
||||
// endpoint (per manual logging). Fail-soft — seeding is best-effort.
|
||||
// ─── Rate/margin awareness ──────────────────────────────────────────────
|
||||
// Derive implied pay and bill rates per worker / per contract without
|
||||
// schema changes. Numbers are industry heuristics — a real deployment
|
||||
// would replace these with the client's actual ATS pay_rate column and
|
||||
// contract bill_rate. The shape stays the same; only the source changes.
|
||||
|
||||
const ROLE_BASE_PAY_RATE: Record<string, number> = {
|
||||
"Electrician": 28,
|
||||
"Welder": 26,
|
||||
"Machine Operator": 24,
|
||||
"Maintenance Tech": 26,
|
||||
"Forklift Operator": 20,
|
||||
"Loader": 17,
|
||||
"Warehouse Associate": 17,
|
||||
"Material Handler": 18,
|
||||
"Production Worker": 18,
|
||||
"Quality Tech": 23,
|
||||
"Line Lead": 22,
|
||||
"Assembler": 18,
|
||||
"Shipping Clerk": 19,
|
||||
};
|
||||
const DEFAULT_BASE_PAY = 19;
|
||||
// Staffing firm typically marks up pay to bill by 35-45% to cover
|
||||
// overhead, insurance, and margin. Using 40% as the midpoint.
|
||||
const BILL_MARKUP = 1.4;
|
||||
|
||||
function impliedPayRate(w: { role?: string | null; reliability?: number | string | null; archetype?: string | null }): number {
|
||||
const role = w.role || "";
|
||||
const base = ROLE_BASE_PAY_RATE[role] ?? DEFAULT_BASE_PAY;
|
||||
const rel = typeof w.reliability === "string" ? parseFloat(w.reliability) : (w.reliability ?? 0.5);
|
||||
const relBump = (isFinite(rel) ? rel : 0.5) * 4;
|
||||
const arch = (w.archetype || "").toLowerCase();
|
||||
const archBump = arch === "specialist" ? 4 : arch === "leader" ? 3 : arch === "reliable" ? 1 : 0;
|
||||
return Math.round((base + relBump + archBump) * 100) / 100;
|
||||
}
|
||||
|
||||
function impliedBillRate(role: string | null | undefined): number {
|
||||
const base = ROLE_BASE_PAY_RATE[role || ""] ?? DEFAULT_BASE_PAY;
|
||||
// Contract bill rate = base pay × markup. This is what a staffing firm
|
||||
// would typically quote for this role — the worker's rate has to be
|
||||
// below this to keep margin.
|
||||
return Math.round((base * BILL_MARKUP) * 100) / 100;
|
||||
}
|
||||
|
||||
// Parse a worker's role / reliability / archetype from a vector chunk
|
||||
// shaped like "Name — Role in City, ST. Skills: ... . Certs: ... .
|
||||
// Archetype: reliable. Reliability: 0.93, Availability: 0.73"
|
||||
function parseWorkerChunk(chunk: string): { role?: string; reliability?: number; archetype?: string } {
|
||||
if (!chunk) return {};
|
||||
const out: any = {};
|
||||
const roleMatch = chunk.match(/—\s*([^\.]+?)\s+in\s+/);
|
||||
if (roleMatch) out.role = roleMatch[1].trim();
|
||||
const relMatch = chunk.match(/Reliability:\s*([\d\.]+)/i);
|
||||
if (relMatch) out.reliability = parseFloat(relMatch[1]);
|
||||
const archMatch = chunk.match(/Archetype:\s*([A-Za-z]+)/i);
|
||||
if (archMatch) out.archetype = archMatch[1];
|
||||
return out;
|
||||
}
|
||||
|
||||
// Attach implied_pay_rate to each hybrid source in place, using either
|
||||
// the row's native fields (from sql_results) or parsed from chunk_text.
|
||||
function enrichWithRates(sources: any[]): void {
|
||||
for (const s of sources || []) {
|
||||
const parsed = parseWorkerChunk(s.chunk_text || "");
|
||||
const w = {
|
||||
role: s.role ?? parsed.role,
|
||||
reliability: s.reliability ?? s.rel ?? parsed.reliability,
|
||||
archetype: s.archetype ?? s.arch ?? parsed.archetype,
|
||||
};
|
||||
s.implied_pay_rate = impliedPayRate(w);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedPlaybookFromContract(c: any) {
|
||||
const names = (c.matches || []).slice(0, 5)
|
||||
.map((m: any) => m.name || m.doc_id)
|
||||
|
||||
@ -222,6 +222,22 @@ function api(path,body){
|
||||
return fetch(A+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(function(r){return r.json()})
|
||||
}
|
||||
|
||||
// Mirror of the Bun-side pay-rate formula so client-side renderers
|
||||
// (main search, modal) can show a rate when they only have worker
|
||||
// fields, not a pre-enriched hybrid source. Keep in sync with
|
||||
// impliedPayRate() in mcp-server/index.ts.
|
||||
var ROLE_BASE_PAY={Electrician:28,Welder:26,'Machine Operator':24,'Maintenance Tech':26,
|
||||
'Forklift Operator':20,Loader:17,'Warehouse Associate':17,'Material Handler':18,
|
||||
'Production Worker':18,'Quality Tech':23,'Line Lead':22,Assembler:18,'Shipping Clerk':19};
|
||||
function computeImpliedPayRate(role,rel,archetype){
|
||||
var base=ROLE_BASE_PAY[role||'']||19;
|
||||
var r=typeof rel==='string'?parseFloat(rel):(rel||0.5);
|
||||
var relBump=(isFinite(r)?r:0.5)*4;
|
||||
var a=(archetype||'').toLowerCase();
|
||||
var archBump=a==='specialist'?4:a==='leader'?3:a==='reliable'?1:0;
|
||||
return Math.round((base+relBump+archBump)*100)/100;
|
||||
}
|
||||
|
||||
function loadLiveContracts(){
|
||||
// Pair live Chicago permits with our 500K worker bench and the
|
||||
// meta-index discovered patterns for each role+geo. This is the
|
||||
@ -258,6 +274,13 @@ function loadLiveContracts(){
|
||||
var sub=document.createElement('div');sub.style.cssText='color:#545d68;font-size:10px;text-align:right';
|
||||
sub.textContent='pool: '+(prop.pool_size||'?').toLocaleString()+' available';
|
||||
right.appendChild(sub);
|
||||
// Rate awareness: show implied bill rate per contract
|
||||
if(c.implied_bill_rate){
|
||||
var rate=document.createElement('div');
|
||||
rate.style.cssText='color:#d29922;font-size:10px;text-align:right;margin-top:3px';
|
||||
rate.textContent='bill rate: $'+c.implied_bill_rate.toFixed(2)+'/hr';
|
||||
right.appendChild(rate);
|
||||
}
|
||||
hdr.appendChild(left);hdr.appendChild(right);card.appendChild(hdr);
|
||||
// Description
|
||||
if(p.description){
|
||||
@ -286,8 +309,17 @@ function loadLiveContracts(){
|
||||
chip.textContent='Endorsed · '+(cand.playbook_citations||[]).length+' playbook'+((cand.playbook_citations||[]).length===1?'':'s');
|
||||
nm.appendChild(chip);
|
||||
}
|
||||
// Rate warning chip when worker's pay exceeds the contract's bill rate
|
||||
if(cand.over_bill_rate){
|
||||
var warn=document.createElement('span');warn.style.cssText='margin-left:6px;padding:2px 7px;border-radius:9px;font-size:9px;font-weight:600;background:#3a1a1a;border:1px solid #f85149;color:#fca5a5;vertical-align:middle';
|
||||
warn.textContent='Over bill rate';
|
||||
warn.title='Worker\'s implied pay rate ($'+(cand.implied_pay_rate||0).toFixed(2)+'/hr) exceeds contract bill rate ($'+(c.implied_bill_rate||0).toFixed(2)+'/hr) — margin at risk';
|
||||
nm.appendChild(warn);
|
||||
}
|
||||
var sub2=document.createElement('div');sub2.style.cssText='color:#545d68;font-size:10px';
|
||||
sub2.textContent=cand.doc_id+' · score '+(cand.score||0).toFixed(3);
|
||||
var subText=cand.doc_id+' · score '+(cand.score||0).toFixed(3);
|
||||
if(cand.implied_pay_rate) subText+=' · pay $'+cand.implied_pay_rate.toFixed(2)+'/hr';
|
||||
sub2.textContent=subText;
|
||||
info.appendChild(nm);info.appendChild(sub2);
|
||||
row.appendChild(av);row.appendChild(info);
|
||||
card.appendChild(row);
|
||||
@ -933,6 +965,10 @@ function doSearch(){
|
||||
var why='Reliability: '+Math.round((w.rel||0)*100)+'%';
|
||||
if(w.avail)why+=' · Available: '+Math.round(w.avail*100)+'%';
|
||||
if(w.archetype)why+=' · '+w.archetype;
|
||||
// Derive and show implied pay rate client-side so the main search
|
||||
// surface matches the live-contracts cards. Same formula as Bun.
|
||||
var rate=computeImpliedPayRate(w.role,w.rel,w.archetype);
|
||||
if(rate) why+=' · pay $'+rate.toFixed(2)+'/hr';
|
||||
addWorkerInsight(out,w.name,detail.join(' · '),why,i,null,wd);
|
||||
});
|
||||
} else {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user