← back to Wallco Ai

lib/design-references.js

227 lines

/**
 * Design reference picker — pulls art-historical anchors from
 *   (a) Met Museum public-domain artworks (dw_unified.met_museum_art)
 *   (b) prior wallco designs already in the catalog (spoon_all_designs)
 * for use as style/composition anchors inside drunk-animal /
 * drunk-zoo / mural generator prompts.
 *
 * Steve directive 2026-05-25 — "designs pulled from the postgresql
 * and met museum images" + "this is our llm". We use real PD artwork
 * and our own prior winners as the inspiration layer for every new
 * prompt, instead of relying purely on the diffusion model's training.
 *
 * USAGE:
 *   const refs = require('./lib/design-references');
 *   const met  = refs.pickMetReference(['bacchanal', 'wine']);
 *   const own  = refs.pickPriorDesignReference('drunk-animals');
 *   // append to prompt body:
 *   //   `inspired by ${met.compactCitation}, composition lineage from ${own.compactCitation}`
 *
 * Both pickers are SYNCHRONOUS (they use execSync against a pre-warmed
 * in-memory cache) so the generator hot paths stay simple. Cache
 * refresh happens at module-load + once every 6 hours (configurable
 * via DESIGN_REFS_TTL_SEC).
 *
 * If PG is unreachable, fall back to a tiny hardcoded list so the
 * generators never throw — just less variety.
 */
'use strict';

const { execSync } = require('child_process');

// ── Theme pools — Met PD query templates keyed by drunk-animal vibe ───
const MET_THEMES = {
  bacchanal:   { sql: "LOWER(title) ~ 'bacchanal|bacchus|dionys|reveler|carous|silen|priap' OR (LOWER(title) ~ 'feast' AND classification IN ('Prints','Paintings','Drawings'))", weight: 3 },
  wine:        { sql: "(LOWER(title) ~ 'wine|cup|goblet|chalice|amphora|krater|kantharos|tankard|tavern|drinking') AND classification IN ('Prints','Paintings','Drawings','Ceramics','Glass','Vases')", weight: 2 },
  engraving:   { sql: "classification ILIKE 'Prints' AND object_begin_date BETWEEN 1700 AND 1899", weight: 1 },
  animal:      { sql: "LOWER(title) ~ 'monkey|giraffe|elephant|orangutan|lemur|sloth|capybara|frog|macaw|parrot|peacock|owl|panda' AND classification IN ('Prints','Paintings','Drawings')", weight: 2 },
  chinoiserie: { sql: "(department = 'Asian Art' AND LOWER(medium) ~ 'silk|cloth|textile') OR LOWER(title) ~ 'chinoiserie|wallpaper'", weight: 1 },
};

// In-memory cache. Key = theme name → array of Met rows.
const METROW_CACHE = {};
let MET_CACHE_AT = 0;
const TTL_SEC = parseInt(process.env.DESIGN_REFS_TTL_SEC || '21600', 10); // 6 hr

// Cap per-theme pool size to keep memory + query cost bounded.
const POOL_SIZE = parseInt(process.env.DESIGN_REFS_POOL_SIZE || '120', 10);

function psqlJson(sql) {
  try {
    const cmd = `psql dw_unified -At -F'\t' -c ${JSON.stringify(sql)}`;
    return execSync(cmd, { encoding: 'utf8', timeout: 12000 });
  } catch (e) {
    return '';
  }
}

function refreshMetCache() {
  const now = Date.now();
  if (now - MET_CACHE_AT < TTL_SEC * 1000 && Object.keys(METROW_CACHE).length) return;
  for (const [theme, cfg] of Object.entries(MET_THEMES)) {
    const sql =
      `SELECT object_id, title, COALESCE(artist,'Unknown') AS artist, ` +
      `COALESCE(NULLIF(date_display,''), object_date, 'date unknown') AS date_display, ` +
      `COALESCE(NULLIF(classification,''), 'Artwork') AS classification, ` +
      `COALESCE(NULLIF(met_url,''), 'https://www.metmuseum.org/art/collection/search/' || object_id) AS met_url, ` +
      `COALESCE(NULLIF(primary_image_small,''), NULLIF(image_url,''), NULLIF(primary_image,'')) AS image ` +
      `FROM met_museum_art ` +
      `WHERE is_public_domain AND active AND (${cfg.sql}) ` +
      `ORDER BY random() LIMIT ${POOL_SIZE};`;
    const raw = psqlJson(sql);
    const rows = raw.split('\n').filter(Boolean).map(line => {
      const [object_id, title, artist, date_display, classification, met_url, image] = line.split('\t');
      return {
        source: 'met',
        theme,
        object_id,
        title: (title || '').slice(0, 120),
        artist: (artist || 'Unknown').split('|')[0].slice(0, 80),
        date_display: (date_display || '').slice(0, 40),
        classification: classification || '',
        met_url, image,
      };
    });
    METROW_CACHE[theme] = rows;
  }
  MET_CACHE_AT = now;
}

// Fallback list — used only when PG is unreachable.
const MET_FALLBACK = [
  { source: 'met', theme: 'bacchanal', object_id: '345721', title: 'A Bacchanal (Silenus & bacchants)', artist: 'Marcantonio Raimondi', date_display: 'ca. 1510–13', classification: 'Prints' },
  { source: 'met', theme: 'wine',      object_id: '436106', title: 'Still Life with Wine Bottle and Apples', artist: 'Henri Fantin-Latour', date_display: '1876', classification: 'Paintings' },
  { source: 'met', theme: 'engraving', object_id: '720507', title: 'Autumn (Wine Harvest)', artist: 'Aignan Thomas Desfriches', date_display: '1768', classification: 'Prints' },
  { source: 'met', theme: 'animal',    object_id: '435716', title: 'Studies of Monkeys', artist: 'Antoine Watteau', date_display: 'ca. 1714–16', classification: 'Drawings' },
];

