← back to Paul Conrad Cartoons

generator/compose-prompts.mjs

223 lines

#!/usr/bin/env node
// compose-prompts.mjs — Shadow Man editorial-cartoon brief composer (TK-12179 step 2).
//
// Composes ORIGINAL single-panel editorial-cartoon briefs from the research theme
// bank (research/theme-bank.json): the bank decides WHICH topics and eras get
// weight; the scenes, metaphors and captions below are newly written stock
// satire built from archetypal, unnamed figures (a senator, a general, a
// lobbyist...). Hard rules enforced in code:
//   - no prompt or caption may name the research subject (the historical
//     cartoonist) or ask for any artist's "style";
//   - no real, named people in prompts (archetypes only);
//   - nothing here recreates a specific archival cartoon (captions are
//     invented; the bank's example caption titles are never copied).
// Optionally blends in today's headline TAGS from crazy-news-channel's
// real-news-data.js (read-only; that repo is never modified).
//
// Selection logic is adapted from crazy-news-channel/scripts/daily-cartoon-gen.mjs
// (date-seeded deterministic shuffle so a re-run for the same seed is idempotent).
//
// Usage: node generator/compose-prompts.mjs [--count=20] [--seed=YYYY-MM-DD] [--out=generator/out/batch.json]
//        node generator/compose-prompts.mjs --from-queue [--count=20] [--seed=...]
//   --from-queue: build the batch from generator/idea-queue.json (Model Arena ideas Steve approved in
//   Inkwell) instead of the scene library; marks each used entry batched_at so it is not re-queued.
//   Output defaults to generator/out/batch-queue-<seed>.json. Same BANNED guard.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(HERE, '..');
const NEWS = path.join(process.env.HOME, 'Projects', 'crazy-news-channel', 'real-news-data.js');
const args = Object.fromEntries(process.argv.slice(2).map(a => { const m = a.match(/^--([^=]+)=(.*)$/); return m ? [m[1], m[2]] : [a.replace(/^--/, ''), true]; }));
const COUNT = parseInt(args.count || '20', 10);
const SEED = args.seed || new Date().toISOString().slice(0, 10);
const OUT = path.resolve(ROOT, args.out || 'generator/out/batch.json');

// ---- deterministic RNG (same LCG family as daily-cartoon-gen.mjs) ----
let s = SEED.split(/\D+/).filter(Boolean).reduce((a, n) => (a * 31 + parseInt(n, 10)) & 0x7fffffff, 7);
const rand = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
const pick = a => a[Math.floor(rand() * a.length)];
const weighted = entries => { const t = entries.reduce((a, [, w]) => a + w, 0); let r = rand() * t; for (const [k, w] of entries) { if ((r -= w) <= 0) return k; } return entries.at(-1)[0]; };

