[object Object]

← back to Dw Pitch Followup

Pitch follow-up: hide mfr# from UI + client letters (toggle), LLM-customized letters, Generate/Regenerate buttons

351e8f9ace74a72e96aa24669c299cd1ab5ec3bc · 2026-06-30 15:39:03 -0700 · Steve Abrams

Files touched

Diff

commit 351e8f9ace74a72e96aa24669c299cd1ab5ec3bc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 30 15:39:03 2026 -0700

    Pitch follow-up: hide mfr# from UI + client letters (toggle), LLM-customized letters, Generate/Regenerate buttons
---
 public/index.html |  50 +++++++++++-----
 server.js         | 174 +++++++++++++++++++++++++++++++++++-------------------
 2 files changed, 147 insertions(+), 77 deletions(-)

diff --git a/public/index.html b/public/index.html
index 6b15820..6217732 100644
--- a/public/index.html
+++ b/public/index.html
@@ -66,6 +66,7 @@
   <span><label>Sort</label><select id="sort"></select></span>
   <span><label>Density</label><input type="range" id="density" min="1" max="5" step="1" /></span>
   <span><input type="search" id="q" placeholder="filter…" /></span>
+  <span><label title="OFF (default) hides manufacturer numbers from view AND from client letters"><input type="checkbox" id="showMfr" /> show mfr #</label></span>
   <span class="spacer"></span>
   <span class="selcount" id="selcount">0 selected</span>
   <button class="btn" id="copyBtn">Copy selected</button>
