313 lines
11 KiB
TypeScript
313 lines
11 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 } 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';
|
|
|
|
export interface ProcessedPost {
|
|
post: RedditPost;
|
|
imageAnalysis: string;
|
|
detectedGender: DetectedGender;
|
|
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 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,
|
|
) {
|
|
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}`);
|
|
}
|
|
|
|
// Select a strategy for A/B testing
|
|
let selectedStrategy: SelectedStrategy | null = null;
|
|
if (this.strategySelector) {
|
|
selectedStrategy = this.strategySelector.selectStrategy();
|
|
if (selectedStrategy) {
|
|
this.logger.info(`Using strategy "${selectedStrategy.id}" (${selectedStrategy.reason})`);
|
|
}
|
|
}
|
|
|
|
// Resolve subreddit-specific tone profile
|
|
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}`);
|
|
}
|
|
|
|
// Full analysis and message generation (only for matching posts)
|
|
let analysis;
|
|
let message;
|
|
try {
|
|
if (tone.scripted) {
|
|
// Replace substitution tokens in the scripted prompt with post-specific data
|
|
let scriptedPrompt = tone.scripted
|
|
.replace('{title}', post.title)
|
|
.replace('{author}', post.author)
|
|
.replace('{flair}', post.link_flair_text || '(none)');
|
|
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
|
base64,
|
|
imageData.mimeType,
|
|
post.title,
|
|
post.author,
|
|
this.successfulExamples,
|
|
scriptedPrompt,
|
|
post.link_flair_text ?? undefined,
|
|
undefined
|
|
));
|
|
} else {
|
|
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
|
base64,
|
|
imageData.mimeType,
|
|
post.title,
|
|
post.author,
|
|
this.successfulExamples,
|
|
selectedStrategy?.promptOverride,
|
|
post.link_flair_text ?? undefined,
|
|
tone.profile.promptGuidance || undefined,
|
|
));
|
|
}
|
|
} 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;
|
|
}
|
|
|
|
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,
|
|
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
|
|
);
|
|
} 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,
|
|
);
|
|
}
|
|
} 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',
|
|
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,
|
|
};
|
|
}
|
|
}
|