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