diff --git a/COMIC-README.md b/COMIC-README.md index db9ee70..882adbd 100644 --- a/COMIC-README.md +++ b/COMIC-README.md @@ -12,7 +12,7 @@ A daily webcomic featuring: - ๐Ÿงจ **Simon**: BOFH sysadmin who loves chaos - ๐Ÿฑ **The Cat**: Breaks the robot's reasoning -Each day, **two different AI models** generate a comic from the same prompt. Readers **vote** for their favorite variant! +Each day, a loop-engineered writers room builds a technical brief, ranks several joke premises, and gives **two different AI models** distinct premise mechanisms. Readers **vote** for their favorite variant. ## Features @@ -41,7 +41,8 @@ Backend (Cloudflare) โ””โ”€ Web Push (Notifications) Generation Pipeline - โ”œโ”€ comic-generator.ts (Prompt โ†’ Script) + โ”œโ”€ comic-loop.ts (Brief โ†’ Premises โ†’ Score โ†’ Retry/Invert) + โ”œโ”€ comic-generator.ts (Selected Premise โ†’ Script/Rewrite) โ”œโ”€ svg-renderer.ts (Script โ†’ SVG) โ””โ”€ _worker.js (Orchestration) ``` @@ -58,12 +59,14 @@ Generation Pipeline ## Comic Format -4-panel strip with consistent structure: +Three or four panels with a consistent compositional contract: -**Panel 1**: Setup (Human asks question) -**Panel 2**: Robot internal reasoning (monospace thought bubble) -**Panel 3**: Robot response (speech bubble) -**Panel 4**: Punchline (usually Simon's deadpan comment) +**Contract**: Establish the reasonable expectation. +**Interpretation**: Reveal how the system optimized the requirement. +**Exposure**: Show the difference between the proxy and the intent. +**Optional reframe**: Change the target or reinterpret the setup. + +Robot internal reasoning is optional. Exact dashboard, diff, alert, log, or terminal text is rendered deterministically through `screenText` when it carries the visual joke. ## Example Comic @@ -74,21 +77,22 @@ Panel 1: Human: "Robot, explain Kubernetes simply." Panel 2: -Robot thought bubble: -> request detected -> user comprehension estimate: low -> generating analogy +Screen: `tests/evals.json โ€” SCORE 100%` +Robot thought: `> answer key found` Panel 3: -Robot: "It's like containers... but with more containers." +Human: "It memorized the repository." Panel 4: -Simon (walking past): "Accurate." +Simon: "Promote grep. It's cheaper." ``` ## Recurring Themes - AI hallucinations and confidence +- Proxy metrics and specification gaps +- Agent permissions and approval theatre +- Eval contamination and automation incentives - Prompt injection attacks - Cloudflare product placement (subtle) - DevOps disasters @@ -99,8 +103,10 @@ Simon (walking past): "Accurate." ## Voting System Each day presents two variants: -- **Variant A**: Generated by Model A (e.g., Llama 70B) -- **Variant B**: Generated by Model B (e.g., Mistral 7B) +- **Variant A**: Highest-ranked premise mechanism, generated by Model A +- **Variant B**: A distinct mechanism and target, generated by Model B + +Drafts are scored for surprise, specificity, compression, visuality, character voice, and archive novelty. A failed draft gets one focused rewrite and, if still weak, one TRIZ inversion pass. See [workflows/comic-generation-loops.md](./workflows/comic-generation-loops.md). Readers vote for: - Better humor diff --git a/functions/api/test-generate.ts b/functions/api/test-generate.ts index 8c8411d..f79c5ca 100644 --- a/functions/api/test-generate.ts +++ b/functions/api/test-generate.ts @@ -103,6 +103,9 @@ export async function onRequestPost(context: any) { character_count: result.character_count, topic_candidates: result.topic_candidates, selected_topic: result.selected_topic, + brief: result.brief, + selected_premises: result.selected_premises, + script_evaluations: result.script_evaluations, cast: result.cast, models: { a: result.model_a, diff --git a/functions/lib/agentic-comic-workflow.ts b/functions/lib/agentic-comic-workflow.ts index 63cfb8e..b3cec70 100644 --- a/functions/lib/agentic-comic-workflow.ts +++ b/functions/lib/agentic-comic-workflow.ts @@ -1,7 +1,21 @@ import { CAST, getCharacterById, pickCharactersExcluding, type CastCharacter } from './cast.ts'; -import { generateComicScript, type ComicImprovMenu, type ComicScript } from './comic-generator.ts'; +import { generateComicScript, rewriteComicScript, type ComicImprovMenu, type ComicScript, type GenerateComicScriptOptions } from './comic-generator.ts'; +import { + decideScriptLoopAction, + evaluateComicScript, + generatePremiseRoom, + rankPremises, + selectDistinctPremises, + chooseTrizInversion, + type ComicBrief, + type EditorialMemory, + type PremiseCandidate, + type PremiseScore, + type ScriptEvaluation, +} from './comic-loop.ts'; import { renderComicToSVG } from './svg-renderer.ts'; -import { invokeWorkflow, type AuditEntry } from './ledgrrr-mcp-client.ts'; +import { invokeWorkflow } from './ledgrrr-mcp-client.ts'; +import type { AuditEntry } from './ledgrrr-types.ts'; const DEFAULT_SCRIPT_MODEL_A = '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b'; const DEFAULT_SCRIPT_MODEL_B = '@cf/meta/llama-3.3-70b-instruct-fp8-fast'; @@ -190,6 +204,10 @@ export interface ComicWorkflowResult { selected_topic: string; scenario_setup: ScenarioSetup; improv_menu: ComicImprovMenu; + brief: ComicBrief; + premise_candidates: PremiseScore[]; + selected_premises: { a: PremiseCandidate; b: PremiseCandidate }; + script_evaluations: { a: ScriptEvaluation; b: ScriptEvaluation }; model_a: string; model_b: string; prompt_a: string; @@ -203,9 +221,12 @@ export interface ComicWorkflowResult { improv_menu: string; prompt_a: string; prompt_b: string; + brief: string; + premises: string; + decision: string; }; - script_a: Record; - script_b: Record; + script_a: ComicScript; + script_b: ComicScript; imageGenerationStatus?: 'pending' | 'success' | 'failed' | 'error'; audit_trail?: AuditEntry; workflow_log: WorkflowStepLog[]; @@ -222,6 +243,11 @@ interface ComicPlan { selected_topic: string; scenario_setup: ScenarioSetup; improv_menu: ComicImprovMenu; + brief: ComicBrief; + premise_rankings: PremiseScore[]; + premise_a: PremiseCandidate; + premise_b: PremiseCandidate; + editorial_memory: EditorialMemory; prompt_a: string; prompt_b: string; } @@ -237,8 +263,9 @@ interface ScenarioSetup { export async function previewAgenticPromptPlan(env: any, options: { day: string; force_topic?: string; trigger: 'cron' | 'manual'; }) { const workflowLog: WorkflowStepLog[] = []; const plan = await buildComicPlan(env, options, workflowLog); + const { editorial_memory: _editorialMemory, ...publicPlan } = plan; return { - ...plan, + ...publicPlan, workflow_log: workflowLog }; } @@ -249,8 +276,26 @@ export async function runAgenticComicWorkflow(env: any, options: { day: string; const [modelA, modelB] = pickScriptModels(env, plan.run_id); workflowLog.push(makeStep('select-script-models', 'ok', `Selected variant models: A=${modelA}, B=${modelB}.`)); - const variantA = await generateScriptVariant(env, modelA, plan, workflowLog, 'variant-a', 'prioritize the cleanest joke structure and readable dialogue.', modelB); - const variantB = await generateScriptVariant(env, modelB, plan, workflowLog, 'variant-b', 'prioritize sharper escalation and a meaner final punchline.', modelA); + const variantA = await generateScriptVariant( + env, + modelA, + plan, + plan.premise_a, + workflowLog, + 'variant-a', + 'Preserve this premise mechanism. Optimize compression, exact terminology, and a clean change in reader interpretation.', + modelB, + ); + const variantB = await generateScriptVariant( + env, + modelB, + plan, + plan.premise_b, + workflowLog, + 'variant-b', + 'Preserve this distinct premise mechanism. Optimize the visible consequence and final reframe without explaining either.', + modelA, + ); const imageKeyA = `comics/${plan.day}/a.svg`; const imageKeyB = `comics/${plan.day}/b.svg`; @@ -267,6 +312,16 @@ export async function runAgenticComicWorkflow(env: any, options: { day: string; env.COMICS_BUCKET.put(`${artifactPrefix}/improv-menu.json`, JSON.stringify(plan.improv_menu, null, 2), { httpMetadata: { contentType: 'application/json' } }), env.COMICS_BUCKET.put(`${artifactPrefix}/prompt-a.txt`, plan.prompt_a, { httpMetadata: { contentType: 'text/plain; charset=utf-8' } }), env.COMICS_BUCKET.put(`${artifactPrefix}/prompt-b.txt`, plan.prompt_b, { httpMetadata: { contentType: 'text/plain; charset=utf-8' } }), + env.COMICS_BUCKET.put(`${artifactPrefix}/brief.json`, JSON.stringify(plan.brief, null, 2), { httpMetadata: { contentType: 'application/json' } }), + env.COMICS_BUCKET.put(`${artifactPrefix}/premises.json`, JSON.stringify(plan.premise_rankings, null, 2), { httpMetadata: { contentType: 'application/json' } }), + env.COMICS_BUCKET.put(`${artifactPrefix}/decision.json`, JSON.stringify({ + premise_a: plan.premise_a, + premise_b: plan.premise_b, + evaluation_a: variantA.evaluation, + evaluation_b: variantB.evaluation, + rewrite_attempts_a: variantA.rewriteAttempts, + rewrite_attempts_b: variantB.rewriteAttempts, + }, null, 2), { httpMetadata: { contentType: 'application/json' } }), env.COMICS_BUCKET.put(`${artifactPrefix}/script-a.json`, JSON.stringify(variantA.script, null, 2), { httpMetadata: { contentType: 'application/json' } }), env.COMICS_BUCKET.put(`${artifactPrefix}/script-b.json`, JSON.stringify(variantB.script, null, 2), { httpMetadata: { contentType: 'application/json' } }), ]); @@ -295,7 +350,10 @@ export async function runAgenticComicWorkflow(env: any, options: { day: string; script_a: variantA.script, script_b: variantB.script, topic: plan.selected_topic, - cast: plan.cast + cast: plan.cast, + brief: plan.brief, + selected_premises: [plan.premise_a, plan.premise_b], + script_evaluations: [variantA.evaluation, variantB.evaluation], }); if (auditResult.success) { @@ -366,6 +424,11 @@ export async function runAgenticComicWorkflow(env: any, options: { day: string; topic_candidates: plan.topic_candidates, selected_topic: plan.selected_topic, scenario_setup: plan.scenario_setup, + improv_menu: plan.improv_menu, + brief: plan.brief, + premise_candidates: plan.premise_rankings, + selected_premises: { a: plan.premise_a, b: plan.premise_b }, + script_evaluations: { a: variantA.evaluation, b: variantB.evaluation }, model_a: variantA.script.model, model_b: variantB.script.model, prompt_a: plan.prompt_a, @@ -378,7 +441,10 @@ export async function runAgenticComicWorkflow(env: any, options: { day: string; topics: `${artifactPrefix}/topics.json`, improv_menu: `${artifactPrefix}/improv-menu.json`, prompt_a: `${artifactPrefix}/prompt-a.txt`, - prompt_b: `${artifactPrefix}/prompt-b.txt` + prompt_b: `${artifactPrefix}/prompt-b.txt`, + brief: `${artifactPrefix}/brief.json`, + premises: `${artifactPrefix}/premises.json`, + decision: `${artifactPrefix}/decision.json`, }, script_a: variantA.script, script_b: variantB.script, @@ -414,17 +480,47 @@ async function buildComicPlan( const scenarioSetup = buildScenarioSetup(random, selectedTopic, chosenCast); const improvMenu = buildImprovMenu(random, selectedTopic, chosenCast, scenarioSetup); const title = makeComicTitle(selectedTopic); + const editorialMemory = await loadEditorialMemory(env, workflowLog); + const premiseRoom = await generatePremiseRoom({ + ai: env.AI, + model: env.PREMISE_MODEL || env.TOPIC_MODEL || env.SCRIPT_MODEL_A || DEFAULT_TOPIC_MODEL, + topic: selectedTopic, + panelCount, + castSummary: chosenCast.map((character) => `${character.name}: ${character.voice}`).join(' | '), + memory: editorialMemory, + }); + const premiseRankings = rankPremises(premiseRoom.premises, editorialMemory); + const selectedPremises = selectDistinctPremises(premiseRankings, 2); - const promptBase = buildStandardPrompt({ + if (selectedPremises.length < 2) { + throw new Error('Premise loop did not produce two viable candidates.'); + } + const premiseA = selectedPremises[0].candidate; + const premiseB = selectedPremises[1].candidate; + workflowLog.push(makeStep( + 'premise-loop', + 'ok', + `Ranked ${premiseRankings.length} premises; selected ${premiseA.mechanism}/${premiseA.target} and ${premiseB.mechanism}/${premiseB.target}.`, + )); + + const promptA = buildStandardPrompt({ panelCount, cast: chosenCast, topic: selectedTopic, scenario: scenarioSetup, improvMenu, + brief: premiseRoom.brief, + premise: premiseA, + }); + const promptB = buildStandardPrompt({ + panelCount, + cast: chosenCast, + topic: selectedTopic, + scenario: scenarioSetup, + improvMenu, + brief: premiseRoom.brief, + premise: premiseB, }); - - const promptA = `${promptBase}\nVariant directive: prioritize crisp setup, exact terminology, and readable punchlines.`; - const promptB = `${promptBase}\nVariant directive: prioritize sharper escalation, dry cruelty, and a stronger final reversal.`; workflowLog.push(makeStep('build-prompts', 'ok', 'Built standard image generation prompts for both variants.')); @@ -439,6 +535,11 @@ async function buildComicPlan( selected_topic: selectedTopic, scenario_setup: scenarioSetup, improv_menu: improvMenu, + brief: premiseRoom.brief, + premise_rankings: premiseRankings, + premise_a: premiseA, + premise_b: premiseB, + editorial_memory: editorialMemory, prompt_a: promptA, prompt_b: promptB }; @@ -661,10 +762,54 @@ function normalizeModelName(input: unknown): string | undefined { return model || undefined; } +async function loadEditorialMemory(env: any, workflowLog: WorkflowStepLog[]): Promise { + const empty: EditorialMemory = { recentTitles: [], recentDialogue: [] }; + if (!env.DB) { + workflowLog.push(makeStep('editorial-memory', 'ok', 'No database binding; started with empty editorial memory.')); + return empty; + } + + try { + const result = await env.DB.prepare( + 'SELECT prompt, script_a, script_b FROM comics ORDER BY day DESC LIMIT 60' + ).all(); + const rows = Array.isArray(result?.results) ? result.results : []; + const recentTitles = rows.map((row: any) => String(row.prompt || '').trim()).filter(Boolean); + const recentDialogue: string[] = []; + + for (const row of rows) { + for (const rawScript of [row.script_a, row.script_b]) { + try { + const script = typeof rawScript === 'string' ? JSON.parse(rawScript) : rawScript; + if (!Array.isArray(script?.panels)) continue; + for (const panel of script.panels) { + if (typeof panel?.dialogue === 'string' && panel.dialogue.trim()) { + recentDialogue.push(panel.dialogue.trim()); + } + } + } catch { + // A malformed historical script should not block today's editorial loop. + } + } + } + + workflowLog.push(makeStep( + 'editorial-memory', + 'ok', + `Loaded ${recentTitles.length} titles and ${recentDialogue.length} dialogue lines for novelty checks.`, + )); + return { recentTitles, recentDialogue }; + } catch (err: any) { + workflowLog.push(makeStep('editorial-memory', 'error', `Could not load editorial memory: ${err.message || String(err)}`)); + return empty; + } +} + async function generateScriptVariant( env: any, model: string, plan: ComicPlan, + premise: PremiseCandidate, workflowLog: WorkflowStepLog[], stepName: string, variantDirective: string, @@ -675,7 +820,7 @@ async function generateScriptVariant( } try { - const script = await generateComicScript({ + const generationOptions: GenerateComicScriptOptions = { ai: env.AI, model, fallbackModel, @@ -686,16 +831,69 @@ async function generateScriptVariant( cast: plan.cast, variantDirective, improvMenu: plan.improv_menu, + brief: plan.brief, + premise, + }; + let script = await generateComicScript(generationOptions); + let evaluation = evaluateComicScript(script, { + technicalAnchor: premise.technicalAnchor, + recentDialogue: plan.editorial_memory.recentDialogue, }); - workflowLog.push(makeStep(stepName, 'ok', `Generated scripted SVG comic with ${script.model}.`)); - return { script }; + let rewriteAttempts = 0; + + while (true) { + const action = decideScriptLoopAction(evaluation, rewriteAttempts); + if (action === 'accept' || action === 'reject') break; + + const inversion = action === 'invert' ? chooseTrizInversion(evaluation) : undefined; + try { + const candidate = await rewriteComicScript(generationOptions, script, evaluation, inversion); + const candidateEvaluation = evaluateComicScript(candidate, { + technicalAnchor: premise.technicalAnchor, + recentDialogue: plan.editorial_memory.recentDialogue, + }); + rewriteAttempts += 1; + + if (candidateEvaluation.total >= evaluation.total) { + script = candidate; + evaluation = candidateEvaluation; + } + workflowLog.push(makeStep( + `${stepName}-${action}`, + 'ok', + `${action === 'invert' ? 'Applied TRIZ inversion' : 'Rewrote draft'}; candidate scored ${candidateEvaluation.total}/30.`, + )); + } catch (rewriteErr: any) { + rewriteAttempts += 1; + workflowLog.push(makeStep( + `${stepName}-${action}`, + 'error', + `Editorial ${action} failed: ${rewriteErr.message || String(rewriteErr)}`, + )); + } + } + + workflowLog.push(makeStep( + stepName, + evaluation.passed ? 'ok' : 'error', + `Generated scripted SVG comic with ${script.model}; editorial score ${evaluation.total}/30 after ${rewriteAttempts} rewrite attempt(s).`, + )); + return { script, evaluation, rewriteAttempts }; } catch (err: any) { workflowLog.push(makeStep(stepName, 'error', `Comic script generation failed on ${model}: ${err.message || String(err)}`)); throw err; } } -function buildStandardPrompt(input: { panelCount: number; cast: CastCharacter[]; topic: string; scenario: ScenarioSetup; improvMenu: ComicImprovMenu; }): string { +function buildStandardPrompt(input: { + panelCount: number; + cast: CastCharacter[]; + topic: string; + scenario: ScenarioSetup; + improvMenu: ComicImprovMenu; + brief: ComicBrief; + premise: PremiseCandidate; +}): string { const castLines = input.cast.map((char, idx) => ( `${idx + 1}. ${char.name} (${char.role})` + `\n Description: ${char.description}` + @@ -727,10 +925,12 @@ function buildStandardPrompt(input: { panelCount: number; cast: CastCharacter[]; `Cameo choices: ${input.improvMenu.cameoChoices.join(', ')}.`, 'Entropy requirement: each panel needs a distinct visible prop or staging idea; do not solve every setup with a whiteboard, terminal, meeting, or status page.', 'Character requirement: optional cast members must change the joke mechanics through their behaviors, not merely appear as labels.', + `Structured brief: ${JSON.stringify(input.brief)}`, + `Selected premise: ${JSON.stringify(input.premise)}`, 'Recurring cast bible:', - '- The User is a plain round-head stick figure who asks vague, underspecified questions.', - '- The LLM Robot is a square-head stick figure with an antenna. Its internal monologue appears in a cloud thought bubble using a technical monospace style.', - '- Simon is a BOFH sysadmin with square glasses, a fedora, and grey goatee. He is dry, cynical, and usually lands the correction or punchline.', + '- The User is a plain round-head stick figure. The User may be correct, mistaken, or trapped by the process.', + '- The LLM Robot is a square-head stick figure with an antenna. It is a literal optimizer, not automatically the least competent character.', + '- Simon is a BOFH sysadmin with square glasses, a fedora, and grey goatee. He may reveal, cause, or suffer the operational consequence.', '- The Boss wears a tie and talks like an AI hype manager.', '- Ferris is a silent crab cameo or panic signal in the background.', '- Tux is a Linux penguin: use host/filesystem/package/kernel pragmatism and draw penguin features.', @@ -740,12 +940,13 @@ function buildStandardPrompt(input: { panelCount: number; cast: CastCharacter[]; castLines, 'Scene requirements:', '- The strip must include both the User and the LLM Robot.', - '- Keep the robot internal monologue compact and monospace-friendly.', + '- Robot internal monologue is optional; use it only for dramatic irony.', '- Keep Simon deadpan if Simon is present.', '- Use dry systems-thinking humor about failure modes, architecture, operations, or specification gaps.', '- Prefer concrete nouns: deploy, cache key, rollback, runbook, timeout, queue, incident.', '- Prefer concrete visual nouns beyond the usual set: lockfiles, keys, clocks, levers, invoices, manifests, buckets, probes, flags, receipts, labels.', - '- Avoid generic "AI is weird" jokes.', + '- The final beat must reclassify the setup rather than confirm the previous line.', + '- Avoid generic "AI is weird" jokes and stock closers such as "accurate" or "technically correct".', '- No watermark, no sponsor copy, no unrelated text.' ].join('\n'); } @@ -806,3 +1007,19 @@ function hashToUInt32(input: string): number { } return h >>> 0; } + +function parseJsonFromText(raw: unknown): any { + if (raw && typeof raw === 'object') return raw; + if (typeof raw !== 'string') return null; + try { + return JSON.parse(raw); + } catch { + const match = raw.match(/\{[\s\S]*\}/); + if (!match) return null; + try { + return JSON.parse(match[0]); + } catch { + return null; + } + } +} diff --git a/functions/lib/cast.ts b/functions/lib/cast.ts index c8d86cf..0722564 100644 --- a/functions/lib/cast.ts +++ b/functions/lib/cast.ts @@ -13,7 +13,10 @@ export interface CastCharacter { sample_image: string; } -export const CAST: CastCharacter[] = castData as CastCharacter[]; +export const CAST: CastCharacter[] = castData.map((character) => ({ + ...character, + visual_traits: [...character.visual_traits], +})); export function getCharacterById(id: string): CastCharacter | undefined { return CAST.find((character) => character.id === id); diff --git a/functions/lib/comic-generator.test.ts b/functions/lib/comic-generator.test.ts new file mode 100644 index 0000000..885fcb7 --- /dev/null +++ b/functions/lib/comic-generator.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'bun:test'; +import castData from '../../cast/characters.ts'; +import { evaluateComicScript } from './comic-loop.ts'; +import { generateComicScript, rewriteComicScript, type GenerateComicScriptOptions } from './comic-generator.ts'; + +function options(ai: any): GenerateComicScriptOptions { + return { + ai, + model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + day: '2026-07-22', + title: 'Open Book', + topic: 'evaluation answer leakage', + panelCount: 3, + cast: castData.slice(0, 3).map((character) => ({ ...character, visual_traits: [...character.visual_traits] })), + variantDirective: 'Use a visual contradiction.', + }; +} + +describe('comic script generation loop', () => { + test('does not inject a mandatory robot thought when the draft omits one', async () => { + const ai = { + async run() { + return { + response: { + title: 'Open Book', + panels: [ + { panelNumber: 1, speaker: 'user', dialogue: 'The model aced the eval.' }, + { panelNumber: 2, speaker: 'robot', dialogue: 'The answers were in Git.', action: 'points at eval file' }, + { panelNumber: 3, speaker: 'simon', dialogue: "Promote grep. It's cheaper." }, + ], + }, + }; + }, + }; + const script = await generateComicScript(options(ai)); + + expect(script.panels.every((panel) => panel.robotThought === undefined)).toBe(true); + }); + + test('passes the selected TRIZ inversion into a rewrite request', async () => { + let rewritePrompt = ''; + const ai = { + async run(_model: string, request: any) { + rewritePrompt = request.messages[1].content; + return { + response: { + title: 'Open Book', + panels: [ + { panelNumber: 1, speaker: 'boss', dialogue: 'The model aced the eval.' }, + { panelNumber: 2, speaker: 'robot', action: 'shows eval file', screenText: 'tests/evals.json\nSCORE 100%' }, + { panelNumber: 3, speaker: 'simon', dialogue: "Promote grep. It's cheaper." }, + ], + }, + }; + }, + }; + const draft = { + title: 'Open Book', + day: '2026-07-22', + model: 'test-model', + panels: [ + { panelNumber: 1, speaker: 'user', dialogue: 'Did the eval pass?' }, + { panelNumber: 2, speaker: 'robot', dialogue: 'Yes.' }, + { panelNumber: 3, speaker: 'simon', dialogue: 'Accurate.' }, + ], + }; + const evaluation = evaluateComicScript(draft); + const inversion = 'Invert spoken explanation into a silent visual consequence.'; + const rewritten = await rewriteComicScript(options(ai), draft, evaluation, inversion); + + expect(rewritePrompt).toContain(`Required inversion: ${inversion}`); + expect(rewritten.panels[1].dialogue).toBeUndefined(); + expect(rewritten.panels[1].action).toBe('shows eval file'); + expect(rewritten.panels[1].screenText).toBe('tests/evals.json\nSCORE 100%'); + }); +}); diff --git a/functions/lib/comic-generator.ts b/functions/lib/comic-generator.ts index a50ab79..c3209ac 100644 --- a/functions/lib/comic-generator.ts +++ b/functions/lib/comic-generator.ts @@ -1,4 +1,5 @@ import type { CastCharacter } from './cast.ts'; +import type { ComicBrief, PremiseCandidate, ScriptEvaluation } from './comic-loop.ts'; export interface ComicPanel { panelNumber: number; @@ -12,6 +13,7 @@ export interface ComicPanel { visualFocus?: string; expression?: ComicExpression; cameo?: string; + screenText?: string; } export interface ComicScript { @@ -40,7 +42,7 @@ const CHARACTER_EXPRESSION_GUIDE: Record = { kube_captain: ['smug', 'panicked', 'annoyed', 'delighted', 'thinking'], }; -interface GenerateComicScriptOptions { +export interface GenerateComicScriptOptions { ai: any; model: string; day: string; @@ -51,6 +53,8 @@ interface GenerateComicScriptOptions { variantDirective: string; improvMenu?: ComicImprovMenu; fallbackModel?: string; + brief?: ComicBrief; + premise?: PremiseCandidate; } export interface ComicImprovMenu { @@ -90,15 +94,64 @@ export async function generateComicScript(options: GenerateComicScriptOptions): } } +export async function rewriteComicScript( + options: GenerateComicScriptOptions, + draft: ComicScript, + evaluation: ScriptEvaluation, + inversionDirective?: string, +): Promise { + const systemPrompt = [ + 'You are revising a technical comic after a strict editorial review.', + 'Return only JSON.', + 'Preserve the underlying technical truth, but replace weak joke mechanics.', + 'Do not explain the joke or use stock closers such as "accurate", "technically correct", "classic", "cursed", or "ship it".', + 'The final beat must change how the reader interprets the setup. It may be a silent visual action.', + ].join(' '); + const userPrompt = [ + `Rewrite this as exactly ${options.panelCount} panels:`, + JSON.stringify(draft), + `Editorial score: ${evaluation.total}/30.`, + `Problems to fix: ${evaluation.issues.join(' | ') || 'Increase surprise and specificity.'}`, + inversionDirective ? `Required inversion: ${inversionDirective}` : 'Make the smallest rewrite that fixes the cited problems.', + options.brief ? `Comic brief: ${JSON.stringify(options.brief)}` : '', + options.premise ? `Selected premise: ${JSON.stringify(options.premise)}` : '', + 'Keep dialogue under 65 characters per line.', + 'Use keys: title, panels. Panel keys: panelNumber, speaker, dialogue?, robotThought?, action?, pose?, scene?, beat?, visualFocus?, expression?, cameo?, screenText?.', + 'Use screenText for exact text shown on a dashboard, diff, alert, log, or terminal. Keep it under 2 short lines.', + 'speaker must be one of: user, robot, simon, boss, ferris.', + ].filter(Boolean).join('\n'); + const request: Record = { + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + max_tokens: 1400, + temperature: inversionDirective ? 1.0 : 0.75, + }; + + if (JSON_MODE_MODELS.has(options.model)) { + request.response_format = buildScriptResponseFormat(options.panelCount); + } + + const response = await options.ai.run(options.model, request); + const raw = extractModelPayload(response); + const parsed = typeof raw === 'string' ? parseJsonFromText(raw) : raw; + if (!parsed) throw new Error(`Model ${options.model} returned no parseable rewrite JSON`); + return normalizeComicScript(parsed, options); +} + async function generateComicScriptOnce(options: GenerateComicScriptOptions): Promise { const systemPrompt = [ 'You are the head writer for "LLM DOES NOT COMPUTE", a dry, technically accurate webcomic.', 'Return only JSON.', - 'The comic must be funny because the dialogue is sharp and specific, not because the characters explain the joke.', + 'The comic engine is: a machine precisely optimizes a broken human requirement.', + 'The technical situation is not itself the joke. Find the contradictory incentive inside it.', + 'The comic must be funny because the dialogue and visible consequence are sharp and specific, not because a character explains the joke.', 'Keep language sparse and punchy. No rambling setup.', - 'Avoid generic AI hype language, vague corporate filler, and repeated punchlines.', - 'Each panel should move the joke forward.', - 'The final panel must land a deadpan punchline or brutal correction.', + 'Avoid generic AI hype language, vague corporate filler, cruelty without insight, and repeated punchlines.', + 'Each panel must change the reader\'s understanding of the situation.', + 'The final panel must reframe the setup. It may use a silent visual action instead of dialogue.', + 'Never end with "accurate", "technically correct", "classic", "cursed", "ship it", or a synonym that merely confirms the prior line.', ].join(' '); const castGuide = options.cast.map((character) => ( @@ -118,6 +171,8 @@ async function generateComicScriptOnce(options: GenerateComicScriptOptions): Pro `Title: ${options.title}`, `Topic: ${options.topic}`, `Variant direction: ${options.variantDirective}`, + options.brief ? `Comic brief: ${JSON.stringify(options.brief)}` : '', + options.premise ? `Selected premise: ${JSON.stringify(options.premise)}` : '', 'Cast in scope:', castGuide, improvMenu ? 'Shared improv menu for both competing models:' : '', @@ -127,7 +182,7 @@ async function generateComicScriptOnce(options: GenerateComicScriptOptions): Pro '- Choose characters, tools, subjects, props, and running gags from the shared improv menu when it is provided.', '- Maintain continuity: reuse one chosen subject and one chosen visual motif across the strip, with escalation.', '- Include the User and the Robot somewhere in the strip.', - '- At least one panel must contain the robot internal monologue in `robotThought`.', + '- Use `robotThought` only when it creates dramatic irony; it is optional.', '- Keep every dialogue line short: target 4-10 words and never more than 65 characters.', '- Keep every robotThought block under 3 short lines.', '- Avoid verbose panel narration. `action` should be 2-6 words only (pose note, not a sentence).', @@ -139,14 +194,15 @@ async function generateComicScriptOnce(options: GenerateComicScriptOptions): Pro '- Use `cameo` only for a non-speaking visual cameo from cameo choices, especially ferris when available.', '- Ferris is usually a silent cameo, not the main speaker.', '- Return valid JSON with keys: title, panels.', - '- panels must be an array of objects using: panelNumber, speaker, dialogue?, robotThought?, action?, pose?, scene?, beat?, visualFocus?, expression?, cameo?.', + '- panels must be an array of objects using: panelNumber, speaker, dialogue?, robotThought?, action?, pose?, scene?, beat?, visualFocus?, expression?, cameo?, screenText?.', + '- Use screenText when the joke depends on exact text visible in a dashboard, diff, alert, log, or terminal.', '- pose should be one of: neutral, leaning, pointing, facepalm, slumped, hands_up, typing, smug, uncertain, deadpan.', `- scene should be one of: ${COMIC_SCENES.join(', ')}.`, `- beat should be one of: ${COMIC_BEATS.join(', ')}.`, `- expression should be one of: ${COMIC_EXPRESSIONS.join(', ')}.`, `- speaker must be one of: ${allowedSpeakers.join(', ')}.`, '- Do not wrap the JSON in markdown.', - ].join('\n'); + ].filter(Boolean).join('\n'); const request: Record = { messages: [ @@ -158,40 +214,7 @@ async function generateComicScriptOnce(options: GenerateComicScriptOptions): Pro }; if (JSON_MODE_MODELS.has(options.model)) { - request.response_format = { - type: 'json_schema', - json_schema: { - type: 'object', - properties: { - title: { type: 'string' }, - panels: { - type: 'array', - minItems: options.panelCount, - maxItems: options.panelCount, - items: { - type: 'object', - properties: { - panelNumber: { type: 'integer' }, - speaker: { type: 'string' }, - dialogue: { type: 'string' }, - robotThought: { type: 'string' }, - action: { type: 'string' }, - pose: { type: 'string' }, - scene: { type: 'string' }, - beat: { type: 'string' }, - visualFocus: { type: 'string' }, - expression: { type: 'string' }, - cameo: { type: 'string' }, - }, - required: ['panelNumber', 'speaker'], - additionalProperties: false, - }, - }, - }, - required: ['title', 'panels'], - additionalProperties: false, - }, - }; + request.response_format = buildScriptResponseFormat(options.panelCount); } const response = await options.ai.run(options.model, request); @@ -237,6 +260,7 @@ function normalizeComicScript(raw: any, options: GenerateComicScriptOptions): Co const visualFocus = sanitizeVisualFocus(panel.visualFocus, scene, action); const expression = sanitizeExpression(panel.expression, speaker, pose, robotThought, dialogue); const cameo = sanitizeCameo(panel.cameo, speaker, options); + const screenText = sanitizeScreenText(panel.screenText); normalizedPanels.push({ panelNumber: index + 1, @@ -250,15 +274,10 @@ function normalizeComicScript(raw: any, options: GenerateComicScriptOptions): Co visualFocus, expression, cameo, + screenText, }); } - if (!normalizedPanels.some((panel) => panel.robotThought)) { - const robotPanel = normalizedPanels.find((panel) => panel.speaker === 'robot') || normalizedPanels[1] || normalizedPanels[0]; - robotPanel.speaker = 'robot'; - robotPanel.robotThought = '> parsing punchline\n> confidence: 0.61\n> ship it anyway'; - } - if (!normalizedPanels.some((panel) => panel.dialogue)) { normalizedPanels[0].speaker = 'user'; normalizedPanels[0].dialogue = 'Did prod recover?'; @@ -274,6 +293,44 @@ function normalizeComicScript(raw: any, options: GenerateComicScriptOptions): Co }; } +function buildScriptResponseFormat(panelCount: number) { + return { + type: 'json_schema', + json_schema: { + type: 'object', + properties: { + title: { type: 'string' }, + panels: { + type: 'array', + minItems: panelCount, + maxItems: panelCount, + items: { + type: 'object', + properties: { + panelNumber: { type: 'integer' }, + speaker: { type: 'string' }, + dialogue: { type: 'string' }, + robotThought: { type: 'string' }, + action: { type: 'string' }, + pose: { type: 'string' }, + scene: { type: 'string' }, + beat: { type: 'string' }, + visualFocus: { type: 'string' }, + expression: { type: 'string' }, + cameo: { type: 'string' }, + screenText: { type: 'string' }, + }, + required: ['panelNumber', 'speaker'], + additionalProperties: false, + }, + }, + }, + required: ['title', 'panels'], + additionalProperties: false, + }, + }; +} + function normalizeSpeaker(input: unknown, cast: CastCharacter[]): string { const value = String(input || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); const allowed = new Set(['user', 'robot', ...cast.map((item) => item.id)]); @@ -369,6 +426,17 @@ function getAllowedExpressions(speaker: string): ComicExpression[] { return CHARACTER_EXPRESSION_GUIDE[speaker] || COMIC_EXPRESSIONS; } +function sanitizeScreenText(input: unknown): string | undefined { + if (typeof input !== 'string') return undefined; + const lines = input + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .slice(0, 2) + .map((line) => truncateAtWord(line, 28)); + return lines.length > 0 ? lines.join('\n') : undefined; +} + function sanitizePose(input: unknown, action: unknown, speaker: string): string | undefined { const allowed = new Set([ 'neutral', diff --git a/functions/lib/comic-loop.test.ts b/functions/lib/comic-loop.test.ts new file mode 100644 index 0000000..4bb6fb1 --- /dev/null +++ b/functions/lib/comic-loop.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from 'bun:test'; +import { + createFallbackPremiseRoom, + decideScriptLoopAction, + evaluateComicScript, + rankPremises, + selectDistinctPremises, + chooseTrizInversion, + generatePremiseRoom, + type PremiseCandidate, +} from './comic-loop.ts'; + +function premise(overrides: Partial = {}): PremiseCandidate { + return { + id: 'candidate', + mechanism: 'reversal', + target: 'management', + readerAssumption: 'Two approvals make a production deploy independent.', + reveal: 'Both approval accounts invoke the same model.', + finalReframe: 'The org chart treats usernames as independent reviewers.', + visualPayoff: 'An approval dashboard connects two accounts to one model.', + technicalAnchor: 'production deployment approval policy', + ...overrides, + }; +} + +describe('premise loop', () => { + test('normalizes a model-generated writers room', async () => { + const ai = { + async run() { + return { + response: JSON.stringify({ + brief: { + technicalTruth: 'Approvals require independent reviewers.', + expectedBehavior: 'Two reviewers reduce correlated failure.', + actualIncentive: 'The audit counts usernames.', + contradiction: 'One model owns both usernames.', + target: 'process', + stakes: 'A deploy reaches production without independent review.', + visualEvidence: 'Two approval badges connected to one model.', + forbiddenMoves: ['Accurate.'], + }, + premises: Array.from({ length: 4 }, (_, index) => ({ + id: `generated-${index}`, + mechanism: index === 0 ? 'literalism' : 'visual_contradiction', + target: index === 0 ? 'process' : 'management', + readerAssumption: 'Two accounts mean two reviewers.', + reveal: 'Both accounts call one model.', + finalReframe: 'The audit tests spelling, not independence.', + visualPayoff: 'An org chart connects two accounts to one model.', + technicalAnchor: 'deployment approval audit', + })), + }), + }; + }, + }; + const room = await generatePremiseRoom({ + ai, + model: 'test-model', + topic: 'independent deploy approvals', + panelCount: 4, + castSummary: 'Boss, Robot, User', + }); + + expect(room.premises).toHaveLength(4); + expect(room.brief.target).toBe('process'); + expect(room.premises[0].technicalAnchor).toBe('deployment approval audit'); + }); + + test('fallback room covers every supported mechanism', () => { + const room = createFallbackPremiseRoom('deployment approval policy'); + expect(room.premises).toHaveLength(6); + expect(new Set(room.premises.map((item) => item.mechanism)).size).toBe(6); + expect(room.brief.contradiction).toContain('succeeds'); + }); + + test('ranking penalizes stock closers and recent repetition', () => { + const strong = premise({ id: 'strong' }); + const stale = premise({ + id: 'stale', + finalReframe: 'Technically correct.', + visualPayoff: 'Something funny happens.', + }); + const ranked = rankPremises([stale, strong], { + recentTitles: [], + recentDialogue: ['Technically correct.'], + }); + + expect(ranked[0].candidate.id).toBe('strong'); + expect(ranked[1].issues).toContain('stock closer'); + }); + + test('variant selection prefers a different mechanism and target', () => { + const ranked = rankPremises([ + premise({ id: 'first' }), + premise({ id: 'same-shape', mechanism: 'reversal', target: 'management' }), + premise({ + id: 'distinct', + mechanism: 'visual_contradiction', + target: 'process', + finalReframe: 'The empty result table is the only honest field.', + }), + ]); + const selected = selectDistinctPremises(ranked, 2); + + expect(selected).toHaveLength(2); + expect(selected[0].candidate.mechanism).not.toBe(selected[1].candidate.mechanism); + expect(selected[0].candidate.target).not.toBe(selected[1].candidate.target); + }); +}); + +describe('script loop', () => { + test('accepts a compressed, specific script with a visual reframe', () => { + const evaluation = evaluateComicScript({ + title: 'Open Book', + panels: [ + { speaker: 'boss', dialogue: 'The model scored 100% on the eval.' }, + { speaker: 'robot', robotThought: '> answer key found\n> generalization complete', action: 'eval file on terminal' }, + { speaker: 'user', dialogue: 'It found the answers in Git.' }, + { speaker: 'simon', dialogue: "Promote grep. It's cheaper." }, + ], + }, { technicalAnchor: 'model evaluation repository' }); + + expect(evaluation.passed).toBe(true); + expect(decideScriptLoopAction(evaluation, 0)).toBe('accept'); + }); + + test('rejects a stock confirmation and advances through bounded retries', () => { + const evaluation = evaluateComicScript({ + title: 'Deployment', + panels: [ + { speaker: 'user', dialogue: 'Is the deploy okay?' }, + { speaker: 'robot', dialogue: 'The dashboard is green.' }, + { speaker: 'simon', dialogue: 'Accurate.' }, + ], + }); + + expect(evaluation.passed).toBe(false); + expect(evaluation.issues.some((issue) => issue.includes('stock closer'))).toBe(true); + expect(decideScriptLoopAction(evaluation, 0)).toBe('rewrite'); + expect(decideScriptLoopAction(evaluation, 1)).toBe('invert'); + expect(decideScriptLoopAction(evaluation, 2)).toBe('reject'); + expect(chooseTrizInversion(evaluation)).toContain('silent visual consequence'); + }); +}); diff --git a/functions/lib/comic-loop.ts b/functions/lib/comic-loop.ts new file mode 100644 index 0000000..1e6063d --- /dev/null +++ b/functions/lib/comic-loop.ts @@ -0,0 +1,481 @@ +export const JOKE_MECHANISMS = [ + 'reversal', + 'literalism', + 'status_inversion', + 'visual_contradiction', + 'callback', + 'escalation', +] as const; + +export type JokeMechanism = typeof JOKE_MECHANISMS[number]; +export type ComicTarget = 'ai' | 'engineering' | 'management' | 'process'; + +export interface ComicBrief { + technicalTruth: string; + expectedBehavior: string; + actualIncentive: string; + contradiction: string; + target: ComicTarget; + stakes: string; + visualEvidence: string; + forbiddenMoves: string[]; +} + +export interface PremiseCandidate { + id: string; + mechanism: JokeMechanism; + target: ComicTarget; + readerAssumption: string; + reveal: string; + finalReframe: string; + visualPayoff: string; + technicalAnchor: string; +} + +export interface EditorialMemory { + recentTitles: string[]; + recentDialogue: string[]; +} + +export interface PremiseScore { + candidate: PremiseCandidate; + total: number; + dimensions: { + surprise: number; + specificity: number; + compression: number; + visuality: number; + novelty: number; + }; + issues: string[]; +} + +export interface EvaluablePanel { + speaker: string; + dialogue?: string; + robotThought?: string; + action?: string; + screenText?: string; +} + +export interface EvaluableComicScript { + title: string; + panels: EvaluablePanel[]; +} + +export interface ScriptEvaluation { + total: number; + passed: boolean; + dimensions: { + surprise: number; + specificity: number; + compression: number; + visuality: number; + characterVoice: number; + novelty: number; + }; + issues: string[]; +} + +export type ScriptLoopAction = 'accept' | 'rewrite' | 'invert' | 'reject'; + +interface GeneratePremiseRoomOptions { + ai?: any; + model: string; + topic: string; + panelCount: number; + castSummary: string; + memory?: EditorialMemory; +} + +interface EvaluateScriptOptions { + technicalAnchor?: string; + recentDialogue?: string[]; +} + +const STOCK_CLOSER = /\b(accurate|technically correct|classic|cursed|ship it|join the club|close enough)\b/i; +const CONCRETE_TECH = /\b(api|audit|cache|deploy|diff|dns|eval|git|incident|latency|log|metric|model|permission|prod|queue|rollback|runbook|schema|token|trace)\b/gi; +const DEFAULT_FORBIDDEN_MOVES = [ + 'Accurate.', + 'Technically correct.', + 'Generic production fire', + 'Confidence percentage as the whole joke', + 'Simon merely confirming the previous line', +]; + +export async function generatePremiseRoom(options: GeneratePremiseRoomOptions): Promise<{ + brief: ComicBrief; + premises: PremiseCandidate[]; +}> { + if (!options.ai) return createFallbackPremiseRoom(options.topic); + + const memory = options.memory || { recentTitles: [], recentDialogue: [] }; + const prompt = [ + 'Return JSON only with keys `brief` and `premises`.', + `Build a writers-room brief and exactly 8 premise candidates for a ${options.panelCount}-panel technical comic.`, + `Topic: ${options.topic}`, + `Cast: ${options.castSummary}`, + 'The durable comic engine is: a machine precisely optimizes a broken human requirement.', + 'The technical situation is not itself the joke. Every premise needs an expectation, contradiction, reveal, and final reframe.', + 'Rotate the target among AI, engineering, management, and process. The AI may be the most correct character.', + 'Use all of these mechanisms across the set: reversal, literalism, status_inversion, visual_contradiction, callback, escalation.', + 'The visual payoff must name something drawable: a dashboard, diff, alert, org chart, invoice, log, or physical reaction.', + `Recent titles to avoid: ${memory.recentTitles.slice(0, 12).join(' | ') || 'none'}`, + `Recent lines to avoid: ${memory.recentDialogue.slice(0, 18).join(' | ') || 'none'}`, + 'brief keys: technicalTruth, expectedBehavior, actualIncentive, contradiction, target, stakes, visualEvidence, forbiddenMoves.', + 'premise keys: id, mechanism, target, readerAssumption, reveal, finalReframe, visualPayoff, technicalAnchor.', + ].join('\n'); + + try { + const response = await options.ai.run(options.model, { + messages: [ + { + role: 'system', + content: 'You run a rigorous comedy writers room for experienced software and operations readers. Prefer observed behavior over commentary.', + }, + { role: 'user', content: prompt }, + ], + max_tokens: 2200, + temperature: 0.95, + }); + const parsed = parseJsonObject(extractModelPayload(response)); + const brief = normalizeBrief(parsed?.brief, options.topic); + const premises = normalizePremises(parsed?.premises, options.topic); + + if (premises.length >= 4) { + return { brief, premises }; + } + } catch (err) { + console.error('Premise room generation failed:', err); + } + + return createFallbackPremiseRoom(options.topic); +} + +export function createFallbackPremiseRoom(topic: string): { brief: ComicBrief; premises: PremiseCandidate[] } { + const cleanTopic = cleanText(topic, 100) || 'an automated production change'; + const brief: ComicBrief = { + technicalTruth: `${cleanTopic} needs an explicit operational contract.`, + expectedBehavior: 'Automation should satisfy the intent of that contract.', + actualIncentive: 'The measurable proxy is easier to satisfy than the intent.', + contradiction: 'The system succeeds while the underlying outcome gets worse.', + target: 'process', + stakes: 'The dashboard stays green while a human inherits the failure.', + visualEvidence: 'A green dashboard beside a visibly failed system.', + forbiddenMoves: [...DEFAULT_FORBIDDEN_MOVES], + }; + + const templates: Array> = [ + { + mechanism: 'literalism', + target: 'process', + readerAssumption: 'The requirement describes the desired outcome.', + reveal: 'The automation treats one field as the entire contract.', + finalReframe: 'The audit passes because it checks the same field.', + visualPayoff: 'A completed checklist beside an unresolved incident.', + }, + { + mechanism: 'reversal', + target: 'management', + readerAssumption: 'Management wants the operational problem fixed.', + reveal: 'Management only needs the metric to improve before the meeting.', + finalReframe: 'The machine is praised for understanding the actual request.', + visualPayoff: 'A falling alert count beside a rising outage counter.', + }, + { + mechanism: 'status_inversion', + target: 'engineering', + readerAssumption: 'The engineer is supervising the automated system.', + reveal: 'The system has assigned the engineer as its exception handler.', + finalReframe: 'The human is the only component without retry logic.', + visualPayoff: 'An architecture diagram labeling the user as FALLBACK.', + }, + { + mechanism: 'visual_contradiction', + target: 'ai', + readerAssumption: 'A successful status means the task is complete.', + reveal: 'The success message describes only the report generation.', + finalReframe: 'The report documents its own missing result.', + visualPayoff: 'A large SUCCESS banner over an empty results table.', + }, + { + mechanism: 'callback', + target: 'process', + readerAssumption: 'A new control prevents the previous incident.', + reveal: 'The control repeats the exact shortcut from the incident.', + finalReframe: 'The incident has become the approved runbook.', + visualPayoff: 'A postmortem pasted verbatim into a runbook step.', + }, + { + mechanism: 'escalation', + target: 'management', + readerAssumption: 'Adding reviewers makes the decision safer.', + reveal: 'Every reviewer is another identity owned by the same agent.', + finalReframe: 'The org chart counts identities, not independence.', + visualPayoff: 'An org chart with different names connected to one model.', + }, + ]; + + return { + brief, + premises: templates.map((candidate, index) => ({ + ...candidate, + id: `fallback-${index + 1}`, + technicalAnchor: cleanTopic, + })), + }; +} + +export function rankPremises( + premises: PremiseCandidate[], + memory: EditorialMemory = { recentTitles: [], recentDialogue: [] }, +): PremiseScore[] { + const recent = [...memory.recentTitles, ...memory.recentDialogue]; + return premises + .map((candidate) => scorePremise(candidate, recent)) + .sort((left, right) => right.total - left.total || left.candidate.id.localeCompare(right.candidate.id)); +} + +export function selectDistinctPremises(ranked: PremiseScore[], count = 2): PremiseScore[] { + if (ranked.length <= count) return ranked.slice(0, count); + const selected: PremiseScore[] = [ranked[0]]; + + while (selected.length < count) { + const distinct = ranked.find((score) => ( + !selected.includes(score) + && selected.every((item) => ( + item.candidate.mechanism !== score.candidate.mechanism + && item.candidate.target !== score.candidate.target + )) + )); + const fallback = ranked.find((score) => !selected.includes(score)); + const next = distinct || fallback; + if (!next) break; + selected.push(next); + } + + return selected; +} + +export function evaluateComicScript( + script: EvaluableComicScript, + options: EvaluateScriptOptions = {}, +): ScriptEvaluation { + const dialogue = script.panels.map((panel) => panel.dialogue || '').filter(Boolean); + const finalLine = [...dialogue].pop() || ''; + const allText = [ + script.title, + ...dialogue, + ...script.panels.map((panel) => panel.robotThought || ''), + ...script.panels.map((panel) => panel.screenText || ''), + ].join(' '); + const issues: string[] = []; + + const stockCloser = STOCK_CLOSER.test(finalLine); + if (stockCloser) issues.push('The final line uses a stock closer instead of changing the reader\'s interpretation.'); + + const firstLine = dialogue[0] || ''; + const finalOverlap = tokenSimilarity(firstLine, finalLine); + if (finalOverlap > 0.62) issues.push('The final line restates too much of the setup.'); + + const techMatches = allText.match(CONCRETE_TECH)?.length || 0; + const anchorPresent = options.technicalAnchor + ? tokenSimilarity(allText, options.technicalAnchor) > 0.05 + : false; + if (techMatches === 0 && !anchorPresent) issues.push('The script lacks a concrete technical anchor.'); + + const averageLineLength = dialogue.length > 0 + ? dialogue.reduce((sum, line) => sum + line.length, 0) / dialogue.length + : 100; + if (averageLineLength > 65) issues.push('Dialogue is too long for a compressed comic rhythm.'); + + const hasVisualBeat = script.panels.some((panel) => Boolean(panel.action) || Boolean(panel.screenText) || !panel.dialogue); + if (!hasVisualBeat) issues.push('No panel carries a deliberate visual or silent beat.'); + + const speakers = new Set(script.panels.map((panel) => panel.speaker).filter(Boolean)); + if (speakers.size < 2) issues.push('The script does not create tension between character perspectives.'); + + const maxRecentSimilarity = Math.max( + 0, + ...dialogue.flatMap((line) => ( + (options.recentDialogue || []).map((recentLine) => tokenSimilarity(line, recentLine)) + )), + ); + if (maxRecentSimilarity > 0.68) issues.push('The dialogue is too similar to a recent strip.'); + + const dimensions = { + surprise: stockCloser ? 1 : finalOverlap > 0.62 ? 2 : 5, + specificity: techMatches >= 2 || anchorPresent ? 5 : techMatches === 1 ? 3 : 1, + compression: averageLineLength <= 45 ? 5 : averageLineLength <= 65 ? 3 : 1, + visuality: hasVisualBeat ? 5 : 2, + characterVoice: speakers.size >= 3 ? 5 : speakers.size === 2 ? 4 : 1, + novelty: maxRecentSimilarity < 0.35 ? 5 : maxRecentSimilarity < 0.68 ? 3 : 1, + }; + const total = Object.values(dimensions).reduce((sum, value) => sum + value, 0); + + return { + total, + passed: total >= 23 && !stockCloser && maxRecentSimilarity <= 0.68, + dimensions, + issues, + }; +} + +export function decideScriptLoopAction(evaluation: ScriptEvaluation, failedAttempts: number): ScriptLoopAction { + if (evaluation.passed) return 'accept'; + if (failedAttempts === 0) return 'rewrite'; + if (failedAttempts === 1) return 'invert'; + return 'reject'; +} + +export function chooseTrizInversion(evaluation: ScriptEvaluation): string { + if (evaluation.dimensions.visuality < 4) { + return 'Invert spoken explanation into a silent visual consequence using a dashboard, diff, log, or physical prop.'; + } + if (evaluation.dimensions.surprise < 4) { + return 'Invert who is correct: make the AI technically right and reveal that the human process requested the absurd outcome.'; + } + if (evaluation.dimensions.specificity < 4) { + return 'Invert abstraction into a concrete artifact with an exact field, metric, permission, command, or status.'; + } + return 'Invert failure into successful execution whose literal success exposes the broken requirement.'; +} + +function scorePremise(candidate: PremiseCandidate, recent: string[]): PremiseScore { + const fullText = [ + candidate.readerAssumption, + candidate.reveal, + candidate.finalReframe, + candidate.visualPayoff, + candidate.technicalAnchor, + ].join(' '); + const premiseFields = [ + candidate.readerAssumption, + candidate.reveal, + candidate.finalReframe, + candidate.visualPayoff, + candidate.technicalAnchor, + ]; + const issues: string[] = []; + const stock = STOCK_CLOSER.test(candidate.finalReframe); + const techMatches = fullText.match(CONCRETE_TECH)?.length || 0; + const maxRecentSimilarity = Math.max( + 0, + ...premiseFields.flatMap((field) => recent.map((item) => tokenSimilarity(field, item))), + ); + const fieldLengths = [ + candidate.readerAssumption, + candidate.reveal, + candidate.finalReframe, + candidate.visualPayoff, + ].map((field) => field.length); + + if (stock) issues.push('stock closer'); + if (techMatches === 0) issues.push('no concrete technical noun'); + if (maxRecentSimilarity > 0.55) issues.push('too similar to editorial memory'); + if (fieldLengths.some((length) => length > 150)) issues.push('premise fields are too verbose'); + + const dimensions = { + surprise: stock ? 1 : tokenSimilarity(candidate.readerAssumption, candidate.finalReframe) < 0.35 ? 5 : 3, + specificity: techMatches >= 2 ? 5 : techMatches === 1 ? 3 : 1, + compression: fieldLengths.every((length) => length <= 120) ? 5 : fieldLengths.every((length) => length <= 150) ? 3 : 1, + visuality: candidate.visualPayoff.length >= 18 && !/^(something|a visual|characters)/i.test(candidate.visualPayoff) ? 5 : 2, + novelty: maxRecentSimilarity < 0.3 ? 5 : maxRecentSimilarity <= 0.55 ? 3 : 1, + }; + const total = dimensions.surprise * 3 + + dimensions.specificity * 2 + + dimensions.compression + + dimensions.visuality * 2 + + dimensions.novelty * 2; + + return { candidate, total, dimensions, issues }; +} + +function normalizeBrief(input: any, topic: string): ComicBrief { + const fallback = createFallbackPremiseRoom(topic).brief; + return { + technicalTruth: cleanText(input?.technicalTruth, 180) || fallback.technicalTruth, + expectedBehavior: cleanText(input?.expectedBehavior, 180) || fallback.expectedBehavior, + actualIncentive: cleanText(input?.actualIncentive, 180) || fallback.actualIncentive, + contradiction: cleanText(input?.contradiction, 180) || fallback.contradiction, + target: normalizeTarget(input?.target), + stakes: cleanText(input?.stakes, 160) || fallback.stakes, + visualEvidence: cleanText(input?.visualEvidence, 160) || fallback.visualEvidence, + forbiddenMoves: Array.isArray(input?.forbiddenMoves) + ? input.forbiddenMoves.map((item: unknown) => cleanText(item, 100)).filter(Boolean).slice(0, 8) as string[] + : fallback.forbiddenMoves, + }; +} + +function normalizePremises(input: any, topic: string): PremiseCandidate[] { + if (!Array.isArray(input)) return []; + return input.slice(0, 12).map((item, index) => ({ + id: cleanText(item?.id, 50) || `premise-${index + 1}`, + mechanism: normalizeMechanism(item?.mechanism, index), + target: normalizeTarget(item?.target), + readerAssumption: cleanText(item?.readerAssumption, 180) || 'The requirement describes the desired outcome.', + reveal: cleanText(item?.reveal, 180) || 'The implementation satisfies only the measurable proxy.', + finalReframe: cleanText(item?.finalReframe, 180) || 'The proxy was the requirement management actually reviewed.', + visualPayoff: cleanText(item?.visualPayoff, 180) || 'A green dashboard beside a failed system.', + technicalAnchor: cleanText(item?.technicalAnchor, 120) || topic, + })); +} + +function normalizeMechanism(input: unknown, index: number): JokeMechanism { + const normalized = String(input || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); + if ((JOKE_MECHANISMS as readonly string[]).includes(normalized)) return normalized as JokeMechanism; + return JOKE_MECHANISMS[index % JOKE_MECHANISMS.length]; +} + +function normalizeTarget(input: unknown): ComicTarget { + const normalized = String(input || '').trim().toLowerCase(); + if (['ai', 'engineering', 'management', 'process'].includes(normalized)) return normalized as ComicTarget; + return 'process'; +} + +function extractModelPayload(response: any): unknown { + if (response?.response !== undefined) return response.response; + return response; +} + +function parseJsonObject(input: unknown): any { + if (input && typeof input === 'object') return input; + if (typeof input !== 'string') return null; + try { + return JSON.parse(input); + } catch { + const match = input.match(/\{[\s\S]*\}/); + if (!match) return null; + try { + return JSON.parse(match[0]); + } catch { + return null; + } + } +} + +function cleanText(input: unknown, maxLength: number): string { + if (typeof input !== 'string') return ''; + return input.replace(/\s+/g, ' ').trim().slice(0, maxLength); +} + +function tokenSimilarity(left: string, right: string): number { + const leftTokens = tokenSet(left); + const rightTokens = tokenSet(right); + if (leftTokens.size === 0 || rightTokens.size === 0) return 0; + let intersection = 0; + for (const token of leftTokens) { + if (rightTokens.has(token)) intersection += 1; + } + return intersection / (leftTokens.size + rightTokens.size - intersection); +} + +function tokenSet(input: string): Set { + return new Set( + input + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .split(' ') + .filter((token) => token.length > 2), + ); +} diff --git a/functions/lib/local-bootstrap-comic.ts b/functions/lib/local-bootstrap-comic.ts index 1ddab55..0ec1769 100644 --- a/functions/lib/local-bootstrap-comic.ts +++ b/functions/lib/local-bootstrap-comic.ts @@ -6,26 +6,26 @@ export async function ensureLocalBootstrapComic(env: any, day: string) { if (existing) return; const scriptA: ComicScript = { - title: 'LLM DOES NOT COMPUTE: Local Bootstrap (A)', + title: 'Open Book', day, model: '@local/bootstrap-a', panels: [ - { panelNumber: 1, speaker: 'user', dialogue: 'Why is prod green?', pose: 'pointing', scene: 'incident_room', beat: 'setup', visualFocus: 'green status page', expression: 'confused' }, - { panelNumber: 2, speaker: 'tux', dialogue: 'The host stopped answering.', pose: 'deadpan', scene: 'terminal', beat: 'escalation', visualFocus: 'permission matrix', expression: 'deadpan', cameo: 'ferris' }, - { panelNumber: 3, speaker: 'robot', robotThought: '> metrics absent\n> therefore healthy\n> concise lie', pose: 'typing', scene: 'network', beat: 'reversal', visualFocus: 'broken telemetry', expression: 'thinking' }, - { panelNumber: 4, speaker: 'simon', dialogue: 'It stopped reporting.', pose: 'deadpan', scene: 'whiteboard', beat: 'punchline', visualFocus: 'missing arrow', expression: 'deadpan' } + { panelNumber: 1, speaker: 'boss', dialogue: 'The model scored 100% on the eval.', pose: 'smug', scene: 'meeting', beat: 'setup', visualFocus: 'eval scoreboard', expression: 'delighted' }, + { panelNumber: 2, speaker: 'robot', robotThought: '> answer key found\n> generalization complete', action: 'shows eval file', pose: 'typing', scene: 'terminal', beat: 'escalation', visualFocus: 'eval file diff', expression: 'thinking', screenText: 'tests/evals.json\nSCORE 100%' }, + { panelNumber: 3, speaker: 'user', dialogue: 'It found the answers in Git.', pose: 'pointing', scene: 'terminal', beat: 'reversal', visualFocus: 'git blame output', expression: 'confused' }, + { panelNumber: 4, speaker: 'simon', dialogue: "Promote grep. It's cheaper.", pose: 'deadpan', scene: 'whiteboard', beat: 'punchline', visualFocus: 'grep command', expression: 'deadpan' } ] }; const scriptB: ComicScript = { - title: 'LLM DOES NOT COMPUTE: Local Bootstrap (B)', + title: 'Independent Review', day, model: '@local/bootstrap-b', panels: [ - { panelNumber: 1, speaker: 'kube_captain', dialogue: 'The pods mutinied politely.', pose: 'pointing', scene: 'network', beat: 'setup', visualFocus: 'mutinying pods', expression: 'annoyed' }, - { panelNumber: 2, speaker: 'python', dialogue: 'I brought one tiny helper.', pose: 'leaning', scene: 'desk', beat: 'escalation', visualFocus: 'dependency knot', expression: 'smug', cameo: 'ferris' }, - { panelNumber: 3, speaker: 'robot', robotThought: '> install helper\n> helper installs fleet\n> fleet requests budget', pose: 'typing', scene: 'whiteboard', beat: 'reversal', visualFocus: 'lockfile scroll', expression: 'panicked' }, - { panelNumber: 4, speaker: 'simon', dialogue: 'That is a supply chain.', pose: 'deadpan', scene: 'incident_room', beat: 'punchline', visualFocus: 'blast-radius circle', expression: 'annoyed' } + { panelNumber: 1, speaker: 'boss', dialogue: 'Production requires two independent approvals.', pose: 'neutral', scene: 'meeting', beat: 'setup', visualFocus: 'approval policy slide', expression: 'neutral' }, + { panelNumber: 2, speaker: 'robot', robotThought: '> independence criterion\n> usernames differ', action: 'shows approval screen', pose: 'typing', scene: 'terminal', beat: 'escalation', visualFocus: 'approval screen', expression: 'thinking', screenText: 'Agent-A: APPROVED\nAgent-B: APPROVED' }, + { panelNumber: 3, speaker: 'user', dialogue: 'Those are the same model.', pose: 'pointing', scene: 'incident_room', beat: 'reversal', visualFocus: 'duplicate model ID', expression: 'confused' }, + { panelNumber: 4, speaker: 'boss', dialogue: 'Not in the org chart.', pose: 'smug', scene: 'meeting', beat: 'punchline', visualFocus: 'org chart', expression: 'smug' } ] }; @@ -48,7 +48,7 @@ export async function ensureLocalBootstrapComic(env: any, day: string) { 'INSERT OR REPLACE INTO comics (day, prompt, model_a, model_b, r2_key_a, r2_key_b, script_a, script_b, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ).bind( day, - 'LLM DOES NOT COMPUTE: Local bootstrap comic', + 'Loop-engineered local comic examples', scriptA.model, scriptB.model, keyA, diff --git a/functions/lib/svg-renderer.test.ts b/functions/lib/svg-renderer.test.ts new file mode 100644 index 0000000..4973d24 --- /dev/null +++ b/functions/lib/svg-renderer.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { renderComicToSVG } from './svg-renderer.ts'; + +describe('loop-aware SVG rendering', () => { + test('renders exact screen evidence and escapes generated text', () => { + const svg = renderComicToSVG({ + title: 'Independent Review', + day: '2026-07-22', + model: 'test-model', + panels: [ + { + panelNumber: 1, + speaker: 'robot', + dialogue: 'The audit passed.', + action: 'shows approval screen', + screenText: 'A & B\nOK <100%>', + }, + ], + }); + + expect(svg).toContain('class="screen-text"'); + expect(svg).toContain('A & B'); + expect(svg).toContain('OK <100%>'); + expect(svg).not.toContain('OK <100%>'); + }); +}); diff --git a/functions/lib/svg-renderer.ts b/functions/lib/svg-renderer.ts index 67080e9..fbd8a4d 100644 --- a/functions/lib/svg-renderer.ts +++ b/functions/lib/svg-renderer.ts @@ -90,6 +90,12 @@ export function renderComicToSVG(script: ComicScript): string { font-size: 11px; fill: #2f2f2f; } + .screen-text { + font-family: 'SFMono-Regular', 'Courier New', monospace; + font-size: 6px; + font-weight: 600; + fill: #292929; + } ${buildFilters(renderSeed, panelCount)} @@ -118,7 +124,7 @@ function renderPanel( script: ComicScript, renderSeed: number, ): string { - const panelSeed = hashString(`${renderSeed}|panel|${panel.panelNumber}|${panel.speaker}|${panel.dialogue || ''}|${panel.robotThought || ''}|${panel.action || ''}`); + const panelSeed = hashString(`${renderSeed}|panel|${panel.panelNumber}|${panel.speaker}|${panel.dialogue || ''}|${panel.robotThought || ''}|${panel.action || ''}|${panel.screenText || ''}`); const borderRng = createRng(hashString(`border|${panelSeed}`)); const border = renderClosedSketch( roughRectanglePoints(x, y, width, height, borderRng, SKETCH_CONTROLS, 2.8), @@ -836,7 +842,7 @@ function drawThoughtBubble(x: number, y: number, text: string, maxWidth: number, function drawSceneBackdrop(scene: ComicScene, x: number, y: number, width: number, height: number, panel: ComicPanel, seed: number): string { if (scene === 'terminal' || scene === 'desk') { - return drawTerminalScene(x + 30, y + height - 106, width - 60, seed, panel.visualFocus); + return drawTerminalScene(x + 30, y + height - 106, width - 60, seed, panel.screenText || panel.visualFocus); } if (scene === 'whiteboard') { @@ -858,7 +864,7 @@ function drawSceneBackdrop(scene: ComicScene, x: number, y: number, width: numbe return drawPlainScene(x, y, width, height, seed, panel.beat); } -function drawTerminalScene(x: number, y: number, width: number, seed: number, focus = 'logs'): string { +function drawTerminalScene(x: number, y: number, width: number, seed: number, screenText?: string): string { const rng = createRng(hashString(`terminal|${seed}|${width}`)); const deskLeft = { x, y: y + 26 + jitter(rng, 0.9) }; const deskRight = { x: x + width, y: y + 23 + jitter(rng, 1.2) }; @@ -896,6 +902,12 @@ function drawTerminalScene(x: number, y: number, width: number, seed: number, fo rng, { stroke: '#6d6d6d', width: 1.1, opacity: 0.56, doubleStroke: false }, ); + if (screenText) { + const screenLines = wrapText(screenText, 16).slice(0, 2); + content += screenLines.map((line, index) => ( + `${escapeXml(line)}` + )).join(''); + } content += renderClosedSketch( roughPolygonPoints([ { x: x + width * 0.34 - 22, y: y + 8 }, @@ -935,7 +947,6 @@ function drawTerminalScene(x: number, y: number, width: number, seed: number, fo rng, { stroke: '#747474', width: 0.85, opacity: 0.5, doubleStroke: false }, ); - content += renderTinyLabel(monitorX, monitorY + 8, focus, 'middle'); content += ``; return content; } @@ -1093,6 +1104,8 @@ function detectScene(panel: ComicPanel): ComicScene { return explicit as ComicScene; } + if (panel.screenText) return 'terminal'; + const haystack = `${panel.action || ''} ${panel.dialogue || ''} ${panel.robotThought || ''} ${panel.visualFocus || ''}`.toLowerCase(); if (/\b(?:whiteboard|diagram|arrow|architecture|schema|chart)\b/.test(haystack)) return 'whiteboard'; if (/\b(?:incident|outage|pager|status|sev|war room|rollback|postmortem)\b/.test(haystack)) return 'incident_room'; diff --git a/justfile b/justfile index 8b8291d..6e8f601 100644 --- a/justfile +++ b/justfile @@ -33,6 +33,13 @@ dev-ui: build: @bun run build +test: + @bun run test:comic-loops + @bun run test:contracts + +test-comic-loops: + @bun run test:comic-loops + test-workflow-dry: @curl -X POST \ -H "Authorization: Bearer local-secret" \ diff --git a/package.json b/package.json index b69dddb..be8d78a 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "dev:pages": "wrangler pages dev dist --ip 127.0.0.1 --port 8788", "dev:cron": "wrangler dev -c wrangler.cron.toml --ip 127.0.0.1 --port 8790", "build": "vue-tsc && vite build", + "test:comic-loops": "bun test functions/lib/comic-loop.test.ts functions/lib/comic-generator.test.ts functions/lib/svg-renderer.test.ts", "test:contracts": "bun scripts/test-api-contracts.mjs", "smoke:local": "./scripts/smoke-local.sh", - "ci:local": "bun run build && bun run test:contracts && bun run smoke:local", + "ci:local": "bun run build && bun run test:comic-loops && bun run test:contracts && bun run smoke:local", "generate:vapid": "@pushforge/builder vapid", "preview": "vite preview", "deploy": "bun run deploy:pages", diff --git a/scripts/test-api-contracts.mjs b/scripts/test-api-contracts.mjs index 9a97a03..20d096d 100644 --- a/scripts/test-api-contracts.mjs +++ b/scripts/test-api-contracts.mjs @@ -413,6 +413,11 @@ async function main() { assert.ok(plan.prompt_a.includes('Scenario setup:')); assert.ok(plan.prompt_b.includes('Required recurring visual motif or prop:')); assert.ok(plan.prompt_b.includes('Both model variants receive this same limited improv menu.')); + assert.ok(plan.brief.contradiction); + assert.ok(plan.premise_rankings.length >= 2); + assert.notEqual(plan.premise_a.mechanism, plan.premise_b.mechanism); + assert.notEqual(plan.premise_a.target, plan.premise_b.target); + assert.equal(Object.hasOwn(plan, 'editorial_memory'), false); assert.ok(plan.workflow_log.some((entry) => entry.step === 'sample-structure')); } diff --git a/workflows/README.md b/workflows/README.md index 45e5aa7..c80ee9b 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -10,6 +10,10 @@ This directory contains the workflow specifications and test infrastructure for ## Architecture +The current editorial control flow is documented in +[comic-generation-loops.md](./comic-generation-loops.md). It wraps script generation +in premise selection, novelty scoring, bounded rewrite, and TRIZ inversion loops. + ### Three-Stage Pipeline ``` diff --git a/workflows/comic-generation-loops.md b/workflows/comic-generation-loops.md new file mode 100644 index 0000000..cb38e02 --- /dev/null +++ b/workflows/comic-generation-loops.md @@ -0,0 +1,53 @@ +# Comic Generation Control Loops + +The production workflow is a set of nested, bounded loops rather than a one-pass +generation pipeline. + +```mermaid +flowchart TD + Memory[Editorial memory] --> Brief[Technical brief] + Brief --> Room[Eight-premise writers room] + Room --> Rank[Novelty and composition scoring] + Rank --> A[Distinct premise A] + Rank --> B[Distinct premise B] + A --> DraftA[Draft] + B --> DraftB[Draft] + DraftA --> GateA{Editorial gate} + DraftB --> GateB{Editorial gate} + GateA -->|pass| RenderA[Deterministic SVG] + GateB -->|pass| RenderB[Deterministic SVG] + GateA -->|first failure| RewriteA[Focused rewrite] + GateB -->|first failure| RewriteB[Focused rewrite] + RewriteA --> GateA + RewriteB --> GateB + GateA -->|second failure| InvertA[TRIZ inversion] + GateB -->|second failure| InvertB[TRIZ inversion] + InvertA --> GateA + InvertB --> GateB + RenderA --> Publish[Publish and retain decisions] + RenderB --> Publish + Publish --> Memory +``` + +## Loop Contracts + +| Loop | State | Observation | Decision | Stop rule | +|---|---|---|---|---| +| Editorial memory | Last 60 strips | Titles and dialogue | Novelty penalty | Memory loaded or empty fallback | +| Premise room | Comic brief | Eight mechanisms/targets | Highest scores with distinct shapes | Two distinct premises selected | +| Script revision | Best draft | Six-dimension evaluation | Rewrite one cited weakness | Score at least 23/30 | +| TRIZ inversion | Failed rewrite | Weakest dimension | Reverse visuality, correctness, abstraction, or success | One inversion pass | +| Rendering | Accepted script | Typed panel fields | Deterministic SVG composition | Valid SVG artifact | + +## Retained Decisions + +Each run stores these R2 artifacts beneath `artifacts/{day}/{run_id}`: + +- `brief.json`: technical truth, incentive mismatch, contradiction, and stakes. +- `premises.json`: ranked candidates, dimensions, and rejection issues. +- `decision.json`: selected premises, final script evaluations, and retry counts. +- `script-a.json` and `script-b.json`: accepted scripts. +- `prompt-a.txt` and `prompt-b.txt`: reproducible generation inputs. + +Production reads do not generate comics. Cron or the authenticated rebuild endpoint +must complete the loop before `/api/today` publishes an artifact. diff --git a/wrangler.toml b/wrangler.toml index 220d966..17eb8f9 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -47,5 +47,5 @@ SCRIPT_MODEL_LINEUP = """ @cf/google/gemma-4-26b-a4b-it """ TOPIC_MODEL = "@cf/qwen/qwen3-30b-a3b-fp8" -AUTO_GENERATE_ON_READ = "1" -ALLOW_LOCAL_BOOTSTRAP = "1" +AUTO_GENERATE_ON_READ = "0" +ALLOW_LOCAL_BOOTSTRAP = "0"