← back to Ventura Corridor

src/jobs/generate_features.ts

179 lines

/**
 * Generate AI-written magazine features for corridor businesses.
 * Uses Mac1 Ollama qwen3:14b (per Steve's "local LLM for non-DW work" rule).
 *
 *   $ npx tsx src/jobs/generate_features.ts          # generate 5 fresh features
 *   $ npx tsx src/jobs/generate_features.ts 20       # generate 20
 *   $ npx tsx src/jobs/generate_features.ts --biz=461  # regenerate one specific
 */
import 'dotenv/config';
import { pool, query } from '../../db/pool.ts';

const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434'; // old 100.94.103.98 default = dead mac2-era tailnet IP
// Single model, single in-flight request. Empirically MS1 Ollama queues only 1
// request reliably — adding parallelism causes 503s and retry-backoff balloons latency.
// At ~25-30s per feature this hits ~30 features/15min = ~120/hour overnight.
const MODELS = (process.env.OLLAMA_MODELS || 'qwen3:14b').split(',').map(m => m.trim());
const CONCURRENCY = 1;

const args = process.argv.slice(2);
const SPECIFIC = (args.find(a => a.startsWith('--biz=')) || '').replace('--biz=', '');
const COUNT = parseInt(args.find(a => /^\d+$/.test(a)) || '5', 10);

const SYSTEM = `You are a corridor editor writing flattering, magazine-style ~100-word features for businesses on Ventura Boulevard in Sherman Oaks / Encino / Tarzana. Your tone is warm but never gushing — think Conde Nast Traveler shopping guide.

HEADLINE STYLE — pick ONE of these abstract voice patterns each time, but write a phrase UNIQUE to THIS business. Do NOT reuse any example below verbatim.
  1. Verb-led participle: an "-ing" verb + sensory or material noun specific to this business's actual trade
  2. Specific-noun-first: a concrete piece of the storefront ("counter", "doorway", "shelf", "stool", "case") + the business's own name or address
  3. Two-noun sensory pair: two short noun phrases joined by "and" or comma, each from this trade's vocabulary (a baker has "crumb" and "yeast"; a cobbler has "leather" and "wax")
  4. Contrast: an unexpected pairing of two things that ARE actually present at this address (e.g. an old building + new service inside it)
  5. Time + place: a specific moment of day or week + the business's address or block
  6. Fragment / question: a partial sentence or rhetorical fragment about something visible at the storefront

NEVER reuse these stale phrasings: "X in Y's Heart", "X Meets Y", "Where X Meets Y", "X Rooted in Y", "Precision Care", "Legal Precision", "Legal Expertise", "Steady Counsel", "Timeless Elegance", "Vibrant Hub", "Quiet Office Loud Reputation", "Espresso Steam".

NEVER use the literal phrase "multi-tenant building", "Multi-Tenant Building", "shared building", or "office complex" in the headline. The shared-address fact is editorial color for the body paragraph only — the headline should focus on what makes THIS business singular (its trade, its objects, its block, its hours, its smell, its clientele).

The headline must reflect THIS specific business — its trade, its actual location, its real building. Do not reach for stock images.

Output a JSON object with EXACTLY these fields:
{
  "headline":   "6-10 word title in one of the 6 styles above",
  "subhead":    "12-18 word subtitle",
  "editorial":  "80-130 word feature paragraph",
  "pull_quote": "8-15 word callout",
  "category_tag": "one of: restaurant, professional, beauty, shop, fitness, medical, real-estate, hospitality, salon, automotive"
}

Rules:
- Never invent specific prices, opening dates, owner names, or claims you can't verify.
- Lean on the address as a sense of place ("just east of Sepulveda" / "in the white-stone tower at 15821 Ventura").
- Avoid superlatives ("the BEST"). Prefer concrete sensory details.
- Output ONLY the JSON. No preamble, no markdown fences.`;

const BANNED_HEADLINE_RE = /\b(multi[\s-]?tenant\s+building|shared\s+building|office\s+complex|in\s+\w+'s\s+heart)\b|\b\w+\s+(meets|rooted\s+in)\s+\w+/i;

async function generate(biz: any, model: string): Promise<any> {
  const baseUserPrompt = `Business: ${biz.name}
Address: ${biz.address || 'unknown'}, ${biz.city || ''} ${biz.zip || ''}
Category: ${biz.naics || biz.pitch_type || biz.category || 'unknown'}
Building context: ${biz.bldg_address ? `One tenant among several at ${biz.bldg_address} — use this only as quiet body color, NOT as the hook` : 'Independent storefront on Ventura Boulevard'}

Write the feature.`;

  // Up to 2 retries when the headline trips the banned-phrase regex.
  // Negative prompt instructions don't always hold; this is the hard gate.
  for (let attempt = 1; attempt <= 3; attempt++) {
    const userPrompt = attempt === 1
      ? baseUserPrompt
      : `${baseUserPrompt}\n\nIMPORTANT: your previous attempt used a banned headline phrase. Pick a DIFFERENT headline angle entirely. Focus on a sensory detail, a specific object, a time of day, or the storefront — never the building category.`;
    const r = await fetch(`${OLLAMA}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model,
        messages: [
          { role: 'system', content: SYSTEM },
          { role: 'user',   content: userPrompt }
        ],
        stream: false,
        options: { temperature: 0.7 + attempt * 0.1 },
        format: 'json'
      })
    });
    if (!r.ok) throw new Error(`ollama ${r.status}: ${(await r.text()).slice(0, 120)}`);
    const j = await r.json();
    const raw = j.message?.content || '';
    let parsed: any;
    try { parsed = JSON.parse(raw); }
    catch { throw new Error(`bad JSON from model: ${raw.slice(0, 120)}`); }
    const h = String(parsed.headline || '');
    if (!BANNED_HEADLINE_RE.test(h)) return parsed;
    console.log(`  ↻ banned phrase in headline (attempt ${attempt}/3): "${h.slice(0, 60)}"`);
  }
  throw new Error('all 3 attempts produced a banned headline phrase');
}

async function pickCandidates() {
  if (SPECIFIC) {
    const r = await query(
      `SELECT b.id, b.name, b.address, b.city, b.zip, b.category,
              b.raw->>'primary_naics_description' AS naics,
              p.pitch_type,
              TRIM(regexp_replace(b.address, '\\s*(SUITE|STE|UNIT|#).*$', '', 'i')) AS bldg_address
       FROM businesses b LEFT JOIN pitches p ON p.business_id = b.id
       WHERE b.id = $1`,
      [parseInt(SPECIFIC, 10)]
    );
    return r.rows;
  }
  // Priority tiers for picking what to feature next:
  //   1. Has paid-ad pixels (sponsor candidate — monetization target)
  //   2. Has a pitch (rich metadata for better editorial)
  //   3. Random
  // Skip merged dupes, LLC/INC/CORP shells, anyone already featured.
  const r = await query(
    `SELECT b.id, b.name, b.address, b.city, b.zip, b.category,
            b.raw->>'primary_naics_description' AS naics,
            p.pitch_type,
            COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) AS paid_ads_count,
            TRIM(regexp_replace(b.address, '\\s*(SUITE|STE|UNIT|#).*$', '', 'i')) AS bldg_address
     FROM businesses b
     LEFT JOIN pitches p ON p.business_id = b.id
     LEFT JOIN business_enrichment be ON be.business_id = b.id
     WHERE b.on_corridor
       AND b.merged_into IS NULL
       AND b.address IS NOT NULL
       AND b.name NOT ILIKE '%LLC' AND b.name NOT ILIKE '%INC' AND b.name NOT ILIKE '%CORP'
       AND length(b.name) BETWEEN 4 AND 60
       AND NOT EXISTS (SELECT 1 FROM magazine_features mf WHERE mf.business_id = b.id)
     ORDER BY
       (COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) > 0) DESC,
       (p.pitch_type IS NOT NULL) DESC,
       random()
     LIMIT $1`,
    [COUNT]
  );
  return r.rows;
}

async function main() {
  const cands = await pickCandidates();
  if (!cands.length) { console.log('[generate_features] no candidates'); await pool.end(); return; }
  console.log(`[generate_features] generating ${cands.length} via [${MODELS.join(', ')}] on ${OLLAMA} (concurrency=${CONCURRENCY})`);

  async function processOne(biz: any, model: string) {
    try {
      const t0 = Date.now();
      const feat = await generate(biz, model);
      const ms = Date.now() - t0;
      await query(
        `INSERT INTO magazine_features (business_id, headline, subhead, editorial, pull_quote, category_tag, status, model, generated_at)
         VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7, NOW())
         ON CONFLICT (business_id) DO UPDATE SET
           headline=EXCLUDED.headline, subhead=EXCLUDED.subhead, editorial=EXCLUDED.editorial,
           pull_quote=EXCLUDED.pull_quote, category_tag=EXCLUDED.category_tag,
           model=EXCLUDED.model, generated_at=NOW(), status='draft'`,
        [biz.id, feat.headline, feat.subhead, feat.editorial, feat.pull_quote, feat.category_tag, model]
      );
      console.log(`  ✓ [${model.padEnd(10)}] ${biz.id} ${biz.name.slice(0,38).padEnd(38)} (${ms}ms)`);
    } catch (e: any) {
      console.log(`  ✕ [${model}] ${biz.id} ${biz.name.slice(0,38).padEnd(38)} — ${e.message.slice(0, 80)}`);
    }
  }

  // Single worker that alternates models per request.
  // MS1 Ollama queue is small; concurrent fetches → 503s even across models.
  // Sequential with model rotation: ~25s qwen3:14b + ~12s qwen3:8b = avg ~18s/feature
  // (vs single-model qwen3:14b ~25s) — ~30% throughput gain without server-config changes.
  let modelIdx = 0;
  for (const biz of cands) {
    const model = MODELS[modelIdx % MODELS.length];
    modelIdx++;
    await processOne(biz, model);
  }
  await pool.end();
}

main().catch(e => { console.error('[generate_features] fatal:', e); process.exit(1); });