import OpenAI from "openai"; import { ResearchAgent, type Fact, type SocialPost } from "@mintel/journaling"; import { MemeGenerator, MemeSuggestion } from "@mintel/meme-generator"; import * as fs from "node:fs/promises"; import * as path from "node:path"; export interface ComponentDefinition { name: string; description: string; usageExample: string; } export interface BlogPostOptions { topic: string; tone?: string; targetAudience?: string; includeMemes?: boolean; includeDiagrams?: boolean; includeResearch?: boolean; availableComponents?: ComponentDefinition[]; } export interface OptimizationOptions { enhanceFacts?: boolean; addMemes?: boolean; addDiagrams?: boolean; availableComponents?: ComponentDefinition[]; projectContext?: string; /** Target audience description for all AI prompts */ targetAudience?: string; /** Tone/persona description for all AI prompts */ tone?: string; /** Prompt for DALL-E 3 style generation */ memeStylePrompt?: string; /** Path to the docs folder (e.g. apps/web/docs) for full persona/tone context */ docsPath?: string; } export interface GeneratedPost { title: string; content: string; research: Fact[]; memes: MemeSuggestion[]; diagrams: string[]; } interface Insertion { afterSection: number; content: string; } // Model configuration: specialized models for different tasks const MODELS = { // Structured JSON output, research planning, diagram models: { STRUCTURED: "google/gemini-3-flash-preview", ROUTING: "google/gemini-3-flash-preview", CONTENT: "google/gemini-3.1-pro-preview", // Mermaid diagram generation - User requested Pro DIAGRAM: "google/gemini-3.1-pro-preview", } as const; /** Strip markdown fences that some models wrap around JSON despite response_format */ function safeParseJSON(raw: string, fallback: any = {}): any { let cleaned = raw.trim(); // Remove ```json ... ``` or ``` ... ``` wrapping if (cleaned.startsWith("```")) { cleaned = cleaned .replace(/^```(?:json)?\s*\n?/, "") .replace(/\n?```\s*$/, ""); } try { return JSON.parse(cleaned); } catch (e) { console.warn( "⚠️ Failed to parse JSON response, using fallback:", (e as Error).message, ); return fallback; } } export class ContentGenerator { private openai: OpenAI; private researchAgent: ResearchAgent; private memeGenerator: MemeGenerator; constructor(apiKey: string) { this.openai = new OpenAI({ apiKey, baseURL: "https://openrouter.ai/api/v1", defaultHeaders: { "HTTP-Referer": "https://mintel.me", "X-Title": "Mintel Content Engine", }, }); this.researchAgent = new ResearchAgent(apiKey); this.memeGenerator = new MemeGenerator(apiKey); } // ========================================================================= // generatePost — for new posts (unchanged from original) // ========================================================================= async generatePost(options: BlogPostOptions): Promise { const { topic, tone = "professional yet witty", includeResearch = true, availableComponents = [], } = options; console.log(`🚀 Starting content generation for: "${topic}"`); let facts: Fact[] = []; if (includeResearch) { console.log("📚 Gathering research..."); facts = await this.researchAgent.researchTopic(topic); } console.log("📝 Creating outline..."); const outline = await this.createOutline(topic, facts, tone); console.log("✍️ Drafting content..."); let content = await this.draftContent( topic, outline, facts, tone, availableComponents, ); const diagrams: string[] = []; if (options.includeDiagrams) { content = await this.processDiagramPlaceholders(content, diagrams); } const memes: MemeSuggestion[] = []; if (options.includeMemes) { const memeIdeas = await this.memeGenerator.generateMemeIdeas( content.slice(0, 4000), ); memes.push(...memeIdeas); } return { title: outline.title, content, research: facts, memes, diagrams }; } // ========================================================================= // generateTldr — Creates a TL;DR block for the given content // ========================================================================= async generateTldr(content: string): Promise { const context = content.slice(0, 3000); const response = await this.openai.chat.completions.create({ model: MODELS.CONTENT, messages: [ { role: "system", content: `Du bist ein kompromissloser Digital Architect. Erstelle ein "TL;DR" für diesen Artikel. REGELN: - 3 knackige Bulletpoints - TON: Sarkastisch, direkt, provokant ("Finger in die Wunde") - Fokussiere auf den wirtschaftlichen Schaden von schlechter Tech - Formatiere als MDX-Komponente:

TL;DR: Warum Ihr Geld verbrennt

  • Punkt 1
  • Punkt 2
  • Punkt 3
`, }, { role: "user", content: context, }, ], }); return response.choices[0].message.content?.trim() ?? ""; } // ========================================================================= // optimizePost — ADDITIVE architecture (never rewrites original content) // ========================================================================= async optimizePost( content: string, options: OptimizationOptions, ): Promise { console.log("🚀 Optimizing existing content (additive mode)..."); // Load docs context if provided let docsContext = ""; if (options.docsPath) { docsContext = await this.loadDocsContext(options.docsPath); console.log(`📖 Loaded ${docsContext.length} chars of docs context`); } const fullContext = [options.projectContext || "", docsContext] .filter(Boolean) .join("\n\n---\n\n"); // Split content into numbered sections for programmatic insertion const sections = this.splitIntoSections(content); console.log(`📋 Content has ${sections.length} sections`); const insertions: Insertion[] = []; const facts: Fact[] = []; const diagrams: string[] = []; const memes: MemeSuggestion[] = []; // Build a numbered content map for LLM reference (read-only) const sectionMap = this.buildSectionMap(sections); // ----- STEP 1: Research ----- if (options.enhanceFacts) { console.log("🔍 Identifying research topics..."); const researchTopics = await this.identifyResearchTopics( content, fullContext, ); console.log(`📚 Researching: ${researchTopics.join(", ")}`); for (const topic of researchTopics) { const topicFacts = await this.researchAgent.researchTopic(topic); facts.push(...topicFacts); } if (facts.length > 0) { console.log(`📝 Planning fact insertions for ${facts.length} facts...`); const factInsertions = await this.planFactInsertions( sectionMap, sections, facts, fullContext, ); insertions.push(...factInsertions); console.log(` → ${factInsertions.length} fact enrichments planned`); } // ----- STEP 1.5: Social Media Extraction (no LLM — regex only) ----- console.log("📱 Extracting existing social media embeds..."); const socialPosts = this.researchAgent.extractSocialPosts(content); // If none exist, fetch real ones via Serper API if (socialPosts.length === 0) { console.log( " → None found. Fetching real social posts via Serper API...", ); const newPosts = await this.researchAgent.fetchRealSocialPosts( content.slice(0, 500), ); socialPosts.push(...newPosts); } if (socialPosts.length > 0) { console.log( `📝 Planning placement for ${socialPosts.length} social media posts...`, ); const socialInsertions = await this.planSocialMediaInsertions( sectionMap, sections, socialPosts, fullContext, ); insertions.push(...socialInsertions); console.log( ` → ${socialInsertions.length} social embeddings planned`, ); } } // ----- STEP 2: Component suggestions ----- if (options.availableComponents && options.availableComponents.length > 0) { console.log("🧩 Planning component additions..."); const componentInsertions = await this.planComponentInsertions( sectionMap, sections, options.availableComponents, fullContext, ); insertions.push(...componentInsertions); console.log( ` → ${componentInsertions.length} component additions planned`, ); } // ----- STEP 3: Diagram generation ----- if (options.addDiagrams) { console.log("📊 Planning diagrams..."); const diagramPlans = await this.planDiagramInsertions( sectionMap, sections, fullContext, ); for (const plan of diagramPlans) { const mermaidCode = await this.generateMermaid(plan.concept); if (!mermaidCode) { console.warn(` ⏭️ Skipping invalid diagram for: "${plan.concept}"`); continue; } diagrams.push(mermaidCode); const diagramId = plan.concept .toLowerCase() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, "") .slice(0, 40); insertions.push({ afterSection: plan.afterSection, content: `
\n \n${mermaidCode}\n \n
`, }); } console.log( ` → ${diagramPlans.length} diagrams planned, ${diagrams.length} valid`, ); } // ----- STEP 4: Meme placement (memegen.link via ArticleMeme) ----- if (options.addMemes) { console.log("✨ Generating meme ideas..."); let memeIdeas = await this.memeGenerator.generateMemeIdeas( content.slice(0, 4000), ); // User requested to explicitly limit memes to max 1 per page to prevent duplication if (memeIdeas.length > 1) { memeIdeas = [memeIdeas[0]]; } memes.push(...memeIdeas); if (memeIdeas.length > 0) { console.log( `🎨 Planning meme placement for ${memeIdeas.length} memes...`, ); const memePlacements = await this.planMemePlacements( sectionMap, sections, memeIdeas, ); for (let i = 0; i < memeIdeas.length; i++) { const meme = memeIdeas[i]; if ( memePlacements[i] !== undefined && memePlacements[i] >= 0 && memePlacements[i] < sections.length ) { const captionsStr = meme.captions.join("|"); insertions.push({ afterSection: memePlacements[i], content: `
\n \n
`, }); } } console.log(` → ${memeIdeas.length} memes placed`); } } // ----- Enforce visual spacing (no consecutive visualizations) ----- this.enforceVisualSpacing(insertions, sections); // ----- Apply all insertions to original content ----- console.log( `\n🔧 Applying ${insertions.length} insertions to original content...`, ); let optimizedContent = this.applyInsertions(sections, insertions); // ----- FINAL AGENTIC REWRITE (Replaces dumb regex scripts) ----- console.log( `\n🧠 Agentic Rewrite: Polishing MDX, fixing syntax, and deduplicating...`, ); const finalRewrite = await this.openai.chat.completions.create({ model: MODELS.CONTENT, messages: [ { role: "system", content: `You are an expert MDX Editor. Your task is to take a draft blog post and output the FINAL, error-free MDX code. CRITICAL RULES: 1. DEDUPLICATION: Ensure there is MAX ONE in the entire post. Remove any duplicates or outdated memes. Ensure there is MAX ONE TL;DR section. Ensure there are no duplicate components. 2. TEXT-TO-COMPONENT RATIO: Ensure there are at least 3-4 paragraphs of normal text between any two visual components (, , , , etc.). If they are clumped together, spread them out or delete the less important ones. 3. SYNTAX: Fix any broken Mermaid/MDX syntax (e.g. unclosed tags, bad quotes). 4. FIDELITY: Preserve the author's original German text, meaning, and tone. Smooth out transitions into the components. 5. NO HALLUCINATION: Do not invent new URLs or facts. Keep the data provided in the draft. 6. OUTPUT: Return ONLY the raw MDX content. No markdown code blocks (\`\`\`mdx), no preamble. Just the raw code file.`, }, { role: "user", content: optimizedContent, }, ], }); optimizedContent = finalRewrite.choices[0].message.content?.trim() || optimizedContent; // Strip any residual markdown formatting fences just in case if (optimizedContent.startsWith("```")) { optimizedContent = optimizedContent .replace(/^```[a-zA-Z]*\n/, "") .replace(/\n```$/, ""); } return { title: "Optimized Content", content: optimizedContent, research: facts, memes, diagrams, }; } // ========================================================================= // ADDITIVE HELPERS — these return JSON instructions, never rewrite content // ========================================================================= private splitIntoSections(content: string): string[] { // Split on double newlines (paragraph/block boundaries in MDX) return content.split(/\n\n+/); } private applyInsertions(sections: string[], insertions: Insertion[]): string { // Sort by section index DESCENDING to avoid index shifting const sorted = [...insertions].sort( (a, b) => b.afterSection - a.afterSection, ); const result = [...sections]; for (const ins of sorted) { const idx = Math.min(ins.afterSection + 1, result.length); result.splice(idx, 0, ins.content); } return result.join("\n\n"); } /** * Enforce visual spacing: visual components must have at least 2 text sections between them. * This prevents walls of visualizations and maintains reading flow. */ private enforceVisualSpacing( insertions: Insertion[], sections: string[], ): void { const visualPatterns = [ " visualPatterns.some((p) => content.includes(p)); // Sort by section ascending insertions.sort((a, b) => a.afterSection - b.afterSection); // Minimum gap of 10 sections between visual components (= ~6-8 text paragraphs) // User requested a better text-to-component ratio (not 1:1) const MIN_VISUAL_GAP = 10; for (let i = 1; i < insertions.length; i++) { if ( isVisual(insertions[i].content) && isVisual(insertions[i - 1].content) ) { const gap = insertions[i].afterSection - insertions[i - 1].afterSection; if (gap < MIN_VISUAL_GAP) { const newPos = Math.min( insertions[i - 1].afterSection + MIN_VISUAL_GAP, sections.length - 1, ); insertions[i].afterSection = newPos; } } } } private buildSectionMap(sections: string[]): string { return sections .map((s, i) => { const preview = s.trim().replace(/\n/g, " ").slice(0, 120); return `[${i}] ${preview}${s.length > 120 ? "…" : ""}`; }) .join("\n"); } private async loadDocsContext(docsPath: string): Promise { try { const files = await fs.readdir(docsPath); const mdFiles = files.filter((f) => f.endsWith(".md")).sort(); const contents: string[] = []; for (const file of mdFiles) { const filePath = path.join(docsPath, file); const text = await fs.readFile(filePath, "utf8"); contents.push(`=== ${file} ===\n${text.trim()}`); } return contents.join("\n\n"); } catch (e) { console.warn(`⚠️ Could not load docs from ${docsPath}: ${e}`); return ""; } } // --- Fact insertion planning (Claude Sonnet — precise content understanding) --- private async planFactInsertions( sectionMap: string, sections: string[], facts: Fact[], context: string, ): Promise { const factsText = facts .map((f, i) => `${i + 1}. ${f.statement} [Source: ${f.source}]`) .join("\n"); const response = await this.openai.chat.completions.create({ model: MODELS.CONTENT, messages: [ { role: "system", content: `You enrich a German blog post by ADDING new paragraphs with researched facts. RULES: - Do NOT rewrite or modify any existing content - Only produce NEW blocks to INSERT after a specific section number - Maximum 5 insertions (only the most impactful facts) - Match the post's tone and style (see context below) - Use the post's JSX components: , for emphasis - Cite sources using ExternalLink: Source: Name - Write in German, active voice, Ich-Form where appropriate CONTEXT (tone, style, persona): ${context.slice(0, 3000)} EXISTING SECTIONS (read-only — do NOT modify these): ${sectionMap} FACTS TO INTEGRATE: ${factsText} Return JSON: { "insertions": [{ "afterSection": 3, "content": "\\n Fact-enriched paragraph text. [Source: Name]\\n" }] } Return ONLY the JSON.`, }, ], response_format: { type: "json_object" }, }); const result = safeParseJSON( response.choices[0].message.content || '{"insertions": []}', { insertions: [] }, ); return (result.insertions || []).filter( (i: any) => typeof i.afterSection === "number" && i.afterSection >= 0 && i.afterSection < sections.length && typeof i.content === "string", ); } // --- Social Media insertion planning --- private async planSocialMediaInsertions( sectionMap: string, sections: string[], posts: SocialPost[], context: string, ): Promise { if (!posts || posts.length === 0) return []; const postsText = posts .map( (p, i) => `[${i}] Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`, ) .join("\n"); const response = await this.openai.chat.completions.create({ model: MODELS.CONTENT, messages: [ { role: "system", content: `You enhance a German blog post by embedding relevant social media posts (YouTube, Twitter, LinkedIn). RULES: - Do NOT rewrite any existing content - Return exactly 1 or 2 high-impact insertions - Choose the best fitting post(s) from the provided list - Use the correct component based on the platform: - youtube -> - twitter -> - linkedin -> - Add a 1-sentence intro paragraph above the embed to contextualize it naturally in the flow of the text (e.g. "Wie Experte XY im folgenden Video detailliert erklärt:"). This context is MANDATORY. Do not just drop the Component without text reference. CONTEXT: ${context.slice(0, 3000)} SOCIAL POSTS AVAILABLE TO EMBED: ${postsText} EXISTING SECTIONS: ${sectionMap} Return JSON: { "insertions": [{ "afterSection": 4, "content": "Wie Experten passend bemerken:\\n\\n" }] } Return ONLY the JSON.`, }, ], response_format: { type: "json_object" }, }); const result = safeParseJSON( response.choices[0].message.content || '{"insertions": []}', { insertions: [] }, ); return (result.insertions || []).filter( (i: any) => typeof i.afterSection === "number" && i.afterSection >= 0 && i.afterSection < sections.length && typeof i.content === "string", ); } // --- Component insertion planning (Claude Sonnet — understands JSX context) --- private async planComponentInsertions( sectionMap: string, sections: string[], components: ComponentDefinition[], context: string, ): Promise { const fullContent = sections.join("\n\n"); const componentsText = components .map((c) => `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`) .join("\n\n"); const usedComponents = components .filter((c) => fullContent.includes(`<${c.name}`)) .map((c) => c.name); const response = await this.openai.chat.completions.create({ model: MODELS.CONTENT, messages: [ { role: "system", content: `You enhance a German blog post by ADDING interactive UI components. STRICT BALANCE RULES: - Maximum 3–4 component additions total - There MUST be at least 3–4 text paragraphs between any two visual components - Visual components MUST NEVER appear directly after each other - Each unique component type should only appear ONCE (e.g., only one WebVitalsScore, one WaterfallChart) - Multiple MetricBar or ComparisonRow in sequence are OK (they form a group) CONTENT RULES: - Do NOT rewrite any existing content — only ADD new component blocks - Do NOT add components already present: ${usedComponents.join(", ") || "none"} - Statistics MUST have comparison context (before/after, competitor vs us) — never standalone numbers - All BoldNumber components MUST include source and sourceUrl props - All ArticleQuote components MUST include source and sourceUrl; add "(übersetzt)" if translated - MetricBar value must be a real number > 0, not placeholder zeros - Carousel items array must have at least 2 items with substantive content - Use exact JSX syntax from the examples CONTEXT: ${context.slice(0, 3000)} EXISTING SECTIONS (read-only): ${sectionMap} AVAILABLE COMPONENTS: ${componentsText} Return JSON: { "insertions": [{ "afterSection": 5, "content": "" }] } Return ONLY the JSON.`, }, ], response_format: { type: "json_object" }, }); const result = safeParseJSON( response.choices[0].message.content || '{"insertions": []}', { insertions: [] }, ); return (result.insertions || []).filter( (i: any) => typeof i.afterSection === "number" && i.afterSection >= 0 && i.afterSection < sections.length && typeof i.content === "string", ); } // --- Diagram planning (Gemini Flash — structured output) --- private async planDiagramInsertions( sectionMap: string, sections: string[], context: string, ): Promise<{ afterSection: number; concept: string }[]> { const fullContent = sections.join("\n\n"); const hasDiagrams = fullContent.includes(" typeof d.afterSection === "number" && d.afterSection >= 0 && d.afterSection < sections.length, ); } // --- Meme placement planning (Gemini Flash — structural positioning) --- private async planMemePlacements( sectionMap: string, sections: string[], memes: MemeSuggestion[], ): Promise { const memesText = memes .map((m, i) => `${i}: "${m.template}" — ${m.captions.join(" / ")}`) .join("\n"); const response = await this.openai.chat.completions.create({ model: MODELS.STRUCTURED, messages: [ { role: "system", content: `Place ${memes.length} memes at appropriate positions in this blog post. Rules: Space them out evenly, place between thematic sections, never at position 0 (the very start). SECTIONS: ${sectionMap} MEMES: ${memesText} Return JSON: { "placements": [sectionNumber, sectionNumber, ...] } One section number per meme, in the same order as the memes list. Return ONLY JSON.`, }, ], response_format: { type: "json_object" }, }); const result = safeParseJSON( response.choices[0].message.content || '{"placements": []}', { placements: [] }, ); return result.placements || []; } // ========================================================================= // SHARED HELPERS // ========================================================================= private async createOutline( topic: string, facts: Fact[], tone: string, ): Promise<{ title: string; sections: string[] }> { const factsContext = facts .map((f) => `- ${f.statement} (${f.source})`) .join("\n"); const response = await this.openai.chat.completions.create({ model: MODELS.STRUCTURED, messages: [ { role: "system", content: `Create a blog post outline on "${topic}". Tone: ${tone}. Incorporating these facts: ${factsContext} Return JSON: { "title": "Catchy Title", "sections": ["Introduction", "Section 1", "Conclusion"] } Return ONLY the JSON.`, }, ], response_format: { type: "json_object" }, }); return safeParseJSON( response.choices[0].message.content || '{"title": "", "sections": []}', { title: "", sections: [] }, ); } private async draftContent( topic: string, outline: { title: string; sections: string[] }, facts: Fact[], tone: string, components: ComponentDefinition[], ): Promise { const factsContext = facts .map((f) => `- ${f.statement} (Source: ${f.source})`) .join("\n"); const componentsContext = components.length > 0 ? `\n\nAvailable Components:\n` + components .map( (c) => `- <${c.name}>: ${c.description}\n Example: ${c.usageExample}`, ) .join("\n") : ""; const response = await this.openai.chat.completions.create({ model: MODELS.CONTENT, messages: [ { role: "system", content: `Write a blog post based on this outline: Title: ${outline.title} Sections: ${outline.sections.join(", ")} Tone: ${tone}. Facts: ${factsContext} ${componentsContext} BLOG POST BEST PRACTICES (MANDATORY): - DEVIL'S ADVOCATE: Füge zwingend eine kurze kritische Sektion ein (z.B. mit \`\` oder \`\`), in der du offen die Nachteile/Kosten/Haken deiner eigenen Lösung ansprichst ("Der Haken an der Sache..."). - FAQ GENERATOR: Am absoluten Ende des Artikels erstellst du zwingend eine Markdown-Liste mit den 3 wichtigsten Fragen (FAQ) und Antworten (jeweils 2 Sätze) für Google Rich Snippets. - Nutze wo passend die obigen React-Komponenten für ein hochwertiges Layout. Format as Markdown. Start with # H1. For places where a diagram would help, insert: Return ONLY raw content.`, }, ], }); return response.choices[0].message.content || ""; } private async processDiagramPlaceholders( content: string, diagrams: string[], ): Promise { const matches = content.matchAll(//g); let processedContent = content; for (const match of Array.from(matches)) { const concept = match[1]; const diagram = await this.generateMermaid(concept); diagrams.push(diagram); const diagramId = concept .toLowerCase() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, "") .slice(0, 40); const mermaidJsx = `\n
\n \n${diagram}\n \n
\n`; processedContent = processedContent.replace( ``, mermaidJsx, ); } return processedContent; } private async generateMermaid(concept: string): Promise { const response = await this.openai.chat.completions.create({ model: MODELS.DIAGRAM, messages: [ { role: "system", content: `Generate a Mermaid.js diagram for: "${concept}". RULES: - Use clear labels in German where appropriate - Keep it EXTREMELY SIMPLE AND COMPACT: strictly max 3-4 nodes for a tiny visual footprint. - Prefer vertical layouts (TD) over horizontal (LR) to prevent wide overflowing graphs. - CRITICAL: Generate ONLY ONE single connected graph. Do NOT generate multiple independent graphs or isolated subgraphs in the same Mermaid block. - No nested subgraphs. Keep instructions short. - Use double-quoted labels for nodes: A["Label"] - VERY CRITICAL: DO NOT use curly braces '{}' or brackets '[]' inside labels unless they are wrapped in double quotes (e.g. A["Text {with braces}"]). - VERY CRITICAL: DO NOT use any HTML tags (no
, no
, no , etc). - VERY CRITICAL: DO NOT use special characters like '&', '<', '>', or double-quotes inside the label strings. They break the mermaid parser in our environment. - Return ONLY the raw mermaid code. No markdown blocks, no backticks. - The first line MUST be a valid mermaid diagram type: graph, flowchart, sequenceDiagram, pie, gantt, stateDiagram, timeline`, }, ], }); const code = response.choices[0].message.content ?.replace(/```mermaid/g, "") .replace(/```/g, "") .trim() || ""; // Validate: must start with a valid mermaid keyword const validStarts = [ "graph", "flowchart", "sequenceDiagram", "pie", "gantt", "stateDiagram", "timeline", "classDiagram", "erDiagram", ]; const firstLine = code.split("\n")[0]?.trim().toLowerCase() || ""; const isValid = validStarts.some((keyword) => firstLine.startsWith(keyword), ); if (!isValid || code.length < 10) { console.warn( `⚠️ Mermaid: Invalid diagram generated for "${concept}", skipping`, ); return ""; } return code; } private async identifyResearchTopics( content: string, context: string, ): Promise { try { console.log("Sending request to OpenRouter..."); const response = await this.openai.chat.completions.create({ model: MODELS.STRUCTURED, messages: [ { role: "system", content: `Analyze the following blog post and identify 3 key topics or claims that would benefit from statistical data or external verification. Return relevant, specific research queries (not too broad). Context: ${context.slice(0, 1500)} Return JSON: { "topics": ["topic 1", "topic 2", "topic 3"] } Return ONLY the JSON.`, }, { role: "user", content: content.slice(0, 4000), }, ], response_format: { type: "json_object" }, }); console.log("Got response from OpenRouter"); const parsed = safeParseJSON( response.choices[0].message.content || '{"topics": []}', { topics: [] }, ); return (parsed.topics || []).map((t: any) => typeof t === "string" ? t : JSON.stringify(t), ); } catch (e: any) { console.error("Error in identifyResearchTopics:", e); throw e; } } }