← back to Small Business Builder
scripts/enrich-rescue.mjs
117 lines
// One-off rescue for the 3 records that hard-failed even with hardened parser:
// qwen3 was either looping in <think>, timing out at 120s, or emitting empty.
//
// Fix: add /no_think directive (qwen3-specific), bump timeout to 240s, bump
// num_predict to 1500. If all that still fails, fall back to a deterministic
// copy_pack derived from slug + description so the row at least has SOMETHING.
import 'dotenv/config';
import { many, query } from '../src/lib/db.js';
const OLLAMA = 'http://192.168.1.133:11434';
const MODEL = 'qwen3:14b';
const SYSTEM = `You are a senior copywriter. Output VALID JSON only — no markdown, no preamble, no explanation, no <think> blocks. Schema:
{"headline":"5-9 words","tagline":"12-22 words","cta_primary":"2-4 words","cta_secondary":"1-3 words","cards":[{"title":"2-3 ALL CAPS WORDS","body":"12-20 words"},{"title":"...","body":"..."},{"title":"...","body":"..."}]}`;
function buildPrompt(a) {
const meta = a.meta_json || {};
const sum = a.summary_json || {};
// /no_think is a qwen3 directive that disables the <think> block.
return `/no_think
PROJECT: ${a.slug}
VERTICAL: ${a.vertical || 'generic'}
TITLE: ${(sum.title || meta.title || a.slug).slice(0, 200)}
DESCRIPTION: ${(sum.tagline || meta.tagline || '').slice(0, 400) || '(none)'}
Output JSON only.`;
}
async function callOllama(prompt) {
const res = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(240_000),
body: JSON.stringify({
model: MODEL,
system: SYSTEM,
prompt,
stream: false,
options: { temperature: 0.5, num_predict: 1500 },
}),
});
if (!res.ok) throw new Error(`ollama ${res.status}`);
const data = await res.json();
const txt = (data.response || '').trim();
const cleaned = txt.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
const unfenced = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '');
const m = unfenced.match(/\{[\s\S]*\}/);
if (!m) throw new Error('no json in response');
let lenient = m[0]
.replace(/,(\s*[}\]])/g, '$1')
.replace(/\}\s*\{/g, '},{') // missing comma between objects in array
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'");
try { return JSON.parse(lenient); }
catch {
lenient = lenient.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (_, s) => '"' + s.replace(/[\r\n]+/g, ' ') + '"');
return JSON.parse(lenient);
}
}
function fallbackPack(a) {
const meta = a.meta_json || {};
const sum = a.summary_json || {};
const title = (sum.title || meta.title || a.slug).replace(/[-_]/g, ' ');
const desc = (sum.tagline || meta.tagline || '').slice(0, 200) || `${a.slug} — a project at ~/Projects/${a.slug}.`;
return {
headline: title.slice(0, 60),
tagline: desc.length >= 12 ? desc : `${title} — built locally and ready to ship.`,
cta_primary: 'Get Started',
cta_secondary: 'Learn More',
cards: [
{ title: 'BUILT LOCAL', body: 'Crafted on the metal — no cloud lag, no rate limits, no surprises.' },
{ title: 'OPEN SOURCE', body: 'Read the code, fork it, ship your own variant — everything is on disk.' },
{ title: 'SHIP TODAY', body: 'Pull it down, point it at your data, and run — under a minute to first request.' },
],
fallback: true,
};
}
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;
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 main() {
const rows = await many("SELECT id, slug, vertical, summary_json, meta_json FROM website_analyses WHERE NOT (summary_json ? 'copy_pack') ORDER BY id");
console.log(`Rescuing ${rows.length} records via /no_think + 240s timeout`);
let ok = 0, fb = 0;
for (const a of rows) {
let pack;
try {
pack = await callOllama(buildPrompt(a));
if (!valid(pack)) throw new Error('invalid pack shape');
console.log(` ${a.slug} → ${pack.headline} (qwen3)`);
ok += 1;
} catch (e) {
pack = fallbackPack(a);
console.log(` ${a.slug} → ${pack.headline} (FALLBACK: ${e.message})`);
fb += 1;
}
const newSummary = { ...(a.summary_json || {}), copy_pack: { ...pack, generated_at: new Date().toISOString(), model: pack.fallback ? 'fallback' : MODEL } };
await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2', [JSON.stringify(newSummary), a.id]);
}
console.log(`\nDONE — qwen3:${ok} fallback:${fb}`);
process.exit(0);
}
main().catch(e => { console.error(e); process.exit(1); });