import type { Config } from '../config/index.js'; import type { MessagesRepository, StrategyStats } from '../database/repositories.js'; import type { Logger } from '../utils/logger.js'; export interface SelectedStrategy { id: string; promptOverride: string; reason: 'exploit' | 'explore'; } export class StrategySelector { private variants: Config['strategies']['variants']; private explorationRate: number; private minSamples: number; constructor( private config: Config, private messagesRepo: MessagesRepository, private logger: Logger, section: 'strategies' | 'chatStrategies' = 'strategies', ) { const cfg = config[section]; this.variants = cfg.variants; this.explorationRate = cfg.explorationRate; this.minSamples = cfg.minSamples; } selectStrategy(): SelectedStrategy | null { if (this.variants.length === 0) { return null; } // Exploration: random strategy if (Math.random() < this.explorationRate) { const variant = this.variants[Math.floor(Math.random() * this.variants.length)]; this.logger.debug(`Strategy selected (explore): ${variant.id}`); return { id: variant.id, promptOverride: variant.promptOverride, reason: 'explore' }; } // Exploitation: pick the best-performing strategy const stats = this.messagesRepo.getStrategyStats(); const statsMap = new Map(stats.map(s => [s.strategyId, s])); // Find strategies with insufficient samples - prioritize exploring them const unexplored = this.variants.filter(v => { const s = statsMap.get(v.id); return !s || s.totalSent < this.minSamples; }); if (unexplored.length > 0) { const variant = unexplored[Math.floor(Math.random() * unexplored.length)]; this.logger.debug(`Strategy selected (under-sampled): ${variant.id}`); return { id: variant.id, promptOverride: variant.promptOverride, reason: 'explore' }; } // All strategies have enough samples - pick the best response rate let bestVariant = this.variants[0]; let bestRate = -1; for (const variant of this.variants) { const s = statsMap.get(variant.id); if (s && s.responseRate > bestRate) { bestRate = s.responseRate; bestVariant = variant; } } this.logger.debug(`Strategy selected (exploit, ${bestRate}% response rate): ${bestVariant.id}`); return { id: bestVariant.id, promptOverride: bestVariant.promptOverride, reason: 'exploit' }; } getStats(): (StrategyStats & { configured: boolean })[] { const dbStats = this.messagesRepo.getStrategyStats(); const statsMap = new Map(dbStats.map(s => [s.strategyId, s])); // Include all configured variants, even if they have no data yet const results: (StrategyStats & { configured: boolean })[] = []; for (const variant of this.variants) { const s = statsMap.get(variant.id); results.push({ strategyId: variant.id, totalSent: s?.totalSent || 0, totalResponses: s?.totalResponses || 0, responseRate: s?.responseRate || 0, configured: true, }); } // Include any historical strategies no longer in config for (const s of dbStats) { if (!this.variants.some(v => v.id === s.strategyId)) { results.push({ ...s, configured: false }); } } return results; } hasStrategies(): boolean { return this.variants.length > 0; } }