← 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
M src/jobs/generate_features.ts
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 →