[object Object]

← 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

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||'')+'&quote='+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=&quote= — 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 →