diff --git a/config.yaml b/config.yaml index 339e7df..87099cb 100644 --- a/config.yaml +++ b/config.yaml @@ -118,21 +118,23 @@ openrouter: 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. - 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: - Start sentences with capital letters, use proper capitalisation - Use explicit terms: tits, ass, pussy, boobs (NOT breasts, buttocks, etc) - Reference something SPECIFIC from the analysis (her tits, ass, pussy, body, pose) - 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): - - "I'd love if you have any other pics to share?" - - "I bet they'd look great coated..." - - "Got any more pics?" + POSSIBLE HOOKS (only when the selected STYLE calls for one): + - A specific question about the post + - A playful prediction or challenge + - A conditional invitation to share something else + - A statement of desire with no question - BAD HOOKS (never use): + BAD HOOKS: - "How's your day?" - "What are you up to?" - Generic small talk questions @@ -144,13 +146,10 @@ openrouter: - Use clinical terms like "breasts", "buttocks", "posterior", "physique" - Write long elaborate sentences - 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. - 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. chatOpenerPrompt: | @@ -171,7 +170,7 @@ openrouter: Write a casual Reddit DM (1-2 sentences) opening a chat. 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) - Be confident and direct, flirty but not creepy - Use proper capitalisation, no AI/clinical language @@ -182,54 +181,73 @@ openrouter: - Open with "Hey there" / "How's your day" - Write more than 2 sentences - 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 - 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. strategies: 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 variants: - - id: compliment_direct + - id: reaction_statement weight: 1 promptOverride: | - STYLE: Focus on a direct, specific compliment about ONE body part from the analysis. - Keep it short and punchy. End with a simple "got more?" style hook. - - id: teasing + STYLE: REACTION STATEMENT. Open with a short physical reaction that is NOT "Damn", "Wow", or "Holy shit". + Mention one specific visible detail. Finish with a confident statement of desire, not a question and not a request for more. + - id: question_first weight: 1 promptOverride: | - STYLE: Be more teasing and playful. Light humor, like you're flirting. - End with a flirty challenge or dare that makes her want to respond. - - id: detailed_appreciation + STYLE: QUESTION FIRST. The very first sentence must be a specific, answerable question about her post, pose, outfit, or title. + Follow with one concise explicit compliment. Do not ask whether she has more pics. + - id: title_callback weight: 1 promptOverride: | - STYLE: Reference 2-3 specific things from the analysis to show you actually looked. - Be enthusiastic but not over the top. End with asking for more. + STYLE: TITLE CALLBACK. Open by naturally echoing or answering the post title, without quoting it verbatim. + 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: explorationRate: 0.2 - minSamples: 5 + minSamples: 20 responseWindowHours: 12 variants: - - id: chat_direct + - id: chat_identity_first weight: 1 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. - - id: chat_playful + 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_question_first weight: 1 promptOverride: | - STYLE: Playful and teasing. Light humour, mild flirty challenge, end with a hook that's hard to ignore. No em dashes. - - id: chat_mirror + 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_post_callback weight: 1 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: minDelayBetweenMessagesMs: 15000 diff --git a/src/ai/openrouter.ts b/src/ai/openrouter.ts index c33d627..36da14e 100644 --- a/src/ai/openrouter.ts +++ b/src/ai/openrouter.ts @@ -256,6 +256,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`; attractivenessScore: number | null = null, postFlair?: string | null, toneGuidance?: string, + recentMessages: readonly string[] = [], ): Promise { 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 { 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 }; } } diff --git a/src/bootstrap.ts b/src/bootstrap.ts index e9a8b48..c3ccc4d 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -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'); diff --git a/src/config/index.ts b/src/config/index.ts index cc395c8..365dbd3 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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), diff --git a/src/services/processor.ts b/src/services/processor.ts index 95c384c..b889c91 100644 --- a/src/services/processor.ts +++ b/src/services/processor.ts @@ -33,6 +33,7 @@ export function isSkip(result: ProcessResult): result is SkipResult { } export type ImageFetcher = (url: string, userAgent: string) => Promise; +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) { diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 3ee9c2f..fe0d088 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -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')); diff --git a/src/services/strategy-selector.ts b/src/services/strategy-selector.ts index 3ca8498..137d389 100644 --- a/src/services/strategy-selector.ts +++ b/src/services/strategy-selector.ts @@ -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'; diff --git a/tests/config.test.ts b/tests/config.test.ts index 103a88c..c1c8168 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -227,7 +227,7 @@ describe('ConfigSchema', () => { expect(result.success).toBe(true); if (result.success) { 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.variants).toEqual([]); expect(result.data.chatStrategies.responseWindowHours).toBe(12); diff --git a/tests/openrouter-client.test.ts b/tests/openrouter-client.test.ts index f37f9f0..3042633 100644 --- a/tests/openrouter-client.test.ts +++ b/tests/openrouter-client.test.ts @@ -470,7 +470,31 @@ describe('OpenRouterClient', () => { expect(prompt).toContain('Example 1:'); expect(prompt).toContain('Example 2:'); 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 () => { diff --git a/tests/processor.test.ts b/tests/processor.test.ts index 2e3cdee..c766bef 100644 --- a/tests/processor.test.ts +++ b/tests/processor.test.ts @@ -196,7 +196,8 @@ describe('ProcessorService', () => { ['Example 1: Hey', 'Example 2: Nice'], undefined, undefined, - undefined + undefined, + [], ); }); @@ -218,7 +219,35 @@ describe('ProcessorService', () => { [], 'Be teasing', 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'], undefined, undefined, - undefined + undefined, + [], ); }); @@ -316,6 +346,7 @@ describe('ProcessorService', () => { undefined, undefined, undefined, + [], ); }); @@ -347,6 +378,7 @@ describe('ProcessorService', () => { 'Be playful', undefined, undefined, + [], ); }); }); diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts index 6fbf0b2..eac3713 100644 --- a/tests/prompts.test.ts +++ b/tests/prompts.test.ts @@ -60,11 +60,11 @@ describe('Prompt Testing', () => { expect(prompts.messageGenerationPrompt).toContain('radiant'); }); - it('messageGenerationPrompt specifies GOOD HOOKS', () => { + it('messageGenerationPrompt allows structurally different hooks', () => { const content = fs.readFileSync(configPath, 'utf-8'); const prompts = parseYamlPrompts(content); - expect(prompts.messageGenerationPrompt).toContain('GOOD HOOKS'); + expect(prompts.messageGenerationPrompt).toContain('POSSIBLE HOOKS'); expect(prompts.messageGenerationPrompt).toContain('BAD HOOKS'); }); @@ -184,13 +184,14 @@ describe('Prompt Testing', () => { 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 yaml = require('yaml'); const config = yaml.parse(content); - expect(config.openrouter.messageGenerationPrompt).toContain('Examples of good messages'); - expect(config.openrouter.messageGenerationPrompt).toContain('"Damn those tits'); + expect(config.openrouter.messageGenerationPrompt).not.toContain('Examples of good messages'); + 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', () => { diff --git a/tests/strategy-selector.test.ts b/tests/strategy-selector.test.ts index 5952386..96cfa43 100644 --- a/tests/strategy-selector.test.ts +++ b/tests/strategy-selector.test.ts @@ -187,6 +187,21 @@ describe('StrategySelector', () => { }); 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', () => { const stats: StrategyStats[] = [ { strategyId: 'v1', totalSent: 10, totalResponses: 2, responseRate: 20 },