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