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:
jjbruntonandClaude Opus 4.7 committed 2026-05-22 22:29:01 +01:00
1 parent 0c7df6e1d3
commit 46a213e3e0
10 files changed
+497 -51

No files matched your search

+94 -16
View File
@@ -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,
};
}
}