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
+38
-1
@@ -41,6 +41,43 @@ describe('ConfigSchema', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('applies default tone to subreddits', () => {
|
||||
const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } }));
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.reddit.subreddits[0].tone).toBe('default');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts valid tone values', () => {
|
||||
for (const tone of ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing']) {
|
||||
const result = ConfigSchema.safeParse({
|
||||
reddit: { subreddits: [{ name: 'test', tone }] },
|
||||
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid tone', () => {
|
||||
const result = ConfigSchema.safeParse({
|
||||
reddit: { subreddits: [{ name: 'test', tone: 'bananas' }] },
|
||||
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts scripted field', () => {
|
||||
const result = ConfigSchema.safeParse({
|
||||
reddit: { subreddits: [{ name: 'test', scripted: 'Hello {author}' }] },
|
||||
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid targetGenders', () => {
|
||||
const result = ConfigSchema.safeParse(minimalConfig({
|
||||
reddit: { subreddits: ['test'], targetGenders: ['male', 'female', 'other', 'unknown'] },
|
||||
@@ -306,7 +343,7 @@ openrouter:
|
||||
`);
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.reddit.subreddits).toEqual([{ name: 'test-sub', type: 'image' }]);
|
||||
expect(config.reddit.subreddits).toEqual([{ name: 'test-sub', type: 'image', tone: 'default' }]);
|
||||
expect(config.reddit.pollIntervalMs).toBe(5000);
|
||||
expect(config.openrouter.apiKey).toBe('yaml-key');
|
||||
});
|
||||
|
||||
+119
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TONE_KEYS,
|
||||
TONE_PROFILES,
|
||||
resolveTone,
|
||||
type ToneKey,
|
||||
} from '../src/config/tone-profiles.js';
|
||||
|
||||
describe('Tone profiles', () => {
|
||||
describe('TONE_KEYS', () => {
|
||||
it('includes default', () => {
|
||||
expect(TONE_KEYS).toContain('default');
|
||||
});
|
||||
|
||||
it('includes all expected profile keys', () => {
|
||||
const expected: ToneKey[] = [
|
||||
'default',
|
||||
'dominant',
|
||||
'submissive',
|
||||
'playful',
|
||||
'direct',
|
||||
'gentle',
|
||||
'kink-friendly',
|
||||
'teasing',
|
||||
];
|
||||
expect(TONE_KEYS).toEqual(expect.arrayContaining(expected));
|
||||
});
|
||||
|
||||
it('defines no duplicate keys', () => {
|
||||
expect(new Set(TONE_KEYS).size).toBe(TONE_KEYS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TONE_PROFILES', () => {
|
||||
for (const key of TONE_KEYS) {
|
||||
it(`has a profile for ${key}`, () => {
|
||||
expect(TONE_PROFILES[key]).toBeDefined();
|
||||
expect(TONE_PROFILES[key].id).toBe(key);
|
||||
expect(typeof TONE_PROFILES[key].label).toBe('string');
|
||||
expect(typeof TONE_PROFILES[key].promptGuidance).toBe('string');
|
||||
});
|
||||
}
|
||||
|
||||
it('default profile has empty guidance', () => {
|
||||
expect(TONE_PROFILES.default.promptGuidance).toBe('');
|
||||
});
|
||||
|
||||
it('non-default profiles have non-empty guidance', () => {
|
||||
for (const key of TONE_KEYS) {
|
||||
if (key === 'default') continue;
|
||||
expect(TONE_PROFILES[key].promptGuidance.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTone', () => {
|
||||
it('returns default for unknown tone string', () => {
|
||||
const result = resolveTone('nonexistent');
|
||||
expect(result.id).toBe('default');
|
||||
});
|
||||
|
||||
it('returns the matching profile for valid keys', () => {
|
||||
expect(resolveTone('dominant').id).toBe('dominant');
|
||||
expect(resolveTone('submissive').id).toBe('submissive');
|
||||
expect(resolveTone('kink-friendly').id).toBe('kink-friendly');
|
||||
expect(resolveTone('gentle').id).toBe('gentle');
|
||||
expect(resolveTone('teasing').id).toBe('teasing');
|
||||
expect(resolveTone('playful').id).toBe('playful');
|
||||
expect(resolveTone('direct').id).toBe('direct');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user