import axios, { AxiosError } from 'axios';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
import type { GeneratedContent, InstagramContent } from '../../types/index.js';
import { appConfig } from '../../core/config/config.js';
import { retry } from '../../core/retry.js';
import { logger } from '../../core/logger.js';
import { getCache, setCache } from '../../core/cache.js';
import { checkRateLimit, getRateLimitInfo } from '../../core/rate-limit.js';
import { RateLimitError } from '../../core/errors.js';
import { API_ENDPOINTS, CACHE_CONFIG, RATE_LIMITS } from '../../core/constants/index.js';
// Get directory path for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface OpenAIResponse {
choices: Array<{
message: {
content: string;
};
}>;
}
/**
* Content Generator Service
*/
export class ContentGenerator {
private openaiApiKey: string | undefined;
constructor() {
this.openaiApiKey = appConfig.getOpenAIApiKey();
}
/**
* Load content generation prompt from file
*
* The prompt is loaded from prompts/content-prompt.txt (ignored by git).
* If the file doesn't exist, it falls back to prompts/content-prompt.example.txt.
*
* To customize the prompt:
* 1. Copy prompts/content-prompt.example.txt to prompts/content-prompt.txt
* 2. Edit prompts/content-prompt.txt with your custom prompt
*
* @returns System prompt for content generation
*/
private loadPrompt(): string {
const projectRoot = path.resolve(__dirname, '../../..');
const promptPath = path.join(projectRoot, 'prompts', 'content-prompt.txt');
const examplePromptPath = path.join(projectRoot, 'prompts', 'content-prompt.example.txt');
try {
// Try to load custom prompt first
if (fs.existsSync(promptPath)) {
return fs.readFileSync(promptPath, 'utf-8').trim();
}
// Fall back to example prompt
if (fs.existsSync(examplePromptPath)) {
logger.warn('Using example prompt. To customize, copy prompts/content-prompt.example.txt to prompts/content-prompt.txt');
return fs.readFileSync(examplePromptPath, 'utf-8').trim();
}
throw new Error('Prompt file not found');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to load content generation prompt: ${errorMessage}. ` +
`Make sure prompts/content-prompt.txt or prompts/content-prompt.example.txt exists. ` +
`See prompts/content-prompt.example.txt for the prompt template.`
);
}
}
/**
* Generate cache key from image path and context
*/
private generateCacheKey(imagePath: string, userContext: string): string {
const hash = crypto.createHash('sha256');
hash.update(imagePath);
hash.update(userContext);
return `content:${hash.digest('hex')}`;
}
/**
* Generate content using OpenAI API
*/
private async generateWithAI(imagePath: string, userContext: string): Promise<GeneratedContent> {
try {
if (!this.openaiApiKey) {
throw new Error('OpenAI API key not configured');
}
// Check cache first
const cacheKey = this.generateCacheKey(imagePath, userContext);
const cached = getCache<GeneratedContent>(cacheKey);
if (cached) {
logger.debug('Content retrieved from cache', { cacheKey });
return cached;
}
// Check rate limit
const allowed = checkRateLimit('openai', RATE_LIMITS.OPENAI_API.REQUESTS_PER_MINUTE, 60 * 1000);
if (!allowed) {
const info = getRateLimitInfo('openai', RATE_LIMITS.OPENAI_API.REQUESTS_PER_MINUTE);
throw new RateLimitError(
`OpenAI API rate limit exceeded. Reset in ${Math.ceil((info?.resetIn || 60000) / 1000)}s`,
info?.resetIn || 60000
);
}
// Check if imagePath is a URL or local file
let imageUrl: string;
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
// Already a URL
imageUrl = imagePath;
} else {
// Local file - read and convert to base64
const imageBuffer = fs.readFileSync(imagePath);
const base64Image = imageBuffer.toString('base64');
imageUrl = `data:image/jpeg;base64,${base64Image}`;
}
// Load prompt from file
// See loadPrompt() method and prompts/content-prompt.example.txt for customization
const systemPrompt = this.loadPrompt();
const response = await retry(async () => {
return await axios.post<OpenAIResponse>(
`${API_ENDPOINTS.OPENAI_API_BASE}/chat/completions`,
{
model: 'gpt-4o',
messages: [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: [
{
type: 'text',
text: `Generate content for this image. ${userContext ? `User context: ${userContext}` : ''}`
},
{
type: 'image_url',
image_url: {
url: imageUrl.startsWith('data:') ? imageUrl : imageUrl
}
}
]
}
],
max_tokens: 1000
},
{
headers: {
'Authorization': `Bearer ${this.openaiApiKey}`,
'Content-Type': 'application/json'
}
}
);
}, 3, 1000);
const contentString = response.data.choices[0]?.message?.content;
if (!contentString) {
throw new Error('No content received from OpenAI');
}
// Clean up markdown code blocks if present
let cleanedContent = contentString.trim();
if (cleanedContent.startsWith('```json')) {
cleanedContent = cleanedContent.replace(/^```json\s*/, '').replace(/\s*```$/, '');
} else if (cleanedContent.startsWith('```')) {
cleanedContent = cleanedContent.replace(/^```\s*/, '').replace(/\s*```$/, '');
}
// Extract JSON from text if it's embedded in explanatory text
// Look for JSON object starting with {
const jsonMatch = cleanedContent.match(/\{[\s\S]*\}/);
if (jsonMatch) {
cleanedContent = jsonMatch[0];
}
// Check if response looks like an error message instead of JSON
if (cleanedContent.startsWith('I\'m sorry') ||
cleanedContent.startsWith('I am sorry') ||
cleanedContent.startsWith('Sorry') ||
cleanedContent.startsWith('Error')) {
throw new Error(`OpenAI returned a text response instead of JSON. Response: ${cleanedContent.substring(0, 200)}...`);
}
// If still doesn't start with {, try to find JSON in the content
if (!cleanedContent.startsWith('{')) {
const jsonMatch2 = cleanedContent.match(/\{[\s\S]*\}/);
if (jsonMatch2) {
cleanedContent = jsonMatch2[0];
} else {
throw new Error(`OpenAI returned a text response instead of JSON. Response: ${cleanedContent.substring(0, 200)}...`);
}
}
let generatedContent: GeneratedContent;
try {
generatedContent = JSON.parse(cleanedContent) as GeneratedContent;
} catch (parseError) {
// If JSON parsing fails, show the actual content for debugging
const parseErrorMessage = parseError instanceof Error ? parseError.message : String(parseError);
throw new Error(`Failed to parse OpenAI response as JSON. Response content: ${cleanedContent.substring(0, 500)}... Original error: ${parseErrorMessage}`);
}
// Cache the result
setCache(cacheKey, generatedContent, CACHE_CONFIG.CONTENT_GENERATION_TTL_MS);
return generatedContent;
} catch (error) {
// If it's already our custom error, re-throw it with more context
if (error instanceof Error && (error.message.includes('OpenAI returned') || error.message.includes('Failed to parse'))) {
const baseMessage = 'Failed to generate content with OpenAI.';
throw new Error(`${baseMessage} ${error.message}`);
}
const axiosError = error as AxiosError;
const status = axiosError.response?.status;
const statusText = axiosError.response?.statusText;
const rawData = axiosError.response?.data;
const serializedData = rawData ? JSON.stringify(rawData) : undefined;
const baseMessage = 'Failed to generate content with OpenAI. Please check OPENAI_API_KEY, model name and request limits.';
logger.error(baseMessage, error instanceof Error ? error : new Error(String(error)), {
status,
statusText,
details: serializedData || axiosError.message,
});
throw new Error(
`${baseMessage} Status: ${status ?? 'unknown'}. Details: ${serializedData || axiosError.message}.`,
);
}
}
/**
* Generate content for Pinterest and Instagram from image
*/
async generateContent(imagePath: string, userContext: string = ''): Promise<GeneratedContent> {
if (!this.openaiApiKey) {
throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY environment variable to generate content.');
}
// Generate content using AI
return await this.generateWithAI(imagePath, userContext);
}
/**
* Format Instagram caption with hashtags
*/
formatInstagramCaption(instagramContent: InstagramContent): string {
return `${instagramContent.caption}\n\n${instagramContent.softCta}\n\n${instagramContent.hashtags}`;
}
}
// Export singleton instance
export const contentGenerator = new ContentGenerator();