← back to Professional Directory
scripts/generate-mockups-v5.js
718 lines
#!/usr/bin/env node
/**
* V5 mockup generator — validation-gated.
*
* Differences from v4:
* - Org-type classifier: dentist, primary_care, chiropractor, acupuncture,
* optometry, podiatry, physical_therapy, eldercare, salon, spa, gym,
* mental_health, vet, hospital, urgent_care, generic
* - Per-type prompt blocks: FAQs, footer disclaimer, team label, services
* anchor list, CTA verb. A hair salon never gets an "insurance" FAQ;
* an RCFE eldercare home gets family-focused FAQs; an L.Ac. gets
* "what to expect at your first visit" not "telehealth available".
* - Post-gen validator kills outputs with: "exceptional/premium/elevate/
* elevated/world-class/cutting-edge/state-of-the-art/leverage/empower/
* streamline" tells, via.placeholder.com URLs, "Team Member [N]" /
* "Doctor 2" / "Provider 3" / "Member [N]" placeholders, FontAwesome
* icon classes without FontAwesome loaded, "medical advice" disclaimer
* on a non-medical org, FAQ topics mismatched to org type.
* - Re-prompts up to 2x with explicit corrections; final fallback writes
* to <id>-<variant>-v5.tainted.html and emits a quality report row.
* - SVG initials-avatar helper replaces ANY remaining via.placeholder.com
* URLs with deterministic 2-letter monogram circles.
*
* Run:
* ORG_ID=22006 node scripts/generate-mockups-v5.js # one org × all 3 variants
* LIMIT=5 node scripts/generate-mockups-v5.js # top-5 orgs
* FORCE=1 ... # overwrite existing
* STRICT=1 ... # never write tainted
* MODEL_HOST=http://192.168.1.133:11434 ... # default MS1
*/
const fs = require('fs');
const path = require('path');
const crypto = require('node:crypto');
const { fetch } = require('undici');
const { pool, query } = require('../agents/shared/db');
const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : 5;
const ORG_ID = process.env.ORG_ID ? Number(process.env.ORG_ID) : null;
const VARIANTS = (process.env.VARIANTS || 'a,b,c').split(',');
const FORCE = process.env.FORCE === '1';
const STRICT = process.env.STRICT === '1';
// Concurrency note (2026-05-02): v5 was fully sequential. Per-variant wall
// clock ≈ 791s avg (qwen3:32b on MS1 with retries-to-attempt-2). Backlog of
// ~101 orgs × 3 variants ≈ 67h. Bumped default CONCURRENCY=2 (parallelize
// over orgs). The bottleneck is MS1 LLM inference, not Mac2 CPU — Mac2 just
// ships prompts over HTTP, so this doesn't compete with pd-* agents or the
// Site Factory / Visual Factory pipelines on Mac2. Going higher than 2 risks
// MS1 model swap-in/swap-out on qwen3:32b (~20GB Q4_K_M) since MS1 also
// serves the rest of the fleet. Override with CONCURRENCY=N env var.
const CONCURRENCY = Math.max(1, Number(process.env.CONCURRENCY || 2));
const OLLAMA = process.env.MODEL_HOST || 'http://192.168.1.133:11434';
const MODEL = process.env.LLM_MOCKUP_MODEL || 'qwen3:32b';
const FALLBACK_MODEL = process.env.LLM_MOCKUP_FALLBACK || 'qwen3:14b';
const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 720_000);
const MAX_RETRIES = Number(process.env.MAX_RETRIES || 2);
const TPL_DIR = path.resolve(__dirname, '../data/site-templates');
const OUT_DIR = path.resolve(__dirname, '../data/mockups');
const QA_PATH = path.resolve(__dirname, '../data/mockups/_v5_quality_report.jsonl');
fs.mkdirSync(OUT_DIR, { recursive: true });
const TEMPLATES = JSON.parse(fs.readFileSync(path.join(TPL_DIR, 'top10.json'), 'utf8'));
// ─── Aesthetic dimensions (carried from v4) ────────────────────────────────
const PALETTES = [
{ id: 'plaster-ink', bg:'#F2EDE3', ink:'#1A1714', accent:'#8A2A1F', accent2:'#C8954F', soul:'cream plaster ground, ink, oxblood, candle flame — bookish editorial' },
{ id: 'cream-moss-gold', bg:'#fbf7f0', ink:'#1c1f1c', accent:'#28412f', accent2:'#b48a3a', soul:'editorial cream paper, deep mossy green, restrained gold' },
{ id: 'noir-violet', bg:'#0a0a0c', ink:'#f4f1ea', accent:'#7c3aed', accent2:'#fbbf24', soul:'midnight noir, electric violet, single warm gold rim' },
{ id: 'rose-terracotta', bg:'#fff5ed', ink:'#291919', accent:'#c97e74', accent2:'#a08458', soul:'soft rose, dusty terracotta, hand-mixed boutique' },
{ id: 'midnight-mint', bg:'#0e1726', ink:'#e2e8f0', accent:'#10b981', accent2:'#34d399', soul:'midnight slate, signal mint, softly glowing' },
{ id: 'paper-cobalt', bg:'#f5f0e8', ink:'#1f2937', accent:'#0369a1', accent2:'#7dd3fc', soul:'aged paper, library cobalt ink, archival' },
{ id: 'amber-night', bg:'#1a0f0a', ink:'#fde68a', accent:'#f59e0b', accent2:'#dc2626', soul:'cinnamon amber, candle warm, deep red glow' },
{ id: 'aqua-cool', bg:'#ecfeff', ink:'#164e63', accent:'#0891b2', accent2:'#22d3ee', soul:'sea-glass aqua, calm cool clinical' },
];
const TYPE_PAIRS = [
{ id: 'serif-sans-classic', head: 'Cormorant Garamond', body: 'Inter', ornament: 'old-style figures + small caps' },
{ id: 'magazine-modern', head: 'Playfair Display', body: 'Inter', ornament: 'editorial sub-heads in italic' },
{ id: 'editorial-fresh', head: 'Fraunces', body: 'DM Sans', ornament: 'soft optical sizes, tracked-out caps' },
{ id: 'literary-clean', head: 'Lora', body: 'Manrope', ornament: 'wide-tracked all-caps eyebrows' },
{ id: 'considered-tech', head: 'Spectral', body: 'Plus Jakarta Sans', ornament: 'monospaced metadata footnotes' },
];
const VOICES = [
{ id: 'warm-trustworthy', description: 'Calm. Family-doctor warmth. Speaks plainly. "we listen first", "your time matters", "for the long haul".' },
{ id: 'clinical-precise', description: 'Confident, evidence-forward. No fluff. "evidence-based", "outcomes you can verify", "transparent pricing".' },
{ id: 'boutique-confident', description: 'Quiet luxury. Hand-picked, considered. "small enough to know your name", "carefully curated", "by appointment".' },
{ id: 'neighborhood-friendly', description: 'LA-local, casual but professional. "your block", "we live here too", "drop in any morning".' },
];
// ─── Org-type classifier ──────────────────────────────────────────────────
function classifyOrgType(o) {
const n = String(o.name || '').toLowerCase();
const t = String(o.type || '').toLowerCase();
// Eldercare comes first — very narrow keywords.
if (/\b(rcfe|arf|hco|assisted living|memory care|skilled nursing|nursing home|board.{1,4}care|hospice|home health)\b/.test(t + ' ' + n)) return 'eldercare';
// Beauty / non-medical
if (/\b(salon|hair|barber|nail|lash|brow|wax|spa|aesthetic|beauty|massage|sauna|float|color bar)\b/.test(n)) {
if (/\b(med ?spa|medspa|medical spa|botox|filler|laser|coolsculpt|injection)\b/.test(n)) return 'spa_aesthetics';
if (/\b(salon|hair|barber|nail|lash|brow|wax|color bar)\b/.test(n)) return 'salon';
return 'spa_aesthetics';
}
if (/\b(gym|fitness|crossfit|pilates|yoga|barre|cycling|cycle bar|spin|strength|f45)\b/.test(n)) return 'gym_wellness';
// Specific medical
if (/\b(dental|dentist|dds|orthodont|endodont|periodont|prosthodont|oral surg)\b/.test(t + ' ' + n)) return 'dentist';
if (/\b(chiropract|chiro\b|dc\s)/i.test(t + ' ' + n)) return 'chiropractor';
if (/\b(acupunct|herb|tcm|traditional chinese|oriental medicine|l\.?ac\.?\b)\b/.test(t + ' ' + n)) return 'acupuncture';
if (/\b(optom|optical|eye care|vision center|ophthalm)\b/.test(t + ' ' + n)) return 'optometry';
if (/\b(podiat|foot.{0,5}clinic|dpm)\b/.test(t + ' ' + n)) return 'podiatry';
if (/\b(physical therapy|pt clinic|sports med|rehab|orthoped)\b/.test(t + ' ' + n)) return 'physical_therapy';
if (/\b(mental health|psychiatr|psycholog|therapy|counsel|behavioral)\b/.test(t + ' ' + n)) return 'mental_health';
if (/\b(veterin|animal hosp|pet clinic|dvm)\b/.test(t + ' ' + n)) return 'vet';
if (/\b(urgent care|walk.?in clinic|express care)\b/.test(t + ' ' + n)) return 'urgent_care';
if (/\b(hospital|medical center)\b/.test(t + ' ' + n) && !/clinic\b/.test(n)) return 'hospital';
// MD / family / specialty fallback
if (/\b(md|m\.d\.|physician|primary care|family medicine|internal medicine|pediatric|cardiolog|dermatolog|gastro|endocrin|neuro|onc|gyne|obgyn|ob.?gyn)\b/.test(t + ' ' + n)) {
if (/\b(primary care|family|internal|pediatric)\b/.test(t + ' ' + n)) return 'primary_care';
return 'specialty_md';
}
return 'generic';
}
// ─── Per-type prompt blocks ──────────────────────────────────────────────
const ORG_TYPE_BLOCKS = {
dentist: {
team_label: 'Dentists & Hygienists',
services_anchor: ['Cleanings & exams', 'Cosmetic dentistry', 'Crowns & bridges', 'Implants', 'Orthodontics', 'Emergency visits'],
faqs: [
'Do you take my insurance? — list 2-3 plausible PPO networks (Delta Dental PPO, Cigna, Aetna), explain you\'re out-of-network for HMOs but submit claims',
'How long is a first visit? — exam + x-rays + cleaning, plan-of-care discussion',
'Do you offer sedation? — nitrous, oral sedation, refer for IV',
'Do you see kids? — youngest age you see, co-treat with parent visit',
],
footer_disclaimer: 'Information herein is general and not a substitute for an in-person dental exam.',
cta_verb: 'Book an exam',
avoid: 'NEVER mention "telehealth" — dentistry is in-person',
},
primary_care: {
team_label: 'Physicians & Care Team',
services_anchor: ['Annual physicals', 'Acute visits', 'Preventive care', 'Chronic disease management', 'Vaccinations', 'Lab draws on-site'],
faqs: [
'Do you take insurance? — list typical PPO acceptance, note out-of-network billing if applicable',
'Same-day appointments? — yes/no policy, after-hours nurse line if any',
'Telehealth available? — for follow-ups and refills, in-person for new visits',
'How do I get records or refills? — patient portal walkthrough',
],
footer_disclaimer: 'Information herein is general and not medical advice. For urgent symptoms, call 911 or visit the emergency room.',
cta_verb: 'Become a patient',
},
specialty_md: {
team_label: 'Specialists',
services_anchor: ['Consultations', 'Diagnostics', 'In-office procedures', 'Surgical referrals', 'Follow-up care', 'Second opinions'],
faqs: [
'Do I need a referral? — referral policy by insurance type',
'How quickly can I be seen? — typical first-visit lead time',
'Where do you operate? — affiliated hospital(s)',
'Do you accept my insurance? — typical PPOs accepted',
],
footer_disclaimer: 'Information herein is general and not medical advice. Always discuss treatment with a licensed clinician.',
cta_verb: 'Schedule a consult',
},
chiropractor: {
team_label: 'Doctor of Chiropractic',
services_anchor: ['Spinal adjustment', 'Soft tissue work', 'Decompression', 'Sports injury', 'Auto-accident care', 'Posture & ergonomics'],
faqs: [
'How long is a first visit? — exam, films if needed, first treatment ~60 min',
'Do you take auto-injury or workers comp? — yes/no, lien arrangements',
'How many visits will I need? — depends on diagnosis, transparent care plan',
'Do you take insurance? — typical PPO networks, cash-pay rate',
],
footer_disclaimer: 'Information herein is general and does not replace clinical evaluation.',
cta_verb: 'Book a first visit',
},
acupuncture: {
team_label: 'Practitioner',
services_anchor: ['Acupuncture', 'Custom herbal formulas', 'Cupping & gua sha', 'Tuina (medical massage)', 'Moxibustion', 'Dietary therapy'],
faqs: [
'What happens at a first visit? — 90-minute intake, tongue + pulse diagnosis, first treatment, custom formula',
'Do the needles hurt? — single-use, hair-fine, most patients sleep through treatment',
'How many sessions to feel better? — depends on chief complaint, transparent typical course',
'Insurance? — out-of-network is most common, superbills issued same day for PPO submission',
],
footer_disclaimer: 'Information herein is general; nothing on this page replaces a clinical consultation.',
cta_verb: 'Book a first visit',
},
optometry: {
team_label: 'Optometrists',
services_anchor: ['Comprehensive eye exam', 'Contact lens fitting', 'Dry eye care', 'Pediatric vision', 'Frames & lenses', 'Same-day glasses'],
faqs: [
'Insurance? — VSP, EyeMed, Davis, out-of-network billing',
'How often should I have my eyes checked? — annual for most adults, annual for kids in school',
'Same-day glasses? — single-vision, in-stock frames typically same day',
'Do you fit hard-to-fit contact lenses? — keratoconus / scleral capability',
],
footer_disclaimer: 'Information herein is general and not medical advice.',
cta_verb: 'Book an exam',
},
podiatry: {
team_label: 'Podiatrist',
services_anchor: ['Diabetic foot care', 'Bunion & hammertoe', 'Custom orthotics', 'Sports injury', 'Heel & arch pain', 'Nail care'],
faqs: [
'Insurance? — Medicare-accepting and PPO networks',
'Do I need a referral? — by insurance plan',
'How are orthotics made? — scan, fabrication time, fit follow-up',
'Same-day appointments? — for acute pain, yes',
],
footer_disclaimer: 'Information herein is general and not medical advice.',
cta_verb: 'Schedule a visit',
},
physical_therapy: {
team_label: 'Physical Therapists',
services_anchor: ['Post-surgical rehab', 'Sports medicine', 'Manual therapy', 'Dry needling', 'Pelvic floor', 'Telehealth follow-ups'],
faqs: [
'Insurance? — typical PPO networks, copay, visits per year',
'Do I need a referral? — direct access in CA, but some insurers require',
'How long is a session? — first 60 min, follow-ups 45 min',
'What should I wear? — comfortable clothing, athletic shoes',
],
footer_disclaimer: 'Information herein is general; specific treatment requires evaluation.',
cta_verb: 'Book a first visit',
},
mental_health: {
team_label: 'Therapists & Psychiatrists',
services_anchor: ['Individual therapy', 'Couples therapy', 'Medication management', 'Telehealth sessions', 'Group therapy', 'Crisis support'],
faqs: [
'Insurance? — list PPOs, sliding scale availability, superbills for out-of-network',
'In person or virtual? — both, after first session',
'Confidentiality? — HIPAA, exceptions for safety',
'How long is a session? — 50-min therapy hour, 30-min med-management',
],
footer_disclaimer: 'If you are in crisis, call or text 988. Information herein is not a substitute for clinical care.',
cta_verb: 'Book a consultation',
},
vet: {
team_label: 'Veterinarians',
services_anchor: ['Wellness exams', 'Vaccinations', 'Dental cleanings', 'Surgery', 'Diagnostics', 'Senior pet care'],
faqs: [
'New-patient process? — bring records, first visit length',
'Pet insurance? — Trupanion, Healthy Paws, claim help',
'Emergency hours? — daytime and after-hours referral',
'Boarding? — yes/no policy',
],
footer_disclaimer: 'For after-hours emergencies, call our partner emergency hospital.',
cta_verb: 'Book a visit',
},
urgent_care: {
team_label: 'Clinicians',
services_anchor: ['Walk-in care', 'Sports injuries', 'Lacerations & stitches', 'X-ray & labs on-site', 'Occupational health', 'Travel medicine'],
faqs: [
'Walk-in or appointment? — both supported, average wait time',
'Insurance? — most PPO networks, self-pay rate',
'When should I go to the ER instead? — chest pain, stroke signs, major trauma',
'Do you do COVID/strep/flu testing? — yes, results timing',
],
footer_disclaimer: 'For life-threatening emergencies call 911 or go to the nearest emergency room.',
cta_verb: 'Check in online',
},
hospital: {
team_label: 'Care Team',
services_anchor: ['Emergency department', 'Inpatient care', 'Surgical services', 'Imaging', 'Specialty clinics', 'Patient & visitor info'],
faqs: [
'How do I get to the ER? — directions, parking',
'Visitor hours? — current policy',
'Records request? — release of information process',
'Billing & financial assistance? — link to financial counselor',
],
footer_disclaimer: 'For life-threatening emergencies dial 911. Information herein is general.',
cta_verb: 'Find a doctor',
},
eldercare: {
team_label: 'Care Team',
services_anchor: ['Activities of daily living', 'Memory care', 'Medication management', 'Meals & dining', 'Family communication', 'Tours by appointment'],
faqs: [
'How do I tour? — by appointment, virtual option',
'License? — RCFE / ARF / HCO number prominently displayed',
'What is included in monthly fee? — base care, levels of additional care',
'Family communication? — care conferences, app, daily notes',
],
footer_disclaimer: 'Licensed by California Department of Social Services Community Care Licensing Division. Information herein is general.',
cta_verb: 'Schedule a tour',
},
salon: {
team_label: 'Stylists',
services_anchor: ['Cut', 'Color', 'Highlights & balayage', 'Treatments', 'Blowouts', 'Bridal & special event'],
faqs: [
'How do I book? — online portal, walk-ins by chair availability',
'Pricing? — starting prices and consultation policy for color',
'Cancellation? — late/no-show policy',
'Products? — line carried in salon, take-home styling',
],
footer_disclaimer: '© current year. All services by appointment.',
cta_verb: 'Book a stylist',
avoid: 'NEVER include "telehealth", "insurance accepted", "medical advice", or any clinical disclaimer language. This is a beauty business, not a clinic.',
},
spa_aesthetics: {
team_label: 'Practitioners',
services_anchor: ['Facials', 'Massage', 'Body treatments', 'Skin consultations', 'Membership', 'Gift cards'],
faqs: [
'How do I book? — online, gift card use',
'Cancellation? — 24-hour policy',
'Are treatments done by licensed estheticians? — yes, list licenses',
'Memberships? — monthly perks',
],
footer_disclaimer: 'Services performed by licensed estheticians. Not a medical treatment.',
cta_verb: 'Book a treatment',
avoid: 'NEVER include "telehealth" or "insurance accepted" — this is a beauty/wellness service.',
},
gym_wellness: {
team_label: 'Coaches',
services_anchor: ['Group classes', 'Personal training', 'Open gym', 'Drop-in policy', 'Memberships', 'Intro week'],
faqs: [
'How do I sign up? — drop-in, intro week, membership tiers',
'What should I bring? — water, towel, indoor shoes',
'Cancellation? — class booking and membership freeze policy',
'Beginner-friendly? — onboarding session policy',
],
footer_disclaimer: 'Consult a physician before beginning any new exercise program.',
cta_verb: 'Try an intro week',
avoid: 'NEVER include "telehealth" or "insurance accepted" — this is fitness, not healthcare.',
},
generic: {
team_label: 'The Team',
services_anchor: ['What we offer', 'Process', 'Results', 'Pricing', 'Locations', 'Contact'],
faqs: [
'How do I get started? — first-step process',
'Pricing? — transparent rate structure',
'Hours & location? — links to map and contact',
'Cancellation policy? — fair and clearly stated',
],
footer_disclaimer: '© current year.',
cta_verb: 'Get in touch',
},
};
// ─── Variant philosophy (carried from v4, edited) ─────────────────────────
const VARIANT_PHILOSOPHY = {
a: {
label: 'Architectural Editorial',
pov: `An award-winning architecture monograph, but for this practice. Restrained.
Deep negative space, asymmetric two-column grids that feel hand-set. The hero is
large quiet typography over a single duotone gradient (no photo). Section headers
are small caps with a 1px hairline rule beneath. First paragraph of each section
gets a drop cap. Numbers are old-style. CTAs are quiet text-with-underline that
grows on hover.`,
structural_constraints: [
'Hero: split 60/40 grid — left = headline (clamp 56-92px) + 1-line subhead + tiny pair of buttons; right = a single duotone gradient block with 1px hairline border, NO photo placeholder labels',
'Section titles in small-caps tracking 0.18em + 1px rule beneath',
'First paragraph of each section: drop cap, 4-line span, in headline serif',
'Asymmetric services grid: 2-1-2 pattern (NOT uniform 3-col)',
'Footnote-style metadata under hero: est. year · neighborhood · practitioner credential',
'Numbers throughout use old-style figures (font-feature-settings: "onum")',
],
},
b: {
label: 'Glass Modernist',
pov: `A 2025 product launch page — dark surface, single vibrant accent, geometric
clarity. Glassmorphism on header (backdrop-filter: blur 12px). Hero has a subtle
radial-gradient glow. Cards have a 1px rgba(255,255,255,0.08) border and lift 2px
on hover. ONE accent color used sparingly. Typography is geometric sans, tight
(-0.02em) at large sizes. Trust band: 4 metrics in horizontal row.`,
structural_constraints: [
'Hero: full-width with radial-gradient glow at 30% 0% (palette accent at 12% opacity)',
'h1: 64-80px, weight 600, line-height 1.05, letter-spacing -0.02em',
'Glass header: backdrop-filter: blur(12px), background rgba(palette-bg, 0.7), 1px bottom border at rgba(255,255,255,0.08)',
'CTA: filled accent button + ghost button with 1px border',
'Services 4-col grid; cards rounded-12, 1px border at rgba(255,255,255,0.08), hover translateY(-2px)',
'Process: 3 horizontal steps with thin connector lines, numbered 01/02/03 in accent',
],
},
c: {
label: 'Boutique Hand-set',
pov: `A small-batch coffee roaster site, but for this practice. Warm,
photo-forward, hand-curated. Hero is photo-dominant (large gradient DIV simulating
a candid practice photo). Headline overlays the photo with a script-display word
followed by a display-serif phrase. Asymmetric grid throughout. Hand-drawn SVG
dividers (simple ink stroke paths) between sections. Footer is a personal sign-off.`,
structural_constraints: [
'Hero: photo-led full-bleed gradient 16:10 with inner shadow, headline overlays at bottom-left with: <span class="script">Welcome to</span> <h1>...</h1>',
'Script font on first 2-3 words: Pinyon Script or Allura via Google Fonts',
'h1 uses display serif at 56-72px',
'Hand-drawn SVG dividers between sections (inline SVG, simple stroke path)',
'Asymmetric services grid: 1 large card on left + 2 stacked cards on right',
'Pull-quote block with 200px-tall quotation mark in palette accent2',
'Footer: handwritten-style sign-off line ("— the team") in script font',
],
},
};
const ANTI_TELLS = [
'NO em-dashes (use commas, periods, colons)',
'NO words: "elevate", "elevated", "premium-quality", "unparalleled", "world-class", "cutting-edge", "state-of-the-art", "leverage", "empower", "streamline"',
'NO "our team of dedicated professionals", "we are committed to providing", "passionate about"',
'NO emoji in body copy',
'NO Font Awesome icon classes (`<i class="fa-...">`) — use inline SVG icons only',
'NO via.placeholder.com URLs — use a CSS gradient or inline SVG',
'NO "Team Member 1/2/3" placeholders — if no real names, render a single solo-practitioner card',
'Trust band metrics MUST be specific and plausible (e.g. "7 years on this block", "2,400+ patients served", "94% would refer a friend"). NEVER round-numbers like "1000+" or "25 years".',
];
function pickCombo(orgId, variant) {
const variantOffset = variant.charCodeAt(0) - 97;
const seed = (Number(orgId) * 31 + variantOffset * 7919) >>> 0;
return {
template: TEMPLATES[seed % TEMPLATES.length],
palette: PALETTES[(seed >>> 3) % PALETTES.length],
typePair: TYPE_PAIRS[(seed >>> 7) % TYPE_PAIRS.length],
voice: VOICES[(seed >>> 11) % VOICES.length],
};
}
// ─── SVG initials avatars ────────────────────────────────────────────────
function svgInitialsAvatar(seed, palette) {
// Stable 2-letter initials per seed.
const h = crypto.createHash('sha256').update(String(seed)).digest();
const A = String.fromCharCode(65 + (h[0] % 26));
const B = String.fromCharCode(65 + (h[1] % 26));
const bg = palette.accent;
const fg = palette.bg;
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><circle cx='100' cy='100' r='100' fill='${bg}'/><text x='50%' y='54%' dominant-baseline='middle' text-anchor='middle' font-family='Georgia,serif' font-size='86' font-weight='400' fill='${fg}' letter-spacing='-2'>${A}${B}</text></svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
// ─── Validator ───────────────────────────────────────────────────────────
const BANNED_WORDS = [
/\bexceptional\b/i,
/\bpremium\b/i,
/\belevat(e|ed|ing)\b/i,
/\bworld[- ]class\b/i,
/\bcutting[- ]edge\b/i,
/\bstate[- ]of[- ]the[- ]art\b/i,
/\bleverage\b/i,
/\bempower\b/i,
/\bstreamline\b/i,
/\bunparalleled\b/i,
/\bpassionate about\b/i,
/our team of dedicated professionals/i,
/we are committed to providing/i,
];
function validateHtml(html, orgType, _o) {
const issues = [];
// Banned phrases
for (const r of BANNED_WORDS) {
const m = html.match(r);
if (m) issues.push(`banned phrase: "${m[0]}"`);
}
// Placeholder URLs
if (/via\.placeholder\.com|placeholder\.com\/\d|placehold\.co|placeimg\.com|loremflickr\.com/i.test(html)) issues.push('placeholder image URL');
// Empty src
if (/<img[^>]+src=["']\s*["']/i.test(html)) issues.push('empty image src');
// Team-member placeholders
if (/\b(Team Member|Doctor|Provider|Practitioner|Member|Stylist|Coach)\s+\d\b/i.test(html)) issues.push('"Team Member N" placeholder name');
if (/\b(Dr\.|MD|DDS|DC|LAc|L\.Ac\.)\s+(Lastname|Smith|Doe|Person)\b/i.test(html)) issues.push('placeholder doctor name');
// FontAwesome icons without FA loaded
const hasFaClasses = /<i\s+class=["'](fa[srlb]?|fab|fas|far|fal)\s+fa-/i.test(html);
const hasFaLoaded = /font.?awesome|use\.fontawesome|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome/i.test(html);
if (hasFaClasses && !hasFaLoaded) issues.push('Font Awesome classes used without FA stylesheet loaded');
// Org-type-specific checks
const nonMedical = ['salon', 'spa_aesthetics', 'gym_wellness'];
if (nonMedical.includes(orgType)) {
if (/\b(insurance|copay|telehealth|medical advice|HIPAA|prescription|refill)\b/i.test(html)) issues.push(`non-medical org (${orgType}) page mentions clinical/insurance topics`);
}
// Beauty/fitness shouldn't have clinical disclaimer
if ((orgType === 'salon' || orgType === 'gym_wellness') && /not (medical|clinical) advice|in[- ]person (clinical|medical) (exam|consultation)/i.test(html)) {
issues.push(`${orgType} should not carry medical-advice disclaimer`);
}
// Required tags
if (!/<title>[^<]+<\/title>/i.test(html)) issues.push('missing <title>');
if (!/<meta\s+name=["']description["']/i.test(html)) issues.push('missing meta description');
if (!/application\/ld\+json/i.test(html)) issues.push('missing JSON-LD');
return { ok: issues.length === 0, issues };
}
function fixupAvatars(html, orgId, palette) {
// Replace placeholder URLs with deterministic initial monograms.
return html
.replace(/https?:\/\/(via\.placeholder\.com|placehold\.co|placeimg\.com|loremflickr\.com)[^"' )]+/gi, (_m) => {
return svgInitialsAvatar(orgId + '-' + Math.random().toString(36).slice(2, 7), palette);
});
}
// ─── Chat widget (slim) ───────────────────────────────────────────────────
function chatWidget(orgId, palette) {
const acc = palette.accent;
return `<style>
#vw-chat-fab{position:fixed;right:24px;bottom:24px;z-index:9998;background:${acc};color:#fff;border:0;border-radius:999px;padding:14px 22px;font:600 13px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;box-shadow:0 8px 32px -12px rgba(0,0,0,.4)}
#vw-chat-panel{position:fixed;right:24px;bottom:84px;z-index:9999;width:360px;max-width:calc(100vw - 48px);height:520px;max-height:80vh;background:#fff;border:1px solid #e6dfd1;border-radius:14px;box-shadow:0 24px 60px -16px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;color:#1c1f1c}
#vw-chat-panel.open{display:flex}
#vw-chat-head{padding:14px 18px;background:${acc};color:#fff;display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px;letter-spacing:.04em;text-transform:uppercase}
#vw-chat-close{background:transparent;border:0;color:#fff;font-size:18px;cursor:pointer}
#vw-chat-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
#vw-chat-log .vw-msg{max-width:85%;padding:8px 12px;border-radius:12px;font-size:13px;line-height:1.45;white-space:pre-wrap}
#vw-chat-log .vw-bot{align-self:flex-start;background:#f4efe2;color:#1c1f1c}
#vw-chat-log .vw-me{align-self:flex-end;background:${acc};color:#fff}
#vw-chat-form{display:flex;gap:6px;padding:10px;border-top:1px solid #e6dfd1;background:#fbf7f0}
#vw-chat-input{flex:1;border:1px solid #e6dfd1;border-radius:8px;padding:9px 12px;font:13px ui-sans-serif,system-ui,sans-serif;outline:none}
#vw-chat-send{background:${acc};color:#fff;border:0;border-radius:8px;padding:9px 14px;font:600 12px ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
</style>
<button id="vw-chat-fab" onclick="document.getElementById('vw-chat-panel').classList.toggle('open')">Ask anything</button>
<div id="vw-chat-panel" role="dialog"><div id="vw-chat-head"><span>Ask the practice</span><button id="vw-chat-close" onclick="document.getElementById('vw-chat-panel').classList.remove('open')" aria-label="Close">×</button></div><div id="vw-chat-log"><div class="vw-msg vw-bot">Hi! Ask me about hours, services, or location.</div></div><form id="vw-chat-form" onsubmit="return vwChatSend(event)"><input id="vw-chat-input" type="text" placeholder="Type a question…" autocomplete="off"/><button id="vw-chat-send" type="submit">Send</button></form></div>
<script>(function(){const ORG_ID=${orgId};const API=(window.PD_API_BASE||'')+'/api/chat';const log=()=>document.getElementById('vw-chat-log');const history=[];function add(t,who){const d=document.createElement('div');d.className='vw-msg vw-'+who;d.textContent=t;log().appendChild(d);log().scrollTop=log().scrollHeight;return d}window.vwChatSend=async function(ev){ev.preventDefault();const i=document.getElementById('vw-chat-input');const m=i.value.trim();if(!m)return false;add(m,'me');i.value='';history.push({role:'user',content:m});const t=add('typing…','bot');t.style.opacity='.7';try{const r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({orgId:ORG_ID,message:m,history:history.slice(-6)})});const j=await r.json();t.style.opacity='1';t.textContent=j.reply||'(error: '+(j.error||'no response')+')';if(j.reply)history.push({role:'assistant',content:j.reply})}catch(e){t.style.opacity='1';t.textContent='(network error)'}return false};})();</script>`;
}
const SYSTEM_PROMPT = `You are an award-winning web designer and healthcare brand strategist. You output ONLY a complete <!doctype html> page — no markdown fences, no preamble, no commentary, no chain-of-thought. ALL CSS in ONE <style> tag in <head>. External assets only via Google Fonts <link>. Include thoughtful SEO meta + a JSON-LD <script type="application/ld+json"> block matching the practice type.
Your work is judged on:
1. Real editorial restraint — not LLM filler.
2. Concrete typographic detail — drop caps, small caps, optical sizes, asymmetric grids, ornaments.
3. Voice in copy — every word feels written by a human who knows this practice.
4. Trust-band metrics that are SPECIFIC and PLAUSIBLE.
5. Working semantic HTML5.`;
function buildPrompt(o, orgType, variant, combo, retryNote) {
const v = VARIANT_PHILOSOPHY[variant];
const block = ORG_TYPE_BLOCKS[orgType] || ORG_TYPE_BLOCKS.generic;
const tplBody = (() => {
try { return fs.readFileSync(path.join(TPL_DIR, combo.template.body_file), 'utf8').slice(0, 4000); }
catch { return ''; }
})();
const kindLabel = orgType.replace(/_/g, ' ');
return `Design a high-craft mobile-friendly landing page for: ${o.name} (a ${kindLabel} in ${o.city || 'Los Angeles'}).
Phone: ${o.phone || '(310) 555-0100'}
Address: ${o.address || `${o.city || 'Los Angeles'}, CA`}
VARIANT ${variant.toUpperCase()}: ${v.label}
PHILOSOPHY:
${v.pov}
STRUCTURAL CONSTRAINTS (must be visible in output):
${v.structural_constraints.map((c, i) => ` ${i + 1}. ${c}`).join('\n')}
ORG-TYPE BLOCK (${orgType}):
- Team label: ${block.team_label}
- Services anchor list: ${block.services_anchor.join(', ')}
- CTA verb: ${block.cta_verb}
- Footer disclaimer: "${block.footer_disclaimer}"
${block.avoid ? `- AVOID: ${block.avoid}` : ''}
FAQ — pick FOUR from this list, write each as a real Q&A in the practice voice:
${block.faqs.map((q, i) => ` ${i + 1}. ${q}`).join('\n')}
PALETTE (${combo.palette.id} — ${combo.palette.soul}):
- background: ${combo.palette.bg}
- ink: ${combo.palette.ink}
- accent: ${combo.palette.accent}
- accent2: ${combo.palette.accent2}
TYPOGRAPHY (${combo.typePair.id}):
- Headlines: '${combo.typePair.head}' (load via Google Fonts <link>)
- Body: '${combo.typePair.body}'
- Ornament: ${combo.typePair.ornament}
VOICE (${combo.voice.id}):
${combo.voice.description}
ANTI-TELLS — these are LLM giveaways. AVOID:
${ANTI_TELLS.map(t => '- ' + t).join('\n')}
STRUCTURAL TEMPLATE (top-rated ${combo.template.score}/100 site, for SECTION ORDERING + density inspiration ONLY — IGNORE its colors, fonts, copy):
Reference: ${combo.template.name} (${combo.template.site})
--- TEMPLATE EXCERPT ---
${tplBody}
--- END ---
REQUIRED SECTIONS in order:
1. Sticky header (logo wordmark + 3-4 nav links + accent phone CTA)
2. Hero (per variant philosophy)
3. Trust band (4 SPECIFIC metrics — see anti-tells)
4. Services / specialties (use the anchor list above; per variant grid pattern)
5. ${block.team_label} (1-3 cards. If no real names, render a single solo-practitioner card with a credentials line)
6. Visit / location (split: address + phone + hours / map placeholder)
7. FAQ (the 4 you picked above, in practice voice)
8. Footer (per variant) — include this exact disclaimer somewhere: "${block.footer_disclaimer}"
SEO:
- <title> "${o.name} — ${kindLabel} in ${o.city || 'LA'}, CA"
- <meta name="description"> 145-160 chars
- Open Graph + Twitter card tags
- JSON-LD with @type matching the org
Self-contained: ALL CSS in one <style> tag. Mobile breakpoint at 768px. Must validate as HTML5.
${retryNote ? `\n!! RETRY — previous attempt failed validation:\n${retryNote}\nFix every issue and regenerate. !!\n` : ''}
Output the complete page now, starting with <!doctype html>:`;
}
function stripFences(s) {
const m = s.match(/```(?:html|HTML)?\s*([\s\S]*?)\s*```/);
return (m ? m[1] : s).trim();
}
function injectChatWidget(html, orgId, palette) {
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, chatWidget(orgId, palette) + '</body>');
return html + chatWidget(orgId, palette);
}
async function callOllama(prompt, system, model) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
try {
const r = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({
model, system, prompt, stream: false,
options: { temperature: 0.62, top_p: 0.92, num_predict: 9000, num_ctx: 8192 },
}),
});
clearTimeout(timer);
if (!r.ok) { console.warn(`[v5] HTTP ${r.status} on ${model}`); return null; }
const j = await r.json();
return (j.response || '').trim();
} catch (e) {
clearTimeout(timer);
if (e.name === 'AbortError') console.warn(`[v5] timed out on ${model} after ${LLM_TIMEOUT_MS}ms`);
else console.warn(`[v5] err on ${model}: ${e.message}`);
return null;
}
}
async function generateOne(o, orgType, variant, combo) {
const t0 = Date.now();
let issues = null, lastHtml = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const retryNote = issues ? issues.map(s => ' - ' + s).join('\n') : '';
const prompt = buildPrompt(o, orgType, variant, combo, retryNote);
// Try primary model on first 2 attempts; fall back on last.
const model = attempt < 2 ? MODEL : FALLBACK_MODEL;
const raw = await callOllama(prompt, SYSTEM_PROMPT, model);
if (!raw) { issues = ['model returned empty']; continue; }
let html = stripFences(raw).replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
if (!/<!doctype|<html/i.test(html)) { issues = ['no <html> in output']; continue; }
html = fixupAvatars(html, o.id, combo.palette);
const v = validateHtml(html, orgType, o);
lastHtml = html;
if (v.ok) {
return { html: injectChatWidget(html, o.id, combo.palette), ms: Date.now() - t0, attempt, ok: true, issues: [] };
}
issues = v.issues;
console.warn(`[v5] attempt ${attempt + 1} failed: ${issues.join('; ')}`);
}
// All retries failed.
if (lastHtml) return { html: injectChatWidget(lastHtml, o.id, combo.palette), ms: Date.now() - t0, attempt: MAX_RETRIES + 1, ok: false, issues: issues || [] };
return null;
}
async function main() {
let rows;
if (ORG_ID) {
rows = (await query(`SELECT id, name, type, address, city, state, zip, phone FROM organizations WHERE id = $1`, [ORG_ID])).rows;
} else {
rows = (await query(`
SELECT id, name, type, address, city, state, zip, phone
FROM organizations
WHERE opted_out = false AND lat IS NOT NULL
AND claim_status = 'unclaimed'
AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)
ORDER BY lead_score DESC NULLS LAST, id
LIMIT $1`, [LIMIT])).rows;
}
console.log(`[v5] processing ${rows.length} orgs × ${VARIANTS.length} variants on ${OLLAMA} model=${MODEL} (fallback=${FALLBACK_MODEL}) concurrency=${CONCURRENCY}`);
let ok = 0, taint = 0, skip = 0, fail = 0;
async function processOrg(o) {
const orgType = classifyOrgType(o);
// Variants stay sequential within an org — keeps a single org's retries
// from saturating MS1, and matches existing log cadence per-org.
for (const v of VARIANTS) {
const out = path.join(OUT_DIR, `${o.id}-${v}-v5.html`);
const tainted = path.join(OUT_DIR, `${o.id}-${v}-v5.tainted.html`);
if ((fs.existsSync(out) || fs.existsSync(tainted)) && !FORCE) { skip++; continue; }
const combo = pickCombo(o.id, v);
const tag = `${combo.template.id}/${combo.palette.id}/${combo.typePair.id}/${combo.voice.id}`;
const result = await generateOne(o, orgType, v, combo);
const head = `[v5] ${String(o.id).padEnd(7)} ${o.name.slice(0, 28).padEnd(28)} ${orgType.padEnd(18)} ${v}=${tag.padEnd(60)}`;
if (!result) { console.log(`${head} FAIL`); fail++; continue; }
const target = result.ok ? out : (STRICT ? null : tainted);
if (target) {
fs.writeFileSync(target, result.html);
if (result.ok) { console.log(`${head} OK ${(result.ms / 1000).toFixed(1)}s ${(result.html.length / 1024).toFixed(1)}kb a${result.attempt}`); ok++; }
else { console.log(`${head} TAINTED ${(result.ms / 1000).toFixed(1)}s [${result.issues.length} issues]`); taint++; }
} else {
console.log(`${head} STRICT-DROP [${result.issues.length} issues]`); fail++;
}
// QA log row
fs.appendFileSync(QA_PATH, JSON.stringify({
ts: new Date().toISOString(), orgId: o.id, name: o.name, orgType, variant: v,
combo: tag, ms: result.ms, attempt: result.attempt, ok: result.ok, issues: result.issues,
}) + '\n');
}
}
// Simple worker-pool over orgs. Each worker pulls the next org from a shared
// index; CONCURRENCY workers run in parallel. Avoids p-limit dependency.
let cursor = 0;
async function worker() {
while (cursor < rows.length) {
const i = cursor++;
try { await processOrg(rows[i]); }
catch (e) { console.error(`[v5] org ${rows[i] && rows[i].id} threw:`, e.message); fail++; }
}
}
const workers = Array.from({ length: Math.min(CONCURRENCY, rows.length) }, () => worker());
await Promise.all(workers);
console.log(`[v5] done. ok=${ok} tainted=${taint} skip=${skip} fail=${fail}`);
await pool.end();
}
if (require.main === module) main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });
module.exports = { classifyOrgType, ORG_TYPE_BLOCKS, validateHtml, svgInitialsAvatar, fixupAvatars };