From 733881a89fa097427ce4dcca3aefe1d4467ba0f5 Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 13:31:36 +0600 Subject: [PATCH 01/10] feat(pipeline): add Director-Critic quality gate with revision loop Extract loadDirectorSystemPrompt() helper for DRY prompt loading. Add reviseDirectorScore() that takes a DirectorScore + CritiqueResult and generates a revised plan. Guards nullable revision_instructions by falling back to the weaknesses array. --- src/agents/creative-director.test.ts | 86 +++++++++++++++++++++++++++- src/agents/creative-director.ts | 83 +++++++++++++++++++++++++-- 2 files changed, 161 insertions(+), 8 deletions(-) diff --git a/src/agents/creative-director.test.ts b/src/agents/creative-director.test.ts index 6753b1bf..33fb2248 100644 --- a/src/agents/creative-director.test.ts +++ b/src/agents/creative-director.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { buildPacingInstruction, PACING_CONFIG } from "./creative-director.js"; +import { describe, expect, it, vi } from "vitest"; +import { buildPacingInstruction, PACING_CONFIG, reviseDirectorScore } from "./creative-director.js"; +import type { DirectorScore } from "../schema/director-score.js"; +import type { LLMProvider } from "../schema/providers.js"; +import type { CritiqueResult } from "./critic.js"; +import type { ResearchResult } from "./research.js"; describe("buildPacingInstruction", () => { // Path 2: explicit archetype — derive tier from config @@ -102,3 +106,81 @@ describe("PACING_CONFIG", () => { expect(PACING_CONFIG.cinematic.max).toBeLessThan(PACING_CONFIG.fast.max); }); }); + +// ── reviseDirectorScore tests ──────────────────────────────────────────────── + +const baseScore: DirectorScore = { + emotional_arc: "curiosity-to-wisdom", + archetype: "infographic", + music_mood: "epic_cinematic", + scenes: [ + { visual_type: "text_card", visual_prompt: "Title", motion: "static", script_line: "Did you know this?", transition: null }, + { visual_type: "ai_image", visual_prompt: "A diagram", motion: "zoom_in", script_line: "Here is the truth.", transition: "crossfade" }, + { visual_type: "stock_video", visual_prompt: "Ocean", motion: "static", script_line: "It changed everything.", transition: "slide_left" }, + { visual_type: "ai_image", visual_prompt: "Chart", motion: "pan_right", script_line: "The numbers prove it.", transition: "crossfade" }, + { visual_type: "stock_image", visual_prompt: "Sunset", motion: "zoom_out", script_line: "Think about that.", transition: "crossfade" }, + { visual_type: "text_card", visual_prompt: "CTA", motion: "static", script_line: "What do you think?", transition: "wipe" }, + { visual_type: "ai_image", visual_prompt: "Finale", motion: "zoom_in", script_line: "Comment below!", transition: null }, + { visual_type: "stock_video", visual_prompt: "Stars", motion: "static", script_line: "Follow for more.", transition: "crossfade" }, + ], +}; + +const baseResearch: ResearchResult = { + summary: "Test topic summary", + key_facts: ["fact1", "fact2"], + mood: "informative", + sources: [], +}; + +const baseCritique: CritiqueResult = { + score: 5, + strengths: ["good hook"], + weaknesses: ["weak pacing", "repetitive visuals"], + revision_needed: true, + revision_instructions: "Improve pacing in scenes 3-5 and add more visual variety.", + weakest_scene_index: 3, +}; + +function mockRevisionLLM(): LLMProvider & { lastUserMessage: string } { + const mock = { + id: "anthropic" as const, + lastUserMessage: "", + generate: vi.fn(async ({ userMessage }: { userMessage: string }) => { + mock.lastUserMessage = userMessage; + return { + data: { ...baseScore }, + usage: { inputTokens: 200, outputTokens: 100 }, + }; + }), + }; + return mock as unknown as LLMProvider & { lastUserMessage: string }; +} + +describe("reviseDirectorScore", () => { + it("generates a revised DirectorScore from critique feedback", async () => { + const llm = mockRevisionLLM(); + const result = await reviseDirectorScore(llm, "test topic", baseResearch, baseScore, baseCritique); + expect(result.data.archetype).toBe("infographic"); + expect(result.data.scenes.length).toBeGreaterThanOrEqual(3); + expect(result.usage.inputTokens).toBe(200); + }); + + it("includes critique details in the user message", async () => { + const llm = mockRevisionLLM(); + await reviseDirectorScore(llm, "test topic", baseResearch, baseScore, baseCritique); + expect(llm.lastUserMessage).toContain("score: 5/10"); + expect(llm.lastUserMessage).toContain("weak pacing"); + expect(llm.lastUserMessage).toContain("Improve pacing in scenes 3-5"); + expect(llm.lastUserMessage).toContain("Weakest scene: Scene 3"); + }); + + it("falls back to weaknesses when revision_instructions is null", async () => { + const llm = mockRevisionLLM(); + const critique: CritiqueResult = { + ...baseCritique, + revision_instructions: null, + }; + await reviseDirectorScore(llm, "test topic", baseResearch, baseScore, critique); + expect(llm.lastUserMessage).toContain("weak pacing; repetitive visuals"); + }); +}); diff --git a/src/agents/creative-director.ts b/src/agents/creative-director.ts index c6328b7f..da936251 100644 --- a/src/agents/creative-director.ts +++ b/src/agents/creative-director.ts @@ -7,6 +7,7 @@ import { loadPlaybook } from "../config/playbook.js"; import { DirectorScore, Motion, MusicMood, TransitionType, VisualType } from "../schema/director-score.js"; import type { LLMProvider, LLMUsage } from "../schema/providers.js"; import type { ResearchResult } from "./research.js"; +import type { CritiqueResult } from "./critic.js"; const SYSTEM_PROMPT_PATH = path.join(process.cwd(), "prompts", "creative-director.md"); @@ -34,12 +35,8 @@ export interface DirectorScoreOutput { usage: LLMUsage; } -export async function generateDirectorScore( - llm: LLMProvider, - topic: string, - researchContext: ResearchResult, - options?: { archetype?: string; pacing?: string; videoEnabled?: boolean }, -): Promise { +/** Load the creative director system prompt with playbook injection */ +function loadDirectorSystemPrompt(): string { let systemPrompt = buildDefaultPrompt(); try { @@ -56,6 +53,17 @@ export async function generateDirectorScore( console.warn(`[creative-director] Playbook not loaded: ${err}`); } + return systemPrompt; +} + +export async function generateDirectorScore( + llm: LLMProvider, + topic: string, + researchContext: ResearchResult, + options?: { archetype?: string; pacing?: string; videoEnabled?: boolean }, +): Promise { + const systemPrompt = loadDirectorSystemPrompt(); + const archetypes = listArchetypes(); const archetypeInstruction = options?.archetype ? `Use the "${options.archetype}" archetype.` @@ -180,3 +188,66 @@ Per-scene word budget: ${cfg.wordsPerScene} words. Total word budget: ${cfg.tota } export { PACING_CONFIG }; + +// ── Revision ───────────────────────────────────────────────────────────────── + +export async function reviseDirectorScore( + llm: LLMProvider, + topic: string, + researchContext: ResearchResult, + originalScore: DirectorScore, + critique: CritiqueResult, + options?: { archetype?: string; pacing?: string; videoEnabled?: boolean }, +): Promise { + const systemPrompt = loadDirectorSystemPrompt(); + + // Build revision instructions from critique, guarding nullable revision_instructions + const revisionGuidance = critique.revision_instructions + ?? `Address these weaknesses: ${critique.weaknesses.join("; ")}`; + + const pacingInstruction = buildPacingInstruction(options?.archetype, options?.pacing); + + const videoEnabled = options?.videoEnabled ?? false; + const visualTypes = videoEnabled + ? "all 5 visual types (ai_image, ai_video, stock_image, stock_video, text_card)" + : "all 4 visual types (ai_image, stock_image, stock_video, text_card)"; + + const userMessage = `Topic: ${topic} + +Research context: +${researchContext.summary} + +Key facts: +${researchContext.key_facts.map((f) => `- ${f}`).join("\n")} + +Mood: ${researchContext.mood} + +${pacingInstruction} +Use ${visualTypes}. + +## Current Plan (score: ${critique.score}/10) + +${JSON.stringify(originalScore, null, 2)} + +## Critic Feedback + +Strengths: ${critique.strengths.join(", ")} +Weaknesses: ${critique.weaknesses.join(", ")} +${critique.weakest_scene_index != null ? `Weakest scene: Scene ${critique.weakest_scene_index}` : ""} + +## Revision Instructions + +${revisionGuidance} + +Revise the DirectorScore to address the weaknesses while preserving the strengths. +Keep the same archetype. Maintain the GOLDEN RULE: never use the same visual_type more than 2 times in a row.`; + + const result = await llm.generate({ + systemPrompt, + userMessage, + schema: DirectorScoreRaw, + }); + + const validated = DirectorScore.parse(result.data); + return { data: validated, usage: result.usage }; +} From ff1c940324311e0041db64a5b1b6d0f14172268a Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 13:31:46 +0600 Subject: [PATCH 02/10] feat(pipeline): wire revision loop into directorStep.execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate → evaluate → revise loop (max 2 rounds, threshold >= 7). Tracks highest-scoring revision. Wraps evaluate() in try/catch for graceful degradation. Moves score.json write, onStageComplete, and dry-run guard after the loop. Uses revised score for cost estimation. Removes stale "revision coming in a future release" message from step-6 critic. --- src/pipeline/orchestrator-callbacks.test.ts | 3 +- src/pipeline/orchestrator-revision.test.ts | 241 ++++++++++++++++++++ src/pipeline/orchestrator.ts | 90 ++++++-- 3 files changed, 309 insertions(+), 25 deletions(-) create mode 100644 src/pipeline/orchestrator-revision.test.ts diff --git a/src/pipeline/orchestrator-callbacks.test.ts b/src/pipeline/orchestrator-callbacks.test.ts index 0f407c17..031927f0 100644 --- a/src/pipeline/orchestrator-callbacks.test.ts +++ b/src/pipeline/orchestrator-callbacks.test.ts @@ -76,12 +76,13 @@ describe("createCliCallbacks", () => { const result = await callbacks.onCostEstimate!( { llmCost: 0, + revisionCost: 0, ttsCost: 0, imageCost: 0, totalCost: 0.1, videoCost: 0, musicCost: 0, - details: { llmCalls: 3, ttsCharacters: 500, aiImages: 2, aiVideos: 0 }, + details: { llmCalls: 3, revisionRounds: 0, ttsCharacters: 500, aiImages: 2, aiVideos: 0 }, }, "gemini", ); diff --git a/src/pipeline/orchestrator-revision.test.ts b/src/pipeline/orchestrator-revision.test.ts new file mode 100644 index 00000000..6eafebaf --- /dev/null +++ b/src/pipeline/orchestrator-revision.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PipelineCallbacks, StageName } from "./orchestrator.js"; +import type { DirectorScore } from "../schema/director-score.js"; +import type { CritiqueResult } from "../agents/critic.js"; + +/** + * Tests for the Director-Critic revision loop inside directorStep.execute. + * + * These test the loop logic by simulating what the orchestrator does: + * generate → evaluate → (revise if score < 7) → use best score. + * + * Full integration tests (real LLM calls) are not covered here. + */ + +const makeScore = (archetype = "infographic", sceneCount = 8): DirectorScore => ({ + emotional_arc: "curiosity-to-wisdom", + archetype, + music_mood: "epic_cinematic", + scenes: Array.from({ length: sceneCount }, (_, i) => ({ + visual_type: (["text_card", "ai_image", "stock_video", "ai_image", "stock_image", "text_card", "ai_image", "stock_video"] as const)[i % 8], + visual_prompt: `Scene ${i + 1}`, + motion: "static" as const, + script_line: `This is scene ${i + 1}.`, + transition: i < sceneCount - 1 ? ("crossfade" as const) : null, + })), +}); + +const makeCritique = (score: number, revisionNeeded: boolean): CritiqueResult => ({ + score, + strengths: ["good hook", "visual variety"], + weaknesses: score < 7 ? ["weak pacing", "repetitive script"] : [], + revision_needed: revisionNeeded, + revision_instructions: revisionNeeded ? "Improve pacing in the middle scenes." : null, + weakest_scene_index: revisionNeeded ? 3 : null, +}); + +/** + * Simulates the revision loop from orchestrator.ts directorStep.execute. + * Extracted here so we can test the logic without running the full Mastra workflow. + */ +async function simulateRevisionLoop(opts: { + initialScore: DirectorScore; + evaluateFn: (score: DirectorScore) => Promise<{ data: CritiqueResult; usage: { inputTokens: number; outputTokens: number } }>; + reviseFn: (score: DirectorScore, critique: CritiqueResult) => Promise<{ data: DirectorScore; usage: { inputTokens: number; outputTokens: number } }>; + callbacks?: PipelineCallbacks; + maxRounds?: number; +}) { + const { initialScore, evaluateFn, reviseFn, callbacks: cb, maxRounds = 2 } = opts; + const llmUsages: { inputTokens: number; outputTokens: number }[] = []; + let score = initialScore; + let bestScore = score; + let bestCritiqueScore = 0; + let revisionRoundsCompleted = 0; + + for (let round = 0; round < maxRounds; round++) { + try { + const critiqueOutput = await evaluateFn(score); + llmUsages.push(critiqueOutput.usage); + const critique = critiqueOutput.data; + + if (critique.score > bestCritiqueScore) { + bestScore = score; + bestCritiqueScore = critique.score; + } + + if (critique.score >= 7 || !critique.revision_needed) break; + + cb?.onProgress?.("director", { type: "revision", round: round + 1, critiqueScore: critique.score }); + cb?.onLog?.(`\n[director] Critic score: ${critique.score}/10 (round ${round + 1}), revising...`); + + const revised = await reviseFn(score, critique); + llmUsages.push(revised.usage); + score = revised.data; + revisionRoundsCompleted++; + } catch (err) { + console.warn(`[director] Revision round ${round + 1} failed: ${err}`); + break; + } + } + + score = bestScore; + return { finalScore: score, bestCritiqueScore, revisionRoundsCompleted, llmUsages }; +} + +describe("Director-Critic revision loop", () => { + it("skips revision when initial score >= 7", async () => { + const evaluateFn = vi.fn().mockResolvedValue({ + data: makeCritique(8, false), + usage: { inputTokens: 100, outputTokens: 50 }, + }); + const reviseFn = vi.fn(); + + const result = await simulateRevisionLoop({ + initialScore: makeScore(), + evaluateFn, + reviseFn, + }); + + expect(evaluateFn).toHaveBeenCalledTimes(1); + expect(reviseFn).not.toHaveBeenCalled(); + expect(result.revisionRoundsCompleted).toBe(0); + expect(result.bestCritiqueScore).toBe(8); + }); + + it("triggers revision when score < 7 and improves to >= 7", async () => { + const revisedScore = makeScore("infographic", 9); + const evaluateFn = vi.fn() + .mockResolvedValueOnce({ data: makeCritique(5, true), usage: { inputTokens: 100, outputTokens: 50 } }) + .mockResolvedValueOnce({ data: makeCritique(8, false), usage: { inputTokens: 100, outputTokens: 50 } }); + const reviseFn = vi.fn().mockResolvedValue({ + data: revisedScore, + usage: { inputTokens: 200, outputTokens: 100 }, + }); + + const result = await simulateRevisionLoop({ + initialScore: makeScore(), + evaluateFn, + reviseFn, + }); + + expect(evaluateFn).toHaveBeenCalledTimes(2); + expect(reviseFn).toHaveBeenCalledTimes(1); + expect(result.revisionRoundsCompleted).toBe(1); + expect(result.finalScore).toBe(revisedScore); // best score is the revised one (scored 8) + expect(result.bestCritiqueScore).toBe(8); + }); + + it("exhausts max rounds and uses highest-scoring revision", async () => { + const round1Score = makeScore("infographic", 9); + const round2Score = makeScore("infographic", 10); + + const evaluateFn = vi.fn() + .mockResolvedValueOnce({ data: makeCritique(4, true), usage: { inputTokens: 100, outputTokens: 50 } }) + .mockResolvedValueOnce({ data: makeCritique(6, true), usage: { inputTokens: 100, outputTokens: 50 } }); + const reviseFn = vi.fn() + .mockResolvedValueOnce({ data: round1Score, usage: { inputTokens: 200, outputTokens: 100 } }) + .mockResolvedValueOnce({ data: round2Score, usage: { inputTokens: 200, outputTokens: 100 } }); + + const result = await simulateRevisionLoop({ + initialScore: makeScore(), + evaluateFn, + reviseFn, + }); + + expect(evaluateFn).toHaveBeenCalledTimes(2); + expect(reviseFn).toHaveBeenCalledTimes(2); + expect(result.revisionRoundsCompleted).toBe(2); + // Best score is round1Score (scored 6) not round2Score (never evaluated) + expect(result.finalScore).toBe(round1Score); + expect(result.bestCritiqueScore).toBe(6); + }); + + it("uses highest-scoring revision when later rounds degrade", async () => { + const round1Score = makeScore("infographic", 9); + const round2Score = makeScore("infographic", 10); + + // Round 1: score 6, revise + // Round 2: score 4 (degradation), revise + // Result: should use round1Score (scored 6), not round2Score (scored 4) + const evaluateFn = vi.fn() + .mockResolvedValueOnce({ data: makeCritique(3, true), usage: { inputTokens: 100, outputTokens: 50 } }) + .mockResolvedValueOnce({ data: makeCritique(6, true), usage: { inputTokens: 100, outputTokens: 50 } }); + const reviseFn = vi.fn() + .mockResolvedValueOnce({ data: round1Score, usage: { inputTokens: 200, outputTokens: 100 } }) + .mockResolvedValueOnce({ data: round2Score, usage: { inputTokens: 200, outputTokens: 100 } }); + + const result = await simulateRevisionLoop({ + initialScore: makeScore(), + evaluateFn, + reviseFn, + }); + + // Best is round1Score which was evaluated at 6 + expect(result.finalScore).toBe(round1Score); + expect(result.bestCritiqueScore).toBe(6); + }); + + it("emits revision progress events", async () => { + const events: Array<{ stage: StageName; data: Record }> = []; + const cb: PipelineCallbacks = { + onProgress(stage, data) { events.push({ stage, data }); }, + onLog() {}, + }; + + const evaluateFn = vi.fn() + .mockResolvedValueOnce({ data: makeCritique(5, true), usage: { inputTokens: 100, outputTokens: 50 } }) + .mockResolvedValueOnce({ data: makeCritique(8, false), usage: { inputTokens: 100, outputTokens: 50 } }); + const reviseFn = vi.fn().mockResolvedValue({ + data: makeScore(), + usage: { inputTokens: 200, outputTokens: 100 }, + }); + + await simulateRevisionLoop({ + initialScore: makeScore(), + evaluateFn, + reviseFn, + callbacks: cb, + }); + + expect(events).toHaveLength(1); + expect(events[0]?.stage).toBe("director"); + expect(events[0]?.data).toEqual({ type: "revision", round: 1, critiqueScore: 5 }); + }); + + it("gracefully handles evaluate() failure", async () => { + const evaluateFn = vi.fn().mockRejectedValue(new Error("LLM JSON parse failed")); + const reviseFn = vi.fn(); + + const result = await simulateRevisionLoop({ + initialScore: makeScore(), + evaluateFn, + reviseFn, + }); + + // Should proceed with initial score, no crash + expect(result.revisionRoundsCompleted).toBe(0); + expect(result.finalScore).toEqual(makeScore()); + expect(reviseFn).not.toHaveBeenCalled(); + }); + + it("accumulates LLM usage across all revision rounds", async () => { + const evaluateFn = vi.fn() + .mockResolvedValueOnce({ data: makeCritique(5, true), usage: { inputTokens: 100, outputTokens: 50 } }) + .mockResolvedValueOnce({ data: makeCritique(8, false), usage: { inputTokens: 120, outputTokens: 60 } }); + const reviseFn = vi.fn().mockResolvedValue({ + data: makeScore(), + usage: { inputTokens: 200, outputTokens: 100 }, + }); + + const result = await simulateRevisionLoop({ + initialScore: makeScore(), + evaluateFn, + reviseFn, + }); + + // 2 evaluate calls + 1 revise call = 3 usage entries + expect(result.llmUsages).toHaveLength(3); + const totalInput = result.llmUsages.reduce((sum, u) => sum + u.inputTokens, 0); + expect(totalInput).toBe(100 + 200 + 120); // eval1 + revise + eval2 + }); +}); diff --git a/src/pipeline/orchestrator.ts b/src/pipeline/orchestrator.ts index e92c9f37..15dd676b 100644 --- a/src/pipeline/orchestrator.ts +++ b/src/pipeline/orchestrator.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import { Mastra } from "@mastra/core"; import { createStep, createWorkflow } from "@mastra/core/workflows"; import { z } from "zod"; -import { generateDirectorScore } from "../agents/creative-director.js"; +import { generateDirectorScore, reviseDirectorScore } from "../agents/creative-director.js"; import { evaluate } from "../agents/critic.js"; import { optimizeImagePrompt } from "../agents/image-prompter.js"; import { research } from "../agents/research.js"; @@ -410,45 +410,93 @@ function buildPipelineWorkflow( cb.onStageStart?.("director"); const start = Date.now(); const videoEnabled = !opts.noVideo && (opts.videoProviders?.length ?? 0) > 0; - const cdOutput = await generateDirectorScore(opts.llm, opts.topic, inputData, { - archetype: opts.archetype, - pacing: opts.pacing, - videoEnabled, - }); - // Store on shared context via closure - directorResult.score = cdOutput.data; - directorResult.config = getArchetype(cdOutput.data.archetype); + const directorOpts = { archetype: opts.archetype, pacing: opts.pacing, videoEnabled }; + + // ── Generate initial DirectorScore ── + const cdOutput = await generateDirectorScore(opts.llm, opts.topic, inputData, directorOpts); llmUsages.push(cdOutput.usage); + let score = cdOutput.data; + + // ── Revision loop: evaluate → revise until score >= 7 or max rounds ── + // + // generate ──► evaluate ──► score >= 7? ──YES──► done + // │ + // NO (round < MAX) + // │ + // revise ──► re-evaluate ──► ... + // + // Tracks the highest-scoring revision (LLM refinement can degrade in later rounds). + const MAX_REVISION_ROUNDS = 2; + let bestScore = score; + let bestCritiqueScore = 0; + let revisionRoundsCompleted = 0; + + for (let round = 0; round < MAX_REVISION_ROUNDS; round++) { + try { + const critiqueOutput = await evaluate(opts.llm, score, opts.topic, opts.pacing); + llmUsages.push(critiqueOutput.usage); + const critique = critiqueOutput.data; + + // Track highest-scoring revision + if (critique.score > bestCritiqueScore) { + bestScore = score; + bestCritiqueScore = critique.score; + } + + if (critique.score >= 7 || !critique.revision_needed) break; + + cb.onProgress?.("director", { type: "revision", round: round + 1, critiqueScore: critique.score }); + cb.onLog?.(`\n[director] Critic score: ${critique.score}/10 (round ${round + 1}), revising...`); + + const revised = await reviseDirectorScore(opts.llm, opts.topic, inputData, score, critique, directorOpts); + llmUsages.push(revised.usage); + score = revised.data; + revisionRoundsCompleted++; + } catch (err) { + // Graceful degradation: if the critic fails, proceed with current best score + console.warn(`[director] Revision round ${round + 1} failed: ${err}`); + break; + } + } + + // Use the highest-scoring revision (not necessarily the last) + score = bestScore; + + // ── Store final score on shared closure state ── + directorResult.score = score; + directorResult.config = getArchetype(score.archetype); + const dur = (Date.now() - start) / 1000; cb.onStageComplete?.( "director", - `${cdOutput.data.scenes.length} scenes, ${cdOutput.data.archetype}`, + `${score.scenes.length} scenes, ${score.archetype}`, dur, ); log.stages.push({ name: "creative-director", duration: dur, status: "done" }); - fs.writeFileSync(scorePath, JSON.stringify(cdOutput.data, null, 2)); - cb.onProgress?.("director", { type: "score", score: cdOutput.data }); + // Write score.json and emit progress AFTER the revision loop + fs.writeFileSync(scorePath, JSON.stringify(score, null, 2)); + cb.onProgress?.("director", { type: "score", score }); - // Dry run handling + // Dry run handling (after revision loop so --dry-run shows revised score) if (opts.dryRun) { cb.onStageSkip?.("tts", "dry run"); cb.onStageSkip?.("visuals", "dry run"); cb.onStageSkip?.("assembly", "dry run"); cb.onStageSkip?.("critic", "dry run"); cb.onLog?.("\n--- DirectorScore ---"); - cb.onLog?.(JSON.stringify(cdOutput.data, null, 2)); + cb.onLog?.(JSON.stringify(score, null, 2)); directorResult.dryRunExit = true; return { done: true }; } - // Cost estimation - const costBreakdown = estimateCost(cdOutput.data, opts.imageProvider, opts.ttsProvider, opts.videoProvider, opts.llm.id, opts.musicProviderKey); + // Cost estimation (uses the final revised score for accurate scene counts) + const costBreakdown = estimateCost(score, opts.imageProvider, opts.ttsProvider, opts.videoProvider, opts.llm.id, opts.musicProviderKey, revisionRoundsCompleted); directorResult.costBreakdown = costBreakdown; log.totalCost = { estimated: costBreakdown.totalCost }; if (cb.onCostEstimate) { - const stockSceneCount = cdOutput.data.scenes.filter( + const stockSceneCount = score.scenes.filter( (s) => s.visual_type === "stock_image" || s.visual_type === "stock_video", ).length; const proceed = await cb.onCostEstimate(costBreakdown, opts.imageProvider, stockSceneCount); @@ -734,13 +782,7 @@ function buildPipelineWorkflow( llmUsages.push(critiqueOutput.usage); const dur = (Date.now() - start) / 1000; - if (critique.revision_needed && critique.score < 7) { - cb.onStageComplete?.("critic", `score ${critique.score}/10, revision needed`, dur); - cb.onLog?.(`\nCritic score: ${critique.score}/10 — ${critique.weaknesses.join(", ")}`); - cb.onLog?.("Revision support coming in a future release. Using current version."); - } else { - cb.onStageComplete?.("critic", `score ${critique.score}/10`, dur); - } + cb.onStageComplete?.("critic", `score ${critique.score}/10`, dur); cb.onProgress?.("critic", { type: "review", score: critique.score, From d9a7d5bc2838c99c30f193f27dbf63b11fcc4f12 Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 13:31:52 +0600 Subject: [PATCH 03/10] feat(pipeline): add revision cost tracking and worker meta persistence Add revisionCost and revisionRounds to CostBreakdown. Format revision cost as a separate line in cost estimates. Add type: "revision" handler to worker onProgress for meta.revisionHistory persistence. --- src/cli/cost-estimator.test.ts | 15 ++++++++++++++- src/cli/cost-estimator.ts | 19 +++++++++++++++---- src/worker.ts | 8 ++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/cli/cost-estimator.test.ts b/src/cli/cost-estimator.test.ts index eca7eac6..b417e700 100644 --- a/src/cli/cost-estimator.test.ts +++ b/src/cli/cost-estimator.test.ts @@ -70,7 +70,7 @@ describe("estimateCost", () => { ]); const result = estimateCost(score); expect(result.totalCost).toBeCloseTo( - result.llmCost + result.ttsCost + result.imageCost + result.videoCost + result.musicCost, + result.llmCost + result.revisionCost + result.ttsCost + result.imageCost + result.videoCost + result.musicCost, ); }); @@ -119,6 +119,19 @@ describe("estimateCost", () => { expect(fal.videoCost).toBeGreaterThan(gemini.videoCost); }); + it("includes revision cost when revisionRounds > 0", () => { + const score = makeScore([{ visual_type: "ai_image", script_line: "Test" }]); + const noRevision = estimateCost(score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 0); + const withRevision = estimateCost(score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 2); + expect(noRevision.revisionCost).toBe(0); + expect(noRevision.details.revisionRounds).toBe(0); + expect(withRevision.revisionCost).toBeGreaterThan(0); + expect(withRevision.details.revisionRounds).toBe(2); + expect(withRevision.totalCost).toBeGreaterThan(noRevision.totalCost); + // Revision cost should be approximately 2 × (critic + director) calls + expect(withRevision.revisionCost).toBeCloseTo(withRevision.totalCost - noRevision.totalCost, 4); + }); + it("uses gemini LLM pricing when llmProvider is gemini", () => { const score = makeScore([{ visual_type: "ai_image", script_line: "Test" }]); const anthropic = estimateCost(score, "gemini", "elevenlabs", undefined, "anthropic"); diff --git a/src/cli/cost-estimator.ts b/src/cli/cost-estimator.ts index 603a93c4..d6afb710 100644 --- a/src/cli/cost-estimator.ts +++ b/src/cli/cost-estimator.ts @@ -10,6 +10,7 @@ import type { export interface CostBreakdown { llmCost: number; + revisionCost: number; ttsCost: number; imageCost: number; videoCost: number; @@ -17,6 +18,7 @@ export interface CostBreakdown { totalCost: number; details: { llmCalls: number; + revisionRounds: number; ttsCharacters: number; aiImages: number; aiVideos: number; @@ -96,6 +98,7 @@ export function estimateCost( videoProvider?: VideoProviderKey, llmProvider: LLMProviderKey = "anthropic", musicProvider: MusicProviderKey = "bundled", + revisionRounds = 0, ): CostBreakdown { const aiImageScenes = score.scenes.filter((s) => s.visual_type === "ai_image").length; const aiVideoScenes = score.scenes.filter((s) => s.visual_type === "ai_video").length; @@ -114,6 +117,9 @@ export function estimateCost( callCost(TOKEN_ESTIMATES.creativeDirector) + callCost(TOKEN_ESTIMATES.critic) + aiImages * callCost(TOKEN_ESTIMATES.imagePrompter); + + // Revision cost: each round = 1 critic call + 1 director-sized revise call + const revisionCost = revisionRounds * (callCost(TOKEN_ESTIMATES.critic) + callCost(TOKEN_ESTIMATES.creativeDirector)); const ttsPerChar = PRICING.ttsPerChar[ttsProvider]; const ttsCost = ttsCharacters * ttsPerChar; const perImage = imageProvider === "openai" ? PRICING.openaiPerImage : PRICING.geminiPerImage; @@ -127,7 +133,7 @@ export function estimateCost( const musicCost = musicProvider === "lyria" ? PRICING.lyriaPerTrack + callCost(TOKEN_ESTIMATES.imagePrompter) : 0; - const totalCost = llmCost + ttsCost + imageCost + videoCost + musicCost; + const totalCost = llmCost + revisionCost + ttsCost + imageCost + videoCost + musicCost; // Per-scene cost breakdown const perScene = score.scenes.map((s) => { @@ -152,8 +158,8 @@ export function estimateCost( }); return { - llmCost, ttsCost, imageCost, videoCost, musicCost, totalCost, - details: { llmCalls, ttsCharacters, aiImages, aiVideos: aiVideoScenes }, + llmCost, revisionCost, ttsCost, imageCost, videoCost, musicCost, totalCost, + details: { llmCalls, revisionRounds, ttsCharacters, aiImages, aiVideos: aiVideoScenes }, perScene, }; } @@ -167,9 +173,14 @@ export function formatCostEstimate( const lines = [ `Estimated cost: $${breakdown.totalCost.toFixed(3)}`, ` LLM: $${breakdown.llmCost.toFixed(4)} (${breakdown.details.llmCalls} calls)`, + ]; + if (breakdown.revisionCost > 0) { + lines.push(` Revise: $${breakdown.revisionCost.toFixed(4)} (${breakdown.details.revisionRounds} revision round${breakdown.details.revisionRounds !== 1 ? "s" : ""})`); + } + lines.push( ` TTS: $${breakdown.ttsCost.toFixed(4)} (${breakdown.details.ttsCharacters} chars)`, ` Images: $${breakdown.imageCost.toFixed(4)} (${breakdown.details.aiImages} AI images @ $${perImage.toFixed(3)}/ea)`, - ]; + ); if (breakdown.details.aiVideos > 0) { lines.push(` Video: $${breakdown.videoCost.toFixed(4)} (${breakdown.details.aiVideos} AI videos)`); } diff --git a/src/worker.ts b/src/worker.ts index 2af3e1cd..584e1112 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -72,6 +72,7 @@ interface JobMeta { criticReview?: { score: number; strengths: string[]; weaknesses: string[] }; musicTrack?: { trackId: string; mood: string; requestedMood: string; fallback: boolean }; musicGeneration?: { provider: string; prompt?: string; metadata?: Record; fallback: boolean }; + revisionHistory?: { round: number; score: number }[]; error?: string; } @@ -166,6 +167,13 @@ const worker = new Worker( fallback: data.fallback as boolean, }; writeMeta(jobDir, meta); + } else if (data.type === "revision") { + if (!meta.revisionHistory) meta.revisionHistory = []; + meta.revisionHistory.push({ + round: data.round as number, + score: data.critiqueScore as number, + }); + writeMeta(jobDir, meta); } else if (data.type === "review") { meta.criticReview = { score: data.score as number, strengths: data.strengths as string[], weaknesses: data.weaknesses as string[] }; writeMeta(jobDir, meta); From 845a54a32058a66503ea312c381dbe6eb60de149 Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 13:31:58 +0600 Subject: [PATCH 04/10] chore: bump version and changelog (v0.15.0) Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 13 +++++++++++++ TODOS.md | 7 +++++++ VERSION | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 123962b3..bc7808f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to OpenReels will be documented in this file. +## [0.15.0] - 2026-04-09 + +### Added +- **Director-Critic quality gate**: the pipeline now evaluates and revises the DirectorScore before any expensive generation begins. A Critic agent scores the plan (1-10) and if below 7, provides revision instructions. The Creative Director revises the plan and re-submits. Up to 2 revision rounds, tracking the highest-scoring version. Revision costs ~$0.02-0.13 in LLM calls vs $0.50-2.00+ for regenerating the full pipeline. +- **Revision cost tracking**: cost estimates now include a "Revise" line item showing actual revision LLM cost. The `CostBreakdown` interface includes `revisionCost` and `revisionRounds` fields. +- **Worker revision history**: `meta.json` now includes a `revisionHistory` array with per-round critic scores, enabling future UI display of the revision journey. + +### Changed +- The Creative Director's system prompt loading is now a shared `loadDirectorSystemPrompt()` helper, used by both initial generation and revision. +- The step-6 critic no longer displays "Revision support coming in a future release" since revision is now active. +- Cost estimation uses the final revised DirectorScore for accurate scene counts when visual types change during revision. +- Dry-run mode now runs after the revision loop, showing the revised plan instead of the first draft. + ## [0.14.0] - 2026-04-09 ### Added diff --git a/TODOS.md b/TODOS.md index 3a782423..4e1a0b49 100644 --- a/TODOS.md +++ b/TODOS.md @@ -96,6 +96,13 @@ **Depends on:** TTS alignment layer (Kokoro + Gemini TTS providers) Deferred from plan: Unified TTS Alignment Layer (CEO review: user prefers unified voice interface later) +## Critic / Quality Gate + +- [ ] **Deterministic structural validation for DirectorScore** — Extract pacing checks (total word count, scene count, per-scene word bounds, consecutive visual_type runs) into a TypeScript validation function. Run it after DirectorScore.parse() in the existing retry loop. Structural violations become retry errors (fed back as error context to the director), not LLM critic concerns. Saves 1-2 LLM calls when the issue is structural, and makes structural quality a guaranteed invariant rather than probabilistic. + **Priority:** P3 + **Depends on:** Director-Critic quality gate (v0.15.0) + Deferred from plan: Director-Critic Quality Gate (eng review: outside voice recommended splitting deterministic vs subjective evaluation) + ## Captions - [ ] **Contextual emphasis on power words** — Creative director marks 1-3 power words per scene with asterisks in script_line. Pipeline strips markers before TTS and records emphasisIndices. CaptionWrapper applies extra animation treatment (larger font-size delta, accent color flash) to emphasis words. Requires: (a) strip asterisks before ALL downstream consumers (critic, image prompter, TTS), not just TTS, (b) creative director prompt update with emphasis marking instructions, (c) emphasisIndices field on CompositionProps (optional, backward compat), (d) CaptionWrapper emphasis detection logic. Per-word asterisks format: `*New* *York*` not `*New York*`. diff --git a/VERSION b/VERSION index a803cc22..a5510516 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.14.0 +0.15.0 From 9b44d408ea2dfa33b9a34b806d9a2bae9621eb4a Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 13:34:52 +0600 Subject: [PATCH 05/10] fix(test): use non-null assertion for array index access in revision test TypeScript strict mode treats array[i % 8] as T | undefined. Add non-null assertion since the modulo guarantees a valid index. --- src/pipeline/orchestrator-revision.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pipeline/orchestrator-revision.test.ts b/src/pipeline/orchestrator-revision.test.ts index 6eafebaf..9c2aad7e 100644 --- a/src/pipeline/orchestrator-revision.test.ts +++ b/src/pipeline/orchestrator-revision.test.ts @@ -16,13 +16,16 @@ const makeScore = (archetype = "infographic", sceneCount = 8): DirectorScore => emotional_arc: "curiosity-to-wisdom", archetype, music_mood: "epic_cinematic", - scenes: Array.from({ length: sceneCount }, (_, i) => ({ - visual_type: (["text_card", "ai_image", "stock_video", "ai_image", "stock_image", "text_card", "ai_image", "stock_video"] as const)[i % 8], - visual_prompt: `Scene ${i + 1}`, - motion: "static" as const, - script_line: `This is scene ${i + 1}.`, - transition: i < sceneCount - 1 ? ("crossfade" as const) : null, - })), + scenes: Array.from({ length: sceneCount }, (_, i) => { + const visualTypes = ["text_card", "ai_image", "stock_video", "ai_image", "stock_image", "text_card", "ai_image", "stock_video"] as const; + return { + visual_type: visualTypes[i % 8]!, + visual_prompt: `Scene ${i + 1}`, + motion: "static" as const, + script_line: `This is scene ${i + 1}.`, + transition: i < sceneCount - 1 ? ("crossfade" as const) : null, + }; + }), }); const makeCritique = (score: number, revisionNeeded: boolean): CritiqueResult => ({ From be8e582c67229e6a401554ed3f7cf2aaae5344de Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 13:37:24 +0600 Subject: [PATCH 06/10] fix(pipeline): prevent archetype drift, fix cost display, improve error context - Force archetype back to original in reviseDirectorScore if LLM changes it - Track evaluationsCompleted separately from revisionRoundsCompleted so cost display includes the gate evaluation (not just revision rounds) - Clarify catch comment: covers both critic and revision failures --- src/agents/creative-director.ts | 7 +++++++ src/cli/cost-estimator.ts | 2 +- src/pipeline/orchestrator.ts | 8 ++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/agents/creative-director.ts b/src/agents/creative-director.ts index da936251..958e33a0 100644 --- a/src/agents/creative-director.ts +++ b/src/agents/creative-director.ts @@ -249,5 +249,12 @@ Keep the same archetype. Maintain the GOLDEN RULE: never use the same visual_typ }); const validated = DirectorScore.parse(result.data); + + // Prevent archetype drift: the LLM may change the archetype during revision + // despite prompt instructions. Force it back to the original. + if (validated.archetype !== originalScore.archetype) { + (validated as { archetype: string }).archetype = originalScore.archetype; + } + return { data: validated, usage: result.usage }; } diff --git a/src/cli/cost-estimator.ts b/src/cli/cost-estimator.ts index d6afb710..91a7af44 100644 --- a/src/cli/cost-estimator.ts +++ b/src/cli/cost-estimator.ts @@ -175,7 +175,7 @@ export function formatCostEstimate( ` LLM: $${breakdown.llmCost.toFixed(4)} (${breakdown.details.llmCalls} calls)`, ]; if (breakdown.revisionCost > 0) { - lines.push(` Revise: $${breakdown.revisionCost.toFixed(4)} (${breakdown.details.revisionRounds} revision round${breakdown.details.revisionRounds !== 1 ? "s" : ""})`); + lines.push(` Gate: $${breakdown.revisionCost.toFixed(4)} (${breakdown.details.revisionRounds} quality gate eval${breakdown.details.revisionRounds !== 1 ? "s" : ""})`); } lines.push( ` TTS: $${breakdown.ttsCost.toFixed(4)} (${breakdown.details.ttsCharacters} chars)`, diff --git a/src/pipeline/orchestrator.ts b/src/pipeline/orchestrator.ts index 15dd676b..cf65f94e 100644 --- a/src/pipeline/orchestrator.ts +++ b/src/pipeline/orchestrator.ts @@ -431,10 +431,13 @@ function buildPipelineWorkflow( let bestCritiqueScore = 0; let revisionRoundsCompleted = 0; + let evaluationsCompleted = 0; + for (let round = 0; round < MAX_REVISION_ROUNDS; round++) { try { const critiqueOutput = await evaluate(opts.llm, score, opts.topic, opts.pacing); llmUsages.push(critiqueOutput.usage); + evaluationsCompleted++; const critique = critiqueOutput.data; // Track highest-scoring revision @@ -453,7 +456,7 @@ function buildPipelineWorkflow( score = revised.data; revisionRoundsCompleted++; } catch (err) { - // Graceful degradation: if the critic fails, proceed with current best score + // Graceful degradation: if the critic or revision fails, proceed with current best score console.warn(`[director] Revision round ${round + 1} failed: ${err}`); break; } @@ -491,7 +494,8 @@ function buildPipelineWorkflow( } // Cost estimation (uses the final revised score for accurate scene counts) - const costBreakdown = estimateCost(score, opts.imageProvider, opts.ttsProvider, opts.videoProvider, opts.llm.id, opts.musicProviderKey, revisionRoundsCompleted); + // Pass evaluationsCompleted so the cost includes the gate evaluation + any revision rounds + const costBreakdown = estimateCost(score, opts.imageProvider, opts.ttsProvider, opts.videoProvider, opts.llm.id, opts.musicProviderKey, evaluationsCompleted); directorResult.costBreakdown = costBreakdown; log.totalCost = { estimated: costBreakdown.totalCost }; From 7e23b6d975a4c2a365ba03007bf92247aa341c59 Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 13:39:23 +0600 Subject: [PATCH 07/10] fix(pipeline): add retry loop to reviseDirectorScore, document cost approximation reviseDirectorScore now retries up to 2 times on schema validation failure, matching the pattern in generateDirectorScore. Previously a transient parse error would waste the entire revision round with nothing to show for it. Add comment noting that revision cost estimate slightly undercounts because revise prompts include the original score JSON (~1-3K extra tokens). --- src/agents/creative-director.ts | 45 +++++++++++++++++++++++---------- src/cli/cost-estimator.ts | 4 ++- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/agents/creative-director.ts b/src/agents/creative-director.ts index 958e33a0..1caa87f7 100644 --- a/src/agents/creative-director.ts +++ b/src/agents/creative-director.ts @@ -242,19 +242,38 @@ ${revisionGuidance} Revise the DirectorScore to address the weaknesses while preserving the strengths. Keep the same archetype. Maintain the GOLDEN RULE: never use the same visual_type more than 2 times in a row.`; - const result = await llm.generate({ - systemPrompt, - userMessage, - schema: DirectorScoreRaw, - }); - - const validated = DirectorScore.parse(result.data); - - // Prevent archetype drift: the LLM may change the archetype during revision - // despite prompt instructions. Force it back to the original. - if (validated.archetype !== originalScore.archetype) { - (validated as { archetype: string }).archetype = originalScore.archetype; + const maxRetries = 2; + let lastError: Error | null = null; + const totalUsage: LLMUsage = { inputTokens: 0, outputTokens: 0 }; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const result = await llm.generate({ + systemPrompt, + userMessage: + attempt > 0 + ? `${userMessage}\n\nPREVIOUS ATTEMPT FAILED: ${lastError?.message}. Fix the issue.` + : userMessage, + schema: DirectorScoreRaw, + }); + + totalUsage.inputTokens += result.usage.inputTokens; + totalUsage.outputTokens += result.usage.outputTokens; + + const validated = DirectorScore.parse(result.data); + + // Prevent archetype drift: the LLM may change the archetype during revision + // despite prompt instructions. Force it back to the original. + if (validated.archetype !== originalScore.archetype) { + (validated as { archetype: string }).archetype = originalScore.archetype; + } + + return { data: validated, usage: totalUsage }; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + console.warn(`[creative-director] Revision attempt ${attempt + 1} failed: ${lastError.message}`); + } } - return { data: validated, usage: result.usage }; + throw new Error(`Revision failed after ${maxRetries} attempts: ${lastError?.message}`); } diff --git a/src/cli/cost-estimator.ts b/src/cli/cost-estimator.ts index 91a7af44..5c17f441 100644 --- a/src/cli/cost-estimator.ts +++ b/src/cli/cost-estimator.ts @@ -118,7 +118,9 @@ export function estimateCost( callCost(TOKEN_ESTIMATES.critic) + aiImages * callCost(TOKEN_ESTIMATES.imagePrompter); - // Revision cost: each round = 1 critic call + 1 director-sized revise call + // Revision cost: each evaluation = 1 critic call + 1 director-sized revise call. + // Slightly undercounts because revise prompts include the original score JSON (~1-3K extra tokens), + // but using the director estimate is a reasonable approximation. const revisionCost = revisionRounds * (callCost(TOKEN_ESTIMATES.critic) + callCost(TOKEN_ESTIMATES.creativeDirector)); const ttsPerChar = PRICING.ttsPerChar[ttsProvider]; const ttsCost = ttsCharacters * ttsPerChar; From f9272e993390c3f7a8e7a0dbe7aea00e13be1664 Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 13:46:57 +0600 Subject: [PATCH 08/10] docs: update project documentation for v0.15.0 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- docs/content/docs/contributing/architecture.mdx | 2 +- docs/content/docs/pipeline/critic.mdx | 13 ++++++++++--- docs/content/docs/pipeline/overview.mdx | 7 ++++--- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ea3d3d28..e5450159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ fixtures/ # sample DirectorScore JSONs ```bash pnpm install # install dependencies pnpm start "topic" # run full pipeline (CLI) -pnpm test # run vitest suite (338 tests) +pnpm test # run vitest suite (349 tests) ``` ### Web UI (Docker Compose) diff --git a/docs/content/docs/contributing/architecture.mdx b/docs/content/docs/contributing/architecture.mdx index f577a46b..181b4de7 100644 --- a/docs/content/docs/contributing/architecture.mdx +++ b/docs/content/docs/contributing/architecture.mdx @@ -39,7 +39,7 @@ AI agent functions that make LLM calls with structured output. Each agent has a | `creative-director.ts` | Creative Director | research data + archetype | DirectorScore | | `image-prompter.ts` | Image Prompter | raw visual prompt | optimized prompt for image gen | | `music-prompter.ts` | Music Prompter | music mood | prompt string for AI music gen | -| `critic.ts` | Critic | rendered video + score | score 0-10, strengths, weaknesses | +| `critic.ts` | Critic | DirectorScore + topic | score 0-10, strengths, weaknesses | ### `src/pipeline/` diff --git a/docs/content/docs/pipeline/critic.mdx b/docs/content/docs/pipeline/critic.mdx index 3fee367e..54a3527c 100644 --- a/docs/content/docs/pipeline/critic.mdx +++ b/docs/content/docs/pipeline/critic.mdx @@ -3,7 +3,7 @@ title: Critic Agent description: How the critic evaluates DirectorScore quality using a weighted rubric and flags revision when scores are low. --- -The Critic is the final stage of the pipeline. It evaluates the DirectorScore against a structured rubric and produces a quality score. If the score falls below the pass threshold, it identifies the weakest scene and provides specific revision instructions. +The Critic evaluates the DirectorScore against a structured rubric and produces a quality score. It runs twice in the pipeline: first as a **quality gate** inside the Director stage (before any expensive generation), and again as a **post-mortem** in the final stage. If the score falls below the pass threshold, it identifies the weakest scene and provides specific revision instructions. ## What it evaluates @@ -73,9 +73,16 @@ interface CritiqueResult { } ``` -## When re-runs happen +## Quality gate (Director-Critic revision loop) -If `revision_needed` is true and the score is below 7, the Critic logs the weaknesses and notes that revision support is coming in a future release. Currently, the pipeline uses the existing DirectorScore as-is -- the Critic serves as a quality signal rather than triggering automatic re-generation. +After the Creative Director generates the initial DirectorScore, the Critic evaluates it immediately. If `revision_needed` is true and the score is below 7, the pipeline enters a revision loop: + +1. The Critic's `revision_instructions` and `weaknesses` are passed back to the Creative Director +2. The Director generates a revised DirectorScore addressing the feedback +3. The Critic re-evaluates the revised plan +4. The loop runs up to 2 rounds, tracking the highest-scoring revision + +This happens before TTS, visuals, or music generation, so revisions cost only LLM calls (~$0.02-0.13) instead of re-running the full pipeline ($0.50-2.00+). If the Critic fails during evaluation, the loop exits gracefully and proceeds with the current best score. The critique results are emitted through the progress callback so the web UI can display the score, strengths, and weaknesses to the user. diff --git a/docs/content/docs/pipeline/overview.mdx b/docs/content/docs/pipeline/overview.mdx index 06076958..b010936e 100644 --- a/docs/content/docs/pipeline/overview.mdx +++ b/docs/content/docs/pipeline/overview.mdx @@ -14,8 +14,9 @@ Topic Research ---- web search grounds the script in facts | v -Director ---- writes the DirectorScore (scenes, archetype, music mood) - | +Director ---- writes the DirectorScore, then Critic evaluates it + | ^ (revision loop: up to 2 rounds until score >= 7) + | | v TTS --------- generates voiceover audio with word-level timestamps | @@ -89,7 +90,7 @@ After all stages complete, actual LLM token usage is aggregated and the real cos The pipeline supports two early exit paths: -- **Dry run** (`--dry-run`) -- stops after the Director stage and prints the DirectorScore JSON. No assets are generated. +- **Dry run** (`--dry-run`) -- stops after the Director stage (including the quality gate revision loop) and prints the final DirectorScore JSON. No assets are generated. - **Cost rejection** -- if the user declines at the cost estimate prompt, the pipeline exits cleanly. Both paths still write `log.json` to the output directory. From 4af706b5e0218231dd552ce756381457035d3a5f Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 13:52:16 +0600 Subject: [PATCH 09/10] fix(pipeline): evaluate final revision after loop exhausts max rounds Previously, the last revision was generated but never scored, wasting the LLM call. Now a final evaluate() runs after the loop if the last revision hasn't been scored yet. The highest-scoring version across all evaluations (including the final one) is used. --- src/pipeline/orchestrator-revision.test.ts | 44 ++++++++++++++++------ src/pipeline/orchestrator.ts | 18 ++++++++- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/pipeline/orchestrator-revision.test.ts b/src/pipeline/orchestrator-revision.test.ts index 9c2aad7e..104c2e53 100644 --- a/src/pipeline/orchestrator-revision.test.ts +++ b/src/pipeline/orchestrator-revision.test.ts @@ -81,6 +81,20 @@ async function simulateRevisionLoop(opts: { } } + // Final evaluation: if the last revision was never scored, give it a chance + if (revisionRoundsCompleted > 0 && score !== bestScore) { + try { + const finalCritique = await evaluateFn(score); + llmUsages.push(finalCritique.usage); + if (finalCritique.data.score > bestCritiqueScore) { + bestScore = score; + bestCritiqueScore = finalCritique.data.score; + } + } catch { + // bestScore from the loop is still valid + } + } + score = bestScore; return { finalScore: score, bestCritiqueScore, revisionRoundsCompleted, llmUsages }; } @@ -128,13 +142,15 @@ describe("Director-Critic revision loop", () => { expect(result.bestCritiqueScore).toBe(8); }); - it("exhausts max rounds and uses highest-scoring revision", async () => { + it("exhausts max rounds and evaluates the final revision", async () => { const round1Score = makeScore("infographic", 9); const round2Score = makeScore("infographic", 10); const evaluateFn = vi.fn() .mockResolvedValueOnce({ data: makeCritique(4, true), usage: { inputTokens: 100, outputTokens: 50 } }) - .mockResolvedValueOnce({ data: makeCritique(6, true), usage: { inputTokens: 100, outputTokens: 50 } }); + .mockResolvedValueOnce({ data: makeCritique(6, true), usage: { inputTokens: 100, outputTokens: 50 } }) + // Final evaluation of round2Score after loop exhausts + .mockResolvedValueOnce({ data: makeCritique(9, false), usage: { inputTokens: 100, outputTokens: 50 } }); const reviseFn = vi.fn() .mockResolvedValueOnce({ data: round1Score, usage: { inputTokens: 200, outputTokens: 100 } }) .mockResolvedValueOnce({ data: round2Score, usage: { inputTokens: 200, outputTokens: 100 } }); @@ -145,24 +161,28 @@ describe("Director-Critic revision loop", () => { reviseFn, }); - expect(evaluateFn).toHaveBeenCalledTimes(2); + // 2 in-loop evaluations + 1 final evaluation + expect(evaluateFn).toHaveBeenCalledTimes(3); expect(reviseFn).toHaveBeenCalledTimes(2); expect(result.revisionRoundsCompleted).toBe(2); - // Best score is round1Score (scored 6) not round2Score (never evaluated) - expect(result.finalScore).toBe(round1Score); - expect(result.bestCritiqueScore).toBe(6); + // round2Score scored 9 in final eval, beating round1Score's 6 + expect(result.finalScore).toBe(round2Score); + expect(result.bestCritiqueScore).toBe(9); }); - it("uses highest-scoring revision when later rounds degrade", async () => { + it("uses highest-scoring revision when final evaluation degrades", async () => { const round1Score = makeScore("infographic", 9); const round2Score = makeScore("infographic", 10); - // Round 1: score 6, revise - // Round 2: score 4 (degradation), revise + // Round 0: initial scores 3, revise → round1Score + // Round 1: round1Score scores 6, revise → round2Score + // Final eval: round2Score scores 4 (degradation) // Result: should use round1Score (scored 6), not round2Score (scored 4) const evaluateFn = vi.fn() .mockResolvedValueOnce({ data: makeCritique(3, true), usage: { inputTokens: 100, outputTokens: 50 } }) - .mockResolvedValueOnce({ data: makeCritique(6, true), usage: { inputTokens: 100, outputTokens: 50 } }); + .mockResolvedValueOnce({ data: makeCritique(6, true), usage: { inputTokens: 100, outputTokens: 50 } }) + // Final evaluation of round2Score — degraded + .mockResolvedValueOnce({ data: makeCritique(4, false), usage: { inputTokens: 100, outputTokens: 50 } }); const reviseFn = vi.fn() .mockResolvedValueOnce({ data: round1Score, usage: { inputTokens: 200, outputTokens: 100 } }) .mockResolvedValueOnce({ data: round2Score, usage: { inputTokens: 200, outputTokens: 100 } }); @@ -173,7 +193,9 @@ describe("Director-Critic revision loop", () => { reviseFn, }); - // Best is round1Score which was evaluated at 6 + // 2 in-loop + 1 final = 3 evaluations + expect(evaluateFn).toHaveBeenCalledTimes(3); + // Best is round1Score which was evaluated at 6 (round2Score's final eval of 4 didn't beat it) expect(result.finalScore).toBe(round1Score); expect(result.bestCritiqueScore).toBe(6); }); diff --git a/src/pipeline/orchestrator.ts b/src/pipeline/orchestrator.ts index cf65f94e..55580b47 100644 --- a/src/pipeline/orchestrator.ts +++ b/src/pipeline/orchestrator.ts @@ -462,7 +462,23 @@ function buildPipelineWorkflow( } } - // Use the highest-scoring revision (not necessarily the last) + // Final evaluation: if the last revision was never scored (loop exhausted), + // give it a chance to compete with bestScore + if (revisionRoundsCompleted > 0 && score !== bestScore) { + try { + const finalCritique = await evaluate(opts.llm, score, opts.topic, opts.pacing); + llmUsages.push(finalCritique.usage); + evaluationsCompleted++; + if (finalCritique.data.score > bestCritiqueScore) { + bestScore = score; + bestCritiqueScore = finalCritique.data.score; + } + } catch { + // If final evaluation fails, bestScore from the loop is still valid + } + } + + // Use the highest-scoring revision score = bestScore; // ── Store final score on shared closure state ── From c478868e7c975274da08b1f25a0cbc79b479c112 Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Thu, 9 Apr 2026 14:00:39 +0600 Subject: [PATCH 10/10] fix(pipeline): split gate evaluations from revision rounds in cost estimate Previously, evaluationsCompleted was passed as a single count and the cost formula charged (critic + director) per unit. This overcounted when the gate evaluated but didn't revise (score >= 7 first try): charged for a director call that never happened. Now estimateCost takes separate gateEvaluations and revisionRounds params. Cost formula: evaluations * critic_cost + revisions * director_cost. --- src/cli/cost-estimator.test.ts | 31 ++++++++++++++------- src/cli/cost-estimator.ts | 17 +++++++---- src/pipeline/orchestrator-callbacks.test.ts | 2 +- src/pipeline/orchestrator.ts | 5 ++-- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/cli/cost-estimator.test.ts b/src/cli/cost-estimator.test.ts index b417e700..ef596ee6 100644 --- a/src/cli/cost-estimator.test.ts +++ b/src/cli/cost-estimator.test.ts @@ -119,17 +119,28 @@ describe("estimateCost", () => { expect(fal.videoCost).toBeGreaterThan(gemini.videoCost); }); - it("includes revision cost when revisionRounds > 0", () => { + it("includes revision cost with separate evaluation and revision counts", () => { const score = makeScore([{ visual_type: "ai_image", script_line: "Test" }]); - const noRevision = estimateCost(score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 0); - const withRevision = estimateCost(score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 2); - expect(noRevision.revisionCost).toBe(0); - expect(noRevision.details.revisionRounds).toBe(0); - expect(withRevision.revisionCost).toBeGreaterThan(0); - expect(withRevision.details.revisionRounds).toBe(2); - expect(withRevision.totalCost).toBeGreaterThan(noRevision.totalCost); - // Revision cost should be approximately 2 × (critic + director) calls - expect(withRevision.revisionCost).toBeCloseTo(withRevision.totalCost - noRevision.totalCost, 4); + // No gate activity + const noGate = estimateCost(score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 0, 0); + expect(noGate.revisionCost).toBe(0); + expect(noGate.details.gateEvaluations).toBe(0); + expect(noGate.details.revisionRounds).toBe(0); + + // Gate only (score >= 7 on first try): 1 eval, 0 revisions + const gateOnly = estimateCost(score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 1, 0); + expect(gateOnly.revisionCost).toBeGreaterThan(0); + expect(gateOnly.details.gateEvaluations).toBe(1); + expect(gateOnly.details.revisionRounds).toBe(0); + + // Full revision: 2 evals + 1 revision + const fullRevision = estimateCost(score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 2, 1); + expect(fullRevision.revisionCost).toBeGreaterThan(gateOnly.revisionCost); + expect(fullRevision.details.gateEvaluations).toBe(2); + expect(fullRevision.details.revisionRounds).toBe(1); + + // Gate-only should cost less than a full revision (no director call) + expect(gateOnly.revisionCost).toBeLessThan(fullRevision.revisionCost); }); it("uses gemini LLM pricing when llmProvider is gemini", () => { diff --git a/src/cli/cost-estimator.ts b/src/cli/cost-estimator.ts index 5c17f441..e774ddf0 100644 --- a/src/cli/cost-estimator.ts +++ b/src/cli/cost-estimator.ts @@ -18,6 +18,7 @@ export interface CostBreakdown { totalCost: number; details: { llmCalls: number; + gateEvaluations: number; revisionRounds: number; ttsCharacters: number; aiImages: number; @@ -98,6 +99,7 @@ export function estimateCost( videoProvider?: VideoProviderKey, llmProvider: LLMProviderKey = "anthropic", musicProvider: MusicProviderKey = "bundled", + gateEvaluations = 0, revisionRounds = 0, ): CostBreakdown { const aiImageScenes = score.scenes.filter((s) => s.visual_type === "ai_image").length; @@ -118,10 +120,10 @@ export function estimateCost( callCost(TOKEN_ESTIMATES.critic) + aiImages * callCost(TOKEN_ESTIMATES.imagePrompter); - // Revision cost: each evaluation = 1 critic call + 1 director-sized revise call. - // Slightly undercounts because revise prompts include the original score JSON (~1-3K extra tokens), - // but using the director estimate is a reasonable approximation. - const revisionCost = revisionRounds * (callCost(TOKEN_ESTIMATES.critic) + callCost(TOKEN_ESTIMATES.creativeDirector)); + // Quality gate cost: critic calls for evaluation + director calls for revision. + // Split because evaluations > revisions (gate always evaluates, only revises if score < 7). + // Slightly undercounts revise calls because the prompt includes the original score JSON (~1-3K extra tokens). + const revisionCost = gateEvaluations * callCost(TOKEN_ESTIMATES.critic) + revisionRounds * callCost(TOKEN_ESTIMATES.creativeDirector); const ttsPerChar = PRICING.ttsPerChar[ttsProvider]; const ttsCost = ttsCharacters * ttsPerChar; const perImage = imageProvider === "openai" ? PRICING.openaiPerImage : PRICING.geminiPerImage; @@ -161,7 +163,7 @@ export function estimateCost( return { llmCost, revisionCost, ttsCost, imageCost, videoCost, musicCost, totalCost, - details: { llmCalls, revisionRounds, ttsCharacters, aiImages, aiVideos: aiVideoScenes }, + details: { llmCalls, gateEvaluations, revisionRounds, ttsCharacters, aiImages, aiVideos: aiVideoScenes }, perScene, }; } @@ -177,7 +179,10 @@ export function formatCostEstimate( ` LLM: $${breakdown.llmCost.toFixed(4)} (${breakdown.details.llmCalls} calls)`, ]; if (breakdown.revisionCost > 0) { - lines.push(` Gate: $${breakdown.revisionCost.toFixed(4)} (${breakdown.details.revisionRounds} quality gate eval${breakdown.details.revisionRounds !== 1 ? "s" : ""})`); + const evals = breakdown.details.gateEvaluations; + const revs = breakdown.details.revisionRounds; + const detail = revs > 0 ? `${evals} eval${evals !== 1 ? "s" : ""}, ${revs} revision${revs !== 1 ? "s" : ""}` : `${evals} eval${evals !== 1 ? "s" : ""}`; + lines.push(` Gate: $${breakdown.revisionCost.toFixed(4)} (${detail})`); } lines.push( ` TTS: $${breakdown.ttsCost.toFixed(4)} (${breakdown.details.ttsCharacters} chars)`, diff --git a/src/pipeline/orchestrator-callbacks.test.ts b/src/pipeline/orchestrator-callbacks.test.ts index 031927f0..af60294b 100644 --- a/src/pipeline/orchestrator-callbacks.test.ts +++ b/src/pipeline/orchestrator-callbacks.test.ts @@ -82,7 +82,7 @@ describe("createCliCallbacks", () => { totalCost: 0.1, videoCost: 0, musicCost: 0, - details: { llmCalls: 3, revisionRounds: 0, ttsCharacters: 500, aiImages: 2, aiVideos: 0 }, + details: { llmCalls: 3, gateEvaluations: 0, revisionRounds: 0, ttsCharacters: 500, aiImages: 2, aiVideos: 0 }, }, "gemini", ); diff --git a/src/pipeline/orchestrator.ts b/src/pipeline/orchestrator.ts index 55580b47..231707eb 100644 --- a/src/pipeline/orchestrator.ts +++ b/src/pipeline/orchestrator.ts @@ -510,8 +510,9 @@ function buildPipelineWorkflow( } // Cost estimation (uses the final revised score for accurate scene counts) - // Pass evaluationsCompleted so the cost includes the gate evaluation + any revision rounds - const costBreakdown = estimateCost(score, opts.imageProvider, opts.ttsProvider, opts.videoProvider, opts.llm.id, opts.musicProviderKey, evaluationsCompleted); + // Pass evaluations and revisions separately: evaluations count critic calls, + // revisions count director calls (evaluations >= revisions since gate always evaluates) + const costBreakdown = estimateCost(score, opts.imageProvider, opts.ttsProvider, opts.videoProvider, opts.llm.id, opts.musicProviderKey, evaluationsCompleted, revisionRoundsCompleted); directorResult.costBreakdown = costBreakdown; log.totalCost = { estimated: costBreakdown.totalCost };