[object Object]

← 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

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 →