← back to Ventura Corridor

src/enrich/score_headlines_batch.ts

93 lines

/**
 * Headline voice classifier — batch mode.
 * Sends all rows in chunks of 50 to ms1 Ollama (mistral:7b) for fast classification.
 * Expects a numbered list back like "1:2\n2:4\n..."
 */
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
const MODEL = process.env.OLLAMA_MODEL || 'mistral:7b';
const BATCH = 5;

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

const SYSTEM = `You classify website headlines into 4 voice buckets:
1 = TEMPLATE (default CMS / boilerplate / just-company-name / "Welcome to X" / "Home")
2 = FUNCTIONAL (descriptive but flat — just states what they do)
3 = SHARP (clear, specific, well-edited)
4 = EXCEPTIONAL (memorable, distinctive, voice-driven)

You will receive a numbered list of headlines. Reply with ONLY one line per item in the form "INDEX:NUMBER", nothing else, no explanation. Example output:
1:2
2:1
3:3
4:4`;

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

async function classifyBatch(rows: Row[]): Promise<Map<number, number>> {
  const list = rows.map((r, i) => `${i + 1}. ${r.headline.replace(/\n/g, ' ').slice(0, 200)}`).join('\n');
  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: list },
      ],
      stream: false,
      options: { temperature: 0, num_predict: 600 },
    }),
  });
  if (!r.ok) throw new Error(`ollama HTTP ${r.status}`);
  const j: any = await r.json();
  const txt = (j.message?.content || '').replace(/<think>[\s\S]*?<\/think>/g, '');
  const out = new Map<number, number>();
  for (const m of txt.matchAll(/^\s*(\d+)\s*:\s*([1-4])/gm)) {
    const idx = parseInt(m[1], 10) - 1;
    const v = parseInt(m[2], 10);
    if (idx >= 0 && idx < rows.length) out.set(rows[idx].business_id, v);
  }
  return out;
}

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
  `);
  const rows = r.rows;
  console.log(`[hvb] ${rows.length} headlines · ${MODEL} · batch=${BATCH}`);

  let done = 0, failed = 0;
  const counts = [0, 0, 0, 0, 0];
  const t0 = Date.now();
  for (let i = 0; i < rows.length; i += BATCH) {
    const slice = rows.slice(i, i + BATCH);
    let result: Map<number, number> | null = null;
    for (let attempt = 0; attempt < 3; attempt++) {
      try { result = await classifyBatch(slice); break; }
      catch (e) { if (attempt === 2) console.error(`[hvb] batch ${i}: ${(e as Error).message}`); }
    }
    if (!result) { failed += slice.length; continue; }
    for (const [bid, voice] of result.entries()) {
      counts[voice]++;
      done++;
      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], bid]
      );
    }
    failed += slice.length - result.size;
    const rate = done / ((Date.now() - t0) / 1000);
    console.log(`  [${done}/${rows.length}] ${rate.toFixed(2)}/s · 1=${counts[1]} 2=${counts[2]} 3=${counts[3]} 4=${counts[4]} · failed=${failed}`);
  }
  console.log(`\n[hvb] done · ${done} · failed ${failed}`);
  console.log(`  TEMPLATE=${counts[1]} FUNCTIONAL=${counts[2]} SHARP=${counts[3]} EXCEPTIONAL=${counts[4]}`);
  await pool.end();
}

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