// ---- original scene library: [scene, caption] per theme (all invented) ----
const SCENES = {
  'Presidential power & scandal': [
    ['a tiny president in an enormous throne-like chair, feet dangling, sawing off the chair legs labeled CHECKS and BALANCES', '"Just a little trim."'],
    ['a White House drawn as a leaking ship, aides bailing water with teacups while the captain insists the sea is dry', '"There is no leak. Next question."'],
    ['a president shredding documents that pile up into a mountain spelling out the very thing he denies', '"Nothing to see here."'],
    ['a giant rubber stamp marked EXECUTIVE ORDER crushing a small Capitol dome', '"We found a shortcut."'],
  ],
  'War & militarism': [
    ['a general pinning a medal on a gravestone while a defense contractor counts money behind him', '"Mission accomplished, again."'],
    ['a giant war machine made of dollar bills, rolling over a tiny olive branch', '"Peace is expensive. War is more profitable."'],
    ['a map room where generals move toy soldiers while real boots stand empty in rows outside the window', '"Acceptable losses."'],
    ['an endless line of flag-draped coffins receding to the horizon under a banner reading LIGHT AT THE END OF THE TUNNEL', '"Almost there."'],
  ],
  'Nuclear arms & the Cold War': [
    ['two tiny statesmen shaking hands atop a gigantic stack of missiles that towers above the clouds', '"A sensible balance."'],
    ['a doomsday clock drawn as a kitchen timer, a diplomat casually setting it to one minute', '"We work better under pressure."'],
    ['a mushroom cloud rendered as a giant question mark over a quiet suburb', '"Who pushed it first?"'],
  ],
  'Civil rights & race': [
    ['a long voting line stretching past a closed polling door guarded by a bureaucrat holding a rulebook', '"Please take a number. Any number but yours."'],
    ['a statue of Justice peeking under her blindfold at the people in front of her', '"Just checking."'],
    ['a staircase labeled OPPORTUNITY with the bottom steps sawed off, a smug official at the top saying come on up', '"The door is wide open!"'],
  ],
  'Guns & violence': [
    ['a lawmaker hiding under his desk behind a stack of thoughts-and-prayers cards while a gun lobbyist pats his head', '"Good boy."'],
    ['a child\'s school backpack drawn as a bulletproof vest on a playground', '"Back to school supplies."'],
    ['a giant revolver used as a podium by a politician giving a speech about freedom', '"We will not be intimidated."'],
  ],
  'Corruption & money in politics': [
    ['a senator as a vending machine, coins going in the slot and legislation dropping out the tray', '"Exact change only."'],
    ['a lobbyist puppeteer working a whole Congress of marionettes, the strings made of dollar signs', '"Democracy in action."'],
    ['a politician taking an oath with one hand on a bible and the other hand behind his back in a donor\'s pocket', '"So help me, donors."'],
  ],
  'Taxes, economy & inequality': [
    ['a fat cat in a top hat lounging on a hammock strung between two tiny exhausted workers', '"Trickle-down is working fine up here."'],
    ['an ordinary taxpayer squeezed in a giant lemon press labeled TAXES while a yacht sails by labeled LOOPHOLE', '"Everybody pays their fair share."'],
    ['a budget pie chart drawn as a real pie, a general eating most of it while a schoolteacher gets crumbs', '"Priorities."'],
  ],
  'Environment & energy': [
    ['an oil executive watering a single dead tree with crude oil from a can', '"It just needs more growth."'],
    ['a planet Earth sweating on a hospital bed while lobbyists argue at the foot about the thermometer', '"The readings are exaggerated."'],
    ['a city skyline disappearing into smog, a mayor pointing proudly at a single potted plant', '"Our green initiative."'],
  ],
  'Courts, law & justice': [
    ['the scales of justice with a wallet on one side outweighing a whole crowd of people on the other', '"Perfectly balanced."'],
    ['a judge with a gavel shaped like a price tag', '"Sold to the highest bidder."'],
    ['a constitution being folded into a paper airplane by men in robes', '"Just a new interpretation."'],
  ],
  'Religion & politics': [
    ['a politician wearing a halo held up by a stick in his back pocket, preaching from a campaign podium', '"Vote your values. Mine are on sale."'],
    ['a collection plate being passed around a legislative chamber', '"Tithing, bipartisan."'],
  ],
  'Press, speech & dissent': [
    ['a reporter\'s typewriter locked in a cage while an official reads a press release to a room of empty chairs', '"Any questions? Good."'],
    ['a giant eraser marked OFFICIAL STORY rubbing out a newspaper headline', '"Corrections department."'],
    ['a protester with a blank sign being arrested for what the sign might say', '"Pre-emptive order."'],
  ],
  'Elections & party politics': [
    ['an elephant and a donkey arm-wrestling on top of a voter who is flattened beneath the table', '"May the best party win."'],
    ['a ballot box drawn as a slot machine, a candidate pulling the lever', '"Feeling lucky, America?"'],
    ['a candidate kissing a baby while picking the baby\'s pocket', '"Family values."'],
    ['a gerrymandered district map shaped like a pretzel with a politician tied up inside it', '"My constituents love me."'],
  ],
  'Local LA & California politics': [
    ['a city hall floating away on a freeway overpass while commuters sit in endless gridlock below', '"Traffic relief is on the way."'],
    ['a mayor cutting a ribbon on a pothole', '"Another infrastructure milestone."'],
  ],
  'Health & medicine': [
    ['a patient on an operating table while insurance executives argue over his wallet instead of his heart', '"Is it a covered organ?"'],
    ['a pharmacy counter with a single pill on a velvet pillow priced like a diamond', '"Generic, you say?"'],
  ],
  'Labor & unions': [
    ['a worker holding up an entire factory on his shoulders while a boss on the roof takes a bow', '"Self-made."'],
    ['a robot handing a pink slip to a worker who built the robot', '"Nothing personal."'],
  ],
  'Middle East & foreign policy': [
    ['a diplomat handing out olive branches with one hand and weapons crates with the other', '"We are committed to peace."'],
    ['a globe balanced on a teetering stack of treaties, a statesman removing one from the bottom', '"This one was outdated."'],
  ],
  'Space & technology': [
    ['a billionaire rocket leaving Earth, leaving behind a sign reading NOT MY PROBLEM', '"One small step for a man."'],
    ['a giant smartphone watching a tiny citizen through its camera', '"We value your privacy."'],
  ],
  'Immigration & borders': [
    ['the Statue of Liberty putting down her torch to install a doorbell camera', '"Who\'s there?"'],
    ['a wall built out of campaign speeches with a ladder made of labor shortages leaning against it', '"Mixed signals."'],
  ],
  'Education': [
    ['a schoolhouse sinking in quicksand labeled BUDGET CUTS while a stadium rises next door', '"Go team."'],
    ['a teacher buying her own chalk at a pawn shop', '"Investing in our future."'],
  ],
  'Memorials & tributes': [
    ['an empty podium with a single lit candle and a folded flag, in solemn quiet', '"The silence says it."'],
  ],
};

