Files
RedditBot/src/services/processor.ts
T

328 lines
12 KiB
TypeScript

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<DownloadedImage>;
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<typeof entry> | 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<ProcessResult> {
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<ProcessResult> {
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,
};
}
}