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.
104 lines
3.4 KiB
TypeScript
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;
|
|
}
|
|
}
|