← back to Small Business Builder
scripts/generate-roasts.mjs
113 lines
// Roast generator — for every website_analyses row, pipe its title +
// description + live_audit signals through qwen3:14b with a "design roast"
// system prompt. Writes the roast to summary_json.roast.
//
// Used by /wall to populate the marquee + per-card roast snippet.
//
// node scripts/generate-roasts.mjs # all unset
// node scripts/generate-roasts.mjs --rebuild # re-roast everyone
// node scripts/generate-roasts.mjs --limit 10
import 'dotenv/config';
import { many, query } from '../src/lib/db.js';
const OLLAMA = 'http://192.168.1.133:11434';
const MODEL = 'qwen3:14b';
const args = new Set(process.argv.slice(2));
const REBUILD = args.has('--rebuild');
const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > 0 ? parseInt(process.argv[i+1], 10) || 0 : 0; })();
const SYSTEM = `You are a stand-up comic who specializes in roasting bad website design. Your roasts are SAVAGE BUT AFFECTIONATE — you want the founder to laugh and then fix things. You have the timing of Bo Burnham and the warmth of Mitch Hedberg.
Given a project's name, vertical, and known audit issues, write a roast that is:
- Exactly ONE punchy paragraph (2-4 sentences, max 60 words)
- Starts with a specific observation, lands with a tight one-liner
- Specific to the project — never generic "your site is broken" filler
- Tongue-in-cheek, never mean-spirited
- /no_think — output the roast directly, no preamble, no <think> blocks
Output ONLY the roast text. No quotes, no markdown, no JSON. Just the paragraph.`;
function buildPrompt(a) {
const meta = a.meta_json || {};
const sum = a.summary_json || {};
const cp = sum.copy_pack || {};
const live = sum.live_audit || {};
const issues = (live.top_3_issues || []).slice(0, 3).join('; ');
return `/no_think
PROJECT: ${a.slug}
VERTICAL: ${a.vertical || 'generic'}
HEADLINE: ${cp.headline || sum.title || meta.title || a.slug}
TAGLINE: ${(cp.tagline || sum.tagline || meta.tagline || '').slice(0, 240)}
LIVE-AUDIT GRADE: ${live.grade?.letter || '?'} (${live.grade?.score ?? 'no fetch'})
KNOWN ISSUES: ${issues || '(no live audit run)'}
Write the roast.`;
}
async function callOllama(prompt) {
const res = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(180_000),
body: JSON.stringify({
model: MODEL,
system: SYSTEM,
prompt,
stream: false,
options: { temperature: 0.95, num_predict: 350 },
}),
});
if (!res.ok) throw new Error(`ollama ${res.status}`);
const data = await res.json();
let txt = (data.response || '').trim();
txt = txt.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
txt = txt.replace(/^["']|["']$/g, ''); // strip wrapping quotes
txt = txt.replace(/^```[a-z]*\s*|```\s*$/gi, '').trim();
// Some qwen3 outputs lead with "Here's a roast:" — strip prefaces.
txt = txt.replace(/^(here'?s|here is|roast:?|sure|okay|alright)[^.]*[:.]?\s*/i, '').trim();
return txt;
}
async function roastOne(a) {
if (!REBUILD && a.summary_json?.roast) return { slug: a.slug, status: 'skip' };
const t0 = Date.now();
let txt = await callOllama(buildPrompt(a));
// Retry once on bad length — qwen3 occasionally returns nothing or
// an over-long monologue. A second sample with same prompt usually
// produces a usable roast.
if (!txt || txt.length < 30 || txt.length > 600) {
txt = await callOllama(buildPrompt(a));
}
if (!txt || txt.length < 30 || txt.length > 600) throw new Error(`bad roast length ${txt?.length || 0}`);
await query(
`UPDATE website_analyses
SET summary_json = COALESCE(summary_json, '{}'::jsonb) || $1::jsonb,
updated_at=NOW()
WHERE id=$2`,
[JSON.stringify({ roast: { text: txt, model: MODEL, generated_at: new Date().toISOString() } }), a.id]
);
return { slug: a.slug, status: 'ok', ms: Date.now() - t0, preview: txt.slice(0, 80) };
}
async function main() {
let rows = await many('SELECT id, slug, vertical, summary_json, meta_json FROM website_analyses ORDER BY id');
if (LIMIT > 0) rows = rows.slice(0, LIMIT);
console.log(`Roasting ${rows.length} via ${MODEL} @ ${OLLAMA} (REBUILD=${REBUILD})`);
let ok = 0, skip = 0, err = 0;
for (let i = 0; i < rows.length; i += 1) {
try {
const r = await roastOne(rows[i]);
if (r.status === 'ok') { ok += 1; console.log(`[${String(i+1).padStart(3,' ')}/${rows.length}] ${r.ms}ms ${r.slug.padEnd(36,' ')} → ${r.preview}…`); }
else { skip += 1; }
} catch (e) {
err += 1;
console.error(`[${String(i+1).padStart(3,' ')}/${rows.length}] ERR ${rows[i].slug}: ${e.message}`);
}
}
console.log(`\nDONE — ok:${ok} skip:${skip} err:${err}`);
process.exit(0);
}
main().catch(e => { console.error(e); process.exit(1); });