← back to Small Business Builder

scripts/enrich-copy-local.mjs

125 lines

// Enrich every website_analyses row's copy via local Ollama on Mac Studio 1.
// Zero Claude tokens. Sequential (Mac1 has OLLAMA_MAX_LOADED_MODELS=1).
//
//   node scripts/enrich-copy-local.mjs              # all unset
//   node scripts/enrich-copy-local.mjs --rebuild    # re-enrich everyone
//   node scripts/enrich-copy-local.mjs --limit 5    # dry test on 5
//
// Writes summary_json.copy_pack = { headline, tagline, cta_primary, cta_secondary, cards: [3] }
// Re-rendering mockups with the new copy is a separate `node scripts/build-all-analyses.mjs --rebuild`.

import 'dotenv/config';
import { many, one, 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 senior copywriter for small businesses. Given a project's name, vertical, and existing description, produce *clean*, *human*, *specific* marketing copy. No corporate fluff. Output VALID JSON only — no markdown, no preamble, no explanation. Schema:
{
  "headline":     "string (5-9 words, sentence case, no period at end)",
  "tagline":      "string (12-22 words, declarative)",
  "cta_primary":  "string (2-4 words, verb-led)",
  "cta_secondary":"string (1-3 words, casual alt)",
  "cards": [
    { "title": "string (2-3 words, ALL CAPS)", "body": "string (12-20 words)" },
    { "title": "string (2-3 words, ALL CAPS)", "body": "string (12-20 words)" },
    { "title": "string (2-3 words, ALL CAPS)", "body": "string (12-20 words)" }
  ]
}`;

function buildPrompt(a) {
  const meta = a.meta_json || {};
  const sum = a.summary_json || {};
  return `PROJECT: ${a.slug}
VERTICAL: ${a.vertical || 'generic'}
TITLE: ${sum.title || meta.title || a.slug}
DESCRIPTION: ${(sum.tagline || meta.tagline || '').slice(0, 600) || '(none — invent something appropriate to vertical)'}

Output JSON only.`;
}

async function callOllama(prompt) {
  const res = await fetch(`${OLLAMA}/api/generate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    signal: AbortSignal.timeout(120_000),
    body: JSON.stringify({
      model: MODEL,
      system: SYSTEM,
      prompt,
      stream: false,
      // Note: NOT using format:'json' — qwen3:14b returns empty {} under that
      // constraint. Better to let it generate freely and regex out the {…} block.
      options: { temperature: 0.7, num_predict: 700 },
    }),
  });
  if (!res.ok) throw new Error(`ollama ${res.status}`);
  const data = await res.json();
  const txt = (data.response || '').trim();
  // qwen3 wraps JSON sometimes in <think>...</think> blocks
  const cleaned = txt.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
  // Strip ```json fences if model wrapped it.
  const unfenced = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '');
  const m = unfenced.match(/\{[\s\S]*\}/);
  if (!m) throw new Error('no json in response');
  // Repair common qwen3 mistakes: trailing commas + smart quotes + unescaped
  // newlines inside string values.
  let lenient = m[0]
    .replace(/,(\s*[}\]])/g, '$1')          // trailing commas
    .replace(/[“”]/g, '"')        // smart double-quotes
    .replace(/[‘’]/g, "'");       // smart single-quotes
  try { return JSON.parse(lenient); } catch (e) {
    // Last resort: replace literal newlines inside quoted strings with spaces.
    lenient = lenient.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (_, s) => '"' + s.replace(/[\r\n]+/g, ' ') + '"');
    return JSON.parse(lenient);
  }
}

function valid(pack) {
  if (!pack
    || typeof pack.headline !== 'string' || pack.headline.length < 4
    || typeof pack.tagline !== 'string' || pack.tagline.length < 12
    || typeof pack.cta_primary !== 'string'
    || !Array.isArray(pack.cards) || pack.cards.length < 2 || pack.cards.length > 4
    || !pack.cards.every(c => c && typeof c.title === 'string' && typeof c.body === 'string')) return false;
  // Normalize: pad to 3 cards or trim. Pad by repeating last card if model returned only 2.
  if (pack.cards.length === 2) pack.cards.push({ ...pack.cards[1] });
  if (pack.cards.length === 4) pack.cards = pack.cards.slice(0, 3);
  return true;
}

async function enrichOne(a) {
  if (!REBUILD && a.summary_json?.copy_pack) return { slug: a.slug, status: 'skip' };
  const t0 = Date.now();
  const pack = await callOllama(buildPrompt(a));
  if (!valid(pack)) throw new Error('invalid pack shape');
  const newSummary = { ...(a.summary_json || {}), copy_pack: { ...pack, generated_at: new Date().toISOString(), model: MODEL } };
  await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2',
              [JSON.stringify(newSummary), a.id]);
  return { slug: a.slug, status: 'ok', ms: Date.now() - t0, headline: pack.headline };
}

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(`enriching ${rows.length} records 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 enrichOne(rows[i]);
      if (r.status === 'ok')   { ok+=1;   console.log(`[${String(i+1).padStart(3,' ')}/${rows.length}] ${r.ms}ms  ${r.slug.padEnd(40,' ')} → ${r.headline}`); }
      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); });