diff --git a/mcp-server/console.html b/mcp-server/console.html
index 7caeaff..c1afc5a 100644
--- a/mcp-server/console.html
+++ b/mcp-server/console.html
@@ -262,6 +262,37 @@ var NAMES_EAST_ASIAN_C=new Set(['Wei','Mei','Yi','Jin','Chen','Lin','Liu','Wang'
var NAMES_HISPANIC_C=new Set(['Carmen','Carlos','Maria','Diego','Hector','Jorge','Julio','Manuel','Miguel','Pedro','Raul','Ricardo','Roberto','Sergio','Antonio','Esperanza','Luz','Sofia','Lucia','Isabella','Camila','Valentina','Mariana','Elena','Rosa','Catalina','Esteban','Fernando','Eduardo','Javier','Alejandro','Andres','Mateo','Santiago','Sebastian','Emilio','Tomas','Cristina','Daniela','Gabriela','Ximena','Adriana','Beatriz','Pilar','Mercedes','Xavier','Marisol','Guadalupe','Lupita','Inez','Itzel','Yesenia','Joaquin','Ignacio','Rafael','Salvador','Cesar','Arturo','Armando','Hugo','Marco','Alejandra','Felipe','Gerardo','Jaime','Leonardo','Luis','Pablo','Ramon']);
var NAMES_BLACK_C=new Set(['DeShawn','Jamal','Aisha','Latoya','Tyrone','Malik','Imani','Keisha','Tariq','Lakisha','Kenya','Tamika','Andre','Marcus','Demetrius','Jermaine','Reggie','Tyrese','Darius','Trevon','Kareem','Damon','Jalen','Jaylen','Dwayne','DaQuan','Aaliyah','Kiara','Janelle','Jasmine','Tanisha','Maurice','Tyrell','Kwame','Khalil','Terrell','Cedric','Nia','Zuri','Jada','Ebony','Dominique']);
var NAMES_MIDDLE_EASTERN_C=new Set(['Layla','Omar','Khalid','Fatima','Yasmin','Hassan','Hussein','Ahmed','Mohamed','Mohammed','Ali','Karim','Yusuf','Yara','Nadia','Zainab','Rania','Samira','Mariam','Salma','Ibrahim','Mahmoud','Saif','Anwar','Bilal','Faisal','Hamza','Imran','Sami','Wael','Zaid','Amira','Iman','Lina','Mona','Noor','Rana','Soha','Zara']);
+// Surname → ethnicity. Surname is more diagnostic than first name
+// for hispanic and asian — "Anna Cruz" is hispanic via surname.
+var SURNAMES_HISPANIC_C=new Set(['Garcia','Rodriguez','Martinez','Hernandez','Lopez','Gonzalez','Perez','Sanchez','Ramirez','Torres','Flores','Rivera','Gomez','Diaz','Reyes','Cruz','Morales','Ortiz','Gutierrez','Chavez','Ramos','Ruiz','Alvarez','Mendoza','Vasquez','Castillo','Jimenez','Moreno','Romero','Herrera','Medina','Aguilar','Vargas','Castro','Fernandez','Guzman','Munoz','Salazar','Ortega','Delgado','Estrada','Ayala','Pena','Cabrera','Alvarado','Espinoza','Padilla','Cardenas','Cortes','Ibarra','Vega','Soto','Lara','Navarro','Campos','Acosta','Rios','Marquez','Sandoval','Maldonado','Solis','Rojas','Mejia','Beltran','Cervantes','Lozano','Carrillo','Trevino','Robles','Tapia','Lugo']);
+var SURNAMES_SOUTH_ASIAN_C=new Set(['Patel','Singh','Kumar','Sharma','Gupta','Shah','Mehta','Desai','Joshi','Reddy','Nair','Iyer','Verma','Agarwal','Kapoor','Chopra','Malhotra','Banerjee','Chatterjee','Mukherjee','Das','Sen','Bose','Roy','Sinha','Trivedi','Pandey','Mishra','Tiwari','Yadav','Chauhan','Rana','Thakur','Pillai','Menon','Krishnan','Rao','Naidu','Pradhan','Acharya','Devi','Kaur']);
+var SURNAMES_EAST_ASIAN_C=new Set(['Chen','Wang','Li','Liu','Yang','Huang','Zhao','Wu','Zhou','Xu','Zhu','Sun','Ma','Lin','Lee','Kim','Park','Choi','Jung','Kang','Cho','Yoon','Han','Lim','Oh','Nakamura','Tanaka','Suzuki','Yamamoto','Sato','Watanabe','Takahashi','Kobayashi','Yoshida','Saito','Nguyen','Tran','Le','Pham','Hoang','Phan','Vu','Vo','Dang','Bui','Do','Ngo','Truong','Mai','Cao','Wong','Tang','Tan','Cheng','Lau','Leung','Ng','Cheung','Yip','Hsu','Tsai','Hsieh']);
+var SURNAMES_MIDDLE_EASTERN_C=new Set(['Khan','Ahmed','Hussein','Hassan','Ali','Mahmoud','Mohamed','Mohammed','Saleh','Aziz','Karim','Hamad','Najjar','Haddad','Khoury','Mansour','Rahman','Iqbal','Malik','Sheikh','Siddiqui','Qureshi','Saeed']);
+
+function guessEthnicityFromName(first, last){
+ if(last){
+ var s=last.replace(/[^A-Za-z]/g,'');
+ if(s){
+ var sc=s[0].toUpperCase()+s.slice(1).toLowerCase();
+ if(SURNAMES_HISPANIC_C.has(sc)) return 'hispanic';
+ if(SURNAMES_MIDDLE_EASTERN_C.has(sc)) return 'middle_eastern';
+ if(SURNAMES_SOUTH_ASIAN_C.has(sc)) return 'south_asian';
+ if(SURNAMES_EAST_ASIAN_C.has(sc)) return 'east_asian';
+ }
+ }
+ if(first){
+ var clean=first.replace(/[^A-Za-z]/g,'');
+ if(clean){
+ var c=clean[0].toUpperCase()+clean.slice(1).toLowerCase();
+ if(NAMES_MIDDLE_EASTERN_C.has(c)) return 'middle_eastern';
+ if(NAMES_BLACK_C.has(c)) return 'black';
+ if(NAMES_HISPANIC_C.has(c)) return 'hispanic';
+ if(NAMES_SOUTH_ASIAN_C.has(c)) return 'south_asian';
+ if(NAMES_EAST_ASIAN_C.has(c)) return 'east_asian';
+ }
+ }
+ return 'caucasian';
+}
function guessEthnicityFromFirstName(n){
if(!n) return 'caucasian';
var clean=n.replace(/[^A-Za-z]/g,''); if(!clean) return 'caucasian';
@@ -285,15 +316,19 @@ function workerRow(name, role, detail, opts){
// same worker always gets the same face. Falls back to monogram if
// pool isn't fetched yet.
var faceKey = (opts.face_key) || name || '';
- var firstName = (name||'').split(/\s+/)[0]||'';
+ var nameParts = (name||'').trim().split(/\s+/);
+ var firstName = nameParts[0]||'';
+ var lastName = nameParts.length > 1 ? nameParts[nameParts.length-1] : '';
var gHint = genderFor(firstName);
- var eHint = guessEthnicityFromFirstName(firstName);
+ var eHint = (typeof guessEthnicityFromName === 'function')
+ ? guessEthnicityFromName(firstName, lastName)
+ : guessEthnicityFromFirstName(firstName);
if(faceKey){
var img=document.createElement('img');
img.alt='';
- // Eager: 11KB thumbs make lazy unnecessary and lazy was racing
- // playwright + retina-decode in field testing.
- img.src = P + '/headshots/' + encodeURIComponent(faceKey) + '?g='+gHint+'&e='+eHint;
+ // Eager + cache-buster v=2: 11KB thumbs are cheap to load fresh
+ // and the v= param invalidates browsers holding old photos.
+ img.src = P + '/headshots/' + encodeURIComponent(faceKey) + '?g='+gHint+'&e='+eHint+'&v=2';
img.onerror=function(){ this.remove(); };
av.appendChild(img);
}
diff --git a/mcp-server/index.ts b/mcp-server/index.ts
index 6a1cf48..ee36059 100644
--- a/mcp-server/index.ts
+++ b/mcp-server/index.ts
@@ -1378,17 +1378,45 @@ async function main() {
if (!F || !F.all.length) {
return new Response("face pool empty", { status: 503 });
}
- // Pool selection: try gender×race intersection first, then
- // gender-only, then race-only, then full pool. Always returns
- // a face so the worker card never falls back to the monogram.
+ // Pool selection: try gender×race intersection first. If
+ // bucket is too sparse to look natural across many cards
+ // (south_asian/black/middle_eastern_woman are 2-10 faces),
+ // hand off to ComfyUI generate so the user sees a unique
+ // face per worker instead of 4 photos shared across 200
+ // cards. Threshold 30 keeps the dense buckets fast and
+ // routes only the sparse ones through GPU.
const wantRace = url.searchParams.get("e") || "";
+ const SPARSE_THRESHOLD = 30;
let pool = F.all;
- if (wantGender && wantRace && F.byGR[wantGender + "/" + wantRace]?.length) {
- pool = F.byGR[wantGender + "/" + wantRace];
+ let bucket = "all";
+ if (wantGender && wantRace) {
+ const gr = F.byGR[wantGender + "/" + wantRace] || [];
+ if (gr.length >= SPARSE_THRESHOLD) {
+ pool = gr;
+ bucket = `gr:${wantGender}/${wantRace}`;
+ } else if (gr.length > 0) {
+ // Sparse intersection — route to ComfyUI for uniqueness.
+ const role = url.searchParams.get("role") || "warehouse worker";
+ const age = url.searchParams.get("age") || "32";
+ const genUrl = `/headshots/generate/${encodeURIComponent(key)}?g=${wantGender}&e=${wantRace}&role=${encodeURIComponent(role)}&age=${age}`;
+ return new Response(null, {
+ status: 302,
+ headers: {
+ "Location": genUrl,
+ "X-Face-Pool-Variant": "sparse-redirect",
+ "X-Face-Pool-Bucket-Size": String(gr.length),
+ },
+ });
+ } else if (F.byG[wantGender]?.length) {
+ pool = F.byG[wantGender];
+ bucket = `g:${wantGender}`;
+ }
} else if (wantGender && F.byG[wantGender]?.length) {
pool = F.byG[wantGender];
+ bucket = `g:${wantGender}`;
} else if (wantRace && F.byR[wantRace]?.length) {
pool = F.byR[wantRace];
+ bucket = `r:${wantRace}`;
}
// Hash key → pool index. djb2-ish, fits any string.
let h = 5381;
@@ -1399,15 +1427,22 @@ async function main() {
// (~580KB). 60× smaller — without this, a 40-card grid
// overruns Chrome's parallel-connection budget and ~75% of
// tiles never finish decoding.
+ //
+ // Cache-Control: 1h public + must-revalidate, NOT immutable.
+ // We deliberately let the browser re-check after pool retags
+ // or face-pool refreshes — `immutable` was pinning stale
+ // photos for 24h after a server-side update.
const thumbName = pick.file.replace(/\.jpg$/, ".webp");
const thumb = Bun.file(`${HEADSHOT_DIR}/_thumbs/${thumbName}`);
if (await thumb.exists()) {
return new Response(thumb, {
headers: {
"Content-Type": "image/webp",
- "Cache-Control": "public, max-age=86400, immutable",
+ "Cache-Control": "public, max-age=3600, must-revalidate",
"X-Face-Pool-Idx": String(pick.id),
"X-Face-Pool-Gender": pick.gender || "untagged",
+ "X-Face-Pool-Bucket": bucket,
+ "X-Face-Pool-Bucket-Size": String(pool.length),
"X-Face-Pool-Variant": "thumb-384",
},
});
@@ -1419,9 +1454,11 @@ async function main() {
return new Response(file, {
headers: {
"Content-Type": "image/jpeg",
- "Cache-Control": "public, max-age=86400, immutable",
+ "Cache-Control": "public, max-age=3600, must-revalidate",
"X-Face-Pool-Idx": String(pick.id),
"X-Face-Pool-Gender": pick.gender || "untagged",
+ "X-Face-Pool-Bucket": bucket,
+ "X-Face-Pool-Bucket-Size": String(pool.length),
"X-Face-Pool-Variant": "native-1024",
},
});
diff --git a/mcp-server/search.html b/mcp-server/search.html
index 5a7ceaa..efbe69e 100644
--- a/mcp-server/search.html
+++ b/mcp-server/search.html
@@ -2312,21 +2312,70 @@ var NAMES_EAST_ASIAN = new Set(['Wei','Mei','Yi','Jin','Chen','Lin','Liu','Wang'
var NAMES_HISPANIC = new Set(['Carmen','Carlos','Maria','Diego','Hector','Jorge','Julio','Manuel','Miguel','Pedro','Raul','Ricardo','Roberto','Sergio','Antonio','Esperanza','Luz','Sofia','Lucia','Isabella','Camila','Valentina','Mariana','Elena','Rosa','Catalina','Esteban','Fernando','Eduardo','Javier','Alejandro','Andres','Mateo','Santiago','Sebastian','Emilio','Tomas','Cristina','Daniela','Gabriela','Ximena','Adriana','Beatriz','Pilar','Consuelo','Dolores','Mercedes','Xavier','Marisol','Guadalupe','Lupita','Inez','Itzel','Yolanda','Yesenia','Monserrat','Renata','Ximena','Joaquin','Ignacio','Rafael','Salvador','Cesar','Arturo','Armando','Hugo','Marco','Alejandra','Alma','Belen','Blanca','Esmeralda','Fatima','Gloria','Imelda','Lourdes','Magdalena','Olga','Paula','Refugio','Rocio','Susana','Teresa','Veronica','Anita','Ernestino','Felipe','Gerardo','Humberto','Jaime','Leonardo','Luis','Pablo','Ramon','Reynaldo','Vincente']);
var NAMES_BLACK = new Set(['DeShawn','Jamal','Aisha','Latoya','Tyrone','Malik','Imani','Keisha','Tariq','Lakisha','Kenya','Tamika','Shaquille','Andre','Marcus','Demetrius','Jermaine','Reggie','Tyrese','Darius','Trevon','Kareem','Damon','Jalen','Jaylen','Dwayne','DaQuan','Latanya','Latrice','Aaliyah','Kiara','Janelle','Jasmine','Tanisha','Yolanda','Maurice','Tyrell','Kwame','Khalil','Rashid','Terrell','Chauncey','Cedric','Maliyah','Imari','Nia','Zuri','Talia','Jada','Ebony','Dominique']);
var NAMES_MIDDLE_EASTERN = new Set(['Layla','Omar','Khalid','Fatima','Yasmin','Hassan','Hussein','Ahmed','Mohamed','Mohammed','Ali','Karim','Yusuf','Yara','Nadia','Zainab','Rania','Samira','Mariam','Salma','Ibrahim','Mahmoud','Saif','Anwar','Bilal','Faisal','Hamza','Imran','Jamal','Rashid','Sami','Tariq','Wael','Yasin','Zaid','Amira','Dunia','Iman','Lina','Mona','Noor','Rana','Sabrina','Soha','Yara','Zara']);
+// Surname dictionaries — surname is more diagnostic than first name
+// for hispanic and asian. Cruz/Garcia/Hernandez/Lopez are nearly always
+// hispanic regardless of first name. Patel/Singh/Kumar are South Asian.
+// Chen/Wang/Liu/Nguyen are East Asian. We check surname BEFORE
+// first-name fallback so "Anna Cruz" → hispanic, not caucasian.
+var SURNAMES_HISPANIC = new Set(['Garcia','Rodriguez','Martinez','Hernandez','Lopez','Gonzalez','Perez','Sanchez','Ramirez','Torres','Flores','Rivera','Gomez','Diaz','Reyes','Cruz','Morales','Ortiz','Gutierrez','Chavez','Ramos','Ruiz','Alvarez','Mendoza','Vasquez','Castillo','Jimenez','Moreno','Romero','Herrera','Medina','Aguilar','Vargas','Castro','Fernandez','Guzman','Munoz','Salazar','Ortega','Delgado','Estrada','Ayala','Pena','Cabrera','Alvarado','Espinoza','Padilla','Cardenas','Cortes','Cortez','Ibarra','Vega','Soto','Lara','Navarro','Campos','Acosta','Rios','Marquez','Velasquez','Velazquez','Sandoval','Maldonado','Solis','Rojas','Pacheco','Mejia','Beltran','Santiago','Cervantes','Lozano','Carrillo','Galvan','Trevino','Galvez','Santana','Galvan','Robles','Valencia','Carrasco','Tapia','Lugo','Barajas','Bautista','Quintero','Salinas','Avila','Macias','Velasco','Gallegos','Calderon']);
+var SURNAMES_SOUTH_ASIAN = new Set(['Patel','Singh','Kumar','Sharma','Gupta','Shah','Khan','Mehta','Desai','Joshi','Reddy','Nair','Iyer','Verma','Agarwal','Kapoor','Chopra','Malhotra','Bhatt','Bhattacharya','Banerjee','Chatterjee','Mukherjee','Das','Sen','Bose','Roy','Saha','Sinha','Trivedi','Pandey','Mishra','Tiwari','Yadav','Chauhan','Rana','Thakur','Pillai','Menon','Krishnan','Subramanian','Raman','Rao','Naidu','Nayak','Mohan','Pradhan','Acharya','Devi','Kaur','Khanna']);
+var SURNAMES_EAST_ASIAN = new Set(['Chen','Wang','Li','Liu','Yang','Huang','Zhao','Wu','Zhou','Xu','Zhu','Sun','Ma','Lin','Lee','Kim','Park','Choi','Jung','Kang','Cho','Yoon','Han','Lim','Oh','Nakamura','Tanaka','Suzuki','Yamamoto','Sato','Watanabe','Takahashi','Kobayashi','Yoshida','Saito','Nguyen','Tran','Le','Pham','Hoang','Phan','Vu','Vo','Dang','Bui','Do','Ho','Ngo','Duong','Truong','Lam','Trinh','Mai','Cao','Lam','Wong','Tang','Tan','Cheng','Lau','Leung','Ng','Cheung','Yu','Yip','Yam','Lo','Hsu','Tsai','Hsieh','Lin']);
+var SURNAMES_MIDDLE_EASTERN = new Set(['Khan','Ahmed','Hussein','Hassan','Ali','Mahmoud','Mohamed','Mohammed','Saleh','Aziz','Karim','Hamad','Najjar','Haddad','Khoury','Mansour','Hakim','Zaman','Rahman','Iqbal','Malik','Sheikh','Siddiqui','Qureshi','Abbasi','Bukhari','Naqvi','Tahir','Anwar','Aslam','Saeed','Rizvi','Farooqi']);
+var SURNAMES_BLACK = new Set(['Washington','Jefferson','Booker','Tubman','Robinson','Jackson','Williams','Brown','Davis','Smith','Johnson','Thomas','Lewis','Walker','Wright','Edwards','Carter','Mitchell','Roberts','Phillips','Bell','Coleman','Patterson','Graham','Bailey','Reed','Cook','Morgan','Bryant','Russell','Hayes','Howard','Ward','Foster','Long','Hill','Murphy','Rivers','Banks','Boyd','Glover','Harper','Jenkins','Wallace','Mason','Spencer','Crawford','Greene','Holmes','Stewart','Pierce','Hardy','Sims','Sutton','Wells','Burke','Hines','Hudson','Mosley','Dawson','Mathis','Lyons','Newton','Watts','Whitaker','Wilkerson']);
+
+function guessEthnicityFromName(firstName, lastName){
+ // Try surname FIRST — it's the stronger signal for hispanic/asian
+ // identity ("Anna Cruz" → hispanic via surname). Black surnames are
+ // mostly anglicized (Williams/Brown/Davis) and ambiguous, so we skip
+ // surname lookup for black and rely on first name. Caucasian is the
+ // implicit default — never explicitly tagged.
+ if(lastName){
+ var s = lastName.replace(/[^A-Za-z]/g,'');
+ if(s){
+ var sc = s[0].toUpperCase() + s.slice(1).toLowerCase();
+ if(SURNAMES_HISPANIC.has(sc)) return 'hispanic';
+ if(SURNAMES_MIDDLE_EASTERN.has(sc)) return 'middle_eastern';
+ if(SURNAMES_SOUTH_ASIAN.has(sc)) return 'south_asian';
+ if(SURNAMES_EAST_ASIAN.has(sc)) return 'east_asian';
+ }
+ }
+ // First-name fallback: distinctive given names (Aisha, DeShawn,
+ // Carmen, Ravi, Mei, Layla) keep their bucket even when paired with
+ // a generic surname.
+ if(firstName){
+ var clean = firstName.replace(/[^A-Za-z]/g,'');
+ if(clean){
+ var c = clean[0].toUpperCase() + clean.slice(1).toLowerCase();
+ if(NAMES_MIDDLE_EASTERN.has(c)) return 'middle_eastern';
+ if(NAMES_BLACK.has(c)) return 'black';
+ if(NAMES_HISPANIC.has(c)) return 'hispanic';
+ if(NAMES_SOUTH_ASIAN.has(c)) return 'south_asian';
+ if(NAMES_EAST_ASIAN.has(c)) return 'east_asian';
+ }
+ }
+ // Black surnames lookup runs LAST because most are anglicized and
+ // would over-trigger if checked early — only a few are diagnostic
+ // enough on their own (e.g., when first name is also generic).
+ if(lastName){
+ var s2 = lastName.replace(/[^A-Za-z]/g,'');
+ if(s2){
+ var sc2 = s2[0].toUpperCase() + s2.slice(1).toLowerCase();
+ if(SURNAMES_BLACK.has(sc2)){
+ // only if first name is also non-distinctive, otherwise leave
+ // it unset. This is conservative — over-tagging black hurts
+ // because the pool only has 14 black faces.
+ }
+ }
+ }
+ return 'caucasian';
+}
+// Back-compat: existing callers pass only the full name as one string.
function guessEthnicityFromFirstName(name){
if(!name) return 'caucasian';
- var clean = name.replace(/[^A-Za-z]/g,'');
- if(!clean) return 'caucasian';
- var c = clean[0].toUpperCase() + clean.slice(1).toLowerCase();
- // Order matters where names overlap. We're CREATING this profile so
- // the assumptions are first-pass confident — fallback is caucasian
- // (the largest US Census bucket), so every worker resolves to a
- // category the face pool can be biased toward.
- if(NAMES_MIDDLE_EASTERN.has(c)) return 'middle_eastern';
- if(NAMES_BLACK.has(c)) return 'black';
- if(NAMES_HISPANIC.has(c)) return 'hispanic';
- if(NAMES_SOUTH_ASIAN.has(c)) return 'south_asian';
- if(NAMES_EAST_ASIAN.has(c)) return 'east_asian';
- return 'caucasian';
+ var parts = String(name).trim().split(/\s+/);
+ var first = parts[0] || '';
+ var last = parts.length > 1 ? parts[parts.length-1] : '';
+ return guessEthnicityFromName(first, last);
}
// Forced-confident gender resolver — defaults to a deterministic guess
// when the name table doesn't match, rather than leaving "unknown."
@@ -2391,16 +2440,19 @@ function addWorkerInsight(parent,name,detail,why,idx,highlight){
// narrow accordingly; until then it falls back to the full pool but
// the URL shape is forward-compatible.
var faceKey = (workerDataRef && (workerDataRef.candidate_id || workerDataRef.doc_id)) || name || '';
- var firstName = (name||'').split(/\s+/)[0]||'';
+ var nameParts = (name||'').trim().split(/\s+/);
+ var firstName = nameParts[0]||'';
+ var lastName = nameParts.length > 1 ? nameParts[nameParts.length-1] : '';
var gHint = genderFor(firstName);
- var eHint = guessEthnicityFromFirstName(firstName);
+ var eHint = guessEthnicityFromName(firstName, lastName);
if(faceKey){
var img=document.createElement('img');
img.alt='';
// No lazy-loading: thumbs are 384x384 webp (~11KB) so eager
- // load is cheap (~500KB for 50 cards) and avoids the off-screen
- // tile flash + scroll-jitter that lazy decode produces here.
- var qs = '?g=' + gHint + '&e=' + eHint;
+ // load is cheap (~500KB for 50 cards). The v= param is a deploy
+ // cache-buster — bump it whenever the pool, surname dict, or
+ // routing logic changes so users don't see stale cached photos.
+ var qs = '?g=' + gHint + '&e=' + eHint + '&role=' + encodeURIComponent(workerDataRef && workerDataRef.role || 'warehouse worker') + '&v=2';
img.src = P + '/headshots/' + encodeURIComponent(faceKey) + qs;
img.onerror=function(){ this.remove(); };
av.appendChild(img);