← back to Stars of Design
scrapers/triage-candidates.js
181 lines
#!/usr/bin/env node
'use strict';
// Tick 2 — Classify sod_email_candidates rows still at status='new' via local
// Ollama (qwen3:14b). Each row gets exactly one of:
// is_designer — individual interior/residential/commercial designer
// is_firm — design firm / studio / agency
// is_vendor — manufacturer, supplier, fabric/wallcovering brand, rep
// is_press — press / editor / publication
// is_noise — personal, unrelated, transactional, family, friends
//
// Why local-only — Steve's standing rule (memory: feedback_never_anthropic_api_key)
// + this is a YOLO loop expected to be unmetered.
//
// Usage:
// node scrapers/triage-candidates.js # all status='new'
// node scrapers/triage-candidates.js --limit=20 # cap for safety
// node scrapers/triage-candidates.js --redo=is_designer # re-triage existing
// node scrapers/triage-candidates.js --dry # don't write
require('dotenv').config();
const { Pool } = require('pg');
const args = process.argv.slice(2).reduce((m, a) => {
const [k, v] = a.replace(/^--/, '').split('=');
m[k] = v ?? true; return m;
}, {});
const LIMIT = parseInt(args.limit || '500', 10);
const REDO = args.redo || null;
const DRY = !!args.dry;
// Default: gemma3:12b. We tried qwen3:14b first but its <think> mode swallows
// the entire 200-token output before producing any JSON, even with format:json.
// gemma3:12b is fast (~0.5s/row) and stable in JSON-mode.
const MODEL = args.model || 'gemma3:12b';
const OLLAMA = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
max: 4,
});
const VALID = new Set(['is_designer','is_firm','is_vendor','is_press','is_noise']);
const SYSTEM = `You classify the role of an email contact from Steve Abrams' inbox.
Steve runs Designer Wallcoverings (DW), a luxury wallpaper/fabric brand. He
receives email from interior designers (his customers + target audience),
design firms (often as a unit), manufacturer vendors (suppliers, brand reps),
press (magazine editors, journalists, design-publication staff), and noise
(family, friends, banks, SaaS notifications, unrelated personal).
Return one of these labels exactly:
is_designer - an individual interior/residential/commercial designer
is_firm - a design firm/studio/agency (often via info@firm.com)
is_vendor - manufacturer, fabric/wallcovering brand, rep, supplier
is_press - press, editor, publication, journalist
is_noise - personal, unrelated, transactional, family, accountant
Also extract a confidence (low/medium/high) and an optional short reason.
Output STRICT JSON only — no preamble, no markdown fences:
{"label":"is_designer","confidence":"high","reason":"..."}
`;
function buildUserPrompt(c) {
const parts = [];
parts.push(`Email: ${c.email}`);
if (c.display_name) parts.push(`Display name: ${c.display_name}`);
parts.push(`Domain: ${c.domain || c.email.split('@')[1]}`);
if (c.notes) parts.push(`Notes / subject snippet: ${c.notes.slice(0, 240)}`);
parts.push(`Thread count with Steve: ${c.thread_count}`);
parts.push(`Replies from Steve: ${c.steve_replied}`);
if (c.signal_score != null) parts.push(`Reply-signal score: ${Number(c.signal_score).toFixed(2)}`);
return parts.join('\n');
}
async function ask(systemPrompt, userPrompt) {
const res = await fetch(`${OLLAMA}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MODEL,
stream: false,
format: 'json',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
options: { temperature: 0.1, num_predict: 200 },
}),
});
if (!res.ok) throw new Error(`ollama ${res.status}: ${(await res.text()).slice(0,200)}`);
const j = await res.json();
const raw = (j.message && j.message.content) || '';
// qwen3 sometimes wraps in <think>…</think> blocks before the JSON
const stripped = raw.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
// First {...} block
const m = stripped.match(/\{[\s\S]*\}/);
if (!m) throw new Error('no JSON object in response: ' + stripped.slice(0,160));
return JSON.parse(m[0]);
}
async function main() {
const where = REDO ? `status = $1` : `status = 'new'`;
const params = REDO ? [REDO, LIMIT] : [LIMIT];
const limitParam = REDO ? '$2' : '$1';
const r = await pool.query(
`SELECT id, email, display_name, domain, notes, thread_count, steve_replied, signal_score
FROM sod_email_candidates
WHERE ${where}
ORDER BY signal_score DESC NULLS LAST, id
LIMIT ${limitParam}`,
params
);
console.log(`[triage] ${r.rows.length} candidate(s) model=${MODEL} dry=${DRY}`);
let runId = null;
if (!DRY) {
const ir = await pool.query(
`INSERT INTO sod_ingest_runs (run_kind, source, status) VALUES ('llm-triage', $1, 'running') RETURNING id`,
[MODEL]
);
runId = ir.rows[0].id;
}
const tally = { is_designer:0, is_firm:0, is_vendor:0, is_press:0, is_noise:0, error:0 };
let promoteList = [];
for (const c of r.rows) {
try {
// Cheap deterministic shortcuts before paying for an LLM call.
if (/^customer\d+@example\.com$/i.test(c.email)) {
const label = 'is_noise';
tally[label]++;
if (!DRY) await pool.query(
`UPDATE sod_email_candidates SET status=$1, notes=CONCAT_WS(' || ', NULLIF(notes,''), 'triage:is_noise(high) — masked-customer placeholder, not a real contact'), updated_at=NOW() WHERE id=$2`,
[label, c.id]
);
console.log(` ${c.email.padEnd(40)} → ${label} (skip-LLM)`);
continue;
}
const out = await ask(SYSTEM, buildUserPrompt(c));
const label = (out.label || '').trim();
if (!VALID.has(label)) throw new Error('invalid label: ' + label);
tally[label] = (tally[label] || 0) + 1;
const noteFrag = `triage:${label}(${out.confidence||'?'})${out.reason ? ' — ' + out.reason.slice(0,120) : ''}`;
console.log(` ${c.email.padEnd(40)} → ${label.padEnd(12)} ${out.confidence||''} · ${out.reason||''}`);
if (!DRY) {
await pool.query(
`UPDATE sod_email_candidates
SET status = $1,
notes = CONCAT_WS(' || ', NULLIF(notes,''), $2::text),
updated_at = NOW()
WHERE id = $3`,
[label, noteFrag, c.id]
);
}
if ((label === 'is_designer' || label === 'is_firm') && (out.confidence === 'high' || out.confidence === 'medium')) {
promoteList.push(c.email);
}
} catch (e) {
tally.error++;
console.error(` ${c.email}: ${e.message}`);
}
}
if (runId) {
await pool.query(
`UPDATE sod_ingest_runs SET status='ok', finished_at=NOW(), records_in=$1, records_out=$2, notes=$3 WHERE id=$4`,
[r.rows.length, r.rows.length - tally.error, JSON.stringify(tally), runId]
);
}
console.log(`[triage] done. tally=${JSON.stringify(tally)} to-promote=${promoteList.length}`);
await pool.end();
// Persist promote list so the wrapper can spawn promote-candidates.js
if (promoteList.length && !DRY) {
const fs = require('fs');
fs.writeFileSync('/tmp/sod-triage-promote.txt', promoteList.join('\n') + '\n');
console.log(`[triage] wrote /tmp/sod-triage-promote.txt with ${promoteList.length} emails`);
}
}
main().catch(e => { console.error('fatal:', e); process.exit(1); });