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