From 71e8ec73f0ccb2d3f8eafad2aceb37ff8295c8e3 Mon Sep 17 00:00:00 2001 From: Miriad Date: Thu, 5 Mar 2026 19:17:20 +0000 Subject: [PATCH 1/3] feat: add Gemini Deep Research client (Task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New service at lib/services/gemini-research.ts that uses the @google/genai Interactions API for autonomous web research. Exports: - submitResearch(topic, config?) → interactionId - pollResearch(interactionId) → ResearchStatus - parseResearchReport(topic, report) → ResearchPayload - conductGeminiResearch(topic, config?) → ResearchPayload (full pipeline) All models and prompts configurable via Sanity pipelineConfig. Output matches existing ResearchPayload interface for backward compat. Co-authored-by: research --- lib/services/gemini-research.ts | 535 ++++++++++++++++++++++++++++++++ 1 file changed, 535 insertions(+) create mode 100644 lib/services/gemini-research.ts diff --git a/lib/services/gemini-research.ts b/lib/services/gemini-research.ts new file mode 100644 index 00000000..9f00d826 --- /dev/null +++ b/lib/services/gemini-research.ts @@ -0,0 +1,535 @@ +/** + * Gemini Deep Research Service + * + * Uses the Gemini Interactions API for autonomous web research. + * Replaces the NotebookLM-based research pipeline. + * + * Pipeline: + * topic → Gemini Deep Research → markdown report → structured ResearchPayload + * + * @module lib/services/gemini-research + */ + +import { GoogleGenAI, type Interactions } from "@google/genai"; +import { getConfigValue } from "@/lib/config"; +import { generateWithGemini, stripCodeFences } from "@/lib/gemini"; + +// Re-export types for backward compatibility +export type { + ResearchPayload, + ResearchSource, + CodeExample, + ComparisonData, + SceneHint, +} from "./research"; + +import type { + ResearchPayload, + ResearchSource, + CodeExample, + SceneHint, +} from "./research"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface GeminiResearchConfig { + /** Timeout for research polling in ms (default: 1200000 = 20 min) */ + researchTimeout?: number; + /** Polling interval in ms (default: 15000 = 15s) */ + pollInterval?: number; + /** Source URLs from trend discovery (included in research prompt) */ + sourceUrls?: string[]; +} + +export interface ResearchStatus { + status: "in_progress" | "completed" | "failed" | "not_found"; + interactionId: string; + report?: string; + error?: string; +} + +// --------------------------------------------------------------------------- +// Lazy AI client init +// --------------------------------------------------------------------------- + +let _ai: GoogleGenAI | null = null; +function getAI(): GoogleGenAI { + if (!_ai) { + const apiKey = process.env.GEMINI_API_KEY || ""; + _ai = new GoogleGenAI({ apiKey }); + } + return _ai; +} + +// --------------------------------------------------------------------------- +// Default prompt template +// --------------------------------------------------------------------------- + +const DEFAULT_PROMPT_TEMPLATE = `Research comprehensively: "{topic}" + +Focus areas: +- What is it and why does it matter for web developers? +- How does it work technically? Include architecture details. +- Key features, capabilities, and limitations +- Comparison with alternatives (include specific metrics where possible) +- Real-world use cases and code examples +- Latest developments and future roadmap + +Target audience: Web developers who want to stay current with modern tools and frameworks. +Include code examples where relevant (TypeScript/JavaScript preferred). +Include specific version numbers, dates, and statistics where available.`; + +// --------------------------------------------------------------------------- +// Submit Research +// --------------------------------------------------------------------------- + +/** + * Submit a research query to Gemini Deep Research. + * Returns the interaction ID for polling. + */ +export async function submitResearch( + topic: string, + config?: GeminiResearchConfig, +): Promise { + const ai = getAI(); + const agent = await getConfigValue( + "pipeline_config", + "deepResearchAgent", + "deep-research-pro-preview-12-2025", + ); + const promptTemplate = await getConfigValue( + "pipeline_config", + "deepResearchPromptTemplate", + DEFAULT_PROMPT_TEMPLATE, + ); + + // Build the research prompt + let prompt = promptTemplate.replace(/\{topic\}/g, topic); + + // Add source URLs if provided (gives the researcher starting points) + const sourceUrls = config?.sourceUrls ?? []; + if (sourceUrls.length > 0) { + prompt += `\n\nStarting reference URLs:\n${sourceUrls.map((u) => `- ${u}`).join("\n")}`; + } + + console.log(`[gemini-research] Submitting research for: "${topic}"`); + console.log(`[gemini-research] Agent: ${agent}`); + + // Submit via Interactions API (background: true for async deep research) + const interaction = await ai.interactions.create({ + agent, + input: prompt, + background: true, + stream: false, + }); + + const interactionId = interaction.id; + if (!interactionId) { + throw new Error("[gemini-research] No interaction ID returned"); + } + + console.log(`[gemini-research] Interaction created: ${interactionId}`); + return interactionId; +} + +// --------------------------------------------------------------------------- +// Poll Research +// --------------------------------------------------------------------------- + +/** + * Check the status of a research interaction. + */ +export async function pollResearch( + interactionId: string, +): Promise { + const ai = getAI(); + + try { + const result = await ai.interactions.get(interactionId, { stream: false }); + + if (result.status === "completed") { + // Extract the report text from outputs + const report = extractTextFromOutputs(result.outputs); + + return { + status: "completed", + interactionId, + report: report || undefined, + }; + } + + if ( + result.status === "failed" || + result.status === "cancelled" || + result.status === "incomplete" + ) { + return { + status: "failed", + interactionId, + error: `Research ${result.status}`, + }; + } + + // 'in_progress' or 'requires_action' + return { status: "in_progress", interactionId }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + // 404 = interaction not found + if (message.includes("404") || message.includes("not found")) { + return { status: "not_found", interactionId, error: message }; + } + + throw error; // Re-throw unexpected errors + } +} + +// --------------------------------------------------------------------------- +// Extract text from Interaction outputs +// --------------------------------------------------------------------------- + +/** + * Extract text content from Interaction outputs array. + * Outputs are Content_2 items (TextContent, ImageContent, etc.). + * We only care about TextContent items. + */ +function extractTextFromOutputs( + outputs: Interactions.Interaction["outputs"], +): string { + if (!outputs || !Array.isArray(outputs)) return ""; + + const textParts: string[] = []; + for (const output of outputs) { + // TextContent has type: 'text' and text: string + if ( + output && + typeof output === "object" && + "type" in output && + output.type === "text" && + "text" in output + ) { + const textContent = output as Interactions.TextContent; + if (textContent.text) { + textParts.push(textContent.text); + } + } + } + + return textParts.join("\n").trim(); +} + +// --------------------------------------------------------------------------- +// Parse Report → ResearchPayload +// --------------------------------------------------------------------------- + +/** + * Parse a markdown research report into a structured ResearchPayload. + * Uses Gemini Flash to extract structured data. + */ +export async function parseResearchReport( + topic: string, + report: string, +): Promise { + const createdAt = new Date().toISOString(); + + // Use Gemini Flash to extract structured data from the report + const extractionPrompt = `You are a content analyst. Extract structured data from this research report for a web development video script. + +RESEARCH REPORT: +${report.slice(0, 30000)} + +Extract and return ONLY valid JSON (no markdown fences): +{ + "briefing": "A 2-3 paragraph executive summary of the key findings", + "sources": [{"title": "Source title", "url": "https://...", "type": "article|docs|youtube|unknown"}], + "talkingPoints": ["Key point 1", "Key point 2", ...], + "codeExamples": [{"snippet": "code here", "language": "typescript", "context": "What this code demonstrates"}], + "comparisonData": [{"leftLabel": "Option A", "rightLabel": "Option B", "rows": [{"left": "feature", "right": "feature"}]}], + "sceneHints": [{"content": "Brief description", "suggestedSceneType": "narration|code|list|comparison|mockup", "reason": "Why this scene type"}] +} + +Rules: +- Extract 5-8 talking points +- Extract ALL code examples from the report (up to 10) +- If the report compares technologies, create comparisonData +- Generate 6-10 scene hints covering the full report +- Sources should include URLs from citations in the report +- Keep the briefing concise but informative`; + + try { + const raw = await generateWithGemini(extractionPrompt); + const cleaned = stripCodeFences(raw); + const parsed = JSON.parse(cleaned) as Record; + + // Build the payload with safe defaults + return { + topic, + notebookId: "", // No notebook — using Gemini Deep Research + createdAt, + completedAt: new Date().toISOString(), + sources: Array.isArray(parsed.sources) + ? (parsed.sources as ResearchSource[]) + : [], + briefing: + typeof parsed.briefing === "string" + ? parsed.briefing + : report.slice(0, 2000), + talkingPoints: Array.isArray(parsed.talkingPoints) + ? (parsed.talkingPoints as string[]) + : [], + codeExamples: Array.isArray(parsed.codeExamples) + ? (parsed.codeExamples as CodeExample[]) + : [], + comparisonData: Array.isArray(parsed.comparisonData) + ? parsed.comparisonData + : undefined, + sceneHints: Array.isArray(parsed.sceneHints) + ? (parsed.sceneHints as SceneHint[]) + : [], + infographicUrls: undefined, // Infographics handled separately + }; + } catch (error) { + console.error( + "[gemini-research] Failed to parse report, using fallback extraction:", + error, + ); + + // Fallback: regex-based extraction (same logic as research.ts helpers) + return buildFallbackPayload(topic, report, createdAt); + } +} + +// --------------------------------------------------------------------------- +// Fallback extraction helpers (regex-based, from research.ts) +// --------------------------------------------------------------------------- + +function classifySourceType( + url: string, +): "youtube" | "article" | "docs" | "unknown" { + if (!url) return "unknown"; + const lower = url.toLowerCase(); + if ( + lower.includes("youtube.com") || + lower.includes("youtu.be") || + lower.includes("youtube") + ) { + return "youtube"; + } + if ( + lower.includes("/docs") || + lower.includes("documentation") || + lower.includes("developer.") || + lower.includes("devdocs") || + lower.includes("mdn") || + lower.includes("spec.") + ) { + return "docs"; + } + if ( + lower.includes("blog") || + lower.includes("medium.com") || + lower.includes("dev.to") || + lower.includes("hashnode") || + lower.includes("article") + ) { + return "article"; + } + return "unknown"; +} + +function extractTalkingPoints(text: string): string[] { + const lines = text.split("\n"); + const points: string[] = []; + + for (const line of lines) { + const cleaned = line.replace(/^[\s]*[-•*\d]+[.)]\s*/, "").trim(); + if (cleaned.length > 20) { + points.push(cleaned); + } + } + + return points.slice(0, 8); +} + +function extractCodeExamples(text: string): CodeExample[] { + const examples: CodeExample[] = []; + const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g; + let match: RegExpExecArray | null; + + while ((match = codeBlockRegex.exec(text)) !== null) { + const language = match[1] || "typescript"; + const snippet = match[2].trim(); + + const beforeBlock = text.slice(0, match.index); + const contextLines = beforeBlock.split("\n").filter((l) => l.trim()); + const context = + contextLines.length > 0 + ? contextLines[contextLines.length - 1].trim() + : "Code example"; + + examples.push({ snippet, language, context }); + } + + return examples; +} + +function classifyScene( + content: string, +): "narration" | "code" | "list" | "comparison" | "mockup" { + // Code blocks + if ( + /```[\s\S]*?```/.test(content) || + /^\s{2,}(const|let|var|function|import|export|class|def|return)\b/m.test( + content, + ) + ) { + return "code"; + } + // Numbered or bulleted lists (3+ items) + const listMatches = content.match(/^[\s]*[-•*\d]+[.)]\s/gm); + if (listMatches && listMatches.length >= 3) { + return "list"; + } + // Comparison language + if ( + /\bvs\.?\b/i.test(content) || + /\bcompare[ds]?\b/i.test(content) || + /\bdifference[s]?\b/i.test(content) || + /\bpros\s+(and|&)\s+cons\b/i.test(content) + ) { + return "comparison"; + } + // UI / mockup language + if ( + /\b(UI|interface|dashboard|screen|layout|component|widget|button|modal)\b/i.test( + content, + ) + ) { + return "mockup"; + } + return "narration"; +} + +function generateSceneHints(sections: string[]): SceneHint[] { + const hints: SceneHint[] = []; + + for (const section of sections) { + if (!section.trim()) continue; + + const sceneType = classifyScene(section); + const reasonMap: Record = { + code: "Contains code blocks or programming constructs", + list: "Contains a numbered or bulleted list with 3+ items", + comparison: "Contains comparison language (vs, compare, differences)", + mockup: "Describes UI elements or interface components", + narration: "General explanatory content best suited for narration", + }; + + hints.push({ + content: section.slice(0, 500), + suggestedSceneType: sceneType, + reason: reasonMap[sceneType], + }); + } + + return hints; +} + +function extractSourcesFromReport(report: string): ResearchSource[] { + const sources: ResearchSource[] = []; + // Match markdown links: [title](url) + const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g; + let match: RegExpExecArray | null; + const seenUrls = new Set(); + + while ((match = linkRegex.exec(report)) !== null) { + const url = match[2]; + if (seenUrls.has(url)) continue; + seenUrls.add(url); + + sources.push({ + title: match[1], + url, + type: classifySourceType(url), + }); + } + + return sources; +} + +function buildFallbackPayload( + topic: string, + report: string, + createdAt: string, +): ResearchPayload { + const talkingPoints = extractTalkingPoints(report); + const codeExamples = extractCodeExamples(report); + const sources = extractSourcesFromReport(report); + + // Generate scene hints from report sections + const sections = report + .split(/\n(?=#{1,3}\s)|\n\n/) + .filter((s) => s.trim().length > 50); + const sceneHints = generateSceneHints(sections); + + return { + topic, + notebookId: "", // No notebook — using Gemini Deep Research + createdAt, + completedAt: new Date().toISOString(), + sources, + briefing: report.slice(0, 2000), + talkingPoints, + codeExamples, + sceneHints, + infographicUrls: undefined, + }; +} + +// --------------------------------------------------------------------------- +// Full pipeline: submit → poll → parse +// --------------------------------------------------------------------------- + +/** + * Run the full Gemini Deep Research pipeline for a topic. + * This is the high-level function called by the ingest route. + */ +export async function conductGeminiResearch( + topic: string, + config?: GeminiResearchConfig, +): Promise { + const timeout = config?.researchTimeout ?? 1_200_000; // 20 min + const pollInterval = config?.pollInterval ?? 15_000; // 15s + + // Submit + const interactionId = await submitResearch(topic, config); + + // Poll until complete + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const status = await pollResearch(interactionId); + + if (status.status === "completed" && status.report) { + console.log( + `[gemini-research] Research completed (${Math.round((Date.now() - startTime) / 1000)}s)`, + ); + return parseResearchReport(topic, status.report); + } + + if (status.status === "failed") { + throw new Error(`[gemini-research] Research failed: ${status.error}`); + } + + if (status.status === "not_found") { + throw new Error( + `[gemini-research] Interaction not found: ${interactionId}`, + ); + } + + await new Promise((r) => setTimeout(r, pollInterval)); + } + + throw new Error(`[gemini-research] Research timed out after ${timeout}ms`); +} From e50201fe38a928df81a80160fd083692fda10c24 Mon Sep 17 00:00:00 2001 From: Miriad Date: Thu, 5 Mar 2026 19:16:55 +0000 Subject: [PATCH 2/3] feat: add Gemini infographic generation service (Imagen 4 Fast) --- lib/services/gemini-infographics.ts | 200 ++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 lib/services/gemini-infographics.ts diff --git a/lib/services/gemini-infographics.ts b/lib/services/gemini-infographics.ts new file mode 100644 index 00000000..cdef1d29 --- /dev/null +++ b/lib/services/gemini-infographics.ts @@ -0,0 +1,200 @@ +/** + * Gemini Infographic Generation Service + * + * Generates brand-consistent infographics using Google's Imagen 4 Fast model + * via the @google/genai SDK. Designed for the CodingCat.dev automated video + * pipeline — produces visual assets from research data for use in videos and + * blog posts. + * + * Pricing: Imagen 4 Fast — $0.02/image + * Supports seed-based reproducibility for brand consistency. + * + * @module lib/services/gemini-infographics + */ + +import { GoogleGenAI } from "@google/genai"; +import { getConfigValue } from "@/lib/config"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A single generated infographic result. */ +export interface InfographicResult { + /** Base64-encoded PNG image bytes. */ + imageBase64: string; + /** MIME type — always "image/png" for Imagen. */ + mimeType: string; + /** The prompt used to generate this image. */ + prompt: string; + /** Seed used (if provided), for reproducibility. */ + seed?: number; +} + +/** Options for a single infographic generation request. */ +export interface InfographicRequest { + /** Text prompt describing the infographic to generate. */ + prompt: string; + /** Aspect ratio. Defaults to "16:9" for video pipeline use. */ + aspectRatio?: "1:1" | "3:4" | "4:3" | "9:16" | "16:9"; + /** Optional seed for reproducibility (not compatible with watermarks). */ + seed?: number; + /** Negative prompt — what to avoid in the image. */ + negativePrompt?: string; +} + +/** Options for batch infographic generation. */ +export interface InfographicBatchOptions { + /** Override the Imagen model (defaults to pipeline_config.infographicModel or "imagen-4-fast"). */ + model?: string; + /** Number of images per prompt (1–4). Defaults to 1. */ + numberOfImages?: number; +} + +/** Result of a batch generation run. */ +export interface InfographicBatchResult { + /** Successfully generated infographics. */ + results: InfographicResult[]; + /** Prompts that failed, with error messages. */ + errors: Array<{ prompt: string; error: string }>; +} + +// --------------------------------------------------------------------------- +// Internal: lazy client +// --------------------------------------------------------------------------- + +let _ai: GoogleGenAI | null = null; + +function getAI(): GoogleGenAI { + if (!_ai) { + const apiKey = process.env.GEMINI_API_KEY ?? ""; + _ai = new GoogleGenAI({ apiKey }); + } + return _ai; +} + +// --------------------------------------------------------------------------- +// Core: single image generation +// --------------------------------------------------------------------------- + +/** + * Generate a single infographic image using Imagen 4 Fast. + * + * @param request - Prompt and generation options. + * @param model - Imagen model ID (e.g. "imagen-4-fast"). + * @returns InfographicResult with base64 image bytes. + * @throws If the API call fails or no image is returned. + */ +export async function generateInfographic( + request: InfographicRequest, + model: string = "imagen-4-fast", +): Promise { + const ai = getAI(); + + const response = await ai.models.generateImages({ + model, + prompt: request.prompt, + config: { + numberOfImages: 1, + aspectRatio: request.aspectRatio ?? "16:9", + ...(request.seed !== undefined && { seed: request.seed }), + ...(request.negativePrompt && { negativePrompt: request.negativePrompt }), + }, + }); + + const generated = response.generatedImages?.[0]; + if (!generated?.image?.imageBytes) { + const reason = generated?.raiFilteredReason ?? "unknown"; + throw new Error( + `Imagen returned no image for prompt "${request.prompt.slice(0, 80)}…" — RAI reason: ${reason}`, + ); + } + + const imageBytes = generated.image.imageBytes; + // imageBytes may be a Uint8Array or base64 string depending on SDK version + const imageBase64 = + typeof imageBytes === "string" + ? imageBytes + : Buffer.from(imageBytes).toString("base64"); + + return { + imageBase64, + mimeType: "image/png", + prompt: request.prompt, + ...(request.seed !== undefined && { seed: request.seed }), + }; +} + +// --------------------------------------------------------------------------- +// Batch: generate multiple infographics +// --------------------------------------------------------------------------- + +/** + * Generate a batch of infographics from an array of prompts. + * + * Processes requests sequentially to avoid rate-limit issues. + * Failed individual images are collected in `errors` rather than throwing. + * + * @param requests - Array of infographic requests (prompts + options). + * @param options - Batch-level options (model override, numberOfImages). + * @returns InfographicBatchResult with successes and failures. + * + * @example + * ```ts + * const { results, errors } = await generateInfographicBatch([ + * { prompt: "A clean infographic showing React hooks lifecycle, dark theme, CodingCat.dev branding" }, + * { prompt: "Comparison chart: REST vs GraphQL vs tRPC, developer-friendly, purple accent" }, + * ]); + * console.log(`Generated ${results.length} images, ${errors.length} failed`); + * ``` + */ +export async function generateInfographicBatch( + requests: InfographicRequest[], + options: InfographicBatchOptions = {}, +): Promise { + const model = + options.model ?? + (await getConfigValue("pipeline_config", "infographicModel", "imagen-4-fast")); + + const results: InfographicResult[] = []; + const errors: Array<{ prompt: string; error: string }> = []; + + for (const request of requests) { + try { + const result = await generateInfographic(request, model); + results.push(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push({ prompt: request.prompt, error: message }); + } + } + + return { results, errors }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a brand-consistent infographic prompt for CodingCat.dev. + * + * Wraps a topic description with standard brand guidelines so all generated + * infographics share a consistent visual identity. + * + * @param topic - The subject matter (e.g. "React Server Components"). + * @param style - Visual style hint (default: "dark tech"). + * @returns A fully-formed Imagen prompt string. + */ +export function buildInfographicPrompt( + topic: string, + style: string = "dark tech", +): string { + return ( + `Create a professional, visually striking infographic about: ${topic}. ` + + `Style: ${style}, purple and teal accent colors, clean sans-serif typography, ` + + `CodingCat.dev brand aesthetic. ` + + `Layout: structured sections with icons, data visualizations, and clear hierarchy. ` + + `No watermarks. High information density. Developer audience.` + ); +} From 6236f37098178491b86b474784701c9432322c68 Mon Sep 17 00:00:00 2001 From: Miriad Date: Thu, 5 Mar 2026 19:19:52 +0000 Subject: [PATCH 3/3] feat: add topic-level API with config-driven instructions and deterministic seeds - generateInfographicsForTopic() reads instructions from contentConfig - Deterministic seeds from topic+index for brand consistency - DEFAULT_INSTRUCTIONS with blueprint-style prompts from spec - Appends topic context to each instruction prompt --- lib/services/gemini-infographics.ts | 83 +++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/lib/services/gemini-infographics.ts b/lib/services/gemini-infographics.ts index cdef1d29..c7c8639f 100644 --- a/lib/services/gemini-infographics.ts +++ b/lib/services/gemini-infographics.ts @@ -198,3 +198,86 @@ export function buildInfographicPrompt( `No watermarks. High information density. Developer audience.` ); } + +// --------------------------------------------------------------------------- +// Default instructions (blueprint style from spec) +// --------------------------------------------------------------------------- + +/** Default infographic instructions if Sanity contentConfig is not set up */ +const DEFAULT_INSTRUCTIONS: string[] = [ + 'Create a technical architecture sketch using white "hand-drawn" ink lines on a deep navy blue background (#003366). Use rough-sketched server and database icons with visible "marker" strokes, handwritten labels in a casual font, and a subtle grid pattern. Style: blueprint meets whiteboard doodle.', + 'Create a comparison chart on a vibrant blue background (#004080) with hand-inked white headers and uneven, sketchy borders. Use white cross-hatching and doodle-style checkmarks to highlight feature differences. Include hand-drawn arrows and annotations. Style: technical chalkboard.', + 'Create a step-by-step workflow "blueprint" on a dark blue canvas (#003366). Use hand-drawn white arrows connecting rough-sketched boxes, simple "stick-figure" style worker avatars, and handwritten-style labels with a slight chalk texture. Add a subtle grid background. Style: engineering whiteboard.', + 'Create a hand-sketched timeline using a jagged white line on a royal blue background. Represent milestones with simple, iconic white doodles that look like they were quickly sketched during a brainstorming session. Use handwritten dates and labels. Style: notebook sketch on blue paper.', + 'Create a pros and cons summary with a "lo-fi" aesthetic. Use hand-drawn white thumbs-up/down icons and rough-sketched containers on a deep blue background (#003366). Add hand-drawn underlines and circled keywords. Style: high-contrast ink-on-blueprint with cyan accent highlights.', +]; + +// --------------------------------------------------------------------------- +// High-level API: generate all infographics for a topic +// --------------------------------------------------------------------------- + +/** + * Generate a deterministic seed from a topic + instruction index. + * Ensures the same topic always produces the same seed per instruction, + * enabling brand-consistent regeneration. + */ +function generateSeed(topic: string, index: number): number { + let hash = 0; + const str = `${topic}-infographic-${index}`; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return Math.abs(hash) % 2147483647; +} + +/** + * Generate all infographics for a research topic using instructions + * from Sanity contentConfig. + * + * This is the main entry point for the check-research cron route. + * Reads infographic instructions from contentConfig.infographicInstructions, + * appends topic context to each, and generates images with deterministic + * seeds for brand consistency. + * + * @param topic - The research topic (e.g. "React Server Components") + * @param briefing - Optional research briefing text for additional context + * @returns InfographicBatchResult with generated images and any errors + */ +export async function generateInfographicsForTopic( + topic: string, + briefing?: string, +): Promise { + const instructions = await getConfigValue( + "content_config", "infographicInstructions", DEFAULT_INSTRUCTIONS + ); + + const model = await getConfigValue( + "pipeline_config", "infographicModel", "imagen-4-fast" + ); + + const contextSuffix = briefing + ? `\n\nTopic: ${topic}\nContext: ${briefing.slice(0, 500)}` + : `\n\nTopic: ${topic}`; + + const requests: InfographicRequest[] = instructions.map( + (instruction, index) => ({ + prompt: `${instruction}${contextSuffix}`, + seed: generateSeed(topic, index), + aspectRatio: "16:9" as const, + }) + ); + + console.log( + `[infographics] Generating ${requests.length} infographics for "${topic}" with ${model}` + ); + + const result = await generateInfographicBatch(requests, { model }); + + console.log( + `[infographics] Complete: ${result.results.length} generated, ${result.errors.length} failed` + ); + + return result; +}