[object Object]

← back to Ventura Corridor

fix(generate_features): post-filter retry on banned headline phrases

00f39c036b8bba17707d96b9086ebf22095aa2dd · 2026-05-07 12:29:49 -0700 · Steve

Negative prompt instructions weren't enough — qwen3:14b ignored the ban
list and produced 'Multi-Tenant Building and BJJ Discipline' on the
first regen attempt with the patched prompt. Adding a hard gate: caller
now retries up to 3 times if BANNED_HEADLINE_RE matches, with escalating
temperature and an inline correction prompt. Throws if all 3 attempts
trip the filter (caller catches and skips). Tick-26.

Files touched

Diff

commit 00f39c036b8bba17707d96b9086ebf22095aa2dd
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 7 12:29:49 2026 -0700

    fix(generate_features): post-filter retry on banned headline phrases
    
    Negative prompt instructions weren't enough — qwen3:14b ignored the ban
    list and produced 'Multi-Tenant Building and BJJ Discipline' on the
    first regen attempt with the patched prompt. Adding a hard gate: caller
    now retries up to 3 times if BANNED_HEADLINE_RE matches, with escalating
    temperature and an inline correction prompt. Throws if all 3 attempts
    trip the filter (caller catches and skips). Tick-26.
---
 src/jobs/generate_features.ts | 56 ++++++++++++++++++++++++++-----------------
 1 file changed, 34 insertions(+), 22 deletions(-)

diff --git a/src/jobs/generate_features.ts b/src/jobs/generate_features.ts
index 46f88f8..9bc427f 100644
--- a/src/jobs/generate_features.ts
+++ b/src/jobs/generate_features.ts
@@ -51,35 +51,47 @@ Rules:
 - Avoid superlatives ("the BEST"). Prefer concrete sensory details.
 - Output ONLY the JSON. No preamble, no markdown fences.`;
 
+const BANNED_HEADLINE_RE = /\b(multi[\s-]?tenant\s+building|shared\s+building|office\s+complex|where\s+\w+\s+meets\s+\w+|in\s+\w+'s\s+heart)\b/i;
+
 async function generate(biz: any, model: string): Promise<any> {
-  const userPrompt = `Business: ${biz.name}
+  const baseUserPrompt = `Business: ${biz.name}
 Address: ${biz.address || 'unknown'}, ${biz.city || ''} ${biz.zip || ''}
 Category: ${biz.naics || biz.pitch_type || biz.category || 'unknown'}
 Building context: ${biz.bldg_address ? `One tenant among several at ${biz.bldg_address} — use this only as quiet body color, NOT as the hook` : 'Independent storefront on Ventura Boulevard'}
 
 Write the feature.`;
 
-  // Single attempt — single-stream sequential rarely 503s.
-  // If it does, log and move on; the next loop iter will pick a different biz.
-  const r = await fetch(`${OLLAMA}/api/chat`, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify({
-      model,
-      messages: [
-        { role: 'system', content: SYSTEM },
-        { role: 'user',   content: userPrompt }
-      ],
-      stream: false,
-      options: { temperature: 0.7 },
-      format: 'json'
-    })
-  });
-  if (!r.ok) throw new Error(`ollama ${r.status}: ${(await r.text()).slice(0, 120)}`);
-  const j = await r.json();
-  const raw = j.message?.content || '';
-  try { return JSON.parse(raw); }
-  catch { throw new Error(`bad JSON from model: ${raw.slice(0, 120)}`); }
+  // Up to 2 retries when the headline trips the banned-phrase regex.
+  // Negative prompt instructions don't always hold; this is the hard gate.
+  for (let attempt = 1; attempt <= 3; attempt++) {
+    const userPrompt = attempt === 1
+      ? baseUserPrompt
+      : `${baseUserPrompt}\n\nIMPORTANT: your previous attempt used a banned headline phrase. Pick a DIFFERENT headline angle entirely. Focus on a sensory detail, a specific object, a time of day, or the storefront — never the building category.`;
+    const r = await fetch(`${OLLAMA}/api/chat`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        model,
+        messages: [
+          { role: 'system', content: SYSTEM },
+          { role: 'user',   content: userPrompt }
+        ],
+        stream: false,
+        options: { temperature: 0.7 + attempt * 0.1 },
+        format: 'json'
+      })
+    });
+    if (!r.ok) throw new Error(`ollama ${r.status}: ${(await r.text()).slice(0, 120)}`);
+    const j = await r.json();
+    const raw = j.message?.content || '';
+    let parsed: any;
+    try { parsed = JSON.parse(raw); }
+    catch { throw new Error(`bad JSON from model: ${raw.slice(0, 120)}`); }
+    const h = String(parsed.headline || '');
+    if (!BANNED_HEADLINE_RE.test(h)) return parsed;
+    console.log(`  ↻ banned phrase in headline (attempt ${attempt}/3): "${h.slice(0, 60)}"`);
+  }
+  throw new Error('all 3 attempts produced a banned headline phrase');
 }
 
 async function pickCandidates() {

← 549a1dd tighten .gitignore: add missing standing-rule patterns (dist  ·  back to Ventura Corridor  ·  feat(news): per-business news rail on map detail panel + new 8afd939 →