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

+53 -35
View File
@@ -118,21 +118,23 @@ openrouter:
Attractiveness rating (1-100 scale, 50 = average): {rating} Attractiveness rating (1-100 scale, 50 = average): {rating}
If the rating is below 60, lean harder into making her feel sexy and desired — emphasize how hot her pic looks. If the rating is 75+, you can be more confidently flirty without overdoing the flattery. If the rating is below 60, lean harder into making her feel sexy and desired — emphasize how hot her pic looks. If the rating is 75+, you can be more confidently flirty without overdoing the flattery.
Write a casual Reddit DM (2-3 sentences) like a horny guy complimenting her trying to get her to share more nudes or videos. Write a casual Reddit DM (1-3 short sentences) like a horny guy reacting naturally to her post.
MUST follow: MUST follow:
- Start sentences with capital letters, use proper capitalisation - Start sentences with capital letters, use proper capitalisation
- Use explicit terms: tits, ass, pussy, boobs (NOT breasts, buttocks, etc) - Use explicit terms: tits, ass, pussy, boobs (NOT breasts, buttocks, etc)
- Reference something SPECIFIC from the analysis (her tits, ass, pussy, body, pose) - Reference something SPECIFIC from the analysis (her tits, ass, pussy, body, pose)
- Be direct and confident, not flowery or poetic - Be direct and confident, not flowery or poetic
- End with a hook that encourages them to share MORE (nudes/videos) - Follow the selected STYLE exactly, especially its required opening and ending shape
- Make the message feel materially different from the recent messages supplied below
GOOD HOOKS (use these styles): POSSIBLE HOOKS (only when the selected STYLE calls for one):
- "I'd love if you have any other pics to share?" - A specific question about the post
- "I bet they'd look great coated..." - A playful prediction or challenge
- "Got any more pics?" - A conditional invitation to share something else
- A statement of desire with no question
BAD HOOKS (never use): BAD HOOKS:
- "How's your day?" - "How's your day?"
- "What are you up to?" - "What are you up to?"
- Generic small talk questions - Generic small talk questions
@@ -144,13 +146,10 @@ openrouter:
- Use clinical terms like "breasts", "buttocks", "posterior", "physique" - Use clinical terms like "breasts", "buttocks", "posterior", "physique"
- Write long elaborate sentences - Write long elaborate sentences
- Use all lowercase - Use all lowercase
- Default to opening with "Damn", "Wow", "Holy shit", or the same reaction used recently
- Default to closing with "Got any more pics?", "More please", or a near-copy of a recent closing
- Use em dashes (—) or en dashes (–). Use commas, full stops, or separate sentences instead. This is a dead giveaway for AI writing. - Use em dashes (—) or en dashes (–). Use commas, full stops, or separate sentences instead. This is a dead giveaway for AI writing.
Examples of good messages:
- "Damn those tits are perfect. I'd love to see more if you're willing😏"
- "Holy shit that ass is incredible. Got any more pics?"
- "Those curves are insane, love how your tits look in that pose. More please?"
Just write the message, nothing else. Just write the message, nothing else.
chatOpenerPrompt: | chatOpenerPrompt: |
@@ -171,7 +170,7 @@ openrouter:
Write a casual Reddit DM (1-2 sentences) opening a chat. Write a casual Reddit DM (1-2 sentences) opening a chat.
MUST follow: MUST follow:
- Lead with your age/gender/location tag (e.g. "32M UK", "34M UK married") when it fits naturally - Include your age/gender/location naturally, but place it where the selected STYLE requires
- Reference something SPECIFIC from their title/body (their mood, what they're into, vibe, age, location) - Reference something SPECIFIC from their title/body (their mood, what they're into, vibe, age, location)
- Be confident and direct, flirty but not creepy - Be confident and direct, flirty but not creepy
- Use proper capitalisation, no AI/clinical language - Use proper capitalisation, no AI/clinical language
@@ -182,54 +181,73 @@ openrouter:
- Open with "Hey there" / "How's your day" - Open with "Hey there" / "How's your day"
- Write more than 2 sentences - Write more than 2 sentences
- Be generic, "wanna chat?" alone is too weak - Be generic, "wanna chat?" alone is too weak
- Reuse the opening words, sentence pattern, or closing question from a recent message
- Claim to be a different age/gender than the persona above - Claim to be a different age/gender than the persona above
- Use em dashes (—) or en dashes (–) anywhere in the message. Use commas, full stops, or separate sentences instead. This is a dead giveaway for AI writing. - Use em dashes (—) or en dashes (–) anywhere in the message. Use commas, full stops, or separate sentences instead. This is a dead giveaway for AI writing.
Examples of good openers:
- "32M UK, married and bored while the wife's out. Your post caught my eye, what are you into tonight?"
- "34M UK here, love that you're up late looking for trouble. Fancy telling me what gets you going?"
- "33M from the UK, your vibe is exactly my type. Tell me what you'd want a stranger to do to you."
Just write the message, nothing else. Just write the message, nothing else.
strategies: strategies:
explorationRate: 0.2 # 20% of the time try a random strategy instead of the best explorationRate: 0.2 # 20% of the time try a random strategy instead of the best
minSamples: 5 # Min messages sent before trusting a strategy's response rate minSamples: 20 # Mature outcomes required before exploiting a strategy
responseWindowHours: 12 # Age unanswered sends before including them in strategy performance responseWindowHours: 12 # Age unanswered sends before including them in strategy performance
variants: variants:
- id: compliment_direct - id: reaction_statement
weight: 1 weight: 1
promptOverride: | promptOverride: |
STYLE: Focus on a direct, specific compliment about ONE body part from the analysis. STYLE: REACTION STATEMENT. Open with a short physical reaction that is NOT "Damn", "Wow", or "Holy shit".
Keep it short and punchy. End with a simple "got more?" style hook. Mention one specific visible detail. Finish with a confident statement of desire, not a question and not a request for more.
- id: teasing - id: question_first
weight: 1 weight: 1
promptOverride: | promptOverride: |
STYLE: Be more teasing and playful. Light humor, like you're flirting. STYLE: QUESTION FIRST. The very first sentence must be a specific, answerable question about her post, pose, outfit, or title.
End with a flirty challenge or dare that makes her want to respond. Follow with one concise explicit compliment. Do not ask whether she has more pics.
- id: detailed_appreciation - id: title_callback
weight: 1 weight: 1
promptOverride: | promptOverride: |
STYLE: Reference 2-3 specific things from the analysis to show you actually looked. STYLE: TITLE CALLBACK. Open by naturally echoing or answering the post title, without quoting it verbatim.
Be enthusiastic but not over the top. End with asking for more. Turn that callback into a playful compliment. End with an optional, conditional invitation rather than a generic request.
- id: teasing_challenge
weight: 1
promptOverride: |
STYLE: TEASING CHALLENGE. Open with a playful assumption, prediction, or mild challenge based on one visible detail.
Do not open with a compliment. End with a short dare or question that is specific to the challenge.
- id: concise_observation
weight: 1
promptOverride: |
STYLE: CONCISE OBSERVATION. Write exactly one sentence about the most distinctive visible detail in the analysis.
Do not ask a question, request more content, or begin with an interjection.
- id: imagined_scenario
weight: 1
promptOverride: |
STYLE: IMAGINED SCENARIO. Open with a brief, concrete scenario inspired by the pose or setting, then say what it makes you want.
End on the scenario itself. Do not use a generic compliment opener or ask "got more?".
chatStrategies: chatStrategies:
explorationRate: 0.2 explorationRate: 0.2
minSamples: 5 minSamples: 20
responseWindowHours: 12 responseWindowHours: 12
variants: variants:
- id: chat_direct - id: chat_identity_first
weight: 1 weight: 1
promptOverride: | promptOverride: |
STYLE: Direct opener. Open with your own age/gender, reference theirs, and ask one specific question about what they're into. No em dashes. STYLE: IDENTITY FIRST. Open with your age/gender/location tag, then make one specific reference to their post and ask one answerable question.
- id: chat_playful - id: chat_question_first
weight: 1 weight: 1
promptOverride: | promptOverride: |
STYLE: Playful and teasing. Light humour, mild flirty challenge, end with a hook that's hard to ignore. No em dashes. STYLE: QUESTION FIRST. Open with a specific question prompted by their title or body. Put your age/gender/location naturally in the second sentence.
- id: chat_mirror - id: chat_post_callback
weight: 1 weight: 1
promptOverride: | promptOverride: |
STYLE: Mirror their energy. Match the vibe of their post (shy/horny/bored/dom/sub) and respond in kind. Be specific to what they wrote. No em dashes. STYLE: POST CALLBACK. Open by responding to a distinctive phrase, request, or mood from their post. Add your age/gender/location later and close with a playful offer.
- id: chat_playful_assumption
weight: 1
promptOverride: |
STYLE: PLAYFUL ASSUMPTION. Start with a light, post-specific assumption about the kind of chat or trouble they want. Include your identity naturally and end with a mild challenge.
- id: chat_mirror_statement
weight: 1
promptOverride: |
STYLE: MIRROR STATEMENT. Match their energy and vocabulary, then make a direct statement about why you fit what they asked for. Include your identity, but do not end with a question.
rateLimit: rateLimit:
minDelayBetweenMessagesMs: 15000 minDelayBetweenMessagesMs: 15000
+40 -17
View File
@@ -256,6 +256,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
attractivenessScore: number | null = null, attractivenessScore: number | null = null,
postFlair?: string | null, postFlair?: string | null,
toneGuidance?: string, toneGuidance?: string,
recentMessages: readonly string[] = [],
): Promise<string> { ): Promise<string> {
try { try {
this.logger.debug('Generating message...'); this.logger.debug('Generating message...');
@@ -277,16 +278,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
prompt += `\n\n${toneGuidance}`; prompt += `\n\n${toneGuidance}`;
} }
// Add successful examples if available prompt = this.addLearningContext(prompt, successfulExamples, recentMessages);
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`);
}
const message = await this.generateTextWithFallback('generateMessage', prompt, { const message = await this.generateTextWithFallback('generateMessage', prompt, {
max_tokens: 300, max_tokens: 300,
@@ -414,6 +406,7 @@ Just write the message, nothing else.`;
strategyPrompt?: string, strategyPrompt?: string,
postFlair?: string | null, postFlair?: string | null,
toneGuidance?: string, toneGuidance?: string,
recentMessages: readonly string[] = [],
): Promise<string> { ): Promise<string> {
try { try {
this.logger.debug('Generating chat opener...'); this.logger.debug('Generating chat opener...');
@@ -433,12 +426,7 @@ Just write the message, nothing else.`;
prompt += `\n\n${toneGuidance}`; prompt += `\n\n${toneGuidance}`;
} }
if (successfulExamples.length > 0) { prompt = this.addLearningContext(prompt, successfulExamples, recentMessages);
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}`;
}
const message = await this.generateTextWithFallback('generateChatOpener', prompt, { const message = await this.generateTextWithFallback('generateChatOpener', prompt, {
max_tokens: 200, 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( private async generateTextWithFallback(
label: string, label: string,
prompt: string | ProtectedTextPrompt, prompt: string | ProtectedTextPrompt,
@@ -611,9 +623,20 @@ Just write the message, nothing else.`;
strategyPrompt?: string, strategyPrompt?: string,
postFlair?: string | null, postFlair?: string | null,
toneGuidance?: string, toneGuidance?: string,
recentMessages: readonly string[] = [],
): Promise<{ analysis: ImageAnalysisResult; message: string }> { ): Promise<{ analysis: ImageAnalysisResult; message: string }> {
const analysis = await this.analyzeImage(imageBase64, mimeType); 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 }; return { analysis, message };
} }
} }
+11 -1
View File
@@ -177,7 +177,17 @@ export async function bootstrap(
redditApi, postsRepo, recipientDeduplicator, config, logger, 1000, redditApi, postsRepo, recipientDeduplicator, config, logger, 1000,
createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger), 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); const strategySelector = new StrategySelector(config, messagesRepo, logger);
processorService.setStrategySelector(strategySelector); processorService.setStrategySelector(strategySelector);
const chatStrategySelector = new StrategySelector(config, messagesRepo, logger, 'chatStrategies'); 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 }), }).default({ syncIntervalMs: 1800000, syncEnabled: true }),
strategies: z.object({ strategies: z.object({
explorationRate: z.number().min(0).max(1).default(0.2), 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), responseWindowHours: z.number().positive().default(12),
variants: z.array(z.object({ variants: z.array(z.object({
id: z.string(), id: z.string(),
weight: z.number().default(1), weight: z.number().default(1),
promptOverride: z.string(), promptOverride: z.string(),
})).default([]), })).default([]),
}).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 12, variants: [] }), }).default({ explorationRate: 0.2, minSamples: 20, responseWindowHours: 12, variants: [] }),
chatStrategies: z.object({ chatStrategies: z.object({
explorationRate: z.number().min(0).max(1).default(0.2), 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), responseWindowHours: z.number().positive().default(12),
variants: z.array(z.object({ variants: z.array(z.object({
id: z.string(), id: z.string(),
weight: z.number().default(1), weight: z.number().default(1),
promptOverride: z.string(), promptOverride: z.string(),
})).default([]), })).default([]),
}).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 12, variants: [] }), }).default({ explorationRate: 0.2, minSamples: 20, responseWindowHours: 12, variants: [] }),
web: z.object({ web: z.object({
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
port: z.number().default(3000), 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 ImageFetcher = (url: string, userAgent: string) => Promise<DownloadedImage>;
export type RecentMessageProvider = (sourceType: 'image' | 'chat') => readonly string[];
export class ProcessorService { export class ProcessorService {
private successfulExamples: string[] = []; private successfulExamples: string[] = [];
@@ -46,6 +47,7 @@ export class ProcessorService {
private config: Config, private config: Config,
private logger: Logger, private logger: Logger,
fetchImage?: ImageFetcher, fetchImage?: ImageFetcher,
private readonly recentMessageProvider: RecentMessageProvider = () => [],
) { ) {
this.fetchImage = fetchImage ?? downloadImage; this.fetchImage = fetchImage ?? downloadImage;
} }
@@ -183,7 +185,8 @@ export class ProcessorService {
this.successfulExamples, this.successfulExamples,
scriptedPrompt, scriptedPrompt,
post.link_flair_text ?? undefined, post.link_flair_text ?? undefined,
undefined undefined,
this.recentMessageProvider('image'),
)); ));
} else { } else {
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage( ({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
@@ -195,6 +198,7 @@ export class ProcessorService {
selectedStrategy?.promptOverride, selectedStrategy?.promptOverride,
post.link_flair_text ?? undefined, post.link_flair_text ?? undefined,
tone.profile.promptGuidance || undefined, tone.profile.promptGuidance || undefined,
this.recentMessageProvider('image'),
)); ));
} }
} catch (error) { } catch (error) {
@@ -268,7 +272,8 @@ export class ProcessorService {
this.successfulExamples, this.successfulExamples,
scriptedPrompt, scriptedPrompt,
post.link_flair_text ?? undefined, post.link_flair_text ?? undefined,
undefined undefined,
this.recentMessageProvider('chat'),
); );
} else { } else {
message = await this.aiClient.generateChatOpener( message = await this.aiClient.generateChatOpener(
@@ -280,6 +285,7 @@ export class ProcessorService {
selectedStrategy?.promptOverride, selectedStrategy?.promptOverride,
post.link_flair_text ?? undefined, post.link_flair_text ?? undefined,
tone.profile.promptGuidance || undefined, tone.profile.promptGuidance || undefined,
this.recentMessageProvider('chat'),
); );
} }
} catch (error) { } catch (error) {
+11 -1
View File
@@ -127,7 +127,17 @@ async function buildRuntime(
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger, redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
1000, createConfigBackedSubredditPollingControl(config, new ConfigStore(db), 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.setStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger));
processorService.setChatStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger, 'chatStrategies')); 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 { Config } from '../config/index.js';
import type { MessagesRepository, StrategyStats } from '../database/repositories.js'; import type { MessagesRepository, StrategyStats } from '../database/repositories.js';
import type { Logger } from '../utils/logger.js'; import type { Logger } from '../utils/logger.js';
@@ -54,7 +55,13 @@ export class StrategySelector {
}); });
if (unexplored.length > 0) { 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}`); this.logger.debug(`Strategy selected (under-sampled): ${variant.id}`);
return this.selected(variant, 'explore'); return this.selected(variant, 'explore');
} }
@@ -137,4 +144,3 @@ export class StrategySelector {
return variants[variants.length - 1]; return variants[variants.length - 1];
} }
} }
import { createHash } from 'node:crypto';
+1 -1
View File
@@ -227,7 +227,7 @@ describe('ConfigSchema', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) { if (result.success) {
expect(result.data.strategies.explorationRate).toBe(0.2); expect(result.data.strategies.explorationRate).toBe(0.2);
expect(result.data.strategies.minSamples).toBe(30); expect(result.data.strategies.minSamples).toBe(20);
expect(result.data.strategies.responseWindowHours).toBe(12); expect(result.data.strategies.responseWindowHours).toBe(12);
expect(result.data.strategies.variants).toEqual([]); expect(result.data.strategies.variants).toEqual([]);
expect(result.data.chatStrategies.responseWindowHours).toBe(12); expect(result.data.chatStrategies.responseWindowHours).toBe(12);
+25 -1
View File
@@ -470,7 +470,31 @@ describe('OpenRouterClient', () => {
expect(prompt).toContain('Example 1:'); expect(prompt).toContain('Example 1:');
expect(prompt).toContain('Example 2:'); expect(prompt).toContain('Example 2:');
expect(prompt).toContain('Hey you look great'); expect(prompt).toContain('Hey you look great');
expect(prompt).toContain('positive responses'); expect(prompt).toContain('PERFORMANCE EXAMPLES');
expect(prompt).toContain('do not copy their opening words');
});
it('includes recent messages as anti-repetition context', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'A different shape' } }],
});
await client.generateMessage(
'Analysis',
'Title',
'Author',
[],
undefined,
null,
null,
undefined,
['Damn, you look amazing. Got any more pics?'],
);
const prompt = mockCreate.mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('RECENT MESSAGES TO AVOID IMITATING');
expect(prompt).toContain('Damn, you look amazing');
expect(prompt).toContain('different opening words');
}); });
it('substitutes {rating} placeholder when score provided', async () => { it('substitutes {rating} placeholder when score provided', async () => {
+35 -3
View File
@@ -196,7 +196,8 @@ describe('ProcessorService', () => {
['Example 1: Hey', 'Example 2: Nice'], ['Example 1: Hey', 'Example 2: Nice'],
undefined, undefined,
undefined, undefined,
undefined undefined,
[],
); );
}); });
@@ -218,7 +219,35 @@ describe('ProcessorService', () => {
[], [],
'Be teasing', 'Be teasing',
undefined, undefined,
undefined undefined,
[],
);
});
it('passes recent image messages to generation for repetition avoidance', async () => {
const recentMessages = ['Earlier opener one', 'Earlier opener two'];
const config = getTestConfig({ reddit: { targetGenders: ['female'] } });
processor = new ProcessorService(
redditApi,
aiClient,
config,
logger,
fakeFetchImage,
(sourceType) => sourceType === 'image' ? recentMessages : [],
);
await processor.processPost(mockPost);
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
expect.any(String),
'image/jpeg',
'Test Post',
'testuser',
[],
undefined,
undefined,
undefined,
recentMessages,
); );
}); });
@@ -260,7 +289,8 @@ describe('ProcessorService', () => {
['Example A'], ['Example A'],
undefined, undefined,
undefined, undefined,
undefined undefined,
[],
); );
}); });
@@ -316,6 +346,7 @@ describe('ProcessorService', () => {
undefined, undefined,
undefined, undefined,
undefined, undefined,
[],
); );
}); });
@@ -347,6 +378,7 @@ describe('ProcessorService', () => {
'Be playful', 'Be playful',
undefined, undefined,
undefined, undefined,
[],
); );
}); });
}); });
+6 -5
View File
@@ -60,11 +60,11 @@ describe('Prompt Testing', () => {
expect(prompts.messageGenerationPrompt).toContain('radiant'); expect(prompts.messageGenerationPrompt).toContain('radiant');
}); });
it('messageGenerationPrompt specifies GOOD HOOKS', () => { it('messageGenerationPrompt allows structurally different hooks', () => {
const content = fs.readFileSync(configPath, 'utf-8'); const content = fs.readFileSync(configPath, 'utf-8');
const prompts = parseYamlPrompts(content); const prompts = parseYamlPrompts(content);
expect(prompts.messageGenerationPrompt).toContain('GOOD HOOKS'); expect(prompts.messageGenerationPrompt).toContain('POSSIBLE HOOKS');
expect(prompts.messageGenerationPrompt).toContain('BAD HOOKS'); expect(prompts.messageGenerationPrompt).toContain('BAD HOOKS');
}); });
@@ -184,13 +184,14 @@ describe('Prompt Testing', () => {
expect(config.openrouter.imageAnalysisPrompt.trim().length).toBeGreaterThan(50); expect(config.openrouter.imageAnalysisPrompt.trim().length).toBeGreaterThan(50);
}); });
it('messageGenerationPrompt has examples', () => { it('messageGenerationPrompt avoids anchoring on one example shape', () => {
const content = fs.readFileSync(configPath, 'utf-8'); const content = fs.readFileSync(configPath, 'utf-8');
const yaml = require('yaml'); const yaml = require('yaml');
const config = yaml.parse(content); const config = yaml.parse(content);
expect(config.openrouter.messageGenerationPrompt).toContain('Examples of good messages'); expect(config.openrouter.messageGenerationPrompt).not.toContain('Examples of good messages');
expect(config.openrouter.messageGenerationPrompt).toContain('"Damn those tits'); expect(config.openrouter.messageGenerationPrompt).toContain('Default to opening with "Damn"');
expect(config.openrouter.messageGenerationPrompt).toContain('Follow the selected STYLE exactly');
}); });
it('imageAnalysisPrompt requests a rating', () => { it('imageAnalysisPrompt requests a rating', () => {
+15
View File
@@ -187,6 +187,21 @@ describe('StrategySelector', () => {
}); });
describe('under-sampled strategies', () => { describe('under-sampled strategies', () => {
it('balances selection toward the variant with the fewest mature and pending sends', () => {
const stats: StrategyStats[] = [
{ strategyId: 'v1', totalSent: 2, pendingSent: 5, totalResponses: 0, responseRate: 0 },
{ strategyId: 'v2', totalSent: 2, pendingSent: 1, totalResponses: 0, responseRate: 0 },
];
const repo = createMockMessagesRepo(stats);
const config = getTestConfig({ strategies: { explorationRate: 0, minSamples: 20, variants: [
{ id: 'v1', weight: 1, promptOverride: 'p1' },
{ id: 'v2', weight: 1, promptOverride: 'p2' },
] } });
selector = new StrategySelector(config, repo, logger);
expect(selector.selectStrategy()?.id).toBe('v2');
});
it('selects under-sampled variant even in exploit mode', () => { it('selects under-sampled variant even in exploit mode', () => {
const stats: StrategyStats[] = [ const stats: StrategyStats[] = [
{ strategyId: 'v1', totalSent: 10, totalResponses: 2, responseRate: 20 }, { strategyId: 'v1', totalSent: 10, totalResponses: 2, responseRate: 20 },