Restructure chat service, web routes, and add test suite
Split monolithic chat service into modular structure with Matrix client support, extract web server into per-resource route modules, introduce bootstrap/main-loop separation, and add comprehensive vitest coverage across services, repositories, and utilities. Adds project docs and CLAUDE.md agent instructions.
This commit is contained in:
1 parent
dfa0580859
commit
633bb0ec04
84 files changed
+19278
-3888
No files matched your search
+100
-13
@@ -3,7 +3,8 @@ 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 } from '../utils/image.js';
|
||||
import { downloadImage, bufferToBase64, isValidImageMimeType, type DownloadedImage } from '../utils/image.js';
|
||||
import { parseGenderTag } from '../utils/gender-tag.js';
|
||||
|
||||
export interface ProcessedPost {
|
||||
post: RedditPost;
|
||||
@@ -13,16 +14,33 @@ export interface ProcessedPost {
|
||||
strategyId: string | null;
|
||||
}
|
||||
|
||||
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
|
||||
) {}
|
||||
private logger: Logger,
|
||||
fetchImage?: ImageFetcher,
|
||||
) {
|
||||
this.fetchImage = fetchImage ?? downloadImage;
|
||||
}
|
||||
|
||||
setStrategySelector(selector: StrategySelector): void {
|
||||
this.strategySelector = selector;
|
||||
@@ -31,6 +49,18 @@ export class ProcessorService {
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
setSuccessfulExamples(examples: string[]): void {
|
||||
this.successfulExamples = examples;
|
||||
if (examples.length > 0) {
|
||||
@@ -38,12 +68,16 @@ export class ProcessorService {
|
||||
}
|
||||
}
|
||||
|
||||
async processPost(post: RedditPost): Promise<ProcessedPost | null> {
|
||||
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 null;
|
||||
return { skipReason: 'no image URL on post' };
|
||||
}
|
||||
|
||||
this.logger.info(`Processing post: "${post.title.slice(0, 50)}..." by u/${post.author}`);
|
||||
@@ -51,22 +85,23 @@ export class ProcessorService {
|
||||
// Download image
|
||||
let imageData;
|
||||
try {
|
||||
imageData = await downloadImage(imageUrl, this.config.reddit.userAgent);
|
||||
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 null;
|
||||
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 null;
|
||||
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 null;
|
||||
return { skipReason: `image too small (${imageData.buffer.length} bytes)` };
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
@@ -81,7 +116,9 @@ export class ProcessorService {
|
||||
this.logger.info(
|
||||
`Skipping post ${post.id} - detected gender "${detectedGender}" not in target list [${targetGenders.join(', ')}]`
|
||||
);
|
||||
return null;
|
||||
return {
|
||||
skipReason: `detected gender "${detectedGender}" not in target list [${targetGenders.join(', ')}]`,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug(`Gender check passed: ${detectedGender}`);
|
||||
@@ -111,14 +148,16 @@ export class ProcessorService {
|
||||
} 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 null;
|
||||
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}`);
|
||||
return null;
|
||||
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 {
|
||||
@@ -129,4 +168,52 @@ export class ProcessorService {
|
||||
strategyId: selectedStrategy?.id || 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})`);
|
||||
}
|
||||
}
|
||||
|
||||
let message: string;
|
||||
try {
|
||||
message = await this.aiClient.generateChatOpener(
|
||||
post.title,
|
||||
post.selftext ?? '',
|
||||
post.author,
|
||||
detectedGender ?? 'unknown',
|
||||
this.successfulExamples,
|
||||
selectedStrategy?.promptOverride,
|
||||
);
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user