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

+119 -2
View File
@@ -11,6 +11,7 @@ const fakeFetchImage: ImageFetcher = async () => ({
import type { StrategySelector, SelectedStrategy } from '../src/services/strategy-selector.js';
import { createMockLogger } from '../tests/mockLogger.js';
import { getTestConfig } from '../tests/testUtils.js';
import type { ToneKey } from '../src/config/tone-profiles.js';
function createMockRedditApi(imageUrl: string | null = 'http://example.com/img.jpg'): RedditApiClient {
return {
@@ -32,9 +33,10 @@ function createMockAiClient(
generateMessage: vi.fn(async (analysis: string) => message),
generateReply: vi.fn(async () => 'Reply'),
detectUpsell: vi.fn(() => ({ isUpsell: false })),
analyzeAndGenerateMessage: vi.fn(async (base64: string, mimeType: string, title: string, author: string, examples?: string[], strategyPrompt?: string) => {
analyzeAndGenerateMessage: vi.fn(async (base64: string, mimeType: string, title: string, author: string, examples?: string[], strategyPrompt?: string, postFlair?: string | null, toneGuidance?: string) => {
return { analysis, message };
}),
generateChatOpener: vi.fn(async () => message),
} as unknown as OpenRouterClient;
}
@@ -88,6 +90,8 @@ describe('ProcessorService', () => {
expect(result.imageAnalysis).toBe('Beautiful woman');
expect(result.detectedGender).toBe('female');
expect(result.generatedMessage).toBe('Hey gorgeous!');
expect(result.toneId).toBe('default');
expect(result.scripted).toBe(false);
});
it('returns null when no image URL', async () => {
@@ -190,6 +194,8 @@ describe('ProcessorService', () => {
'Test Post',
'testuser',
['Example 1: Hey', 'Example 2: Nice'],
undefined,
undefined,
undefined
);
});
@@ -210,7 +216,9 @@ describe('ProcessorService', () => {
'Test Post',
'testuser',
[],
'Be teasing'
'Be teasing',
undefined,
undefined
);
});
@@ -250,6 +258,8 @@ describe('ProcessorService', () => {
'Test Post',
'testuser',
['Example A'],
undefined,
undefined,
undefined
);
});
@@ -304,6 +314,8 @@ describe('ProcessorService', () => {
'female',
[],
undefined,
undefined,
undefined,
);
});
@@ -333,6 +345,8 @@ describe('ProcessorService', () => {
'female',
[],
'Be playful',
undefined,
undefined,
);
});
});
@@ -346,4 +360,107 @@ describe('ProcessorService', () => {
expect(logExists).toBe(false);
});
});
describe('subreddit tone', () => {
it('logs tone name when non-default tone is set', async () => {
const config = getTestConfig({
reddit: {
subreddits: [
{ name: 'test1', type: 'image', tone: 'kink-friendly' },
{ name: 'test2', type: 'image' },
],
targetGenders: ['female'],
},
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const tonedPost = { ...mockPost, subreddit: 'test1' };
await processor.processPost(tonedPost);
expect(logger.logs.some(l => l.message.includes('Using tone "Kink-Friendly"'))).toBe(true);
});
it('respects tone guidance in message generation', async () => {
const config = getTestConfig({
reddit: {
subreddits: [
{ name: 'test1', type: 'image', tone: 'dominant' },
{ name: 'test2', type: 'image' },
],
targetGenders: ['female'],
},
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const tonedPost = { ...mockPost, subreddit: 'test1' };
await processor.processPost(tonedPost);
// 8th arg (index 7) after strategyPrompt and postFlair is toneGuidance
const callArgs = vi.mocked(aiClient.analyzeAndGenerateMessage).mock.calls[0];
expect(callArgs[7]).toContain('STYLE TONE');
expect(callArgs[7]).toContain('commanding');
});
it('creates result with correct toneId', async () => {
const config = getTestConfig({
reddit: {
subreddits: [
{ name: 'test1', type: 'image', tone: 'gentle' },
{ name: 'test2', type: 'image' },
],
targetGenders: ['female'],
},
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const tonedPost = { ...mockPost, subreddit: 'test1' };
const result = await processor.processPost(tonedPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.toneId).toBe('gentle');
expect(result.scripted).toBe(false);
});
it('passes tone guidance to chat opener', async () => {
const chatPost: RedditPost = {
...mockPost,
id: 't3_chat_tone',
subreddit: 'SextingRush',
title: '[25F] looking for a dom',
selftext: 'want to serve',
is_self: true,
};
const config = getTestConfig({
reddit: {
subreddits: [
{ name: 'SextingRush', type: 'chat', tone: 'dominant' },
],
targetGenders: ['female'],
},
});
const ai = {
...createMockAiClient(),
generateChatOpener: vi.fn(async () => 'Kneel down.'),
} as unknown as OpenRouterClient;
processor = new ProcessorService(redditApi, ai, config, logger, fakeFetchImage);
await processor.processPost(chatPost);
const callArgs = vi.mocked(ai.generateChatOpener).mock.calls[0];
expect(callArgs[7]).toContain('STYLE TONE');
expect(callArgs[7]).toContain('imperative');
});
it('uses default tone when subreddit is not configured', async () => {
const config = getTestConfig();
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const unknownPost = { ...mockPost, subreddit: 'unknownsub' };
const result = await processor.processPost(unknownPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.toneId).toBe('default');
});
});
});