From 4804a601b4c1aff815cead36adb0c51a2e0ba79f Mon Sep 17 00:00:00 2001 From: brianh Date: Tue, 1 Sep 2026 11:23:56 +1000 Subject: [PATCH 1/3] feat: loop-engineer comic generation Rewrite comic script generation into a scored writers-room loop: typed brief/premise generation, heuristic scoring (surprise, specificity, compression, visuality, character voice, archive novelty), and a bounded rewrite -> TRIZ-inversion -> reject state machine. Retains briefs, premise rankings, editorial decisions, and rewrite counts as reproducible R2 artifacts. Adds deterministic screenText rendering for dashboards, logs, eval files, alerts, and approval screens, integrated alongside the existing scene/pose/expression composition system. Disables production generation-on-read and refreshes the local bootstrap comics. Rebased onto main after PR #24 (scenario/improv-menu system and model lineup diversification); both feature sets are merged so comic panels carry scene, pose, beat, expression, cameo, AND screenText, and prompts combine the shared improv menu with the structured brief/premise. Co-authored-by: brianh --- COMIC-README.md | 36 +- functions/api/test-generate.ts | 3 + functions/lib/agentic-comic-workflow.ts | 261 +++++++++++-- functions/lib/cast.ts | 5 +- functions/lib/comic-generator.test.ts | 76 ++++ functions/lib/comic-generator.ts | 164 +++++--- functions/lib/comic-loop.test.ts | 145 +++++++ functions/lib/comic-loop.ts | 481 ++++++++++++++++++++++++ functions/lib/local-bootstrap-comic.ts | 22 +- functions/lib/svg-renderer.test.ts | 26 ++ functions/lib/svg-renderer.ts | 21 +- justfile | 7 + package.json | 3 +- scripts/test-api-contracts.mjs | 5 + workflows/README.md | 4 + workflows/comic-generation-loops.md | 53 +++ wrangler.toml | 4 +- 17 files changed, 1212 insertions(+), 104 deletions(-) create mode 100644 functions/lib/comic-generator.test.ts create mode 100644 functions/lib/comic-loop.test.ts create mode 100644 functions/lib/comic-loop.ts create mode 100644 functions/lib/svg-renderer.test.ts create mode 100644 workflows/comic-generation-loops.md 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" From b454b8f3d069757692753d7bae7af03fdc206589 Mon Sep 17 00:00:00 2001 From: brianh Date: Tue, 1 Sep 2026 11:42:29 +1000 Subject: [PATCH 2/3] chore: retrigger CI to check Cloudflare Pages build flake From 4fa6090ae751c723bc2496a2ca4ae3948824b77e Mon Sep 17 00:00:00 2001 From: brianh Date: Wed, 2 Sep 2026 19:10:29 +1000 Subject: [PATCH 3/3] fix(comics): editorial reject gate actually blocks publish now, wire test:comic-loops into CI decideScriptLoopAction (comic-loop.ts) only reaches 'reject' when evaluation.passed is false after the bounded rewrite+invert attempts are exhausted, but runAgenticComicWorkflow never checked .passed before rendering/persisting - a script that failed editorial review three times still shipped as the daily comic. Now throws before any R2/D1 write if either variant's evaluation.passed is false. Every existing caller already handles this gracefully: today.ts falls back to the most recent comic, test-generate.ts returns a 500 with the rejection scores, and the cron scheduled() handler logs + re-throws (visible failure, no partial writes, fresh attempt next day since the R2 existence-check means nothing was written). Also wires test:comic-loops (9 tests covering comic-loop/comic-generator/ svg-renderer) into .github/workflows/build.yaml - it existed and passed locally but wasn't part of the actual CI gate. Verified locally before committing: bun run build, test:comic-loops (9/9 pass), test:contracts, and the full smoke:local suite (including /api/today, which exercises this exact code path) all pass with this change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015kUr7Kf9wN15TPqDiKsiKw --- .github/workflows/build.yaml | 3 +++ functions/lib/agentic-comic-workflow.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c702f07..efcb786 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -24,6 +24,9 @@ jobs: - name: Build run: bun run build + - name: Comic loop tests + run: bun run test:comic-loops + - name: Contract tests run: bun run test:contracts diff --git a/functions/lib/agentic-comic-workflow.ts b/functions/lib/agentic-comic-workflow.ts index b3cec70..44b3525 100644 --- a/functions/lib/agentic-comic-workflow.ts +++ b/functions/lib/agentic-comic-workflow.ts @@ -297,6 +297,31 @@ export async function runAgenticComicWorkflow(env: any, options: { day: string; modelA, ); + // decideScriptLoopAction (comic-loop.ts) only ever reaches 'reject' when + // evaluation.passed is false after the bounded rewrite+invert attempts are + // exhausted - so evaluation.passed here is exactly "did this variant clear + // the editorial gate, not just run out of retries". Nothing below this + // point (SVG render, R2 write, D1 insert) should ever run for a rejected + // variant - every caller (today.ts, test-generate.ts, the cron scheduled() + // handler) already has error handling that falls back or surfaces this + // clearly rather than silently shipping unreviewed content. + const rejectedVariants = [ + !variantA.evaluation.passed ? 'A' : null, + !variantB.evaluation.passed ? 'B' : null, + ].filter((v): v is string => v !== null); + if (rejectedVariants.length > 0) { + workflowLog.push(makeStep( + 'editorial-gate', + 'error', + `Variant(s) ${rejectedVariants.join(', ')} failed editorial review after rewrite+invert attempts (score ${variantA.evaluation.total}/30, ${variantB.evaluation.total}/30) - refusing to publish.`, + )); + throw new Error( + `Editorial gate rejected variant(s) ${rejectedVariants.join(', ')} for ${plan.day} - not publishing. ` + + `A: ${variantA.evaluation.total}/30 (passed=${variantA.evaluation.passed}), ` + + `B: ${variantB.evaluation.total}/30 (passed=${variantB.evaluation.passed}).` + ); + } + const imageKeyA = `comics/${plan.day}/a.svg`; const imageKeyB = `comics/${plan.day}/b.svg`; const artifactPrefix = `artifacts/${plan.day}/${plan.run_id}`;