const ERA_FLAVOR = {
  '1950s': 'mid-century props, fedoras and rotary phones',
  '1960s': '1960s props, skinny ties and television antennas',
  '1970s': '1970s props, wide lapels and gas lines',
  '1980s': '1980s props, power suits and big desk phones',
  '1990s': '1990s props, pagers and cable news monitors',
  '2000s': '2000s props, flip phones and flat screens',
  '2010s': 'modern props, smartphones and podiums',
  '2020s': 'contemporary props, smartphones and livestream cameras',
};

// Style block: generic editorial-cartoon technique words only (no artist names).
// Kept short: CLIP truncates at 77 tokens, so the scene goes FIRST and style last.
const STYLE = 'bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire';
const BANNED = /conrad|in the style of|style of [A-Z]|pulitzer/i;

function loadNewsThemes(themeNames) {
  try {
    const src = fs.readFileSync(NEWS, 'utf8');
    const arr = JSON.parse(src.slice(src.indexOf('['), src.lastIndexOf(']') + 1));
    const tagMap = { immigration: 'Immigration & borders', elections: 'Elections & party politics', congress: 'Corruption & money in politics', 'white house': 'Presidential power & scandal', economy: 'Taxes, economy & inequality', climate: 'Environment & energy', courts: 'Courts, law & justice', 'supreme court': 'Courts, law & justice', guns: 'Guns & violence', health: 'Health & medicine', war: 'War & militarism', military: 'War & militarism', tech: 'Space & technology', education: 'Education', labor: 'Labor & unions' };
    const counts = {};
    for (const st of arr) for (const t of st.tags || []) { const th = tagMap[t]; if (th && themeNames.includes(th)) counts[th] = (counts[th] || 0) + 1; }
    return { stories: arr.length, counts };
  } catch { return null; }
}