@@ -97,6 +98,10 @@ const LS = (k,d)=>{try{return JSON.parse(localStorage.getItem('dwpf:'+k))??d}cat
 const setLS=(k,v)=>localStorage.setItem('dwpf:'+k, JSON.stringify(v));
 const fmtWhen = iso => { if(!iso) return ''; const d=new Date(iso); return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
 const esc = s => String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+// mfr-number scrub for display: drop SKU-ish tokens (any alnum run containing a digit), keep names.
+function stripMfr(s){ return String(s||'').replace(/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/g,t=>/\d/.test(t)?'':t).replace(/\s*[|·•—–-]+\s*/g,' · ').replace(/(?:^[\s·]+|[\s·]+$)/g,'').replace(/(?:·\s*){2,}/g,'· ').replace(/\bSample\b\s*$/i,'').replace(/\s{2,}/g,' ').trim().replace(/[·\s]+$/,''); }
+function showMfr(){ return !!document.getElementById('showMfr')?.checked; }
+const lbl = s => showMfr() ? String(s||'').trim() : stripMfr(s);
 
 function selSet(){ return new Set(LS('sel:'+cur, [])); }
 function saveSel(set){ setLS('sel:'+cur, [...set]); paintSelCount(); }
@@ -126,24 +131,24 @@ function cardHTML(r){
   if(cur==='list3'){
     head = `<div><div class="co">${esc(r.vendor)} <span class="chip r">${r.days_overdue}d overdue</span></div>
       <div class="sub">${esc(r.company||('account '+r.account))} · acct ${esc(r.account)}</div></div>`;
-    facts = `<div class="chips"><span class="chip k">${esc(r.sku||'—')}</span><span class="chip">${esc(r.pattern||'—')}</span></div>`;
+    facts = `<div class="chips">${showMfr()?`<span class="chip k">${esc(r.sku||'—')}</span>`:''}<span class="chip">${esc(lbl(r.pattern)||'pattern')}</span></div>`;
   } else if(cur==='list2'){
     head = `<div><div class="co">${esc(r.company)}</div><div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
     facts = `<div class="chips"><span class="chip a">${r.invoice_count} sample inv</span><span class="chip">max $${r.max_total}</span></div>
-      ${r.details&&r.details[0]?`<div class="pats">${esc(r.details[0])}</div>`:''}`;
+      ${(()=>{const d=r.details&&r.details[0]?lbl(r.details[0]):'';return d?`<div class="pats">${esc(d)}</div>`:''})()}`;
   } else {
     head = `<div><div class="co">${esc(r.company)} <span class="chip g">${r.ratio}% sent</span></div>
       <div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
     facts = `<div class="chips"><span class="chip">${r.sent}/${r.ordered} sent</span>${r.missing?`<span class="chip a">${r.missing} missing</span>`:''}</div>
-      ${r.patterns&&r.patterns.length?`<div class="pats">${esc(r.patterns.slice(0,3).join(' · '))}</div>`:''}`;
+      ${(()=>{const p=(r.patterns||[]).map(lbl).filter(Boolean).slice(0,3).join(' · ');return p?`<div class="pats">${esc(p)}</div>`:''})()}`;
   }
   return `<div class="card ${sel?'sel':''}" data-k="${esc(k)}">
     <div class="top"><input type="checkbox" ${sel?'checked':''} data-sel/>${head}</div>
     ${facts}
     <div class="when" title="${esc(r.when_iso||'')}">🕓 ${esc(r.when_label||'when')}: ${esc(fmtWhen(r.when_iso))}</div>
-    <div class="acts"><button class="btn" data-gen>Generate letter</button></div>
+    <div class="acts"><button class="btn gold" data-gen>Generate letter</button></div>
     <div class="letter"><div class="subj"></div><div class="ctxnote"></div><textarea spellcheck="false"></textarea>
-      <div class="acts"><button class="btn" data-copy>Copy letter</button></div></div>
+      <div class="acts"><button class="btn" data-regen>↻ Regenerate</button><button class="btn" data-copy>Copy letter</button><span class="meta" data-variant></span></div></div>
   </div>`;
 }
 
@@ -165,25 +170,34 @@ function rerender(){
   renderChunk();
 }
 
-async function genLetter(card){
+async function genLetter(card, opts={}){
   const r = card._row;
   const box = card.querySelector('.letter');
-  if(box.classList.contains('show')){ box.classList.remove('show'); return; }
-  box.classList.add('show');
   const ta = card.querySelector('textarea'); const subj=card.querySelector('.subj'); const note=card.querySelector('.ctxnote');
-  const cacheK = 'letter:'+cur+':'+rowKey(r);
-  const cached = LS(cacheK,null);
-  if(cached){ subj.textContent='Subject: '+cached.subject; ta.value=cached.body; note.textContent=cached.ctx; note.className='ctxnote '+(cached.found?'found':''); return; }
-  subj.textContent='composing…'; ta.value='';
+  const vlabel = card.querySelector('[data-variant]'); const rbtn = card.querySelector('[data-regen]');
+  const regen = !!opts.regenerate;
+  if(!regen){ if(box.classList.contains('show')){ box.classList.remove('show'); return; } box.classList.add('show'); }
+  else { box.classList.add('show'); }
+  const mfr = showMfr()?1:0;
+  const seed = regen ? (card._seed||1)+1 : (card._seed||1);
+  card._seed = seed;
+  const cacheK = `letter:${cur}:${rowKey(r)}:m${mfr}:s${seed}`;
+  if(!regen){ const c=LS(cacheK,null); if(c){ subj.textContent='Subject: '+c.subject; ta.value=c.body; note.textContent=c.ctx; note.className='ctxnote '+(c.found?'found':''); vlabel.textContent=c.vmeta||''; return; } }
+  subj.textContent=regen?'regenerating…':'composing…'; ta.value=''; vlabel.textContent='';
+  if(rbtn){ rbtn.disabled=true; rbtn.textContent='…'; }
   try{
-    const res = await fetch('/api/letter',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({list:cur,row:r})});
+    const res = await fetch('/api/letter',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({list:cur,row:r,seed,showMfr:showMfr()})});
     const j = await res.json();
     if(j.error) throw new Error(j.error);
     subj.textContent='Subject: '+j.subject; ta.value=j.body;
-    const ctx = j.context?.found ? `✓ grounded in last Gmail thread (${j.context.account}${j.context.subject?': "'+j.context.subject+'"':''})` : 'no prior Gmail thread found — composed from project data';
+    const ctx = j.context?.found ? `✓ grounded in last Gmail thread (${j.context.account}${j.context.subject?': "'+j.context.subject+'"':''})` : 'composed from project data (no prior thread)';
     note.textContent=ctx; note.className='ctxnote '+(j.context?.found?'found':'');
-    setLS(cacheK,{subject:j.subject,body:j.body,ctx,found:!!j.context?.found});
+    const vmeta = `variant ${j.variant||seed} · ${j.engine||'local'}${j.engineNote?' · '+j.engineNote:''}`;
+    vlabel.textContent=vmeta;
+    setLS(cacheK,{subject:j.subject,body:j.body,ctx,found:!!j.context?.found,vmeta});
+    setLS('lastletter:'+cur+':'+rowKey(r),{subject:j.subject,body:j.body});
   }catch(e){ subj.textContent='error'; note.textContent=e.message; }
+  finally{ if(rbtn){ rbtn.disabled=false; rbtn.textContent='↻ Regenerate'; } }
 }
 
 function selectedRows(){
@@ -192,7 +206,7 @@ function selectedRows(){
 }
 function selectedAsText(){
   return selectedRows().map(r=>{
-    const k='letter:'+cur+':'+rowKey(r); const L=LS(k,null);
+    const k='lastletter:'+cur+':'+rowKey(r); const L=LS(k,null);
     const who = cur==='list3'?`${r.vendor} (re: ${r.company||r.account}, ${r.sku})`:`${r.company} <${r.email||'no-email'}>`;
     const hdr = `=== ${who} ===`;
     return L ? `${hdr}\nSubject: ${L.subject}\n\n${L.body}\n` : `${hdr}\n(no letter generated)\n`;
@@ -215,10 +229,14 @@ document.getElementById('q').addEventListener('input',()=>rerender());
 const dens=document.getElementById('density');
 dens.value = LS('density',3); document.documentElement.style.setProperty('--cols', LS('density',3));
 dens.addEventListener('input',e=>{ setLS('density',+e.target.value); document.documentElement.style.setProperty('--cols', e.target.value); });
+const mfrToggle=document.getElementById('showMfr');
+mfrToggle.checked = LS('showMfr',false);
+mfrToggle.addEventListener('change',e=>{ setLS('showMfr',e.target.checked); rerender(); });
 
 document.getElementById('grid').addEventListener('click',e=>{
   const card=e.target.closest('.card'); if(!card) return;
   if(e.target.matches('[data-gen]')) return genLetter(card);
+  if(e.target.matches('[data-regen]')) return genLetter(card,{regenerate:true});
   if(e.target.matches('[data-copy]')){ const ta=card.querySelector('textarea'); navigator.clipboard.writeText(`Subject: ${card.querySelector('.subj').textContent.replace(/^Subject: /,'')}\n\n${ta.value}`); e.target.textContent='Copied ✓'; setTimeout(()=>e.target.textContent='Copy letter',1200); }
 });
 document.getElementById('grid').addEventListener('change',e=>{
diff --git a/server.js b/server.js
index df94aab..7e9b9fa 100644
--- a/server.js
+++ b/server.js
@@ -2,12 +2,16 @@
 //   GET  /                 → the 3-tab pitch viewer (public/index.html)
 //   GET  /healthz          → liveness
 //   GET  /api/lists        → data/lists.json (the 3 built lists)
-//   POST /api/letter       → compose a concise follow-up letter for one row,
-//                            grounded in the client's last Gmail thread (George READ).
-//                            Returns { subject, body, context } — NEVER sends/drafts.
+//   POST /api/letter       → compose a CUSTOMIZED follow-up letter for one row,
+//                            grounded in the client's last Gmail thread (George READ)
+//                            + all the project facts we hold. Returns { subject, body,
+//                            context, variant, engine }. NEVER sends/drafts.
 //
-// No Gmail drafts, no sends. Steve SELECTS pitches in the viewer; selection is
-// local (localStorage) + an optional export. George is used read-only for context.
+// HARD RULE (Steve 2026-06-30): NEVER expose a manufacturer number (mfr SKU /
+// article code) in the UI or in a CLIENT-facing letter unless explicitly asked
+// (the "show mfr#" toggle). Vendor-chase notes (list3) may name the SKU because
+// the recipient IS the vendor. mfr scrubbing is applied defensively on both the
+// inputs and the generated output for client lists.
 
 import express from 'express';
 import fs from 'node:fs';
@@ -19,8 +23,10 @@ const PORT = Number(process.env.PORT || 9768);
 const DATA_DIR = path.join(__dirname, 'data');
 const LISTS = path.join(DATA_DIR, 'lists.json');
 const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
-// Local George basic auth is admin:<empty> unless GEORGE_BASIC_AUTH_PASS is set.
 const GEORGE_AUTH = 'Basic ' + Buffer.from(`admin:${process.env.GEORGE_BASIC_AUTH_PASS || ''}`).toString('base64');
+// Local LLM for genuine customization ($0). Falls back to deterministic compose if unreachable.
+const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
+const LETTER_MODEL = process.env.LETTER_MODEL || 'hermes3:8b';
 
 const app = express();
 app.use(express.json({ limit: '512kb' }));
@@ -44,6 +50,24 @@ app.get('/api/lists', (_req, res) => {
   res.type('application/json').send(fs.readFileSync(LISTS, 'utf8'));
 });
 
+// --- mfr-number scrub (HARD RULE) -----------------------------------
+// Drop any token that looks like a manufacturer SKU / article number: an
+// alphanumeric run (optionally hyphenated) that CONTAINS a digit. Keeps real
+// words (pattern + brand names like "Oxel Cork", "Phillipe Romano").
+function stripMfr(s) {
+  if (!s) return '';
+  return String(s)
+    .replace(/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/g, (tok) => (/\d/.test(tok) ? '' : tok))
+    .replace(/\s*[|·•—–-]+\s*/g, ' · ')
+    .replace(/(?:^[\s·]+|[\s·]+$)/g, '')
+    .replace(/(?:·\s*){2,}/g, '· ')
+    .replace(/\s{2,}/g, ' ')
+    .replace(/\bSample\b\s*$/i, '')
+    .trim().replace(/[·\s]+$/, '');
+}
+// Clean a pattern/detail label for client eyes (name only). showMfr bypasses.
+const clientLabel = (s, showMfr) => (showMfr ? String(s || '').trim() : stripMfr(s));
+
 // --- George read-only context ---------------------------------------
 async function georgeContext(email) {
   if (!email) return { found: false };
@@ -65,83 +89,111 @@ async function georgeContext(email) {
   return { found: false };
 }
 
-// --- letter composition (deterministic, editable, $0 local) ----------
+// --- structured facts we hand to the writer (mfr-scrubbed for clients) ----
 function firstName(company) {
   const t = String(company || '').trim().split(/[\s,]+/)[0] || 'there';
   return /^account$/i.test(t) ? 'there' : t;
 }
-function composeLetter(list, row, ctx) {
+function buildFacts(list, row, ctx, showMfr) {
+  const f = [];
   const name = firstName(row.company);
-  const ref = ctx.found
-    ? `\n\nWhen we last connected${ctx.subject ? ` about "${ctx.subject}"` : ''}, I wanted to make sure nothing fell through the cracks.`
-    : '';
-  if (list === 'list3') {
-    // Vendor-facing chase note.
-    const vendor = row.vendor || 'team';
-    return {
-      subject: `Sample memo follow-up — ${row.sku || row.pattern || 'pending request'}`,
-      body:
-`Hi ${vendor},
-
-Following up on a sample memo we requested ${row.req_iso ? `on ${new Date(row.req_iso).toLocaleDateString()}` : 'recently'} — it's now ${row.days_overdue} days out and we haven't received it.
-
-  • Pattern: ${row.pattern || '—'}
-  • SKU: ${row.sku || '—'}
-  • For our client: ${row.company || `account ${row.account}`}
-
-Could you confirm whether this shipped, and if not, the expected ship date? Our client is waiting on it. Happy to resend the request if it didn't come through.
-
-Thank you,
-Designer Wallcoverings`,
-    };
+  if (list === 'list1') {
+    const pats = (row.patterns || []).map((p) => clientLabel(p, showMfr)).filter(Boolean);
+    f.push(`Client: ${row.company || 'this client'}`);
+    f.push(`They received ${row.sent} of ${row.ordered} sample(s) they requested${row.missing ? ` (${row.missing} still in transit)` : ' — all in hand'}.`);
+    if (pats.length) f.push(`Pattern(s) by name: ${pats.slice(0, 4).join(', ')}.`);
+    f.push(`Goal: warmly convert sampling into an order; offer to confirm stock/pricing or send coordinating options.`);
+  } else if (list === 'list2') {
+    const dets = (row.details || []).map((d) => clientLabel(d, showMfr)).filter(Boolean);
+    f.push(`Client: ${row.company || 'this client'}`);
+    f.push(`History: ${row.invoice_count} sample-only invoice(s); never placed a firm order.`);
+    if (dets.length) f.push(`Sampled (by name): ${dets.slice(0, 4).join('; ')}.`);
+    f.push(`Goal: re-engage warmly, see if samples landed, offer next step (stock, pricing, more options).`);
   }
-  if (list === 'list2') {
-    return {
-      subject: `Following up on your Designer Wallcoverings samples`,
-      body:
-`Hi ${name},
-
-I wanted to follow up on the samples we recently sent over${row.details && row.details[0] ? ` (${row.details[0].replace(/\s*-\s*Sample\s*$/i, '')})` : ''}. I hope they arrived safely and gave you a good feel for the material.${ref}
-
-Have you had a chance to review them? If you're ready to move forward I can confirm current stock and pricing, and if you'd like to see anything else — a different colorway, scale, or coordinating pattern — just say the word and I'll get options out to you.
+  if (ctx && ctx.found) {
+    f.push(`Most recent email thread${ctx.subject ? ` was titled "${clientLabel(ctx.subject, showMfr)}"` : ''}${ctx.date ? ` (${new Date(ctx.date).toLocaleDateString()})` : ''}.`);
+    if (ctx.snippet) f.push(`That thread snippet: "${clientLabel(ctx.snippet, showMfr)}".`);
+  }
+  return { name, facts: f };
+}
 
-Always happy to help however is easiest for you.
+// --- LLM letter (genuinely customized, varied by seed) ---------------
+async function composeLLM(list, row, ctx, seed, showMfr) {
+  const { name, facts } = buildFacts(list, row, ctx, showMfr);
+  const sys = `You write short, warm, genuinely personal follow-up emails for Designer Wallcoverings, a luxury wallcovering house, to design clients. Voice: gracious, specific, never pushy, never templated. HARD RULES: (1) NEVER include any manufacturer SKU, article number, or product code — refer to products by NAME only. (2) 110-160 words. (3) No placeholders or brackets. (4) Open with "Hi ${name}," and close with "Warm regards,\\nDesigner Wallcoverings". (5) Weave in the specific facts so it reads one-to-one. Return STRICT JSON: {"subject": "...", "body": "..."} and nothing else.`;
+  const usr = `Write variant #${seed} (make it distinct from other variants in opening + framing). Facts:\n- ${facts.join('\n- ')}`;
+  const body = {
+    model: LETTER_MODEL, stream: false, format: 'json',
+    options: { temperature: 0.55 + ((seed - 1) % 4) * 0.13, seed: 1000 + seed },
+    messages: [{ role: 'system', content: sys }, { role: 'user', content: usr }],
+  };
+  const r = await fetch(`${OLLAMA}/api/chat`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body), signal: AbortSignal.timeout(30000),
+  });
+  if (!r.ok) throw new Error(`ollama ${r.status}`);
+  const j = await r.json();
+  const out = JSON.parse(j.message?.content || '{}');
+  let subject = String(out.subject || '').trim();
+  let letter = String(out.body || '').trim();
+  if (!letter) throw new Error('empty LLM letter');
+  if (!showMfr) { subject = stripMfr(subject) || subject; letter = scrubBodyMfr(letter); }
+  return { subject: subject || 'Following up on your Designer Wallcoverings samples', body: letter, engine: LETTER_MODEL };
+}
+// line-safe scrub for a full letter body (only nukes SKU-ish tokens, keeps prose)
+function scrubBodyMfr(text) {
+  return String(text).replace(/\b(?=[A-Za-z]*\d)[A-Za-z]{1,5}[A-Za-z0-9]*-?\d[A-Za-z0-9-]*\b/g, '').replace(/ {2,}/g, ' ');
+}
 
-Warm regards,
-Designer Wallcoverings`,
+// --- deterministic fallback (rich + seed-varied) ---------------------
+function composeDeterministic(list, row, ctx, seed, showMfr) {
+  const { name } = buildFacts(list, row, ctx, showMfr);
+  const ref = ctx && ctx.found
+    ? ` When we last connected${ctx.subject ? ` about "${clientLabel(ctx.subject, showMfr)}"` : ''}, I wanted to make sure nothing slipped through the cracks.` : '';
+  const opens = [
+    `I wanted to follow up on the samples we sent your way`,
+    `I've been thinking about your project and wanted to check back in on the samples we sent`,
+    `Just circling back on the samples now in your hands`,
+  ];
+  const closes = ['Warm regards,', 'With thanks,', 'Always happy to help —'];
+  const op = opens[(seed - 1) % opens.length];
+  const cl = closes[(seed - 1) % closes.length];
+  if (list === 'list3') { // vendor chase — SKU is appropriate (recipient is the vendor)
+    const vendor = row.vendor || 'team';
+    return {
+      subject: `Sample memo follow-up — ${row.pattern || row.sku || 'pending request'}`,
+      body: `Hi ${vendor},\n\nFollowing up on a sample memo we requested ${row.req_iso ? `on ${new Date(row.req_iso).toLocaleDateString()}` : 'recently'} — it's now ${row.days_overdue} days out and we haven't received it.\n\n  • Pattern: ${row.pattern || '—'}\n  • SKU: ${row.sku || '—'}\n  • For our client: ${row.company || `account ${row.account}`}\n\nCould you confirm whether this shipped, and if not, the expected ship date? Our client is waiting on it.\n\nThank you,\nDesigner Wallcoverings`,
+      engine: 'deterministic',
     };
   }
-  // list1 — samples sent, near-complete
-  const pats = (row.patterns || []).slice(0, 4).join(', ');
+  const pats = list === 'list1'
+    ? (row.patterns || []).map((p) => clientLabel(p, showMfr)).filter(Boolean).slice(0, 3).join(', ')
+    : (row.details || []).map((d) => clientLabel(d, showMfr)).filter(Boolean).slice(0, 2).join('; ');
   return {
-    subject: `Following up on your wallpaper samples`,
-    body:
-`Hi ${name},
-
-Just checking in on the samples we sent your way${pats ? ` — ${pats}` : ''}. I hope you've had a chance to live with them and see how they read in your space.${ref}
-
-If any of them feel right, I'd love to help with the next step — I can confirm stock, pull current pricing, or send a few coordinating options to round out the look. And if none quite landed, tell me what's missing and I'll curate a fresh set.
-
-No rush at all — just here whenever you're ready.
-
-Warm regards,
-Designer Wallcoverings`,
+    subject: list === 'list2' ? `Following up on your Designer Wallcoverings samples` : `Following up on your wallpaper samples`,
+    body: `Hi ${name},\n\n${op}${pats ? ` — ${pats}` : ''}. I hope they arrived safely and gave you a real feel for the material.${ref}\n\nHave you had a chance to live with them? If any feel right, I'd love to help with the next step — I can confirm current stock and pricing, or pull a few coordinating options to round out the look. And if none quite landed, tell me what's missing and I'll curate a fresh set.\n\nNo rush at all — just here whenever you're ready.\n\n${cl}\nDesigner Wallcoverings`,
+    engine: 'deterministic',
   };
 }
 
 app.post('/api/letter', async (req, res) => {
   try {
-    const { list, row } = req.body || {};
+    const { list, row, seed = 1, showMfr = false } = req.body || {};
     if (!list || !row) return res.status(400).json({ error: 'list + row required' });
-    // Vendor chase (list3) doesn't have a client email to search.
     const ctx = list === 'list3' ? { found: false } : await georgeContext(row.email);
-    const { subject, body } = composeLetter(list, row, ctx);
-    res.json({ subject, body, context: ctx, cost: '$0 (local + George read)' });
+    let result;
+    if (list === 'list3') {
+      result = composeDeterministic(list, row, ctx, seed, showMfr); // vendor note stays deterministic
+    } else {
+      try { result = await composeLLM(list, row, ctx, seed, showMfr); }
+      catch (e) { result = { ...composeDeterministic(list, row, ctx, seed, showMfr), engineNote: 'LLM unavailable (' + e.message + ') — deterministic fallback' }; }
+    }
+    res.json({ subject: result.subject, body: result.body, context: ctx, variant: seed, engine: result.engine, engineNote: result.engineNote, cost: '$0 (local + George read)' });
   } catch (e) {
     res.status(500).json({ error: e.message });
   }
 });
 
 app.listen(PORT, '127.0.0.1', () => {
-  console.log(`[dw-pitch-followup] listening on http://127.0.0.1:${PORT}  (auth ${BASIC_AUTH.split(':')[0]} / …)`);
+  console.log(`[dw-pitch-followup] listening on http://127.0.0.1:${PORT}  (auth ${BASIC_AUTH.split(':')[0]} / …)  letters via ${LETTER_MODEL}`);
 });

← 91a8b82 auto-save: 2026-06-30T14:41:08 (1 files) — deploy/  ·  back to Dw Pitch Followup  ·  ui: show last-sample-sent date as a chip on every card (uses d2a48d9 →