← back to Stars of Design
feat(starsofdesign/tick2): local-LLM triage on remaining candidates
fed7183bfa88cddabc0678fda113a83ac4eabaea · 2026-05-12 08:19:29 -0700 · Steve Abrams
scrapers/triage-candidates.js — drives Ollama (gemma3:12b by default)
in JSON-mode to label each sod_email_candidates row as one of:
is_designer / is_firm / is_vendor / is_press / is_noise. Writes both
the status enum and a human-readable triage note (label, confidence,
short reason) into notes via CONCAT_WS so the original signal-score
note is preserved.
Why gemma3:12b not qwen3:14b — first pass at qwen3:14b returned empty
content for all 39 rows because its <think> mode swallows the entire
num_predict=200 budget before producing JSON. Switched default to
gemma3:12b which is ~0.5s/row and JSON-stable.
Deterministic short-circuit for masked customer rows — the 25 digital_
sample_orders rows wear synthetic customer{1..25}@example.com addresses
(privacy-mask in dw_unified.digital_sample_orders); those skip the LLM
entirely and resolve straight to is_noise.
Tick 2 results on 39 still-new candidates:
is_designer 2 (paulmontgomery.com, beverlyhillswallpaper.com)
is_firm 2 (desima.com, newwall.com)
is_vendor 5 (priti, printmurals, fahertybrand, toplinegraphics, profectionpainting)
is_press 0
is_noise 30 (25 masked-customer + studentdebtcrisis/filmla/trueaccord/etc.)
errors 0
Status snapshot post-tick:
candidates_total 236 (was 236)
status_new 0 (was 39 — fully drained)
status_is_designer 200
status_is_firm 1 (4 firm-labeled rows kept their LLM label after
promote-candidates.js attempted name extraction)
status_is_vendor 5
status_is_noise 30
Known gap (for tick 3): promote-candidates.js can't materialize sod_designers
/sod_firms rows when display_name is empty — guessNameAndFirm() returns
{person:null, firm:null} and produces designer=null firm=null. Tick 3's
signature-block parser fixes this by extracting names from each
candidate's last-seen email signature. Tick 2 deliberately doesn't try to
backfill name-from-domain because that's tick 3's job and the cleaner
fix.
Files touched
A scrapers/triage-candidates.js
Diff
commit fed7183bfa88cddabc0678fda113a83ac4eabaea
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 12 08:19:29 2026 -0700
feat(starsofdesign/tick2): local-LLM triage on remaining candidates
scrapers/triage-candidates.js — drives Ollama (gemma3:12b by default)
in JSON-mode to label each sod_email_candidates row as one of:
is_designer / is_firm / is_vendor / is_press / is_noise. Writes both
the status enum and a human-readable triage note (label, confidence,
short reason) into notes via CONCAT_WS so the original signal-score
note is preserved.
Why gemma3:12b not qwen3:14b — first pass at qwen3:14b returned empty
content for all 39 rows because its <think> mode swallows the entire
num_predict=200 budget before producing JSON. Switched default to
gemma3:12b which is ~0.5s/row and JSON-stable.
Deterministic short-circuit for masked customer rows — the 25 digital_
sample_orders rows wear synthetic customer{1..25}@example.com addresses
(privacy-mask in dw_unified.digital_sample_orders); those skip the LLM
entirely and resolve straight to is_noise.
Tick 2 results on 39 still-new candidates:
is_designer 2 (paulmontgomery.com, beverlyhillswallpaper.com)
is_firm 2 (desima.com, newwall.com)
is_vendor 5 (priti, printmurals, fahertybrand, toplinegraphics, profectionpainting)
is_press 0
is_noise 30 (25 masked-customer + studentdebtcrisis/filmla/trueaccord/etc.)
errors 0
Status snapshot post-tick:
candidates_total 236 (was 236)
status_new 0 (was 39 — fully drained)
status_is_designer 200
status_is_firm 1 (4 firm-labeled rows kept their LLM label after
promote-candidates.js attempted name extraction)
status_is_vendor 5
status_is_noise 30
Known gap (for tick 3): promote-candidates.js can't materialize sod_designers
/sod_firms rows when display_name is empty — guessNameAndFirm() returns
{person:null, firm:null} and produces designer=null firm=null. Tick 3's
signature-block parser fixes this by extracting names from each
candidate's last-seen email signature. Tick 2 deliberately doesn't try to
backfill name-from-domain because that's tick 3's job and the cleaner
fix.
---
scrapers/triage-candidates.js | 180 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 180 insertions(+)
diff --git a/scrapers/triage-candidates.js b/scrapers/triage-candidates.js
new file mode 100644
index 0000000..71ed43a
--- /dev/null
+++ b/scrapers/triage-candidates.js
@@ -0,0 +1,180 @@
+#!/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); });
← 1542658 feat(starsofdesign/tick1): Gmail + Shopify recon → 236 candi
·
back to Stars of Design
·
gucci hero anatomy — logo UL + hamburger UR + huge wordmark/ 51fa79b →