From f9a408e4c45593728d95d1ddad550c4e9d18ca41 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Apr 2026 00:44:18 -0500 Subject: [PATCH] =?UTF-8?q?Surname=20=E2=86=92=20ethnicity=20routing=20+?= =?UTF-8?q?=20ComfyUI=20fallback=20for=20sparse=20pool=20buckets=20+=20cac?= =?UTF-8?q?he-buster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems J flagged ("not matching properly", "same faces", "still showing old icons") had three different roots: 1. MISMATCH: front-end was first-name only, so "Anna Cruz" / "Patricia Garcia" / "John Jimenez" all defaulted to caucasian. Added SURNAMES_HISPANIC / _SOUTH_ASIAN / _EAST_ASIAN / _MIDDLE_EASTERN dicts to both search.html and console.html. Surname is checked FIRST (stronger signal for hispanic + asian than first names), then first-name fallback. Cruz → hispanic, Patel → south_asian, Nguyen → east_asian, regardless of first name. 2. SAME FACES: pool buckets are uneven — woman/south_asian=3, man/black=4, woman/middle_eastern=2 — so any worker in those buckets collapses to 2-4 photos no matter how good the hash is. /headshots/:key now 302-redirects to /headshots/generate/:key when the gender × race intersection is below 30 faces. ComfyUI on-demand gives infinite uniqueness for the sparse buckets (deterministic-per-worker via djb2 seed). Dense buckets still serve from the pool — no GPU cost there. 3. STALE CACHE: Cache-Control was max-age=86400, immutable — pinned old photos in browsers for 24h after any server-side update. Dropped to max-age=3600, must-revalidate, and added a v=2 cache-buster query param to all front-end /headshots/ URLs so existing cached entries are bypassed on next page load. Also surfacing X-Face-Pool-Bucket / Bucket-Size headers for diagnosis. Verified: playwright run shows surname routing correct (Torres, Rivera, Alvarez, Gutierrez, Patel, Nguyen, Omar all bucketed correctly), sparse buckets 302 to ComfyUI, dense buckets stay on the thumb pool. Co-Authored-By: Claude Opus 4.7 (1M context) --- mcp-server/console.html | 45 ++++++++++++++++++--- mcp-server/index.ts | 51 ++++++++++++++++++++---- mcp-server/search.html | 88 ++++++++++++++++++++++++++++++++--------- 3 files changed, 154 insertions(+), 30 deletions(-) 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);