← back to Ventura Corridor
scripts/generate-gallery-pieces.js
165 lines
#!/usr/bin/env node
// generate-gallery-pieces.js — for each business, generate AI art metadata via
// local qwen3:14b. INSERT into gallery_pieces idempotent on business_id.
// Designed to run autonomously at 3am (no Claude, no paid APIs).
//
// Category priority (highest first): restaurants, salons, hotels, cafes,
// law firms, clinics, dentists, then the rest. Stops at --limit.
import pg from 'pg';
const { Client } = pg;
const args = process.argv.slice(2);
const arg = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i+1] : d; };
const LIMIT = parseInt(arg('--limit', '2000'), 10);
const PRIORITY = [
'amenity: restaurant',
'amenity: fast food',
'amenity: cafe',
'shop: hairdresser',
'shop: beauty',
'tourism: hotel',
'office: lawyer',
'amenity: dentist',
'amenity: clinic',
'shop: clothes',
'shop: supermarket',
'shop: department store',
'office: company',
];
// Ventura Blvd runs east-west. Approximate lon bounds (rough):
// East end (Encino): lon ≈ -118.510
// West end (Studio City): lon ≈ -118.380
const LON_EAST = -118.510;
const LON_WEST = -118.380;
function neighborhoodFor(lon) {
if (lon == null) return 'Sherman Oaks';
if (lon <= -118.470) return 'Encino';
if (lon <= -118.430) return 'Sherman Oaks';
return 'Studio City';
}
function positionPctFor(lon) {
if (lon == null) return null;
const pct = ((lon - LON_EAST) / (LON_WEST - LON_EAST)) * 100;
return Math.max(0, Math.min(100, +pct.toFixed(2)));
}
async function qwen3(prompt) {
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.85, num_predict: 400 },
messages: [
{ role: 'system', content: `/no_think
You are an art curator imagining the artwork visible inside or outside a Ventura Boulevard business. Each business gets ONE imagined piece of art — a mural, framed print, sculpture, neon sign, ceramic, installation, woven hanging, photograph, sign-painting, or whatever fits the business's vibe. The art doesn't have to literally exist — you're imagining what art SHOULD be there, in keeping with the business's character, neighborhood, and category.
Output STRICT JSON only:
{
"art_title": "<3-6 word evocative title — '[Series Name], No. <N>' format welcome — e.g. 'The Tide Pool Series, No. 3'>",
"art_medium": "<one of: mural, framed-print, sculpture, neon, ceramic, photograph, sign-painting, woven, installation, mosaic, oil, watercolor>",
"art_year_est": <integer year between 1965 and 2025>,
"art_description": "<2-3 sentence prose description of the imagined piece, where it lives in the space, what it depicts. Evocative, in the voice of an art critic.>",
"color_palette": ["<#hex>","<#hex>","<#hex>"]
}
No <think> blocks. Just the JSON.` },
{ role: 'user', content: prompt },
],
}),
});
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; }
}
async function generateFor(b) {
const lon = b.lon != null ? Number(b.lon) : null;
const lat = b.lat != null ? Number(b.lat) : null;
const neighborhood = neighborhoodFor(lon);
const positionPct = positionPctFor(lon);
const prompt = `Business: ${b.name}
Category: ${b.category}
Address: ${b.address || 'Ventura Blvd, ' + neighborhood}
Neighborhood: ${neighborhood}
Imagine the single most striking piece of art visible to a visitor or passerby of this business. Generate the JSON.`;
const piece = await qwen3(prompt);
if (!piece || !piece.art_title) return null;
return {
business_id: b.id,
art_title: String(piece.art_title).slice(0, 200),
art_medium: String(piece.art_medium || 'print').toLowerCase().slice(0, 60),
art_year_est: parseInt(piece.art_year_est, 10) || null,
art_description: String(piece.art_description || '').slice(0, 1200),
color_palette: Array.isArray(piece.color_palette) ? piece.color_palette.slice(0, 6) : [],
lon, lat,
neighborhood,
position_pct: positionPct,
image_source: 'ai_generated',
generator: 'corridor-design-system',
};
}
async function main() {
const pg = new Client({ database: 'ventura_corridor', host: '/tmp', user: process.env.USER });
await pg.connect();
// Build the ordered work-list by category priority + within-category exclude-already-done.
const params = [LIMIT];
const orderCase = PRIORITY.map((c,i) => `WHEN $${i+2} THEN ${i}`).join(' ');
PRIORITY.forEach(c => params.push(c));
const sql = `
SELECT b.id, b.name, b.category, b.address, b.lng AS lon, b.lat
FROM businesses b
LEFT JOIN gallery_pieces g ON g.business_id = b.id
WHERE g.id IS NULL
AND b.name IS NOT NULL
ORDER BY CASE b.category ${orderCase} ELSE 99 END, b.id
LIMIT $1
`;
const todo = await pg.query(sql, [LIMIT, ...PRIORITY]);
console.log(` queue: ${todo.rows.length} businesses to generate`);
let ok = 0, fail = 0;
const t0 = Date.now();
for (const b of todo.rows) {
try {
const piece = await generateFor(b);
if (!piece) { fail++; process.stdout.write('✗'); continue; }
await pg.query(
`INSERT INTO gallery_pieces
(business_id, art_title, art_medium, art_year_est, art_description,
color_palette, image_source, lon, lat, neighborhood, position_pct, generator)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12)
ON CONFLICT (business_id) DO NOTHING`,
[piece.business_id, piece.art_title, piece.art_medium, piece.art_year_est,
piece.art_description, JSON.stringify(piece.color_palette),
piece.image_source, piece.lon, piece.lat, piece.neighborhood,
piece.position_pct, piece.generator]
);
ok++;
if (ok % 50 === 0) {
const elapsed = (Date.now() - t0) / 1000;
console.log(`\n · ${ok}/${todo.rows.length} (${(ok/elapsed).toFixed(1)}/s, eta ${Math.ceil((todo.rows.length-ok)/Math.max(1,ok/elapsed)/60)}m)`);
} else { process.stdout.write('·'); }
} catch (e) {
fail++; process.stdout.write('!');
console.error('\n · err', b.id, e.message);
}
}
await pg.end();
console.log(`\nDone: ${ok} ingested · ${fail} failed · ${((Date.now()-t0)/1000/60).toFixed(1)} min`);
}
main().catch(e => { console.error(e); process.exit(1); });