Files
RedditBot/src/services/strategy-selector.ts
T
jjbrunton 633bb0ec04 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.
2026-05-21 01:32:10 +01:00

104 lines
3.4 KiB
TypeScript

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;
}
}