← 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); });