← back to Dw Pitch Followup
pitch: auto follow-up triage — reads the email thread → Follow up? YES/NO + notes (delays, deadlines, status)
8e921dc572895df2f75436baea06448e1ff08f3e · 2026-07-15 10:09:49 -0700 · Steve
New /api/followup-analysis reads the client thread via George and runs local Ollama (hermes3:8b, $0) to decide follow-up yes/no with a reason, concrete notes (delays/deadlines/promises/status), and a deadline date. Drawer shows a prominent Follow up? badge (green YES / red NO) auto-loaded on open, cached 6h per client, with a ↻ re-analyze. Deadline sanitized to real date-or-empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M public/index.htmlM server.js
Diff
commit 8e921dc572895df2f75436baea06448e1ff08f3e
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 15 10:09:49 2026 -0700
pitch: auto follow-up triage — reads the email thread → Follow up? YES/NO + notes (delays, deadlines, status)
New /api/followup-analysis reads the client thread via George and runs local Ollama (hermes3:8b, $0) to decide follow-up yes/no with a reason, concrete notes (delays/deadlines/promises/status), and a deadline date. Drawer shows a prominent Follow up? badge (green YES / red NO) auto-loaded on open, cached 6h per client, with a ↻ re-analyze. Deadline sanitized to real date-or-empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
public/index.html | 49 +++++++++++++++++++++++++++++++++++++++++++++++--
server.js | 44 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 91 insertions(+), 2 deletions(-)
diff --git a/public/index.html b/public/index.html
index 4e57e0c..ced0f75 100644
--- a/public/index.html
+++ b/public/index.html
@@ -42,6 +42,18 @@
.pgallery a{color:var(--gold);text-decoration:none;font-size:13px}
.pgallery a:hover{text-decoration:underline}
.pgallery .pg-name{font-size:13px;color:var(--ink)}
+ .followup{background:var(--panel2);border:1px solid var(--line);border-radius:8px}
+ .fu-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+ .fu-badge{font-weight:700;font-size:12.5px;padding:2px 10px;border-radius:12px;border:1px solid var(--line);color:var(--mut)}
+ .fu-badge.yes{background:#16351c;color:#7ee093;border-color:#2f6b3d}
+ .fu-badge.no{background:#3a1512;color:#f0a3a0;border-color:#7a3330}
+ .fu-badge.pend{opacity:.7}
+ .fu-re{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:6px;cursor:pointer;font-size:12px;padding:1px 7px}
+ .fu-re:hover{color:var(--gold);border-color:var(--gold)}
+ .fu-src{font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;margin-left:auto}
+ .fu-notes .fu-reason{margin-top:6px;font-size:13px;color:var(--ink)}
+ .fu-notes .fu-dl{margin-top:4px;font-size:12px;color:var(--amber)}
+ .fu-notes .fu-ul{margin:6px 0 0;padding-left:18px;font-size:12.5px;color:var(--mut);line-height:1.45}
.emhdr{cursor:pointer;font-weight:600;user-select:none;display:flex;align-items:center;gap:6px}
.emhdr:hover{color:var(--gold)}
.emhdr .emcaret{color:var(--mut);font-size:11px}
@@ -394,7 +406,9 @@ function merchBlock(r){
return out;
}
function panelContextHTML(list,r){
- let html=discoBanner(r)+postSampleBlock(r);
+ let html='';
+ if(list!=='list3') html+=`<div class="drow followup"><div class="fu-head">🧭 <b>Follow up?</b> <span class="fu-badge pend" data-fubadge>analyzing…</span> <button class="fu-re" data-fu-re title="Re-analyze the thread" hidden>↻</button><span class="fu-src">from email thread · local AI</span></div><div class="fu-notes" data-funotes></div></div>`;
+ html+=discoBanner(r)+postSampleBlock(r);
if(r.project) html+=`<div class="drow">🏠 <b>Current project:</b> ${esc(lbl(r.project))}</div>`;
if(list!=='list3') html+=merchBlock(r);
html+=samplesBlock(r);
@@ -428,7 +442,7 @@ function openPanelFor(card,{generate=false}={}){
if(reorder){ document.getElementById('pSubj').innerHTML='<span class="reorder-note">Reorder Samples?? — nothing has shipped yet, so there’s nothing to follow up on.</span>'; }
openPanel();
renderLetterGallery(r);
- if(list!=='list3') loadLastCorr(r); // emails load on demand when the collapsed section is expanded
+ if(list!=='list3'){ loadLastCorr(r); loadFollowup(r); } // emails load on demand when the collapsed section is expanded
if(generate && !reorder) composeLetter({});
}
// Small product strip under the letter: thumbnail + link to the DW product page for each
@@ -460,6 +474,35 @@ function paintLastCorr(wrap,span,val){
wrap.classList.add('found');
span.innerHTML=`${esc(fmtWhen(val.date))}${val.subject?` · "${esc(val.subject.slice(0,60))}"`:''}${val.account?` <span class="meta">(${esc(val.account)})</span>`:''}`;
}
+// Auto follow-up triage: reads the client's email thread → Yes/No + notes (local AI, $0).
+// Cached per client for 6h so re-opening is instant; ↻ forces a fresh read.
+async function loadFollowup(r,force){
+ const badge=document.querySelector('#pCtx [data-fubadge]'), notes=document.querySelector('#pCtx [data-funotes]'), reb=document.querySelector('#pCtx [data-fu-re]');
+ if(!badge)return; const email=(r.email||'').trim();
+ if(!email){badge.textContent='no email on file';badge.className='fu-badge';return}
+ const ck='followup:'+email;
+ if(!force){const c=LS(ck,null); if(c&&c.v&&(Date.now()-(c.at||0)<6*3600e3)){paintFollowup(badge,notes,reb,c.v);return}}
+ badge.textContent='analyzing thread…'; badge.className='fu-badge pend'; if(notes)notes.innerHTML=''; if(reb)reb.hidden=true;
+ const q=(Array.isArray(r.merch_invoices)?r.merch_invoices:[]).find(i=>!i.booked);
+ const quote=q&&q.invoice?('#'+q.invoice):'';
+ try{
+ const v=await (await fetch(API('/api/followup-analysis?email='+encodeURIComponent(email)+'&company='+encodeURIComponent(r.company||'')+'"e='+encodeURIComponent(quote)))).json();
+ setLS(ck,{at:Date.now(),v}); paintFollowup(badge,notes,reb,v);
+ }catch(e){ badge.textContent='analysis failed'; badge.className='fu-badge'; if(reb)reb.hidden=false; }
+}
+function paintFollowup(badge,notes,reb,v){
+ const d=v.followUp||'unknown';
+ badge.textContent = d==='yes'?'YES — follow up':(d==='no'?'NO — hold':'—');
+ badge.className='fu-badge '+(d==='yes'?'yes':(d==='no'?'no':''));
+ let html='';
+ if(v.count===0){ html='<div class="fu-reason">No email history with this client to analyze.</div>'; }
+ else{
+ if(v.reason) html+=`<div class="fu-reason">${esc(v.reason)}</div>`;
+ if(v.deadline) html+=`<div class="fu-dl">⏰ deadline ${esc(v.deadline)}</div>`;
+ if(Array.isArray(v.notes)&&v.notes.length) html+='<ul class="fu-ul">'+v.notes.map(n=>`<li>${esc(n)}</li>`).join('')+'</ul>';
+ }
+ if(notes)notes.innerHTML=html; if(reb)reb.hidden=false;
+}
// Full email thread history with this client (both directions), loaded when the drawer opens.
async function loadEmails(r){
const el=document.querySelector('#pCtx [data-emails]'); if(!el||el.dataset.loaded)return;
@@ -603,6 +646,8 @@ document.getElementById('sentBtn').addEventListener('click',openSentLog);
document.getElementById('pClose').addEventListener('click',()=>{document.body.classList.remove('log-mode');closePanel();});
// Expand/collapse the client's email history — lazy-load on first expand (collapsed by default).
document.getElementById('pCtx').addEventListener('click',e=>{
+ // 0) re-run the follow-up analysis
+ if(e.target.closest('[data-fu-re]')){ if(active&&active.row)loadFollowup(active.row,true); return; }
// 1) expand/collapse the whole email-history section
const t=e.target.closest('[data-emtoggle]');
if(t){
diff --git a/server.js b/server.js
index 9b8cb8f..639383f 100644
--- a/server.js
+++ b/server.js
@@ -499,6 +499,50 @@ app.get('/api/emails', async (req, res) => {
}
});
+// Read the client's recent thread + decide whether to follow up, with operational notes.
+// Local LLM (Ollama) → $0. Returns { followUp:'yes'|'no'|'unknown', reason, notes[], deadline }.
+async function analyzeFollowup(email, context = {}) {
+ const emails = await georgeEmails(email, 12);
+ if (!emails.length) return { followUp: 'unknown', reason: 'no email history with this client', notes: [], deadline: '', count: 0 };
+ const today = new Date().toISOString().slice(0, 10);
+ const thread = emails.map((m) => `[${m.dir === 'in' ? 'CLIENT→us' : 'us→CLIENT'} ${String(m.date).slice(0, 16)}] ${m.subject} :: ${m.snippet}`).join('\n');
+ const sys = `You are a sales follow-up triage assistant for Designer Wallcoverings. Given recent email correspondence with a client, decide whether the sales team should PROACTIVELY FOLLOW UP with the client now, and extract operational notes. Today is ${today}. Return STRICT JSON: {"followUp":"yes"|"no","reason":"<=12 words","notes":["short bullet"],"deadline":"YYYY-MM-DD or empty"}.
+Decide "yes" (nudge the client) when: the client asked something we haven't answered; we sent a quote/samples and they went quiet; we promised an update and time passed; a deadline is near; or an open thread has gone cold.
+Decide "no" when: the ball is clearly in OUR court and we acted very recently; the client just replied and we owe the next step within normal time; an order was just placed or shipped (no chase needed); or they declined/unsubscribed.
+notes: 2-4 terse, concrete bullets on delays, deadlines, promises made, or current status. No fluff.`;
+ const usr = `Client: ${context.company || email} <${email}>${context.quote ? ` — has an OPEN QUOTE ${context.quote} with no order` : ''}\nCorrespondence (newest first):\n${thread}`;
+ try {
+ const r = await fetch(`${OLLAMA}/api/chat`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: LETTER_MODEL, stream: false, format: 'json', keep_alive: '30m', options: { temperature: 0.2 }, messages: [{ role: 'system', content: sys }, { role: 'user', content: usr }] }),
+ signal: AbortSignal.timeout(90000),
+ });
+ if (!r.ok) throw new Error(`ollama ${r.status}`);
+ const out = JSON.parse((await r.json()).message?.content || '{}');
+ const decision = /^y/i.test(String(out.followUp || '')) ? 'yes' : (/^n/i.test(String(out.followUp || '')) ? 'no' : 'unknown');
+ return {
+ followUp: decision,
+ reason: String(out.reason || '').replace(/\s+/g, ' ').slice(0, 160),
+ notes: Array.isArray(out.notes) ? out.notes.map((n) => String(n).replace(/\s+/g, ' ').slice(0, 160)).filter(Boolean).slice(0, 5) : [],
+ deadline: (String(out.deadline || '').match(/\d{4}-\d{2}-\d{2}/) || [''])[0], // real date or nothing (LLM sometimes echoes "or empty")
+ count: emails.length,
+ };
+ } catch (e) {
+ return { followUp: 'unknown', reason: 'analysis unavailable', notes: [], deadline: '', count: emails.length, error: e.message };
+ }
+}
+
+// GET /api/followup-analysis?email=&company="e= — Yes/No + notes from the email thread (local $0).
+app.get('/api/followup-analysis', async (req, res) => {
+ try {
+ const email = String(req.query.email || '').trim();
+ if (!email) return res.json({ followUp: 'unknown', reason: 'no email on file', notes: [] });
+ res.json(await analyzeFollowup(email, { company: String(req.query.company || ''), quote: String(req.query.quote || '') }));
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// GET /api/email?id=&account= — one full message, HTML stripped to readable text (expand-in-place).
app.get('/api/email', async (req, res) => {
try {
← e039b5a pitch: click any email in the history to expand its full bod
·
back to Dw Pitch Followup
·
pitch: Follow up? YES/NO chip on every card + per-column ⚡ T 5e218be →