Add varied messaging experiments

This commit is contained in:
jjbrunton committed 2026-08-09 10:28:55 +01:00
1 parent 43f459a024
commit 23d4c3a320
12 files changed
+217 -72

No files matched your search

+40 -17
View File
@@ -256,6 +256,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
attractivenessScore: number | null = null,
postFlair?: string | null,
toneGuidance?: string,
recentMessages: readonly string[] = [],
): Promise<string> {
try {
this.logger.debug('Generating message...');
@@ -277,16 +278,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
prompt += `\n\n${toneGuidance}`;
}
// Add successful examples if available
if (successfulExamples.length > 0) {
const examplesText = successfulExamples
.map((ex, i) => `Example ${i + 1}:\n${ex}`)
.join('\n\n');
prompt = `Here are some examples of messages that got positive responses:\n\n${examplesText}\n\n---\n\nNow generate a new message in a similar style:\n\n${prompt}`;
this.logger.debug(`Including ${successfulExamples.length} successful examples in prompt`);
}
prompt = this.addLearningContext(prompt, successfulExamples, recentMessages);
const message = await this.generateTextWithFallback('generateMessage', prompt, {
max_tokens: 300,
@@ -414,6 +406,7 @@ Just write the message, nothing else.`;
strategyPrompt?: string,
postFlair?: string | null,
toneGuidance?: string,
recentMessages: readonly string[] = [],
): Promise<string> {
try {
this.logger.debug('Generating chat opener...');
@@ -433,12 +426,7 @@ Just write the message, nothing else.`;
prompt += `\n\n${toneGuidance}`;
}
if (successfulExamples.length > 0) {
const examplesText = successfulExamples
.map((ex, i) => `Example ${i + 1}:\n${ex}`)
.join('\n\n');
prompt = `Here are openers that got responses:\n\n${examplesText}\n\n---\n\nNow generate a new opener in a similar style:\n\n${prompt}`;
}
prompt = this.addLearningContext(prompt, successfulExamples, recentMessages);
const message = await this.generateTextWithFallback('generateChatOpener', prompt, {
max_tokens: 200,
@@ -476,6 +464,30 @@ Just write the message, nothing else.`;
};
}
private addLearningContext(
prompt: string,
successfulExamples: readonly string[],
recentMessages: readonly string[],
): string {
const sections: string[] = [];
if (successfulExamples.length > 0) {
sections.push(
'PERFORMANCE EXAMPLES (use only as evidence about themes that earned replies; do not copy their opening words, sentence structure, or closing hook):\n' +
successfulExamples.map((example, index) => `${index + 1}. ${example}`).join('\n'),
);
this.logger.debug(`Including ${successfulExamples.length} successful message examples as performance context`);
}
if (recentMessages.length > 0) {
sections.push(
'RECENT MESSAGES TO AVOID IMITATING:\n' +
recentMessages.slice(0, 12).map((message, index) => `${index + 1}. ${message}`).join('\n') +
'\nYour output must use different opening words, a different sentence pattern, and a different closing device from these messages.',
);
this.logger.debug(`Including ${Math.min(recentMessages.length, 12)} recent messages for repetition avoidance`);
}
return sections.length > 0 ? `${sections.join('\n\n')}\n\n---\n\n${prompt}` : prompt;
}
private async generateTextWithFallback(
label: string,
prompt: string | ProtectedTextPrompt,
@@ -611,9 +623,20 @@ Just write the message, nothing else.`;
strategyPrompt?: string,
postFlair?: string | null,
toneGuidance?: string,
recentMessages: readonly string[] = [],
): Promise<{ analysis: ImageAnalysisResult; message: string }> {
const analysis = await this.analyzeImage(imageBase64, mimeType);
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples, strategyPrompt, analysis.attractivenessScore, postFlair, toneGuidance);
const message = await this.generateMessage(
analysis.description,
postTitle,
authorName,
successfulExamples,
strategyPrompt,
analysis.attractivenessScore,
postFlair,
toneGuidance,
recentMessages,
);
return { analysis, message };
}
}
+11 -1
View File
@@ -177,7 +177,17 @@ export async function bootstrap(
redditApi, postsRepo, recipientDeduplicator, config, logger, 1000,
createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
);
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
const processorService = new ProcessorService(
redditApi,
aiClient,
config,
logger,
undefined,
(sourceType) => messagesRepo
.getRecent(20)
.filter((message) => message.sourceType === sourceType || (sourceType === 'image' && !message.sourceType))
.map((message) => message.message),
);
const strategySelector = new StrategySelector(config, messagesRepo, logger);
processorService.setStrategySelector(strategySelector);
const chatStrategySelector = new StrategySelector(config, messagesRepo, logger, 'chatStrategies');
+4 -4
View File
@@ -130,24 +130,24 @@ export const ConfigSchema = z.object({
}).default({ syncIntervalMs: 1800000, syncEnabled: true }),
strategies: z.object({
explorationRate: z.number().min(0).max(1).default(0.2),
minSamples: z.number().int().positive().transform((value) => Math.max(30, value)).default(30),
minSamples: z.number().int().positive().transform((value) => Math.max(20, value)).default(20),
responseWindowHours: z.number().positive().default(12),
variants: z.array(z.object({
id: z.string(),
weight: z.number().default(1),
promptOverride: z.string(),
})).default([]),
}).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 12, variants: [] }),
}).default({ explorationRate: 0.2, minSamples: 20, responseWindowHours: 12, variants: [] }),
chatStrategies: z.object({
explorationRate: z.number().min(0).max(1).default(0.2),
minSamples: z.number().int().positive().transform((value) => Math.max(30, value)).default(30),
minSamples: z.number().int().positive().transform((value) => Math.max(20, value)).default(20),
responseWindowHours: z.number().positive().default(12),
variants: z.array(z.object({
id: z.string(),
weight: z.number().default(1),
promptOverride: z.string(),
})).default([]),
}).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 12, variants: [] }),
}).default({ explorationRate: 0.2, minSamples: 20, responseWindowHours: 12, variants: [] }),
web: z.object({
enabled: z.boolean().default(true),
port: z.number().default(3000),
+8 -2
View File
@@ -33,6 +33,7 @@ export function isSkip(result: ProcessResult): result is SkipResult {
}
export type ImageFetcher = (url: string, userAgent: string) => Promise<DownloadedImage>;
export type RecentMessageProvider = (sourceType: 'image' | 'chat') => readonly string[];
export class ProcessorService {
private successfulExamples: string[] = [];
@@ -46,6 +47,7 @@ export class ProcessorService {
private config: Config,
private logger: Logger,
fetchImage?: ImageFetcher,
private readonly recentMessageProvider: RecentMessageProvider = () => [],
) {
this.fetchImage = fetchImage ?? downloadImage;
}
@@ -183,7 +185,8 @@ export class ProcessorService {
this.successfulExamples,
scriptedPrompt,
post.link_flair_text ?? undefined,
undefined
undefined,
this.recentMessageProvider('image'),
));
} else {
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
@@ -195,6 +198,7 @@ export class ProcessorService {
selectedStrategy?.promptOverride,
post.link_flair_text ?? undefined,
tone.profile.promptGuidance || undefined,
this.recentMessageProvider('image'),
));
}
} catch (error) {
@@ -268,7 +272,8 @@ export class ProcessorService {
this.successfulExamples,
scriptedPrompt,
post.link_flair_text ?? undefined,
undefined
undefined,
this.recentMessageProvider('chat'),
);
} else {
message = await this.aiClient.generateChatOpener(
@@ -280,6 +285,7 @@ export class ProcessorService {
selectedStrategy?.promptOverride,
post.link_flair_text ?? undefined,
tone.profile.promptGuidance || undefined,
this.recentMessageProvider('chat'),
);
}
} catch (error) {
+11 -1
View File
@@ -127,7 +127,17 @@ async function buildRuntime(
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
1000, createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
);
const processorService = new ProcessorService(redditApi, aiClient, accountConfig, logger);
const processorService = new ProcessorService(
redditApi,
aiClient,
accountConfig,
logger,
undefined,
(sourceType) => messagesRepo
.getRecent(20)
.filter((message) => message.sourceType === sourceType || (sourceType === 'image' && !message.sourceType))
.map((message) => message.message),
);
processorService.setStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger));
processorService.setChatStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger, 'chatStrategies'));
+8 -2
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import type { Config } from '../config/index.js';
import type { MessagesRepository, StrategyStats } from '../database/repositories.js';
import type { Logger } from '../utils/logger.js';
@@ -54,7 +55,13 @@ export class StrategySelector {
});
if (unexplored.length > 0) {
const variant = this.weightedChoice(unexplored);
const observedCount = (variant: Config['strategies']['variants'][number]): number => {
const stats = statsMap.get(variant.id);
return (stats?.totalSent ?? 0) + (stats?.pendingSent ?? 0);
};
const smallestSample = Math.min(...unexplored.map(observedCount));
const leastSampled = unexplored.filter((variant) => observedCount(variant) === smallestSample);
const variant = this.weightedChoice(leastSampled);
this.logger.debug(`Strategy selected (under-sampled): ${variant.id}`);
return this.selected(variant, 'explore');
}
@@ -137,4 +144,3 @@ export class StrategySelector {
return variants[variants.length - 1];
}
}
import { createHash } from 'node:crypto';