function pickRand(arr) { return arr[Math.floor(Math.random() * arr.length)]; }

function compactCitation(row) {
  if (!row) return null;
  const yr = row.date_display ? `, ${row.date_display}` : '';
  return `the Met's "${row.title}" (${row.artist}${yr})`;
}

/**
 * Pick a Met Museum PD reference matching one or more themes.
 * @param {string[]} themes  e.g. ['bacchanal', 'wine']. Defaults to all.
 * @returns {object} { theme, title, artist, date_display, met_url, compactCitation }
 */
function pickMetReference(themes) {
  refreshMetCache();
  const pool = [];
  const want = (themes && themes.length) ? themes : Object.keys(MET_THEMES);
  for (const t of want) {
    const rows = METROW_CACHE[t] || [];
    const weight = (MET_THEMES[t] && MET_THEMES[t].weight) || 1;
    for (let i = 0; i < weight; i++) pool.push(...rows);
  }
  const row = pool.length ? pickRand(pool) : pickRand(MET_FALLBACK);
  return { ...row, compactCitation: compactCitation(row) };
}

// ── Prior wallco designs from PG (composition reference) ──────────────
const PRIOR_CACHE = {};
let PRIOR_CACHE_AT = 0;

function refreshPriorCache() {
  const now = Date.now();
  if (now - PRIOR_CACHE_AT < TTL_SEC * 1000 && Object.keys(PRIOR_CACHE).length) return;
  // Pull the top-rated published prior designs per category (stars desc),
  // then random within ties.
  const sql =
    `SELECT id, ` +
    `CASE WHEN category LIKE 'drunk-zoo-36%' THEN 'drunk-zoo-36' ELSE category END AS bucket, ` +
    `COALESCE(LEFT(prompt, 160), '') AS prompt_head, ` +
    `COALESCE(user_stars, 0) AS stars, ` +
    `COALESCE(dominant_hex, '#888888') AS dominant_hex ` +
    `FROM spoon_all_designs ` +
    `WHERE is_published AND (category IN ('drunk-animals','drunk-monkeys-v2','drunk-animals-designer') ` +
    `OR category LIKE 'drunk-zoo-36%') ` +
    `ORDER BY stars DESC NULLS LAST, random() LIMIT 600;`;
  const raw = psqlJson(sql);
  const buckets = {};
  for (const line of raw.split('\n').filter(Boolean)) {
    const [id, bucket, prompt_head, stars, dominant_hex] = line.split('\t');
    if (!buckets[bucket]) buckets[bucket] = [];
    buckets[bucket].push({
      source: 'prior',
      id: parseInt(id, 10),
      category: bucket,
      prompt_head: (prompt_head || '').slice(0, 160),
      stars: parseInt(stars, 10) || 0,
      dominant_hex,
    });
  }
  Object.assign(PRIOR_CACHE, buckets);
  PRIOR_CACHE_AT = now;
}

function pickPriorDesignReference(category) {
  refreshPriorCache();
  const bucket =
    (category && category.startsWith('drunk-zoo-36')) ? 'drunk-zoo-36' :
    category || 'drunk-animals';
  const rows = PRIOR_CACHE[bucket] || PRIOR_CACHE['drunk-animals'] || [];
  if (!rows.length) return null;
  const row = pickRand(rows);
  return {
    ...row,
    compactCitation: `composition lineage from wallco internal design #${row.id} (${row.dominant_hex} dominant)`,
  };
}

// ── Combined helper — return both refs ready-to-paste into a prompt ──
function pickReferences(category) {
  const m = pickMetReference(themesForCategory(category));
  const p = pickPriorDesignReference(category);
  return {
    met: m,
    prior: p,
    promptClause: [
      m && `inspired by ${m.compactCitation}`,
      p && p.compactCitation,
    ].filter(Boolean).join(', '),
  };
}

function themesForCategory(category) {
  // Map each drunk-animal sub-category to a sensible Met theme mix.
  if (!category) return ['bacchanal', 'wine', 'animal'];
  if (category.startsWith('drunk-zoo-36'))     return ['bacchanal', 'animal', 'engraving'];
  if (category === 'drunk-monkeys-v2')         return ['animal', 'bacchanal'];
  if (category === 'drunk-animals-designer')   return ['chinoiserie', 'wine', 'engraving'];
  return ['bacchanal', 'wine', 'animal'];
}

module.exports = {
  pickMetReference,
  pickPriorDesignReference,
  pickReferences,
  themesForCategory,
  MET_THEMES,
};

// CLI smoke-test
if (require.main === module) {
  console.log('Met cache warming...');
  refreshMetCache();
  for (const t of Object.keys(MET_THEMES)) {
    console.log(`  ${t}: ${(METROW_CACHE[t]||[]).length} rows`);
  }
  console.log('\nPrior cache warming...');
  refreshPriorCache();
  for (const b of Object.keys(PRIOR_CACHE)) {
    console.log(`  ${b}: ${PRIOR_CACHE[b].length} rows`);
  }
  console.log('\nSample reference pairs:');
  for (const cat of ['drunk-animals', 'drunk-zoo-36', 'drunk-monkeys-v2', 'drunk-animals-designer']) {
    const r = pickReferences(cat);
    console.log(`\n  [${cat}]`);
    console.log(`    met:   ${r.met && r.met.compactCitation}`);
    console.log(`    prior: ${r.prior && r.prior.compactCitation}`);
    console.log(`    clause: ${r.promptClause}`);
  }
}