import type { RedditApiClient, RedditPost } from '../reddit/api.js'; import type { OpenRouterClient, DetectedGender } from '../ai/openrouter.js'; import type { Logger } from '../utils/logger.js'; import type { Config, PhysiqueTargeting } from '../config/index.js'; import type { StrategySelector, SelectedStrategy } from './strategy-selector.js'; import { downloadImage, bufferToBase64, isValidImageMimeType, type DownloadedImage } from '../utils/image.js'; import { parseGenderTag } from '../utils/gender-tag.js'; import { resolveTone, type ToneProfile, type ToneKey } from '../config/tone-profiles.js'; import { evaluatePhysiqueTargeting } from './physique-targeting.js'; export interface ProcessedPost { post: RedditPost; imageAnalysis: string; detectedGender: DetectedGender; attractivenessScore: number | null; physiqueScore: number | null; physiqueTargeting: PhysiqueTargeting | null; sourceImageUrl: string | null; generatedMessage: string; strategyId: string | null; experimentId: string | null; promptVersion: string | null; selectionReason: 'explore' | 'exploit' | null; modelId: string; sourceType: 'image' | 'chat'; toneId: ToneKey; scripted: boolean; } export interface SkipResult { skipReason: string; } export type ProcessResult = ProcessedPost | SkipResult; export function isSkip(result: ProcessResult): result is SkipResult { return 'skipReason' in result; } export type ImageFetcher = (url: string, userAgent: string) => Promise; export type RecentMessageProvider = (sourceType: 'image' | 'chat') => readonly string[]; export class ProcessorService { private successfulExamples: string[] = []; private strategySelector: StrategySelector | null = null; private chatStrategySelector: StrategySelector | null = null; private fetchImage: ImageFetcher; constructor( private redditApi: RedditApiClient, private aiClient: OpenRouterClient, private config: Config, private logger: Logger, fetchImage?: ImageFetcher, private readonly recentMessageProvider: RecentMessageProvider = () => [], ) { this.fetchImage = fetchImage ?? downloadImage; } setStrategySelector(selector: StrategySelector): void { this.strategySelector = selector; if (selector.hasStrategies()) { this.logger.info(`Strategy A/B testing enabled with ${this.config.strategies.variants.length} variants`); } } setChatStrategySelector(selector: StrategySelector): void { this.chatStrategySelector = selector; if (selector.hasStrategies()) { this.logger.info(`Chat strategy A/B testing enabled with ${this.config.chatStrategies.variants.length} variants`); } } private getSubredditType(name: string): 'image' | 'chat' { const entry = this.config.reddit.subreddits.find((s) => s.name.toLowerCase() === name.toLowerCase()); return entry?.type ?? 'image'; } private getSubredditEntry(name: string): NonNullable | undefined { const entry = this.config.reddit.subreddits.find((s) => s.name.toLowerCase() === name.toLowerCase()); return entry ?? undefined; } private resolveTone(name: string): { profile: ToneProfile; scripted: string | null } { const entry = this.getSubredditEntry(name); if (!entry) { return { profile: resolveTone('default'), scripted: null }; } return { profile: resolveTone(entry.tone), scripted: entry.scripted ?? null, }; } setSuccessfulExamples(examples: string[]): void { this.successfulExamples = examples; if (examples.length > 0) { this.logger.info(`Loaded ${examples.length} successful message examples for learning`); } } async processPost(post: RedditPost): Promise { if (this.getSubredditType(post.subreddit) === 'chat') { return this.processChatPost(post); } const imageUrl = this.redditApi.getImageUrl(post); if (!imageUrl) { this.logger.warn(`No image URL found for post ${post.id}`); return { skipReason: 'no image URL on post' }; } this.logger.info(`Processing post: "${post.title.slice(0, 50)}..." by u/${post.author}`); // Download image let imageData; try { imageData = await this.fetchImage(imageUrl, this.config.reddit.userAgent); } catch (error) { const msg = error instanceof Error ? error.message : String(error); this.logger.error(`Failed to download image from ${imageUrl}:`, error); return { skipReason: `image download failed: ${msg}` }; } // Validate image type if (!isValidImageMimeType(imageData.mimeType)) { this.logger.warn(`Invalid image type ${imageData.mimeType} for post ${post.id}`); return { skipReason: `unsupported image type (${imageData.mimeType})` }; } // Check image size (skip very small images like thumbnails) if (imageData.buffer.length < 5000) { this.logger.warn(`Image too small (${imageData.buffer.length} bytes) for post ${post.id}`); return { skipReason: `image too small (${imageData.buffer.length} bytes)` }; } // Convert to base64 const base64 = bufferToBase64(imageData.buffer); // Quick gender check first (saves API costs on non-matching posts) const targetGenders = this.config.reddit.targetGenders; if (targetGenders.length > 0) { const detectedGender = await this.aiClient.detectGenderOnly(base64, imageData.mimeType); if (!targetGenders.includes(detectedGender)) { this.logger.info( `Skipping post ${post.id} - detected gender "${detectedGender}" not in target list [${targetGenders.join(', ')}]` ); return { skipReason: `detected gender "${detectedGender}" not in target list [${targetGenders.join(', ')}]`, }; } this.logger.debug(`Gender check passed: ${detectedGender}`); } // Analyze before strategy selection and opener generation so a rejected // physique score costs only the analysis request. let analysis; try { analysis = await this.aiClient.analyzeImage(base64, imageData.mimeType); } catch (error) { if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) { this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`); return { skipReason: 'AI refused to analyze image' }; } throw error; } const physiqueDecision = evaluatePhysiqueTargeting( analysis.physiqueScore, this.config.reddit.physiqueTargeting, ); if (!physiqueDecision.accepted) { this.logger.info(`Skipping post ${post.id} - ${physiqueDecision.reason}`); return { skipReason: physiqueDecision.reason }; } let selectedStrategy: SelectedStrategy | null = null; if (this.strategySelector) { selectedStrategy = this.strategySelector.selectStrategy(); if (selectedStrategy) { this.logger.info(`Using strategy "${selectedStrategy.id}" (${selectedStrategy.reason})`); } } const tone = this.resolveTone(post.subreddit); if (tone.scripted) { this.logger.info(`Using scripted message for r/${post.subreddit}`); } else if (tone.profile.id !== 'default') { this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`); } const strategyPrompt = tone.scripted ? tone.scripted .replace('{title}', post.title) .replace('{author}', post.author) .replace('{flair}', post.link_flair_text || '(none)') : selectedStrategy?.promptOverride; const message = await this.aiClient.generateMessage( analysis.description, post.title, post.author, this.successfulExamples, strategyPrompt, analysis.attractivenessScore, post.link_flair_text ?? undefined, tone.scripted ? undefined : tone.profile.promptGuidance || undefined, this.recentMessageProvider('image'), ); if (!message || message.length < 10) { this.logger.warn( `Generated message too short or empty for post ${post.id} (${message?.length ?? 0} chars): ${JSON.stringify(message?.slice(0, 200) ?? '')}`, ); return { skipReason: `generated message too short (${message?.length ?? 0} chars)` }; } return { post, imageAnalysis: analysis.description, detectedGender: analysis.detectedGender, attractivenessScore: analysis.attractivenessScore, physiqueScore: analysis.physiqueScore, physiqueTargeting: { ...this.config.reddit.physiqueTargeting }, sourceImageUrl: imageUrl, generatedMessage: message, strategyId: selectedStrategy?.id || null, experimentId: selectedStrategy?.experimentId ?? null, promptVersion: selectedStrategy?.promptVersion ?? null, selectionReason: selectedStrategy?.reason ?? null, modelId: this.config.openrouter.imageAnalysisModel, sourceType: 'image', toneId: tone.profile.id, scripted: tone.scripted !== null, }; } private async processChatPost(post: RedditPost): Promise { this.logger.info(`Processing chat post: "${post.title.slice(0, 50)}..." by u/${post.author}`); // Gender pre-filtering for chat subs happens in MonitorService — by the // time a chat post reaches the processor it's already passed the filter. // We still parse the tag here so it can be threaded into the opener prompt. const haystack = `${post.title}\n${post.selftext ?? ''}`; const detectedGender = parseGenderTag(haystack); let selectedStrategy: SelectedStrategy | null = null; if (this.chatStrategySelector) { selectedStrategy = this.chatStrategySelector.selectStrategy(); if (selectedStrategy) { this.logger.info(`Using chat strategy "${selectedStrategy.id}" (${selectedStrategy.reason})`); } } // Resolve subreddit-specific tone profile const tone = this.resolveTone(post.subreddit); if (tone.scripted) { this.logger.info(`Using scripted opener for r/${post.subreddit}`); } else if (tone.profile.id !== 'default') { this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`); } let message: string; try { if (tone.scripted) { let scriptedPrompt = tone.scripted .replace('{title}', post.title) .replace('{author}', post.author) .replace('{flair}', post.link_flair_text || '(none)'); message = await this.aiClient.generateChatOpener( post.title, post.selftext ?? '', post.author, detectedGender ?? 'unknown', this.successfulExamples, scriptedPrompt, post.link_flair_text ?? undefined, undefined, this.recentMessageProvider('chat'), ); } else { message = await this.aiClient.generateChatOpener( post.title, post.selftext ?? '', post.author, detectedGender ?? 'unknown', this.successfulExamples, selectedStrategy?.promptOverride, post.link_flair_text ?? undefined, tone.profile.promptGuidance || undefined, this.recentMessageProvider('chat'), ); } } catch (error) { this.logger.error(`Chat opener generation failed for post ${post.id}:`, error); throw error; } if (!message || message.length < 10) { this.logger.warn( `Generated chat opener too short or empty for post ${post.id} (${message?.length ?? 0} chars): ${JSON.stringify(message?.slice(0, 200) ?? '')}`, ); return { skipReason: `generated chat opener too short (${message?.length ?? 0} chars)` }; } return { post, imageAnalysis: `[chat] ${post.title}${post.selftext ? ` — ${post.selftext.slice(0, 200)}` : ''}`, detectedGender: detectedGender ?? 'unknown', attractivenessScore: null, physiqueScore: null, physiqueTargeting: null, sourceImageUrl: null, generatedMessage: message, strategyId: selectedStrategy?.id || null, experimentId: selectedStrategy?.experimentId ?? null, promptVersion: selectedStrategy?.promptVersion ?? null, selectionReason: selectedStrategy?.reason ?? null, modelId: this.config.openrouter.chatModel, sourceType: 'chat', toneId: tone.profile.id, scripted: tone.scripted !== null, }; } }