[object Object]

← back to Dw Pitch Followup

pitch-followup: anti-hallucination guardrails + LLM reliability

9abe8a29698f063d5759822f4c7ae5a5b17eaf4e · 2026-07-07 09:11:31 -0700 · Steve

- Whitelist product names: the writer may ONLY name a pattern that's in the
  client's actual sampled set (buildFacts now returns allowed[]); empty →
  model names nothing, speaks generically. Reject-guard throws on any ™/®
  or 'X Collection' name not on the whitelist → falls back to the accurate
  deterministic draft. No invented collection names can reach a client draft.
- Fix subject-greeting leak (subject is now a standalone line) + guarantee
  the body opens with 'Hi <name>,'; strip stray ™/®.
- Reliability: keep_alive 30m + widen Ollama timeout 30s→90s so drafts stop
  cold-timing-out to the generic fallback. Verified across seeds.

Files touched

Diff

commit 9abe8a29698f063d5759822f4c7ae5a5b17eaf4e
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 09:11:31 2026 -0700

    pitch-followup: anti-hallucination guardrails + LLM reliability
    
    - Whitelist product names: the writer may ONLY name a pattern that's in the
      client's actual sampled set (buildFacts now returns allowed[]); empty →
      model names nothing, speaks generically. Reject-guard throws on any ™/®
      or 'X Collection' name not on the whitelist → falls back to the accurate
      deterministic draft. No invented collection names can reach a client draft.
    - Fix subject-greeting leak (subject is now a standalone line) + guarantee
      the body opens with 'Hi <name>,'; strip stray ™/®.
    - Reliability: keep_alive 30m + widen Ollama timeout 30s→90s so drafts stop
      cold-timing-out to the generic fallback. Verified across seeds.
---
 server.js | 36 +++++++++++++++++++++++++++++++-----
 1 file changed, 31 insertions(+), 5 deletions(-)

diff --git a/server.js b/server.js
index 7e9b9fa..eefe808 100644
--- a/server.js
+++ b/server.js
@@ -97,14 +97,17 @@ function firstName(company) {
 function buildFacts(list, row, ctx, showMfr) {
   const f = [];
   const name = firstName(row.company);
+  let allowed = [];   // the ONLY product names the letter may use — the client's actual samples
   if (list === 'list1') {
     const pats = (row.patterns || []).map((p) => clientLabel(p, showMfr)).filter(Boolean);
+    allowed = pats;
     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);
+    allowed = dets;
     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('; ')}.`);
@@ -114,22 +117,28 @@ function buildFacts(list, row, ctx, showMfr) {
     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 };
+  return { name, facts: f, allowed: allowed.filter(Boolean) };
 }
 
 // --- 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 { name, facts, allowed } = buildFacts(list, row, ctx, showMfr);
+  const allowList = allowed || [];
+  // The whitelist the writer is bound to. Empty → the model must name nothing specific.
+  const allowedLine = allowList.length
+    ? `ALLOWED PATTERN NAMES (the ONLY product names you may write): ${allowList.slice(0, 6).join(', ')}.`
+    : `ALLOWED PATTERN NAMES: none — do NOT name any specific product, pattern, or collection; refer to them generically as "the samples" / "the pieces you received".`;
+  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) You may ONLY name a specific product/pattern/collection if that exact name is in the ALLOWED PATTERN NAMES list; otherwise speak generically ("the samples", "the pieces you received") — NEVER invent, guess, embellish, trademark (no ™/®), or append the word "Collection" to a name. (3) 110-160 words. (4) No placeholders or brackets. (5) Open the BODY with "Hi ${name}," and close with "Warm regards,\\nDesigner Wallcoverings". (6) The SUBJECT is a short standalone subject line (5-9 words) — NOT a greeting; never start it with "Hi", "Hello", or the client's name. Return STRICT JSON: {"subject": "...", "body": "..."} and nothing else.`;
+  const usr = `Write variant #${seed} (make it distinct from other variants in opening + framing).\n${allowedLine}\nFacts:\n- ${facts.join('\n- ')}`;
   const body = {
     model: LETTER_MODEL, stream: false, format: 'json',
+    keep_alive: '30m',   // pin the model resident so back-to-back drafts don't cold-reload past the timeout
     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),
+    body: JSON.stringify(body), signal: AbortSignal.timeout(90000), // headroom for a cold model reload; keep_alive keeps subsequent calls fast
   });
   if (!r.ok) throw new Error(`ollama ${r.status}`);
   const j = await r.json();
@@ -137,6 +146,23 @@ async function composeLLM(list, row, ctx, seed, showMfr) {
   let subject = String(out.subject || '').trim();
   let letter = String(out.body || '').trim();
   if (!letter) throw new Error('empty LLM letter');
+
+  // Anti-hallucination guard: reject any product/collection name not on the whitelist. Signals =
+  // a ™/® glyph (the house never uses them → the model embellished) or a Capitalized phrase followed
+  // by "Collection". Any candidate whose core isn't allow-listed → throw, and the caller falls back
+  // to the accurate deterministic draft. Never lets an invented pattern name reach a client draft.
+  const allowLC = new Set(allowList.map((s) => s.toLowerCase().trim()));
+  const candidates = [];
+  for (const m of letter.matchAll(/([A-Z][A-Za-z'’-]+(?:\s+[A-Z][A-Za-z'’-]+){0,3})\s*(?:™|®|\bCollection\b)/g)) candidates.push(m[1]);
+  const bad = candidates.map((c) => c.trim()).find((c) => c && !allowLC.has(c.toLowerCase()));
+  if (bad) throw new Error(`named an unverified pattern ("${bad}")`);
+
+  // Cleanup: strip stray trademark glyphs, fix subject-greeting leak, ensure the body greeting.
+  const noTM = (s) => s.replace(/[™®©]/g, '').replace(/[ \t]{2,}/g, ' ').trim();
+  subject = noTM(subject); letter = noTM(letter);
+  if (/^\s*(hi|hello|dear|hey)\b/i.test(subject)) subject = 'Following up on your Designer Wallcoverings samples';
+  if (!/^\s*hi\b/i.test(letter)) letter = `Hi ${name},\n\n${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 };
 }

← 9027026 chore: macstudio3 migration — reconcile from mac2 + repoint  ·  back to Dw Pitch Followup  ·  chore: version bump (session close — anti-hallucination guar 2507156 →