Add subreddit tone profiles for message style guidance
Introduces a tone-profile system that lets the processor adapt outgoing message style per-subreddit (and per-flair) instead of using one tone for everything. Profiles act as a "tone mic" layered after the base prompt and strategy override, so they shape voice without redefining the whole message. - src/config/tone-profiles.ts defines the catalog (default, dominant, submissive, playful, direct, ...) and a resolveTone() helper that falls back to the default profile for unknown keys. - config.yaml maps subreddits to tone keys; the schema in src/config/index.ts validates it. - src/services/processor.ts threads the resolved tone into the prompt build, picking based on subreddit and optional link flair. - src/reddit/api.ts adds link_flair_text to RedditPost so the processor can use it for tone selection. - src/ai/openrouter.ts accepts and forwards the tone-shaped prompt. - public/index.html surfaces the active tone in the dashboard. - Tests cover the catalog, config validation, and processor integration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c7df6e1d3
commit
46a213e3e0
10 files changed
+497
-51
No files matched your search
+21
-4
@@ -226,6 +226,8 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
successfulExamples: string[] = [],
|
||||
strategyPrompt?: string,
|
||||
attractivenessScore: number | null = null,
|
||||
postFlair?: string | null,
|
||||
toneGuidance?: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
this.logger.debug('Generating message...');
|
||||
@@ -234,13 +236,19 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
.replace('{analysis}', imageAnalysis)
|
||||
.replace('{title}', postTitle)
|
||||
.replace('{author}', authorName)
|
||||
.replace('{rating}', attractivenessScore != null ? String(attractivenessScore) : 'unknown');
|
||||
.replace('{rating}', attractivenessScore != null ? String(attractivenessScore) : 'unknown')
|
||||
.replace('{flair}', postFlair || '(no flair)');
|
||||
|
||||
// Append strategy-specific style guidance if provided
|
||||
if (strategyPrompt) {
|
||||
prompt += `\n\n${strategyPrompt}`;
|
||||
}
|
||||
|
||||
// Append tone guidance if provided
|
||||
if (toneGuidance) {
|
||||
prompt += `\n\n${toneGuidance}`;
|
||||
}
|
||||
|
||||
// Add successful examples if available
|
||||
if (successfulExamples.length > 0) {
|
||||
const examplesText = successfulExamples
|
||||
@@ -371,6 +379,8 @@ Just write the message, nothing else.`;
|
||||
detectedGender: DetectedGender,
|
||||
successfulExamples: string[] = [],
|
||||
strategyPrompt?: string,
|
||||
postFlair?: string | null,
|
||||
toneGuidance?: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
this.logger.debug('Generating chat opener...');
|
||||
@@ -379,12 +389,17 @@ Just write the message, nothing else.`;
|
||||
.replace('{title}', postTitle)
|
||||
.replace('{selftext}', selftext || '(no body)')
|
||||
.replace('{author}', authorName)
|
||||
.replace('{gender}', detectedGender);
|
||||
.replace('{gender}', detectedGender)
|
||||
.replace('{flair}', postFlair || '(no flair)');
|
||||
|
||||
if (strategyPrompt) {
|
||||
prompt += `\n\n${strategyPrompt}`;
|
||||
}
|
||||
|
||||
if (toneGuidance) {
|
||||
prompt += `\n\n${toneGuidance}`;
|
||||
}
|
||||
|
||||
if (successfulExamples.length > 0) {
|
||||
const examplesText = successfulExamples
|
||||
.map((ex, i) => `Example ${i + 1}:\n${ex}`)
|
||||
@@ -482,10 +497,12 @@ Just write the message, nothing else.`;
|
||||
postTitle: string,
|
||||
authorName: string,
|
||||
successfulExamples: string[] = [],
|
||||
strategyPrompt?: string
|
||||
strategyPrompt?: string,
|
||||
postFlair?: string | null,
|
||||
toneGuidance?: 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);
|
||||
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples, strategyPrompt, analysis.attractivenessScore, postFlair, toneGuidance);
|
||||
return { analysis, message };
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import dotenv from 'dotenv';
|
||||
import { TONE_KEYS } from './tone-profiles.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -10,6 +11,8 @@ export const SubredditConfigSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.enum(['image', 'chat']).default('image'),
|
||||
weight: z.number().positive().optional(),
|
||||
tone: z.enum(TONE_KEYS).default('default'),
|
||||
scripted: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SubredditConfig = z.infer<typeof SubredditConfigSchema>;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Tone profiles for subreddit-specific message style guidance.
|
||||
*
|
||||
* Each profile defines body-language instructions the LLM uses to adapt the
|
||||
* message to the subreddit's culture. Profiles are applied AFTER the base
|
||||
* prompt and strategy override, so they act as a tone mic rather than
|
||||
* redefining the entire message.
|
||||
*/
|
||||
|
||||
export const TONE_KEYS = [
|
||||
'default',
|
||||
'dominant',
|
||||
'submissive',
|
||||
'playful',
|
||||
'direct',
|
||||
'gentle',
|
||||
'kink-friendly',
|
||||
'teasing',
|
||||
] as const;
|
||||
|
||||
export type ToneKey = (typeof TONE_KEYS)[number];
|
||||
|
||||
export interface ToneProfile {
|
||||
id: ToneKey;
|
||||
/** One-line label shown in logs. */
|
||||
label: string;
|
||||
/** Instructions injected into the prompt to shape the AI's tone. */
|
||||
promptGuidance: string;
|
||||
}
|
||||
|
||||
export const TONE_PROFILES: Record<ToneKey, ToneProfile> = {
|
||||
default: {
|
||||
id: 'default',
|
||||
label: 'Standard',
|
||||
promptGuidance: '',
|
||||
},
|
||||
dominant: {
|
||||
id: 'dominant',
|
||||
label: 'Dominant',
|
||||
promptGuidance:
|
||||
'STYLE TONE: Be direct, firm, and commanding. Use imperative language ("send me", "I want", "show me"). ' +
|
||||
'Don\'t ask hesitantly — tell. Don\'t apologise or hedge. ' +
|
||||
'Be clear about what you want from her. Make it feel like an order wrapped in lust, not a request.',
|
||||
},
|
||||
submissive: {
|
||||
id: 'submissive',
|
||||
label: 'Submissive',
|
||||
promptGuidance:
|
||||
'STYLE TONE: Be deferential and eager to please. Ask what she wants and how you can make her feel good. ' +
|
||||
'Use language like "I\'d love to" "please" "if you\'d let me". ' +
|
||||
'Show enthusiasm for following her lead. Be warm, attentive, and completely focused on her pleasure.',
|
||||
},
|
||||
playful: {
|
||||
id: 'playful',
|
||||
label: 'Playful',
|
||||
promptGuidance:
|
||||
'STYLE TONE: Be fun, teasing, and light-hearted. Make her smile. Use playful challenges, light teasing, ' +
|
||||
'and cheeky banter. Keep it hot but with a grin — like you\'re having fun while flirting.',
|
||||
},
|
||||
direct: {
|
||||
id: 'direct',
|
||||
label: 'Direct',
|
||||
promptGuidance:
|
||||
'STYLE TONE: Cut straight to the point. No preamble, no dance. Say exactly what you noticed and what you want. ' +
|
||||
'Be honest and raw — she posted nudes to get a reaction, give her one. One or two sentences max.',
|
||||
},
|
||||
gentle: {
|
||||
id: 'gentle',
|
||||
label: 'Gentle',
|
||||
promptGuidance:
|
||||
'STYLE TONE: Be warm, complimenting, and let her feel desired without being crude. ' +
|
||||
'Focus on how her body makes you feel rather than explicit demands. ' +
|
||||
'Use phrases like "you\'re amazing", "I love your curves", "you look incredible". ' +
|
||||
'Still flirty and sexual but in a softer, more admiring way.',
|
||||
},
|
||||
'kink-friendly': {
|
||||
id: 'kink-friendly',
|
||||
label: 'Kink-Friendly',
|
||||
promptGuidance:
|
||||
'STYLE TONE: Be shameless and open about kinks. This is a kink-friendly subspace — ' +
|
||||
'don\'t hold back. Use explicit language freely. If the post hints at something specific, ' +
|
||||
'respond to that directly. Be bold about desires, into anything vibe. ' +
|
||||
'Reference kink-aware language naturally (dom/sub, bdsm, control, discipline, force) if it fits.',
|
||||
},
|
||||
teasing: {
|
||||
id: 'teasing',
|
||||
label: 'Teasing',
|
||||
promptGuidance:
|
||||
'STYLE TONE: Set up a payoff but don\'t deliver it straight away. Tease, provoke, make her ask for more. ' +
|
||||
'Use challenges ("bet you won\'t"), withhold compliments slightly, make her work for it. ' +
|
||||
'Leave her wanting to prove something or respond.',
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveTone(tone: string): ToneProfile {
|
||||
const key = TONE_KEYS.find((k) => k === tone) ?? 'default';
|
||||
return TONE_PROFILES[key];
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface RedditPost {
|
||||
created_utc: number;
|
||||
over_18: boolean;
|
||||
spoiler: boolean;
|
||||
link_flair_text?: string | null;
|
||||
}
|
||||
|
||||
// Extracts URL-ish tokens (with or without scheme) and returns the first
|
||||
|
||||
+94
-16
@@ -5,6 +5,7 @@ import type { Config } from '../config/index.js';
|
||||
import type { StrategySelector, SelectedStrategy } from './strategy-selector.js';
|
||||
import { downloadImage, bufferToBase64, isValidImageMimeType, type DownloadedImage } from '../utils/image.js';
|
||||
import { parseGenderTag } from '../utils/gender-tag.js';
|
||||
import { resolveTone, type ToneProfile, type ToneKey } from '../config/tone-profiles.js';
|
||||
|
||||
export interface ProcessedPost {
|
||||
post: RedditPost;
|
||||
@@ -12,6 +13,8 @@ export interface ProcessedPost {
|
||||
detectedGender: DetectedGender;
|
||||
generatedMessage: string;
|
||||
strategyId: string | null;
|
||||
toneId: ToneKey;
|
||||
scripted: boolean;
|
||||
}
|
||||
|
||||
export interface SkipResult {
|
||||
@@ -61,6 +64,22 @@ export class ProcessorService {
|
||||
return entry?.type ?? 'image';
|
||||
}
|
||||
|
||||
private getSubredditEntry(name: string): NonNullable<typeof entry> | undefined {
|
||||
const entry = this.config.reddit.subreddits.find((s) => s.name.toLowerCase() === name.toLowerCase());
|
||||
return entry ?? undefined;
|
||||
}
|
||||
|
||||
private resolveTone(name: string): { profile: ToneProfile; scripted: string | null } {
|
||||
const entry = this.getSubredditEntry(name);
|
||||
if (!entry) {
|
||||
return { profile: resolveTone('default'), scripted: null };
|
||||
}
|
||||
return {
|
||||
profile: resolveTone(entry.tone),
|
||||
scripted: entry.scripted ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
setSuccessfulExamples(examples: string[]): void {
|
||||
this.successfulExamples = examples;
|
||||
if (examples.length > 0) {
|
||||
@@ -133,18 +152,46 @@ export class ProcessorService {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve subreddit-specific tone profile
|
||||
const tone = this.resolveTone(post.subreddit);
|
||||
if (tone.scripted) {
|
||||
this.logger.info(`Using scripted message for r/${post.subreddit}`);
|
||||
} else if (tone.profile.id !== 'default') {
|
||||
this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`);
|
||||
}
|
||||
|
||||
// Full analysis and message generation (only for matching posts)
|
||||
let analysis;
|
||||
let message;
|
||||
try {
|
||||
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
||||
base64,
|
||||
imageData.mimeType,
|
||||
post.title,
|
||||
post.author,
|
||||
this.successfulExamples,
|
||||
selectedStrategy?.promptOverride
|
||||
));
|
||||
if (tone.scripted) {
|
||||
// Replace substitution tokens in the scripted prompt with post-specific data
|
||||
let scriptedPrompt = tone.scripted
|
||||
.replace('{title}', post.title)
|
||||
.replace('{author}', post.author)
|
||||
.replace('{flair}', post.link_flair_text || '(none)');
|
||||
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
||||
base64,
|
||||
imageData.mimeType,
|
||||
post.title,
|
||||
post.author,
|
||||
this.successfulExamples,
|
||||
scriptedPrompt,
|
||||
post.link_flair_text ?? undefined,
|
||||
undefined
|
||||
));
|
||||
} else {
|
||||
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
||||
base64,
|
||||
imageData.mimeType,
|
||||
post.title,
|
||||
post.author,
|
||||
this.successfulExamples,
|
||||
selectedStrategy?.promptOverride,
|
||||
post.link_flair_text ?? undefined,
|
||||
tone.profile.promptGuidance || undefined,
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
|
||||
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
|
||||
@@ -166,6 +213,8 @@ export class ProcessorService {
|
||||
detectedGender: analysis.detectedGender,
|
||||
generatedMessage: message,
|
||||
strategyId: selectedStrategy?.id || null,
|
||||
toneId: tone.profile.id,
|
||||
scripted: tone.scripted !== null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,16 +235,43 @@ export class ProcessorService {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve subreddit-specific tone profile
|
||||
const tone = this.resolveTone(post.subreddit);
|
||||
if (tone.scripted) {
|
||||
this.logger.info(`Using scripted opener for r/${post.subreddit}`);
|
||||
} else if (tone.profile.id !== 'default') {
|
||||
this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`);
|
||||
}
|
||||
|
||||
let message: string;
|
||||
try {
|
||||
message = await this.aiClient.generateChatOpener(
|
||||
post.title,
|
||||
post.selftext ?? '',
|
||||
post.author,
|
||||
detectedGender ?? 'unknown',
|
||||
this.successfulExamples,
|
||||
selectedStrategy?.promptOverride,
|
||||
);
|
||||
if (tone.scripted) {
|
||||
let scriptedPrompt = tone.scripted
|
||||
.replace('{title}', post.title)
|
||||
.replace('{author}', post.author)
|
||||
.replace('{flair}', post.link_flair_text || '(none)');
|
||||
message = await this.aiClient.generateChatOpener(
|
||||
post.title,
|
||||
post.selftext ?? '',
|
||||
post.author,
|
||||
detectedGender ?? 'unknown',
|
||||
this.successfulExamples,
|
||||
scriptedPrompt,
|
||||
post.link_flair_text ?? undefined,
|
||||
undefined
|
||||
);
|
||||
} else {
|
||||
message = await this.aiClient.generateChatOpener(
|
||||
post.title,
|
||||
post.selftext ?? '',
|
||||
post.author,
|
||||
detectedGender ?? 'unknown',
|
||||
this.successfulExamples,
|
||||
selectedStrategy?.promptOverride,
|
||||
post.link_flair_text ?? undefined,
|
||||
tone.profile.promptGuidance || undefined,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Chat opener generation failed for post ${post.id}:`, error);
|
||||
throw error;
|
||||
@@ -214,6 +290,8 @@ export class ProcessorService {
|
||||
detectedGender: detectedGender ?? 'unknown',
|
||||
generatedMessage: message,
|
||||
strategyId: selectedStrategy?.id || null,
|
||||
toneId: tone.profile.id,
|
||||
scripted: tone.scripted !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user