← back to Ventura Corridor

scripts/fill-tier-copy.cjs

82 lines

#!/usr/bin/env node
// fill-tier-copy.js — backfill marketing_copy JSONB on tier_pricing rows
// where it's NULL. Uses local qwen3:14b. Per-row generation, idempotent
// (only fills NULL — re-run safe).

const { execSync } = require('child_process');
const DB = process.env.DB_NAME || 'ventura_corridor';
const PSQL = (process.platform === 'linux') ? `sudo -n -u postgres psql ${DB} -At -F"|" -q` : `psql ${DB} -At -F"|" -q`;
function psql(sql) { return execSync(PSQL, { input: sql, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim(); }

async function qwen3(sysPrompt, userPrompt) {
  const r = await fetch('http://127.0.0.1:11434/api/chat', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: 'qwen3:14b',
      stream: false, think: false, format: 'json',
      options: { temperature: 0.65, num_predict: 400 },
      messages: [
        { role: 'system', content: '/no_think\n' + sysPrompt },
        { role: 'user', content: userPrompt },
      ],
    }),
  });
  if (!r.ok) throw new Error('ollama http ' + r.status);
  const j = await r.json();
  const raw = (j.message?.content || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim();
  try { return JSON.parse(raw); }
  catch { const m = raw.match(/\{[\s\S]*\}/); return m ? JSON.parse(m[0]) : null; }
}

const SYS = `You are a copywriter for Ventura Corridor, a directory of businesses on Ventura Boulevard. You write tier marketing copy for business owners deciding whether to upgrade their listing. Tone: confident, friendly, sales-aware, no buzzwords, no AI talk.

Output STRICT JSON only:
{
  "title": "<5-9 words — tier display name + price>",
  "subtitle": "<one sentence — the value prop in plain English>",
  "bullets": ["<concise feature 1>","<feature 2>","<feature 3>","<feature 4>","<feature 5>"],
  "cta": "<2-4 word call to action — verb-led>"
}

Bullets must be 3-6 items. Each bullet is a real feature, not a vibe. Mention specific things (logo, photos, video, booking, badge, priority sort, lead form, weekly digest, reservations link, etc.) appropriate to the tier level and the business category.`;

const TIER_HINTS = {
  basic:    { intent: 'free baseline — what every listing gets', features: 'OSM-sourced name + address + phone + hours; standard sort; no logo' },
  featured: { intent: 'mid-tier — visual upgrade + boost', features: 'logo + photos + boosted in search/browse + a badge + category tags' },
  verified: { intent: 'mid-tier — credentials + intake', features: 'verification badge + credentials displayed + lead/intake form + headshot' },
  spotlight:{ intent: 'top-tier — video + priority + niche extras', features: 'video embed + reservations/booking link + priority placement + weekly digest + promo slot' },
};

async function main() {
  const rows = psql(`SELECT category||'|'||tier||'|'||display_name||'|'||price_cents FROM tier_pricing WHERE marketing_copy IS NULL ORDER BY category, price_cents`).split('\n').filter(Boolean);
  console.log(`📝 Filling marketing_copy for ${rows.length} tier rows · qwen3:14b`);
  let ok = 0, fail = 0;
  const t0 = Date.now();
  for (const line of rows) {
    const [category, tier, display, priceCents] = line.split('|');
    const price = parseInt(priceCents, 10);
    const hint = TIER_HINTS[tier] || TIER_HINTS.featured;
    const user = `Category: ${category} (OSM-style)
Tier: ${tier}
Display name: ${display}
Price: ${price === 0 ? 'FREE' : '$' + (price/100).toFixed(0) + '/month'}
Tier role: ${hint.intent}
Typical features at this tier: ${hint.features}

Generate JSON.`;
    try {
      const copy = await qwen3(SYS, user);
      if (!copy || !copy.bullets || !Array.isArray(copy.bullets)) { fail++; process.stdout.write('✗'); continue; }
      const json = JSON.stringify(copy).replace(/'/g, "''");
      psql(`UPDATE tier_pricing SET marketing_copy = '${json}'::jsonb WHERE category = '${category.replace(/'/g, "''")}' AND tier = '${tier}'`);
      ok++;
      process.stdout.write('.');
    } catch (e) {
      fail++; process.stdout.write('✗');
      console.error(`\n  · err ${category}/${tier}:`, e.message);
    }
  }
  console.log(`\nDone: ${ok} filled · ${fail} failed · ${((Date.now()-t0)/1000).toFixed(1)}s`);
}
main().catch(e => { console.error(e); process.exit(1); });