Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m12s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m59s
Monorepo Pipeline / 🏗️ Build (push) Successful in 6m52s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
991 lines
32 KiB
TypeScript
991 lines
32 KiB
TypeScript
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-2.5-flash",
|
||
ROUTING: "google/gemini-2.5-flash",
|
||
CONTENT: "google/gemini-2.5-pro",
|
||
// Mermaid diagram generation - User requested Pro
|
||
DIAGRAM: "google/gemini-2.5-pro",
|
||
} 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<GeneratedPost> {
|
||
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<string> {
|
||
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:
|
||
<div className="my-8 p-6 bg-slate-50 border-l-4 border-blue-600 rounded-r-xl">
|
||
<H3>TL;DR: Warum Ihr Geld verbrennt</H3>
|
||
<ul className="list-disc pl-5 space-y-2 mb-0">
|
||
<li>Punkt 1</li>
|
||
<li>Punkt 2</li>
|
||
<li>Punkt 3</li>
|
||
</ul>
|
||
</div>`,
|
||
},
|
||
{
|
||
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<GeneratedPost> {
|
||
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: `<div className="my-8">\n <Mermaid id="${diagramId}" title="${plan.concept}" showShare={true}>\n${mermaidCode}\n </Mermaid>\n</div>`,
|
||
});
|
||
}
|
||
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: `<div className="my-8">\n <ArticleMeme template="${meme.template}" captions="${captionsStr}" />\n</div>`,
|
||
});
|
||
}
|
||
}
|
||
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 <ArticleMeme> 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 (<Mermaid>, <ArticleMeme>, <StatsGrid>, <BoldNumber>, 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 = [
|
||
"<Mermaid",
|
||
"<ArticleMeme",
|
||
"<StatsGrid",
|
||
"<StatsDisplay",
|
||
"<BoldNumber",
|
||
"<MetricBar",
|
||
"<ComparisonRow",
|
||
"<PremiumComparisonChart",
|
||
"<DiagramFlow",
|
||
"<DiagramPie",
|
||
"<DiagramGantt",
|
||
"<DiagramState",
|
||
"<DiagramSequence",
|
||
"<DiagramTimeline",
|
||
"<Carousel",
|
||
"<WebVitalsScore",
|
||
"<WaterfallChart",
|
||
];
|
||
const isVisual = (content: string) =>
|
||
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<string> {
|
||
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<Insertion[]> {
|
||
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 <Paragraph> 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: <Paragraph>, <Marker> for emphasis
|
||
- Cite sources using ExternalLink: <ExternalLink href="URL">Source: Name</ExternalLink>
|
||
- 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": "<Paragraph>\\n Fact-enriched paragraph text. [Source: Name]\\n</Paragraph>" }] }
|
||
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<Insertion[]> {
|
||
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 -> <YouTubeEmbed videoId="ID" />
|
||
- twitter -> <TwitterEmbed tweetId="ID" theme="light" />
|
||
- linkedin -> <LinkedInEmbed urn="ID" />
|
||
- 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": "<Paragraph>Wie Experten passend bemerken:</Paragraph>\\n\\n<TwitterEmbed tweetId=\\"123456\\" theme=\\"light\\" />" }] }
|
||
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<Insertion[]> {
|
||
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": "<StatsDisplay value=\\"100\\" label=\\"PageSpeed Score\\" subtext=\\"Kein Kompromiss.\\" />" }] }
|
||
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("<Mermaid") || fullContent.includes("<Diagram");
|
||
|
||
const response = await this.openai.chat.completions.create({
|
||
model: MODELS.STRUCTURED,
|
||
messages: [
|
||
{
|
||
role: "system",
|
||
content: `Analyze this German blog post and suggest 1-2 Mermaid diagrams.
|
||
${hasDiagrams ? "The post already has diagrams. Only suggest NEW concepts not already visualized." : ""}
|
||
${context.slice(0, 1500)}
|
||
|
||
SECTIONS:
|
||
${sectionMap}
|
||
|
||
Return JSON:
|
||
{ "diagrams": [{ "afterSection": 5, "concept": "Descriptive concept name" }] }
|
||
Maximum 2 diagrams. Return ONLY the JSON.`,
|
||
},
|
||
],
|
||
response_format: { type: "json_object" },
|
||
});
|
||
|
||
const result = safeParseJSON(
|
||
response.choices[0].message.content || '{"diagrams": []}',
|
||
{ diagrams: [] },
|
||
);
|
||
return (result.diagrams || []).filter(
|
||
(d: any) =>
|
||
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<number[]> {
|
||
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<string> {
|
||
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 \`<ComparisonRow>\` oder \`<IconList>\`), 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: <!-- DIAGRAM_PLACEHOLDER: Concept Name -->
|
||
Return ONLY raw content.`,
|
||
},
|
||
],
|
||
});
|
||
return response.choices[0].message.content || "";
|
||
}
|
||
|
||
private async processDiagramPlaceholders(
|
||
content: string,
|
||
diagrams: string[],
|
||
): Promise<string> {
|
||
const matches = content.matchAll(/<!-- DIAGRAM_PLACEHOLDER: (.+?) -->/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<div className="my-8">\n <Mermaid id="${diagramId}" title="${concept}" showShare={true}>\n${diagram}\n </Mermaid>\n</div>\n`;
|
||
processedContent = processedContent.replace(
|
||
`<!-- DIAGRAM_PLACEHOLDER: ${concept} -->`,
|
||
mermaidJsx,
|
||
);
|
||
}
|
||
return processedContent;
|
||
}
|
||
|
||
private async generateMermaid(concept: string): Promise<string> {
|
||
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 <br>, no <br/>, no <b>, 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<string[]> {
|
||
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;
|
||
}
|
||
}
|
||
}
|