← 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
M public/index.htmlM server.js
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=>({'&':'&','<':'<','>':'>','"':'"'}[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 →