[object Object]

← back to Stars of Design

feat(starsofdesign): signature-block LLM extraction → Jo Hempelmann shipped

f5688ed6b9dd65c52ca6854df1c9f16ca88b0565 · 2026-05-12 09:19:41 -0700 · Steve Abrams

scrapers/extract-from-signatures.js — for each candidate with empty
display_name (148 such), pull most-recent George info@ message body,
slice to signature region (after -- delimiter or last 14 non-empty
lines), gemma3:12b in JSON-mode extracts:
  full_name, firm_name, firm_city, firm_role,
  linkedin_personal, linkedin_company, website, phone.

Then materializes:
  - UPDATE sod_email_candidates.display_name + last_signature
  - INSERT sod_designers + sod_firms with role/city
  - INSERT sod_designer_firm join with extracted title
  - INSERT linkedin/website URLs into sod_links (kind='sig-extract')

Two important guardrails:
  - Length sanity check on name (3-80 chars) and firm (3-120 chars).
  - Hard refusal of 'Designer Wallcoverings*' as firm_name — that's
    DW's OWN outgoing signature embedded in the contact's reply, NOT
    their firm. LLM grabbed it from rpaschall@pinecrestinsurance.com,
    kcfox007@gmail.com, angela@handwerk.company first run; guard
    added, hallucinated 'Designer Wallcoverings and Fabrics' firm row
    deleted.

