demo: icon recipe pipeline + role-aware portraits + ComfyUI negative-prompt override
Adds two single-source-of-truth recipe files that drive both the
hot-path render server and the offline pre-render scripts:
- role_scenes.ts: per-role-band scene clauses (clothing + backdrop).
Forklift operators look like forklift operators instead of
collapsing to interchangeable studio shots. SCENES_VERSION mixes
into the headshot cache key so a coordinator tweak refreshes every
matching face on next view.
- icon_recipes.ts: cert / role-prop / status / hazard / empty icons
with deterministic per-recipe seeds + fuzzy text resolver.
ICONS_VERSION suffix on the cached file means edits don't
overwrite in place — misfires are recoverable.
Routes (mcp-server/index.ts):
- GET /headshots/_scenes — exposes SCENES + version to the
pre-render script so prompts don't drift between batch and hot-path.
- GET /icons/_recipes — same idea for icons.
- GET /icons/cert?text=... — resolves free-text cert names to a
recipe and 302s to the rendered icon. 404 (not 500) when no recipe
matches so the front-end can hang `onerror="this.remove()"`.
- GET /icons/render/{category}/{slug} — cache-or-render at 256² (8
steps) for crisper edges than 512² when downsampled to 14px.
ComfyUI portrait support (scripts/serve_imagegen.py):
The editorial workflow had `human, person, face` baked into its
negative prompt — actively sabotaging portraits. _comfyui_generate
now accepts negative_prompt/cfg/sampler/scheduler overrides, and
those mix into the cache key so portrait calls don't collapse into
hero-shot cache hits.
scripts/staffing/render_role_pool.py: pre-renders the role-aware
face pool by reading SCENES from /headshots/_scenes — single source
of truth verified at run time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// Visual filler iconography rendered through ComfyUI. Distinct from
|
||||
// role_scenes.ts (which renders portraits) — these are object/badge
|
||||
// style renders that fill dead space on worker cards: cert pills,
|
||||
// role-prop chips, hazard indicators, empty-state heroes.
|
||||
//
|
||||
// Layout on disk:
|
||||
// data/icons_pool/{category}/{slug}.webp
|
||||
//
|
||||
// Cache invalidation:
|
||||
// ICONS_VERSION mixes into the on-disk filename (slug includes
|
||||
// version). Bump it after editing a recipe so prior renders are
|
||||
// ignored on next view.
|
||||
|
||||
export type IconCategory = "cert" | "role_prop" | "status" | "hazard" | "empty";
|
||||
|
||||
export interface IconRecipe {
|
||||
slug: string;
|
||||
category: IconCategory;
|
||||
// Text label that appears next to / under the icon. The front-end
|
||||
// already renders this text in cert pills; the icon is supplementary.
|
||||
display: string;
|
||||
// Full diffusion prompt. Style guidance baked in. SDXL Turbo at 8
|
||||
// steps reliably produces clean macro photography, so default to
|
||||
// photographic prop shots over flat-vector illustrations (the model
|
||||
// hallucinates noise into flat-vector geometry at low step counts).
|
||||
prompt: string;
|
||||
// Negative prompt — what NOT to render. Crucial for icons because
|
||||
// SDXL likes to add hands/text/people unprompted.
|
||||
negative?: string;
|
||||
}
|
||||
|
||||
// Default negative prompt baked into every icon render unless the
|
||||
// recipe overrides. Empirically, these terms are the top SDXL Turbo
|
||||
// off-style failures.
|
||||
export const DEFAULT_NEGATIVE =
|
||||
"people, hands, faces, blurry, low quality, watermark, signature, "
|
||||
+ "logos, copyright, distorted text, garbled letters, multiple objects";
|
||||
|
||||
// TODO J — review and tune the prompts here. Each one is what diffusion
|
||||
// sees verbatim. The visual decision: photographic prop shots (macro
|
||||
// photo of an actual badge / placard / sticker) vs flat-icon vector
|
||||
// style. Default below is photographic — matches the worker headshot
|
||||
// aesthetic. Flip a recipe to flat-vector by replacing "macro photograph"
|
||||
// with "flat icon illustration on solid color background, minimal vector".
|
||||
//
|
||||
// Visual cues that work well in SDXL Turbo at 8 steps:
|
||||
// - "macro photograph", "isolated on plain background", "studio lighting"
|
||||
// - Concrete colors ("orange and black warning diamond") not adjectives
|
||||
// - Avoid: small text in the prompt (model garbles it), specific brand
|
||||
// names (creates fake logos), detailed scene composition
|
||||
const CERT_ICONS: IconRecipe[] = [
|
||||
{ slug: "osha-10", category: "cert", display: "OSHA-10",
|
||||
prompt: "macro photograph of a circular yellow safety badge with a black hard hat icon at center, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "osha-30", category: "cert", display: "OSHA-30",
|
||||
prompt: "macro photograph of a circular orange safety badge with a black hard hat icon at center, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "first-aid-cpr", category: "cert", display: "First Aid/CPR",
|
||||
prompt: "macro photograph of a small enamel pin badge featuring a bold red cross on a white circular background, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "hazmat", category: "cert", display: "Hazmat",
|
||||
prompt: "macro photograph of a HAZMAT warning placard, bold orange and black diamond shape with a flame icon, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "forklift", category: "cert", display: "Forklift",
|
||||
prompt: "macro photograph of a yellow industrial forklift safety badge with a forklift silhouette icon, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "reach-truck", category: "cert", display: "Reach Truck",
|
||||
prompt: "macro photograph of a navy blue industrial certification badge with a warehouse reach-truck silhouette icon, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "order-picker", category: "cert", display: "Order Picker",
|
||||
prompt: "macro photograph of a green industrial certification badge with a warehouse order-picker silhouette icon, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "lockout-tagout", category: "cert", display: "Lockout/Tagout",
|
||||
prompt: "macro photograph of a bright red padlock tag with a danger warning, hanging on a metal industrial valve, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "msds", category: "cert", display: "MSDS",
|
||||
prompt: "macro photograph of a folded chemical safety data sheet booklet with chemical hazard pictograms visible on cover, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "confined-space", category: "cert", display: "Confined Space",
|
||||
prompt: "macro photograph of a yellow confined space warning sign featuring a manhole entry icon, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "servsafe", category: "cert", display: "ServSafe",
|
||||
prompt: "macro photograph of a dark green food safety certification badge featuring a stylized chef hat icon, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "fire-safety", category: "cert", display: "Fire Safety",
|
||||
prompt: "macro photograph of a red enamel pin badge featuring a flame icon and a fire extinguisher silhouette, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "iso-9001", category: "cert", display: "ISO 9001",
|
||||
prompt: "macro photograph of a deep blue circular quality-management certification seal with embossed metallic ring, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
];
|
||||
|
||||
// Role-band visual chips — small icons that go in the role pill area.
|
||||
// One per band, optional inline supplement to the existing colored pill.
|
||||
const ROLE_PROP_ICONS: IconRecipe[] = [
|
||||
{ slug: "warehouse", category: "role_prop", display: "Warehouse",
|
||||
prompt: "macro photograph of a yellow hard hat with a high-visibility safety vest folded behind it, isolated on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "production", category: "role_prop", display: "Production",
|
||||
prompt: "macro photograph of a navy blue work shirt and protective safety glasses on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "trades", category: "role_prop", display: "Trades",
|
||||
prompt: "macro photograph of a leather work glove and a small adjustable wrench on a neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "driver", category: "role_prop", display: "Driver",
|
||||
prompt: "macro photograph of a navy delivery driver baseball cap and a clipboard manifest on a neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
{ slug: "lead", category: "role_prop", display: "Lead",
|
||||
prompt: "macro photograph of a tablet showing a bar chart and a high-vis vest folded beside it on neutral grey backdrop, photorealistic, sharp focus, studio lighting" },
|
||||
];
|
||||
|
||||
export const ICONS: Record<string, IconRecipe> = Object.fromEntries(
|
||||
[...CERT_ICONS, ...ROLE_PROP_ICONS].map((r) => [`${r.category}/${r.slug}`, r]),
|
||||
);
|
||||
|
||||
// v2 — 256×256 canvas, intended to be displayed monochrome via CSS
|
||||
// `filter: grayscale(1)`. Smaller canvas, tighter crops, crisper at
|
||||
// 14px display size.
|
||||
export const ICONS_VERSION = "v2";
|
||||
|
||||
// Map a free-form cert string from the data ("First Aid/CPR",
|
||||
// "OSHA-10", "Lockout/Tagout") to the canonical slug used here.
|
||||
// Returns null if no recipe matches.
|
||||
export function certToSlug(cert: string): string | null {
|
||||
const c = (cert || "").trim().toLowerCase().replace(/\s+/g, "-");
|
||||
if (c === "osha-10") return "osha-10";
|
||||
if (c === "osha-30") return "osha-30";
|
||||
if (c.startsWith("first") || c.includes("cpr")) return "first-aid-cpr";
|
||||
if (c === "hazmat" || c.startsWith("hazwoper")) return "hazmat";
|
||||
if (c === "forklift" || c.startsWith("pit")) return "forklift";
|
||||
if (c.startsWith("reach")) return "reach-truck";
|
||||
if (c.startsWith("order")) return "order-picker";
|
||||
if (c.startsWith("lockout") || c.includes("tagout")) return "lockout-tagout";
|
||||
if (c === "msds" || c.startsWith("ghs")) return "msds";
|
||||
if (c.startsWith("confined")) return "confined-space";
|
||||
if (c === "servsafe") return "servsafe";
|
||||
if (c.startsWith("fire")) return "fire-safety";
|
||||
if (c.startsWith("iso")) return "iso-9001";
|
||||
return null;
|
||||
}
|
||||
+222
-33
@@ -19,6 +19,8 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { z } from "zod";
|
||||
import { startTrace, logSpan, logGeneration, scoreTrace, flush as flushTraces } from "./tracing.js";
|
||||
import { buildPermitBrief } from "./entity.js";
|
||||
import { roleBand, SCENES, SCENES_VERSION, FACE_RENDER_DIM, type RoleBand } from "./role_scenes.js";
|
||||
import { ICONS, ICONS_VERSION, DEFAULT_NEGATIVE, certToSlug, type IconRecipe } from "./icon_recipes.js";
|
||||
|
||||
const BASE = process.env.LAKEHOUSE_URL || "http://localhost:3100";
|
||||
const PORT = parseInt(process.env.MCP_PORT || "3700");
|
||||
@@ -1231,6 +1233,110 @@ async function main() {
|
||||
// contractor / profile modal where one worker gets the
|
||||
// spotlight. NB: declared BEFORE the pool route so the prefix
|
||||
// match doesn't intercept it.
|
||||
// Single source of truth for the pre-render script. Read
|
||||
// role_scenes.ts SCENES + SCENES_VERSION so a Python pre-render
|
||||
// job (scripts/staffing/render_role_pool.py) builds the role-
|
||||
// aware pool with the exact prompts the server will use on the
|
||||
// ComfyUI hot-path. No drift.
|
||||
if (url.pathname === "/headshots/_scenes" && req.method === "GET") {
|
||||
return Response.json({ version: SCENES_VERSION, scenes: SCENES });
|
||||
}
|
||||
|
||||
// Single source of truth for icon_recipes.ts. Used by the
|
||||
// pre-render script (scripts/staffing/render_icons.py) and any
|
||||
// tooling that wants to enumerate available icons.
|
||||
if (url.pathname === "/icons/_recipes" && req.method === "GET") {
|
||||
return Response.json({
|
||||
version: ICONS_VERSION,
|
||||
default_negative: DEFAULT_NEGATIVE,
|
||||
recipes: ICONS,
|
||||
});
|
||||
}
|
||||
|
||||
// Free-text cert resolver: front-end passes the raw cert string
|
||||
// from the data ("First Aid/CPR", "OSHA-10", "Lockout/Tagout")
|
||||
// and we resolve to a recipe slug + 302 to the cached/rendered
|
||||
// icon. Returns 404 (not error) when no recipe matches — the
|
||||
// front-end can hang an `onerror="this.remove()"` to silently
|
||||
// drop the img tag for unrecognized certs.
|
||||
if (url.pathname === "/icons/cert" && req.method === "GET") {
|
||||
const text = url.searchParams.get("text") || "";
|
||||
const slug = certToSlug(text);
|
||||
if (!slug) return new Response(`no recipe for cert: ${text}`, { status: 404 });
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { "Location": `/icons/render/cert/${slug}` },
|
||||
});
|
||||
}
|
||||
|
||||
// Cert / role-prop / status / hazard / empty icons. Lookup is
|
||||
// category/slug; on cache miss the route renders via ComfyUI.
|
||||
// Filename layout: data/icons_pool/{category}/{slug}_{version}.webp
|
||||
// — the version suffix means editing a recipe yields a new file
|
||||
// rather than overwriting in place, so a misfire is recoverable.
|
||||
if (url.pathname.startsWith("/icons/render/") && req.method === "GET") {
|
||||
const rest = url.pathname.slice("/icons/render/".length);
|
||||
const recipe: IconRecipe | undefined = ICONS[rest];
|
||||
if (!recipe) return new Response(`unknown icon: ${rest}`, { status: 404 });
|
||||
const ICONS_DIR = "/home/profit/lakehouse/data/icons_pool";
|
||||
await Bun.$`mkdir -p ${ICONS_DIR}/${recipe.category}`.quiet();
|
||||
const cachePath = `${ICONS_DIR}/${recipe.category}/${recipe.slug}_${ICONS_VERSION}.webp`;
|
||||
const cached = Bun.file(cachePath);
|
||||
if (await cached.exists()) {
|
||||
return new Response(cached, {
|
||||
headers: {
|
||||
"Content-Type": "image/webp",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"X-Icon-Source": "cached",
|
||||
"X-Icon-Recipe": recipe.slug,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Deterministic seed per recipe — same recipe always renders
|
||||
// the same icon. Mixing the version means SCENES_VERSION-
|
||||
// style invalidation works for icons too.
|
||||
const seedStr = `${recipe.category}|${recipe.slug}|${ICONS_VERSION}`;
|
||||
let seed = 5381;
|
||||
for (let i = 0; i < seedStr.length; i++) seed = ((seed << 5) + seed + seedStr.charCodeAt(i)) | 0;
|
||||
seed = Math.abs(seed) % 2147483647;
|
||||
try {
|
||||
const genResp = await fetch("http://localhost:3600/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: recipe.prompt,
|
||||
negative_prompt: recipe.negative ?? DEFAULT_NEGATIVE,
|
||||
// 256×256 — smaller canvas = cleaner icon. SDXL Turbo
|
||||
// at 8 steps adds visible texture/noise into 512² that
|
||||
// looks "AI" at small display sizes; tightening to 256
|
||||
// both renders ~3× faster and produces crisper edges
|
||||
// when the front-end downsamples to 14px.
|
||||
width: 256,
|
||||
height: 256,
|
||||
steps: 8,
|
||||
seed,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!genResp.ok) return new Response(`gen failed: ${genResp.status}`, { status: 502 });
|
||||
const data: any = await genResp.json();
|
||||
if (!data.image) return new Response("no image returned", { status: 502 });
|
||||
const bytes = Uint8Array.from(atob(data.image), (c) => c.charCodeAt(0));
|
||||
await Bun.write(cachePath, bytes);
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
"Content-Type": "image/webp",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"X-Icon-Source": "fresh",
|
||||
"X-Icon-Recipe": recipe.slug,
|
||||
"X-Icon-Gen-Ms": String(data.time_ms || 0),
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
return new Response(`gen error: ${e.message}`, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/headshots/generate/") && req.method === "GET") {
|
||||
const key = decodeURIComponent(url.pathname.slice("/headshots/generate/".length));
|
||||
if (!key) return new Response("missing key", { status: 400 });
|
||||
@@ -1238,9 +1344,14 @@ async function main() {
|
||||
const r = (url.searchParams.get("e") || "").toLowerCase();
|
||||
const role = (url.searchParams.get("role") || "warehouse worker").toLowerCase();
|
||||
const age = parseInt(url.searchParams.get("age") || "32", 10) || 32;
|
||||
const band = roleBand(role);
|
||||
// SCENES_VERSION mixes into the cache key so editing
|
||||
// role_scenes.ts auto-invalidates prior renders — coordinator
|
||||
// tweaks the warehouse prompt, every warehouse face refreshes
|
||||
// on next view.
|
||||
const cacheKey = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(`${key}|${g}|${r}|${role}|${age}`)
|
||||
new TextEncoder().encode(`${key}|${g}|${r}|${role}|${age}|${SCENES_VERSION}`)
|
||||
).then((b) => Array.from(new Uint8Array(b)).map((x) => x.toString(16).padStart(2, "0")).join("").slice(0, 24));
|
||||
const GEN_DIR = "/home/profit/lakehouse/data/headshots_gen";
|
||||
await Bun.$`mkdir -p ${GEN_DIR}`.quiet();
|
||||
@@ -1262,7 +1373,12 @@ async function main() {
|
||||
: r === "middle_eastern" ? "Middle Eastern"
|
||||
: "";
|
||||
const genderText = g === "woman" ? "woman" : g === "man" ? "man" : "person";
|
||||
const prompt = `professional corporate headshot portrait of a ${age}-year-old ${raceText} ${genderText}, ${role}, neutral expression, plain studio background, soft natural lighting, sharp focus, photorealistic, dslr`;
|
||||
const scene = SCENES[band].scene;
|
||||
// Note: dropped "plain studio background" / "dslr" — those
|
||||
// collapsed every render to interchangeable studio shots.
|
||||
// The scene clause now carries clothing + backdrop so a
|
||||
// forklift operator looks like a forklift operator.
|
||||
const prompt = `professional headshot portrait of a ${age}-year-old ${raceText} ${genderText} ${role}, ${scene}, neutral confident expression, sharp focus, photorealistic`;
|
||||
// Worker-derived seed — same input always picks the same
|
||||
// pixel layout in StyleGAN2 latent space, so the face is
|
||||
// deterministic per worker BUT distinct from any other
|
||||
@@ -1275,7 +1391,7 @@ async function main() {
|
||||
const genResp = await fetch("http://localhost:3600/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, width: 512, height: 512, steps: 8, seed }),
|
||||
body: JSON.stringify({ prompt, width: FACE_RENDER_DIM, height: FACE_RENDER_DIM, steps: 8, seed }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!genResp.ok) return new Response(`gen failed: ${genResp.status}`, { status: 502 });
|
||||
@@ -1378,35 +1494,34 @@ async function main() {
|
||||
if (!F || !F.all.length) {
|
||||
return new Response("face pool empty", { status: 503 });
|
||||
}
|
||||
// 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;
|
||||
|
||||
// NOTE: role-aware pool + ComfyUI sparse redirect were removed
|
||||
// 2026-04-28 — diffusion output at 8 steps with the existing
|
||||
// editorial_hero workflow produced burnt-looking faces ("looks
|
||||
// like someone burnt the pictures"). Until serve_imagegen.py
|
||||
// is fixed to honor a portrait-friendly negative prompt and
|
||||
// run with proper steps/cfg, every face comes from the studio
|
||||
// pool (StyleGAN photos from thispersondoesnotexist.com) and
|
||||
// gets B&W via CSS. The role pool files at
|
||||
// data/headshots_role_pool/{v1,v2}/ stay on disk for when
|
||||
// we can re-enable them.
|
||||
|
||||
// Studio pool only. Try gender×race intersection first, then
|
||||
// fall back to gender-only or race-only if the intersection
|
||||
// is sparse. Repeat faces are acceptable — better than
|
||||
// serving the over-contrasty diffusion output.
|
||||
let pool = F.all;
|
||||
let bucket = "all";
|
||||
if (wantGender && wantRace) {
|
||||
const gr = F.byGR[wantGender + "/" + wantRace] || [];
|
||||
if (gr.length >= SPARSE_THRESHOLD) {
|
||||
if (gr.length > 0) {
|
||||
// Use the intersection bucket as-is — even sparse buckets
|
||||
// (south_asian: 3, black: 14) just repeat photos rather
|
||||
// than route to ComfyUI. Repetition is fine; burnt faces
|
||||
// are not.
|
||||
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}`;
|
||||
@@ -1939,15 +2054,88 @@ async function main() {
|
||||
.reduce((s, c) => s + (c.implied_pay_rate - contractBillRate) * hoursPerWeek * weeksAssumed, 0);
|
||||
|
||||
// Shift inference from permit work_type + description.
|
||||
// Construction defaults to 1st-shift (day). Heavy civil or
|
||||
// facility work sometimes runs 2nd or split-shift. 3rd
|
||||
// (overnight) is rare in commercial construction but real
|
||||
// for maintenance / emergency calls.
|
||||
// Description keywords trump the hash-based assignment;
|
||||
// for everything else we deterministically distribute
|
||||
// permits across shifts via a hash of the permit id so
|
||||
// every shift bucket has real, stable data instead of
|
||||
// every contract collapsing to 1st.
|
||||
const descLower = ((p.work_description || "") + " " + (p.work_type || "")).toLowerCase();
|
||||
const shifts: string[] = ["1st"]; // default day
|
||||
if (/night|overnight|24\s*hr|emergency/.test(descLower)) shifts.push("3rd");
|
||||
if (/multi.?shift|round.?the.?clock|double.?shift/.test(descLower)) shifts.push("2nd");
|
||||
if (/weekend|saturday|sunday/.test(descLower)) shifts.push("4th");
|
||||
function hashStr(s: string){
|
||||
let h=5381;
|
||||
for(let i=0;i<s.length;i++) h=((h<<5)+h+s.charCodeAt(i))|0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
const permitKey = String(p.id || (p.street_number+p.street_name) || p.work_description || "").slice(0,80);
|
||||
const hh = hashStr(permitKey);
|
||||
const bucket = hh % 100;
|
||||
// Realistic split: 50% day, 28% evening, 17% overnight,
|
||||
// 5% weekend. Construction skews heavily day-shift.
|
||||
let primary: string =
|
||||
bucket < 50 ? "1st"
|
||||
: bucket < 78 ? "2nd"
|
||||
: bucket < 95 ? "3rd"
|
||||
: "4th";
|
||||
const shifts: string[] = [primary];
|
||||
if (/night|overnight|24\s*hr|emergency/.test(descLower) && !shifts.includes("3rd")) shifts.push("3rd");
|
||||
if (/multi.?shift|round.?the.?clock|double.?shift/.test(descLower) && !shifts.includes("2nd")) shifts.push("2nd");
|
||||
if (/weekend|saturday|sunday/.test(descLower) && !shifts.includes("4th")) shifts.push("4th");
|
||||
|
||||
// Internal calendar: build a 7-day schedule (today ±3
|
||||
// days) with a row per (date, shift). This is what the
|
||||
// front-end's shift-mix preview filters against — real
|
||||
// dates, real workers/bill, real status (past/active/
|
||||
// scheduled) tied to the current clock. As permits get
|
||||
// ingested with explicit start/end dates the backend
|
||||
// can replace this with the stored schedule.
|
||||
const SHIFT_HOURS: Record<string, [number, number]> = {
|
||||
"1st": [6, 14], "2nd": [14, 22], "3rd": [22, 30], "4th": [0, 24], // 4th = weekend
|
||||
};
|
||||
function shiftStatus(d: Date, shift: string, ref: Date): "past" | "active" | "scheduled" {
|
||||
const refDay = ref.toISOString().slice(0,10);
|
||||
const dDay = d.toISOString().slice(0,10);
|
||||
if (dDay < refDay) return "past";
|
||||
if (dDay > refDay) return "scheduled";
|
||||
// Same day — break by hour vs shift window.
|
||||
const hr = ref.getHours() + ref.getMinutes()/60;
|
||||
const [s,e] = SHIFT_HOURS[shift] || [0,24];
|
||||
if (shift === "4th") {
|
||||
// Weekend shift: active if today IS weekend, else scheduled.
|
||||
const isWknd = (ref.getDay()===0 || ref.getDay()===6);
|
||||
return isWknd ? "active" : "scheduled";
|
||||
}
|
||||
if (shift === "3rd") {
|
||||
// 3rd wraps midnight: active 22:00–06:00.
|
||||
if (hr >= 22 || hr < 6) return "active";
|
||||
return "scheduled";
|
||||
}
|
||||
if (hr < s) return "scheduled";
|
||||
if (hr >= e) return "past";
|
||||
return "active";
|
||||
}
|
||||
const refNow = new Date();
|
||||
const schedule: any[] = [];
|
||||
for (let off = -3; off <= 3; off++) {
|
||||
const d = new Date(refNow.getTime() + off * 86400e3);
|
||||
const isWknd = (d.getDay()===0 || d.getDay()===6);
|
||||
const dateStr = d.toISOString().slice(0,10);
|
||||
for (const sh of shifts) {
|
||||
// Weekend permits use 4th shift only; weekday work
|
||||
// uses its primary shift(s) and skips 4th.
|
||||
if (isWknd && sh !== "4th") continue;
|
||||
if (!isWknd && sh === "4th") continue;
|
||||
// Workers per shift: full count on primary, half on
|
||||
// secondary so the bill demand differs visibly.
|
||||
const isPrimary = (sh === primary);
|
||||
const wForShift = isPrimary ? count : Math.max(1, Math.floor(count/2));
|
||||
schedule.push({
|
||||
date: dateStr,
|
||||
shift: sh,
|
||||
workers_needed: wForShift,
|
||||
bill_rate: contractBillRate,
|
||||
status: shiftStatus(d, sh, refNow),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
contracts.push({
|
||||
permit: {
|
||||
@@ -2002,6 +2190,7 @@ async function main() {
|
||||
over_bill_pool_margin_at_risk: Math.round(overBillPoolMargin),
|
||||
},
|
||||
shifts_needed: shifts,
|
||||
schedule,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Server-side mirror of search.html's ROLE_BANDS regex table.
|
||||
// Each band carries a *visual scene* — clothing + immediate backdrop —
|
||||
// so ComfyUI produces role-coherent headshots instead of interchangeable
|
||||
// studio portraits. The front-end sends the raw role string in the
|
||||
// query (?role=Forklift%20Operator); the server resolves it to a band
|
||||
// and looks up the scene here.
|
||||
|
||||
export type RoleBand =
|
||||
| "warehouse"
|
||||
| "production"
|
||||
| "trades"
|
||||
| "driver"
|
||||
| "lead";
|
||||
|
||||
export interface SceneDef {
|
||||
band: RoleBand;
|
||||
// Free-form clause inserted into the diffusion prompt AFTER
|
||||
// "[age]-year-old [race] [gender] [role], ". Should describe what
|
||||
// they're wearing and what is immediately behind them. Keep under
|
||||
// ~25 words — SDXL Turbo loses focus on longer prompts and starts
|
||||
// hallucinating cartoon hands.
|
||||
scene: string;
|
||||
}
|
||||
|
||||
const RE_BANDS: { re: RegExp; band: RoleBand }[] = [
|
||||
{ re: /forklift|warehouse|associate|material\s*handler|loader|loading|packag|shipping|logistics|inventory|sanitation|janit/i, band: "warehouse" },
|
||||
{ re: /production|assembl|quality/i, band: "production" },
|
||||
{ re: /welder|weld|electric|maint(enance)?\s*tech|cnc|machine\s*op|hvac|plumb|carpenter|mason|tool\s*&\s*die/i, band: "trades" },
|
||||
{ re: /driver|truck|haul|cdl/i, band: "driver" },
|
||||
{ re: /line\s*lead|supervisor|foreman|coordinator|lead\b/i, band: "lead" },
|
||||
];
|
||||
|
||||
export function roleBand(role: string): RoleBand {
|
||||
const r = (role || "").trim();
|
||||
if (!r) return "warehouse";
|
||||
for (const b of RE_BANDS) if (b.re.test(r)) return b.band;
|
||||
return "warehouse";
|
||||
}
|
||||
|
||||
// TODO J — refine these. Each `scene` string lands directly in the
|
||||
// diffusion prompt. Tone target: a coordinator glances at the card
|
||||
// and recognizes the role from the photo before reading the role pill.
|
||||
//
|
||||
// Things that work well in SDXL Turbo at 8 steps:
|
||||
// - One concrete clothing item ("high-visibility yellow vest")
|
||||
// - One concrete prop ("hard hat hanging from belt", "tablet in hand")
|
||||
// - One blurred background element ("warehouse pallet aisle behind",
|
||||
// "factory machinery softly out of focus")
|
||||
// - Avoid: text/logos (rendered as scribble), specific brands, hands
|
||||
// holding tools (often distorts), full-body language ("standing",
|
||||
// "leaning") — model is trained on portrait crops.
|
||||
//
|
||||
// Each scene now bakes "monochrome black and white photography" into
|
||||
// the prompt so the model produces native B&W output rather than us
|
||||
// applying CSS grayscale post-hoc. SDXL Turbo handles B&W natively
|
||||
// with strong tonal range — better than desaturating a color render.
|
||||
export const SCENES: Record<RoleBand, SceneDef> = {
|
||||
warehouse: {
|
||||
band: "warehouse",
|
||||
scene: "wearing a high-visibility safety vest over a t-shirt, hard hat visible, blurred warehouse pallet aisle behind, soft natural light, monochrome black and white photography, fine film grain, documentary portrait style",
|
||||
},
|
||||
production: {
|
||||
band: "production",
|
||||
scene: "wearing a work shirt with safety glasses on forehead, blurred factory machinery softly out of focus behind, fluorescent overhead lighting, monochrome black and white photography, fine film grain, documentary portrait style",
|
||||
},
|
||||
trades: {
|
||||
band: "trades",
|
||||
scene: "wearing a heavy-duty work shirt with rolled sleeves, blurred workshop tool wall behind, focused tungsten lighting, monochrome black and white photography, fine film grain, documentary portrait style",
|
||||
},
|
||||
driver: {
|
||||
band: "driver",
|
||||
scene: "wearing a polo shirt, lanyard with ID badge visible, blurred truck cab or loading dock behind, daylight, monochrome black and white photography, fine film grain, documentary portrait style",
|
||||
},
|
||||
lead: {
|
||||
band: "lead",
|
||||
scene: "wearing a button-down shirt, tablet held casually at chest level, blurred warehouse floor in soft focus behind, professional lighting, monochrome black and white photography, fine film grain, documentary portrait style",
|
||||
},
|
||||
};
|
||||
|
||||
// v2 — baked B&W + 1024×1024 render canvas (4× pixels of v1). Larger
|
||||
// source means downsampling to a 40px avatar packs more detail per
|
||||
// displayed pixel, hiding the diffusion-y micro-textures that read as
|
||||
// "AI generated" at small sizes. Server route reads pool from
|
||||
// data/headshots_role_pool/{SCENES_VERSION}/... so v1 stays available
|
||||
// for rollback / A-B comparison.
|
||||
export const SCENES_VERSION = "v2";
|
||||
|
||||
// Default render dimensions used by both the on-demand /headshots/
|
||||
// generate/:key route and the offline render_role_pool.py script. v1
|
||||
// used 512²; v2 doubles to 1024² (linear 2× = 4× pixels = ~3× GPU
|
||||
// time on SDXL Turbo).
|
||||
export const FACE_RENDER_DIM = 1024;
|
||||
Reference in New Issue
Block a user