← back to Professional Directory

scripts/audit-mockups.js

165 lines

#!/usr/bin/env node
/**
 * audit-mockups.js — run the v5 validator over every existing mockup HTML
 * file in data/mockups/ and report tainted vs clean outputs.
 *
 * Read-only: writes a single _v5_audit_report.jsonl + prints a summary.
 * Does NOT delete or regenerate anything. Use the output to decide which
 * orgs need a v5 re-gen pass.
 */
const fs = require('fs');
const path = require('path');
const { pool, query } = require('../agents/shared/db');

const MOCKUP_DIR = path.join(__dirname, '..', 'data', 'mockups');
const REPORT_PATH = path.join(MOCKUP_DIR, '_v5_audit_report.jsonl');

// ── lifted from generate-mockups-v5.js ────────────────────────────────────
function classifyOrgType(o) {
  const n = String(o.name || '').toLowerCase();
  const t = String(o.type || '').toLowerCase();
  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';
  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';
  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';
  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';
}

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) {
  const issues = [];
  for (const r of BANNED_WORDS) {
    const m = html.match(r);
    if (m) issues.push(`banned phrase: "${m[0]}"`);
  }
  if (/via\.placeholder\.com|placeholder\.com\/\d|placehold\.co|placeimg\.com|loremflickr\.com/i.test(html)) issues.push('placeholder image URL');
  if (/<img[^>]+src=["']\s*["']/i.test(html)) issues.push('empty image src');
  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');
  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');
  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`);
  }
  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`);
  }
  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 };
}

// ── walk + report ─────────────────────────────────────────────────────────
async function main() {
  const files = fs.readdirSync(MOCKUP_DIR)
    .filter(f => f.endsWith('.html') && !f.startsWith('_'))
    .sort();
  console.log(`[audit] scanning ${files.length} mockup files`);

  // collect distinct org IDs
  const orgIds = [...new Set(files.map(f => f.split('-')[0]).filter(s => /^\d+$/.test(s)))];
  console.log(`[audit] looking up ${orgIds.length} distinct orgs from PG`);

  const r = await query(
    `SELECT id, name, type FROM organizations WHERE id = ANY($1::int[])`,
    [orgIds.map(Number)]
  );
  const orgById = new Map(r.rows.map(o => [String(o.id), o]));

  // Truncate previous report
  fs.writeFileSync(REPORT_PATH, '');

  const stats = {
    total: 0, ok: 0, tainted: 0, missing_org: 0,
    issue_counts: {},
    by_orgtype: {},
  };
  const taintedFiles = [];

  for (const f of files) {
    stats.total++;
    const orgId = f.split('-')[0];
    const org = orgById.get(orgId);
    if (!org) {
      stats.missing_org++;
      const rec = { ts: new Date().toISOString(), file: f, ok: false, issues: ['org not in DB'], orgType: null };
      fs.appendFileSync(REPORT_PATH, JSON.stringify(rec) + '\n');
      continue;
    }
    const orgType = classifyOrgType(org);
    const html = fs.readFileSync(path.join(MOCKUP_DIR, f), 'utf8');
    const v = validateHtml(html, orgType);
    stats.by_orgtype[orgType] = stats.by_orgtype[orgType] || { ok: 0, tainted: 0 };
    if (v.ok) {
      stats.ok++;
      stats.by_orgtype[orgType].ok++;
    } else {
      stats.tainted++;
      stats.by_orgtype[orgType].tainted++;
      taintedFiles.push({ file: f, orgId, orgType, issues: v.issues });
      for (const issue of v.issues) {
        const key = issue.replace(/"[^"]+"/g, '"…"').slice(0, 80);
        stats.issue_counts[key] = (stats.issue_counts[key] || 0) + 1;
      }
    }
    const rec = { ts: new Date().toISOString(), file: f, orgId, orgName: org.name, orgType, ok: v.ok, issues: v.issues };
    fs.appendFileSync(REPORT_PATH, JSON.stringify(rec) + '\n');
  }

  // Print summary
  console.log('\n=== AUDIT SUMMARY ===');
  console.log(`total files:    ${stats.total}`);
  console.log(`clean (ok):     ${stats.ok}  (${(stats.ok/stats.total*100).toFixed(1)}%)`);
  console.log(`tainted:        ${stats.tainted}  (${(stats.tainted/stats.total*100).toFixed(1)}%)`);
  console.log(`missing org:    ${stats.missing_org}`);
  console.log('\n--- by org type ---');
  for (const [t, v] of Object.entries(stats.by_orgtype).sort((a,b)=>(b[1].ok+b[1].tainted)-(a[1].ok+a[1].tainted))) {
    const tot = v.ok + v.tainted;
    console.log(`  ${t.padEnd(18)} ${String(v.ok).padStart(4)} ok / ${String(v.tainted).padStart(4)} tainted   (${tot} total, ${(v.tainted/tot*100).toFixed(0)}% bad)`);
  }
  console.log('\n--- top issues ---');
  const top = Object.entries(stats.issue_counts).sort((a,b)=>b[1]-a[1]).slice(0, 15);
  for (const [k, n] of top) console.log(`  ${String(n).padStart(4)}  ${k}`);

  // Top tainted orgs (need re-gen)
  const orgFails = {};
  for (const t of taintedFiles) orgFails[t.orgId] = (orgFails[t.orgId] || 0) + 1;
  const orgsToRegen = Object.entries(orgFails).filter(([_, n]) => n >= 2).sort((a,b)=>b[1]-a[1]);
  console.log(`\n--- orgs with 2+ tainted variants (regen candidates): ${orgsToRegen.length} ---`);
  for (const [oid, n] of orgsToRegen.slice(0, 20)) {
    const o = orgById.get(oid);
    console.log(`  ${oid}  ${n} bad   ${o ? o.name : '?'}`);
  }

  console.log(`\nfull report: ${REPORT_PATH}`);
  await pool.end();
}

main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });