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