Add varied messaging experiments
This commit is contained in:
1 parent
43f459a024
commit
23d4c3a320
12 files changed
+217
-72
No files matched your search
+53
-35
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in new issue
Block a user