function fromQueue() {
  const QUEUE = path.join(ROOT, 'generator', 'idea-queue.json');
  const q = fs.existsSync(QUEUE) ? JSON.parse(fs.readFileSync(QUEUE, 'utf8')) : { items: [] };
  const todo = q.items.filter(b => !b.batched_at).slice(0, COUNT);
  if (!todo.length) { console.log('idea-queue: nothing un-batched'); return; }
  const out = path.resolve(ROOT, args.out || `generator/out/batch-queue-${SEED}.json`);
  const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
  const items = todo.map((b, i) => {
    const prompt = `${b.scene.replace(/\.$/, '')}. ${STYLE}`;
    if (BANNED.test(prompt) || BANNED.test(b.caption)) throw new Error(`banned term in queued idea ${b.id}`);
    const caption = /^["“]/.test(b.caption) ? b.caption : `"${b.caption}"`;
    return { id: `shadowman-${stamp}-q${String(i + 1).padStart(3, "0")}`, theme: b.theme, era: b.era || "contemporary", source: b.source, idea_id: b.id, title: b.title, scene: b.scene, caption, prompt, seed: Math.floor(rand() * 2 ** 31) };
  });
  fs.mkdirSync(path.dirname(out), { recursive: true });
  fs.writeFileSync(out, JSON.stringify({ generated_at: new Date().toISOString(), seed: SEED, from_queue: true, items }, null, 2));
  const now = new Date().toISOString();
  for (const b of todo) { b.batched_at = now; b.batch = path.relative(ROOT, out); }
  fs.writeFileSync(QUEUE, JSON.stringify(q, null, 2) + '\n');
  console.log(`Composed ${items.length} briefs from idea-queue -> ${path.relative(ROOT, out)}`);
}

function main() {
  if (args['from-queue']) return fromQueue();
  const bank = JSON.parse(fs.readFileSync(path.join(ROOT, 'research', 'theme-bank.json'), 'utf8'));
  const themeNames = Object.keys(bank.themes).filter(t => SCENES[t]);
  const news = loadNewsThemes(themeNames);
  // Weight = archival theme volume (sqrt-damped so the long tail still appears)
  const baseW = themeNames.map(t => [t, Math.sqrt(bank.themes[t].total)]);
  const used = new Set();
  const items = [];
  const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
  for (let i = 0; i < COUNT; i++) {
    // ~30% of briefs are "today's desk": themes weighted by current headline tags.
    const fromNews = news && Object.keys(news.counts).length && rand() < 0.3;
    const theme = fromNews ? weighted(Object.entries(news.counts)) : weighted(baseW);
    const eraEntries = Object.entries(bank.themes[theme].by_decade).filter(([d]) => ERA_FLAVOR[d]);
    const era = fromNews ? '2020s' : (eraEntries.length ? weighted(eraEntries) : '2020s');
    let options = SCENES[theme].filter(([sc]) => !used.has(sc));
    if (!options.length) options = SCENES[theme];
    const [scene, caption] = pick(options);
    used.add(scene);
    const prompt = `${scene}, ${ERA_FLAVOR[era]}. ${STYLE}`;
    if (BANNED.test(prompt) || BANNED.test(caption)) throw new Error(`banned term in prompt ${i}`);
    items.push({
      id: `shadowman-${stamp}-${String(i + 1).padStart(3, '0')}`,
      theme, era, source: fromNews ? 'theme-bank+today-headline-tags' : 'theme-bank',
      scene, caption, prompt,
      seed: Math.floor(rand() * 2 ** 31),
    });
  }
  fs.mkdirSync(path.dirname(OUT), { recursive: true });
  fs.writeFileSync(OUT, JSON.stringify({ generated_at: new Date().toISOString(), seed: SEED, news_blend: news ? { stories: news.stories, theme_counts: news.counts } : null, items }, null, 2));
  console.log(`Composed ${items.length} briefs -> ${path.relative(ROOT, OUT)}${news ? ` (news blend: ${news.stories} stories)` : ''}`);
  for (const it of items) console.log(`  ${it.id}  [${it.era}] ${it.theme} :: ${it.caption}`);
}
main();