← back to Ventura Corridor

src/enrich/score_headlines.ts

111 lines

/**
 * Headline voice classifier — runs against ms1 Ollama (qwen3:14b).
 *
 * For each business with an extracted headline, asks the local LLM to
 * classify the voice into one of four buckets:
 *
 *   1 TEMPLATE     — default WordPress / generic ("Welcome to X", "Home")
 *   2 FUNCTIONAL   — descriptive but flat (just lists what they do)
 *   3 SHARP        — clear and specific, well-edited
 *   4 EXCEPTIONAL  — memorable, distinctive, voice-driven
 *
 * Single concise prompt per row; no streaming. 472 rows × ~1.5s = ~12 min.
 * Steve's preferred Ollama target: 192.168.1.133:11434 (Mac Studio 1).
 */
import { query, pool } from '../../db/pool.ts';

const OLLAMA = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434'; // Mac1 LAN 192.168.1.133 unreachable since ~2026-07 — local Ollama has qwen3:14b
const MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';

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

const SYSTEM = `You classify website primary headlines into one of four voice buckets.

1 TEMPLATE — default CMS or boilerplate. Examples: "Welcome to Acme", "Home", "Untitled", "Acme Inc.", just-the-company-name.
2 FUNCTIONAL — descriptive but flat. Just states what they do. Example: "Plumbing services in Encino".
3 SHARP — clear, specific, well-edited. Example: "Same-day implants in 60 minutes."
4 EXCEPTIONAL — memorable, distinctive, voice-driven. Example: "We don't do haircuts. We do disguises."

Respond with ONLY a single digit 1, 2, 3, or 4. No explanation. No /think tag.`;

interface Row {
  business_id: number;
  headline: string;
}

async function classify(headline: string): Promise<number | null> {
  const r = await fetch(`${OLLAMA}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: MODEL,
      messages: [
        { role: 'system', content: SYSTEM },
        { role: 'user', content: `Headline: ${JSON.stringify(headline)}\n\nClassify (1, 2, 3, or 4):` },
      ],
      stream: false,
      options: { temperature: 0, num_predict: 8 },
      think: false,
    }),
  });
  if (!r.ok) return null;
  const j: any = await r.json();
  const txt = (j.message?.content || '').trim();
  // Strip qwen3 <think>...</think> if present
  const cleaned = txt.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
  const m = cleaned.match(/[1-4]/);
  return m ? parseInt(m[0], 10) : null;
}

async function main() {
  const r = await query<Row>(`
    SELECT business_id, headline
    FROM business_enrichment
    WHERE headline IS NOT NULL
      AND length(headline) >= 4
      AND headline_voice IS NULL
    ORDER BY business_id
  `);
  console.log(`[hv] classifying ${r.rowCount} headlines via ${MODEL} on ${OLLAMA}…`);

  let counts = [0, 0, 0, 0, 0];
  let processed = 0, failed = 0;
  const t0 = Date.now();

  for (const row of r.rows) {
    let voice: number | null = null;
    for (let attempt = 0; attempt < 2; attempt++) {
      try {
        voice = await classify(row.headline);
        if (voice) break;
      } catch (e) {
        if (attempt === 1) console.error(`[hv]   attempt failed for #${row.business_id}: ${(e as Error).message}`);
      }
    }
    if (!voice) { failed++; continue; }
    counts[voice]++;
    processed++;
    await query(
      `UPDATE business_enrichment
         SET headline_voice = $1,
             headline_voice_label = $2,
             headline_voice_at = now()
       WHERE business_id = $3`,
      [voice, VOICE_LABELS[voice], row.business_id]
    );
    if (processed % 25 === 0) {
      const rate = processed / ((Date.now() - t0) / 1000);
      console.log(`  [${processed}/${r.rowCount}] ${rate.toFixed(2)}/s · 1=${counts[1]} 2=${counts[2]} 3=${counts[3]} 4=${counts[4]} · failed=${failed}`);
    }
  }

  console.log(`\n[hv] done · ${processed} processed · ${failed} failed`);
  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('[hv]', e); process.exit(1); });