First-pass result on 25 highest-signal empty-name candidates:
  jo@katieswallpaper.ca → Jo Hempelmann, Katie's Wallpaper Installation
    Inc., Los Angeles, Wallpaper Installation Project Manager
  export@bmwallpaper.com → Jenny Liu (name only — no firm in sig)
  jcamacho427@gmail.com → Jerry Camacho (later flagged is_noise —
    Steve's accountant, not a designer)
  rpaschall / kcfox007 / angela@handwerk → hallucinated DW (guarded
    after run)
  17 of 25 had no message in info@ (personal-Gmail-only contacts)

Manual cleanup tied to this tick (27 candidates flipped is_noise):
  Family: nataliaabrams (Steve's wife), bobfox729, billfox92117,
  jiabramsla, dylan.r.fox, kcfox007 (all relatives).
  Service providers / vendors: hrftax53 (accountant), jcamacho427
  (accountant), tom@osbornepartners (financial advisor), michael@
  montereyrentals (landlord), mike@5to1web (IT vendor), rpaschall@
  pinecrestinsurance (insurance), angela@handwerk, email@rentmanager,
  circulation1@labj*, jimdultz@comcast (vendor),
  carole.fabrics@carolefabrics, helen.brier.b6@mail.toray (Toray),
  kundservice@photowall (Photowall), noreply@zopim (transactional),
  natalia/sabrina@studentdebtcrisis (wife's nonprofit), chounanian
  (ex-DW VP), browntwn / mitz / greg_perry (personal).

Ledger after this tick:
  real_designer_candidates  200  (was 225 — 25 dropped to noise)
  noise_total               58   (was 31)
  vendors_total             84
  sod_designers (real)       4   (was 1 — Jo Hempelmann added)
  sod_designers (reps)      40
  sod_firms_total           82   (+1 Katie's Wallpaper, hallucinated DW removed)

Files touched

Diff

commit f5688ed6b9dd65c52ca6854df1c9f16ca88b0565
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 09:19:41 2026 -0700

    feat(starsofdesign): signature-block LLM extraction → Jo Hempelmann shipped
    
    scrapers/extract-from-signatures.js — for each candidate with empty
    display_name (148 such), pull most-recent George info@ message body,
    slice to signature region (after -- delimiter or last 14 non-empty
    lines), gemma3:12b in JSON-mode extracts:
      full_name, firm_name, firm_city, firm_role,
      linkedin_personal, linkedin_company, website, phone.
    
    Then materializes:
      - UPDATE sod_email_candidates.display_name + last_signature
      - INSERT sod_designers + sod_firms with role/city
      - INSERT sod_designer_firm join with extracted title
      - INSERT linkedin/website URLs into sod_links (kind='sig-extract')
    
    Two important guardrails:
      - Length sanity check on name (3-80 chars) and firm (3-120 chars).
      - Hard refusal of 'Designer Wallcoverings*' as firm_name — that's
        DW's OWN outgoing signature embedded in the contact's reply, NOT
        their firm. LLM grabbed it from rpaschall@pinecrestinsurance.com,
        kcfox007@gmail.com, angela@handwerk.company first run; guard
        added, hallucinated 'Designer Wallcoverings and Fabrics' firm row
        deleted.
    
    First-pass result on 25 highest-signal empty-name candidates:
      jo@katieswallpaper.ca → Jo Hempelmann, Katie's Wallpaper Installation
        Inc., Los Angeles, Wallpaper Installation Project Manager
      export@bmwallpaper.com → Jenny Liu (name only — no firm in sig)
      jcamacho427@gmail.com → Jerry Camacho (later flagged is_noise —
        Steve's accountant, not a designer)
      rpaschall / kcfox007 / angela@handwerk → hallucinated DW (guarded
        after run)
      17 of 25 had no message in info@ (personal-Gmail-only contacts)
    
    Manual cleanup tied to this tick (27 candidates flipped is_noise):
      Family: nataliaabrams (Steve's wife), bobfox729, billfox92117,
      jiabramsla, dylan.r.fox, kcfox007 (all relatives).
      Service providers / vendors: hrftax53 (accountant), jcamacho427
      (accountant), tom@osbornepartners (financial advisor), michael@
      montereyrentals (landlord), mike@5to1web (IT vendor), rpaschall@
      pinecrestinsurance (insurance), angela@handwerk, email@rentmanager,
      circulation1@labj*, jimdultz@comcast (vendor),
      carole.fabrics@carolefabrics, helen.brier.b6@mail.toray (Toray),
      kundservice@photowall (Photowall), noreply@zopim (transactional),
      natalia/sabrina@studentdebtcrisis (wife's nonprofit), chounanian
      (ex-DW VP), browntwn / mitz / greg_perry (personal).
    
    Ledger after this tick:
      real_designer_candidates  200  (was 225 — 25 dropped to noise)
      noise_total               58   (was 31)
      vendors_total             84
      sod_designers (real)       4   (was 1 — Jo Hempelmann added)
      sod_designers (reps)      40
      sod_firms_total           82   (+1 Katie's Wallpaper, hallucinated DW removed)
---
 scrapers/extract-from-signatures.js | 264 ++++++++++++++++++++++++++++++++++++
 1 file changed, 264 insertions(+)

diff --git a/scrapers/extract-from-signatures.js b/scrapers/extract-from-signatures.js
new file mode 100644
index 0000000..fe00ee9
--- /dev/null
+++ b/scrapers/extract-from-signatures.js
@@ -0,0 +1,264 @@
+#!/usr/bin/env node
+'use strict';
+// Tick 3 — Fill empty display_name + firm/role/city/LinkedIn for sod_email_candidates
+// by parsing each contact's most-recent George info@ message body. Why this
+// tick — promote-candidates.js can't materialize sod_designers/sod_firms rows
+// without a name, and Gmail MCP returned bare-email From: headers for many
+// threads. Their actual identity lives in the message body's signature block.
+//
+// Flow per candidate:
+//   1. george /api/search?account=info&q=from:<email> OR to:<email>  → message ids
+//   2. george /api/messages/<id>?account=info                          → full body
+//   3. Slice body to plausible signature region (last ~1.5 KB)
+//   4. gemma3:12b in JSON mode → { full_name, firm_name, firm_city, firm_role,
+//                                  linkedin_personal, linkedin_company, website, phone }
+//   5. UPDATE sod_email_candidates(display_name, last_signature, notes)
+//   6. INSERT linkedin/site URLs into sod_links via designer_id / firm_id
+//   7. Promote via promote-candidates.js when the LLM gives us at least a
+//      person OR firm name.
+//
+// Usage:
+//   node scrapers/extract-from-signatures.js --limit=25
+//   node scrapers/extract-from-signatures.js --email=jane@studio.com   # one row
+//   node scrapers/extract-from-signatures.js --dry                     # no writes
+
+require('dotenv').config();
+const { Pool } = require('pg');
+const path = require('path');
+
+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 || '25', 10);
+const ONLY_EMAIL = args.email || null;
+const DRY = !!args.dry;
+const MODEL = args.model || 'gemma3:12b';
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const GEORGE_AUTH = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
+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 SYSTEM = `You extract the actual person and firm identity from an email signature
+block. The body is the most recent email from / to a designer or firm DW corresponds
+with. Look for the signature region (after a -- delimiter, or the final
+4-12 lines containing name + title + firm + phone + url patterns).
+
+Output STRICT JSON, no preamble:
+{
+  "full_name":         "<person's first+last>     or null",
+  "firm_name":         "<firm/studio/agency>      or null",
+  "firm_city":         "<city the firm is in>     or null",
+  "firm_role":         "<the person's title>      or null (Principal, Senior Designer, Founder, Partner, etc.)",
+  "linkedin_personal": "<linkedin.com/in/...      or null>",
+  "linkedin_company":  "<linkedin.com/company/... or null>",
+  "website":           "<firm primary URL         or null>",
+  "phone":             "<phone (E.164 preferred)  or null>"
+}
+
+Rules:
+- Don't invent. If a field isn't clearly in the body, return null.
+- Skip the email-quoting (lines starting with >).
+- Skip footer disclaimers and unsubscribe links.
+- If the body is just a Shopify order notification with no signature, return
+  all nulls.
+- Strip emoji and decorative characters from names.
+`;
+
+async function ask(systemPrompt, userPrompt) {
+  const r = 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.slice(0, 6000) },
+      ],
+      options: { temperature: 0.1, num_predict: 280 },
+    }),
+  });
+  if (!r.ok) throw new Error(`ollama ${r.status}`);
+  const j = await r.json();
+  const raw = (j.message && j.message.content) || '';
+  const stripped = raw.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+  const m = stripped.match(/\{[\s\S]*\}/);
+  if (!m) throw new Error('no JSON in response');
+  return JSON.parse(m[0]);
+}
+
+async function georgeSearch(q, max = 1) {
+  const url = `${GEORGE}/api/search?account=info&q=${encodeURIComponent(q)}&maxResults=${max}`;
+  const r = await fetch(url, { headers: { Authorization: GEORGE_AUTH } });
+  if (!r.ok) return null;
+  const j = await r.json();
+  return (j.messages || [])[0] || null;
+}
+async function georgeGet(id) {
+  const url = `${GEORGE}/api/messages/${id}?account=info`;
+  const r = await fetch(url, { headers: { Authorization: GEORGE_AUTH } });
+  if (!r.ok) return null;
+  return r.json();
+}
+
+function trimToSignature(body) {
+  if (!body) return '';
+  // Drop quoted-reply lines (start with > or "On <date> ... wrote:")
+  const lines = body.split(/\r?\n/);
+  const cutIdx = lines.findIndex(l => /^On\s+.+\s+wrote:\s*$/.test(l));
+  const sliced = cutIdx > 0 ? lines.slice(0, cutIdx) : lines;
+  const noQuotes = sliced.filter(l => !/^[>]/.test(l));
+  // Find -- delimiter
+  let sigStart = -1;
+  for (let i = noQuotes.length - 1; i >= 0 && i >= noQuotes.length - 40; i--) {
+    if (/^--\s*$/.test(noQuotes[i])) { sigStart = i; break; }
+  }
+  if (sigStart >= 0) return noQuotes.slice(sigStart, sigStart + 15).join('\n');
+  // No -- delimiter: take last 14 non-empty lines
+  const nonEmpty = noQuotes.map(l => l.trim()).filter(Boolean);
+  return nonEmpty.slice(-14).join('\n');
+}
+
+function slugify(s) {
+  return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,100);
+}
+
+async function processCandidate(c) {
+  // Search George for the most recent message exchanged with this email.
+  // Try `from:` first (incoming), then `to:` (outgoing).
+  let m = await georgeSearch(`from:${c.email}`, 1);
+  if (!m) m = await georgeSearch(`to:${c.email}`, 1);
+  if (!m) { console.log(`  ${c.email.padEnd(36)} no-message`); return { skipped: true }; }
+  const full = await georgeGet(m.id);
+  if (!full) { console.log(`  ${c.email.padEnd(36)} get-failed`); return { skipped: true }; }
+  // Body candidates: plaintext_body OR snippet OR payload.body
+  const body = full.plaintext_body || full.body || full.snippet || '';
+  if (!body || body.length < 60) { console.log(`  ${c.email.padEnd(36)} body-too-short`); return { skipped: true }; }
+  const sig = trimToSignature(body);
+  if (!sig || sig.length < 30) { console.log(`  ${c.email.padEnd(36)} no-sig-region`); return { skipped: true }; }
+
+  let parsed;
+  try {
+    parsed = await ask(SYSTEM, `Email: ${c.email}\nDomain: ${c.domain}\n\nSignature region:\n${sig}`);
+  } catch (e) {
+    console.log(`  ${c.email.padEnd(36)} llm-fail: ${e.message}`);
+    return { skipped: true };
+  }
+
+  // Refuse obviously-bad LLM output
+  const name = parsed.full_name && parsed.full_name.length > 2 && parsed.full_name.length < 80 ? parsed.full_name.trim() : null;
+  let firm   = parsed.firm_name && parsed.firm_name.length > 2 && parsed.firm_name.length < 120 ? parsed.firm_name.trim() : null;
+  // GUARD: the LLM occasionally grabs DW's outgoing signature ("Designer
+  // Wallcoverings and Fabrics") and reports it as the contact's firm. The
+  // contact is NOT working for DW — that's our own signature inside an
+  // outgoing message the contact replied to. Refuse.
+  if (firm && /^Designer\s+Wallcoverings/i.test(firm)) firm = null;
+
+  console.log(`  ${c.email.padEnd(36)} → name=${name||'-'}  firm=${firm||'-'}  city=${parsed.firm_city||'-'}`);
+
+  if (DRY) return { dry: true, parsed };
+
+  // 1. Update candidate row
+  await pool.query(`
+    UPDATE sod_email_candidates
+       SET display_name   = COALESCE($1, display_name),
+           last_signature = $2,
+           notes          = CONCAT_WS(' || ', NULLIF(notes,''), 'sig-extract:' || COALESCE($1,'null') || '|' || COALESCE($3,'null') || '|' || COALESCE($4,'null')),
+           updated_at     = NOW()
+     WHERE id = $5`,
+    [name, sig, firm, parsed.firm_role, c.id]);
+
+  // 2. If we got a person name OR firm name, materialize in sod_designers/sod_firms
+  let designerId = null, firmId = null;
+  if (firm) {
+    const slug = slugify(firm);
+    const f = await pool.query(`
+      INSERT INTO sod_firms (slug, name, city, primary_email)
+      VALUES ($1, $2, $3, $4)
+      ON CONFLICT (slug) DO UPDATE SET
+        city          = COALESCE(sod_firms.city, EXCLUDED.city),
+        primary_email = COALESCE(sod_firms.primary_email, EXCLUDED.primary_email),
+        updated_at    = NOW()
+      RETURNING id`,
+      [slug, firm, parsed.firm_city, c.email]);
+    firmId = f.rows[0].id;
+  }
+  if (name) {
+    const slug = slugify(name);
+    const d = await pool.query(`
+      INSERT INTO sod_designers (slug, full_name, primary_email, city, role, headshot_source)
+      VALUES ($1, $2, $3, $4, $5, 'made-with-ai')
+      ON CONFLICT (slug) DO UPDATE SET
+        primary_email = COALESCE(sod_designers.primary_email, EXCLUDED.primary_email),
+        city          = COALESCE(sod_designers.city, EXCLUDED.city),
+        role          = COALESCE(sod_designers.role, EXCLUDED.role),
+        updated_at    = NOW()
+      RETURNING id`,
+      [slug, name, c.email, parsed.firm_city, parsed.firm_role]);
+    designerId = d.rows[0].id;
+    if (firmId) {
+      await pool.query(`
+        INSERT INTO sod_designer_firm (designer_id, firm_id, title, is_current, source)
+        VALUES ($1, $2, $3, true, 'sig-extract')
+        ON CONFLICT DO NOTHING`,
+        [designerId, firmId, parsed.firm_role || 'Designer']);
+    }
+  }
+  // 3. Stash any LinkedIn / website URLs
+  for (const [url, kind] of [
+    [parsed.linkedin_personal, 'linkedin'],
+    [parsed.linkedin_company,  'linkedin'],
+    [parsed.website,           'firm-site'],
+  ]) {
+    if (!url || !/^https?:\/\//.test(url)) continue;
+    if (!designerId && !firmId) continue;
+    await pool.query(`
+      INSERT INTO sod_links (designer_id, firm_id, url, kind, added_by)
+      VALUES ($1, $2, $3, $4, 'sig-extract')
+      ON CONFLICT DO NOTHING`,
+      [designerId, firmId, url, kind]);
+  }
+  if (parsed.phone && designerId) {
+    // store phone in sod_designers via bio fallback isn't right; skip for now.
+  }
+  return { ok: true, name, firm };
+}
+
+async function main() {
+  let sql, params;
+  if (ONLY_EMAIL) {
+    sql = `SELECT id, email, domain, signal_score
+             FROM sod_email_candidates
+            WHERE email = $1
+            LIMIT 1`;
+    params = [ONLY_EMAIL];
+  } else {
+    sql = `SELECT id, email, domain, signal_score
+             FROM sod_email_candidates
+            WHERE status = 'is_designer'
+              AND (display_name IS NULL OR display_name = '')
+            ORDER BY signal_score DESC NULLS LAST
+            LIMIT $1`;
+    params = [LIMIT];
+  }
+  const r = await pool.query(sql, params);
+  console.log(`[sig-extract] ${r.rows.length} candidate(s)  model=${MODEL}  dry=${DRY}`);
+  const tally = { ok:0, withName:0, withFirm:0, skipped:0 };
+  for (const c of r.rows) {
+    const res = await processCandidate(c);
+    if (res.ok || res.dry) {
+      tally.ok++;
+      if (res.name) tally.withName++;
+      if (res.firm) tally.withFirm++;
+    } else tally.skipped++;
+  }
+  console.log(`[sig-extract] done. ${JSON.stringify(tally)}`);
+  await pool.end();
+}
+main().catch(e => { console.error('fatal:', e); process.exit(1); });

← 4ced542 expand to 150 designers — added 62 active US firms with firm  ·  back to Stars of Design  ·  feat(starsofdesign): DEEP George info@ recon (7-window × 8-t a7bdb25 →