← 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 };