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