← back to Ventura Corridor

src/enrich/score_headlines_heur.ts

125 lines

/**
 * Headline voice classifier — pure heuristic, no LLM.
 *
 * Rough but fast. Buckets:
 *   1 TEMPLATE     — boilerplate / default-CMS / just-company-name
 *   2 FUNCTIONAL   — descriptive but flat
 *   3 SHARP        — specific claim, well-edited
 *   4 EXCEPTIONAL  — distinctive voice, rhetorical flourish
 *
 * Run instantly across 472 rows with zero inference cost.
 */
import { query, pool } from '../../db/pool.ts';

const VOICE_LABELS = ['', 'TEMPLATE', 'FUNCTIONAL', 'SHARP', 'EXCEPTIONAL'];

const TEMPLATE_PATTERNS = [
  /^welcome\s+to\b/i,
  /^home\s*$/i,
  /^home\s*page$/i,
  /^untitled/i,
  /^index\s*(of|page)?/i,
  /\|\s*default\s+page$/i,
  /lorem ipsum/i,
  // Generic JS-error / SPA loading H1s
  /there is some problem/i,
  /^(oops|error|something went wrong|page not found|404)\b/i,
  /^loading\b|^please wait/i,
  /^enable javascript|enable cookies/i,
  /^just a moment|^one moment/i,
  // Corporate-chain locator pages
  /^find (a|an|the) .{1,30} (near|in) /i,
  /^locations? near you$/i,
  /^locate (a|an|the) /i,
];

const FUNCTIONAL_HINTS = [
  /^(we\s+(are|provide|offer|specialize))/i,
  /\b(serving|servicing|located in|conveniently located)\b/i,
  /\b(your local|your trusted|your one-stop)\b/i,
  /\b(quality|professional|expert|reliable|premier|leading|best)\b/i,
];

const EXCEPTIONAL_HINTS = [
  /[—–]/,        // em / en dash
  /\.\.\./,      // ellipsis
  /["']/,        // quotation marks
  /[?!](?!\s*$)/,// internal question/exclam, not just terminal
  /\b(don't|won't|can't|isn't|never|always|nothing|everything)\b/i,
];

function normalize(s: string): string {
  return s.toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
}

function classify(headline: string, companyName: string): number {
  const h = headline.trim();
  const wordCount = h.split(/\s+/).length;
  const nh = normalize(h);
  const nn = normalize(companyName);

  // 1. TEMPLATE
  for (const re of TEMPLATE_PATTERNS) if (re.test(h)) return 1;
  // Just-company-name (or very close to it)
  if (nh === nn) return 1;
  if (nh.length > 0 && nn.length > 0 && (nh.includes(nn) || nn.includes(nh)) && Math.abs(nh.length - nn.length) < 8) return 1;
  // Address-listing template: "[Brand] [number] [Street] in [City]"  — Domino's, Starbucks, etc.
  if (/\b\d{2,5}\s+[A-Za-z]+\s+(blvd|ave|st|rd|way|drive|dr|street|avenue|boulevard)\b/i.test(h)) return 1;
  if (/^\S.{0,40}\s+\d+\s+\S+\s+(blvd|ave|st|rd)\s+in\s+/i.test(h)) return 1;
  // Headline starts with the company name AND is short — basically a label
  if (nn.length > 3 && nh.startsWith(nn) && wordCount <= 6) return 1;
  // Single short word that looks like a default
  if (wordCount === 1 && nh.length < 25) return 1;

  // 4. EXCEPTIONAL — has rhetorical signal AND reasonable length
  let exceptionalScore = 0;
  for (const re of EXCEPTIONAL_HINTS) if (re.test(h)) exceptionalScore++;
  if (exceptionalScore >= 2 && wordCount >= 4 && wordCount <= 18) return 4;
  if (exceptionalScore >= 1 && wordCount >= 5 && wordCount <= 12 && /[.!?]$/.test(h.trim()) === false) {
    // tighter check — has voice + concise
    if (!/(welcome|home|index|page)/i.test(h)) return 4;
  }

  // 2. FUNCTIONAL — has functional hints
  for (const re of FUNCTIONAL_HINTS) if (re.test(h)) return 2;

  // 3. SHARP — 4-15 words, no boilerplate, has a noun-claim
  if (wordCount >= 4 && wordCount <= 15) {
    // bonus: contains a number or specific claim
    if (/\b(\d+|first|only|exclusive|unique|fresh|same-day|signature)\b/i.test(h)) return 3;
    if (wordCount >= 5 && wordCount <= 10) return 3;
  }

  // Default to FUNCTIONAL when nothing else fires
  return 2;
}

async function main() {
  const r = await query<{ business_id: number; headline: string; name: string }>(`
    SELECT e.business_id, e.headline, b.name
    FROM business_enrichment e
    JOIN businesses b ON b.id = e.business_id
    WHERE e.headline IS NOT NULL AND length(e.headline) >= 4
    ORDER BY e.business_id
  `);
  console.log(`[hvh] classifying ${r.rowCount} headlines · heuristic`);

  const counts = [0, 0, 0, 0, 0];
  for (const row of r.rows) {
    const v = classify(row.headline, row.name);
    counts[v]++;
    await query(
      `UPDATE business_enrichment SET headline_voice=$1, headline_voice_label=$2, headline_voice_at=now() WHERE business_id=$3`,
      [v, VOICE_LABELS[v], row.business_id]
    );
  }
  console.log(`[hvh] done`);
  console.log(`  TEMPLATE     : ${counts[1]}`);
  console.log(`  FUNCTIONAL   : ${counts[2]}`);
  console.log(`  SHARP        : ${counts[3]}`);
  console.log(`  EXCEPTIONAL  : ${counts[4]}`);
  await pool.end();
}

main().catch((e) => { console.error('[hvh]', e); process.exit(1); });