Add commenter targeting mode

This commit is contained in:
Jamie Brunton committed 2026-08-14 08:51:27 +01:00
1 parent 0dfdda61fd
commit e9a4c965ee
23 files changed
+374 -10

No files matched your search

+1 -1
View File
@@ -434,7 +434,7 @@ openrouter:
const config = loadConfig();
expect(config.reddit.subreddits).toEqual([{
name: 'test-sub', enabled: true, type: 'image', tone: 'default',
name: 'test-sub', enabled: true, type: 'image', targetingMode: 'post-author', tone: 'default',
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
}]);
expect(config.reddit.pollIntervalMs).toBe(5000);
+10 -1
View File
@@ -179,6 +179,16 @@ describe('subreddits page', () => {
});
describe('editing one subreddit', () => {
it('can target qualifying commenters on an image subreddit', async () => {
renderSubreddits();
const dialog = await openEditor('sleepy');
const user = userEvent.setup();
await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Recipient' }), 'comment-author');
await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0].find((item: any) => item.name === 'sleepy').targetingMode).toBe('comment-author');
});
it('previews the share the chosen weight will actually produce', async () => {
renderSubreddits();
const dialog = await openEditor('casualconversation');
@@ -597,4 +607,3 @@ describe('subreddits page', () => {
expect(await findRows()).toHaveLength(3);
});
});
+78
View File
@@ -675,4 +675,82 @@ describe('MonitorService', () => {
expect(result[0].id).toBe('t3_m');
});
});
describe('comment-author targeting', () => {
it('turns a high-confidence targeted reaction into a commenter candidate with source media intact', async () => {
const source: RedditPost = {
id: 'post1', name: 't3_post1', subreddit: 'scene', author: 'poster', title: 'A scene',
url: 'https://example.com/scene.jpg', permalink: '/r/scene/comments/post1/a_scene/', thumbnail: '',
is_self: false, created_utc: 100, over_18: true, spoiler: false,
};
const redditApi = createMockRedditApi([source]);
redditApi.getBrowsingComments = vi.fn().mockResolvedValue([
{
fullname: 't1_match', kind: 'comment', subreddit: 'scene', author: 'reactor',
permalink: '/r/scene/comments/post1/a_scene/match/', createdAt: 101_000, score: 2,
text: "I'd love that to be done to me", nsfw: true, parentPostId: 'post1',
distinguished: null, removed: false,
},
{
fullname: 't1_praise', kind: 'comment', subreddit: 'scene', author: 'fan',
permalink: '/r/scene/comments/post1/a_scene/praise/', createdAt: 102_000, score: 1,
text: 'Great post, thanks for sharing', nsfw: true, parentPostId: 'post1',
distinguished: null, removed: false,
},
]);
const screener = {
screenCommentReactions: vi.fn().mockResolvedValue([
{ id: 't1_match', qualifies: true, expressedGender: 'female', confidence: 0.94, evidence: 'personal desire' },
{ id: 't1_praise', qualifies: false, expressedGender: 'unknown', confidence: 0.98, evidence: 'generic praise' },
]),
};
const config = getTestConfig({ reddit: {
subreddits: [{ name: 'scene', type: 'image', targetingMode: 'comment-author' }],
targetGenders: ['female'],
} });
monitor = new MonitorService(
redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, undefined, screener,
);
const candidates = await monitor.fetchCandidates(config.reddit.subreddits[0]);
expect(candidates).toHaveLength(1);
expect(candidates[0]).toMatchObject({
id: 't1_match', name: 't1_match', author: 'reactor', url: source.url,
permalink: '/r/scene/comments/post1/a_scene/match/',
targetComment: {
body: "I'd love that to be done to me", sourcePostId: 'post1',
sourcePostAuthor: 'poster', expressedGender: 'female', confidence: 0.94,
},
});
await monitor.fetchCandidates(config.reddit.subreddits[0]);
expect(screener.screenCommentReactions).toHaveBeenCalledTimes(1);
});
it('fails closed on low-confidence or non-qualifying reactions', async () => {
const source = {
id: 'post1', name: 't3_post1', subreddit: 'scene', author: 'poster', title: 'A scene',
url: 'https://example.com/scene.jpg', permalink: '/r/scene/comments/post1/', thumbnail: '',
is_self: false, created_utc: 100, over_18: true, spoiler: false,
} as RedditPost;
const redditApi = createMockRedditApi([source]);
redditApi.getBrowsingComments = vi.fn().mockResolvedValue([
{ fullname: 't1_low', author: 'a', text: 'I want this as well', removed: false, createdAt: 101_000, permalink: '/low' },
{ fullname: 't1_male', author: 'b', text: 'As a man I want this', removed: false, createdAt: 102_000, permalink: '/male' },
]);
const screener = { screenCommentReactions: vi.fn().mockResolvedValue([
{ id: 't1_low', qualifies: true, expressedGender: 'female', confidence: 0.79, evidence: '' },
{ id: 't1_male', qualifies: false, expressedGender: 'male', confidence: 0.99, evidence: '' },
]) };
const config = getTestConfig({ reddit: {
subreddits: [{ name: 'scene', type: 'image', targetingMode: 'comment-author' }],
targetGenders: ['female'],
} });
monitor = new MonitorService(
redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, undefined, screener,
);
await expect(monitor.fetchCandidates(config.reddit.subreddits[0])).resolves.toEqual([]);
});
});
});
+29
View File
@@ -135,6 +135,24 @@ describe('OpenRouterClient', () => {
});
});
describe('screenCommentReactions', () => {
it('parses batched reaction verdicts and fences comments as untrusted data', async () => {
mockCreate.mockResolvedValueOnce({
model: 'chat-model', usage: {},
choices: [{ message: { content: '{"results":[{"id":"t1_a","qualifies":true,"expressedGender":"female","confidence":0.92,"evidence":"wants the depicted role"}]}' } }],
});
await expect(client.screenCommentReactions([
{ id: 't1_a', text: "I'd love that to be done to me" },
])).resolves.toEqual([
{ id: 't1_a', qualifies: true, expressedGender: 'female', confidence: 0.92, evidence: 'wants the depicted role' },
]);
const request = mockCreate.mock.calls[0][0];
expect(request.temperature).toBe(0);
expect(request.messages[1].content).toContain('<<<UNTRUSTED COMMENT>>>');
});
});
describe('isRefusal (internal)', () => {
// We test refusal detection by checking the analyzeImage behavior
// since isRefusal is private. We do this by mocking responses that
@@ -638,6 +656,17 @@ describe('OpenRouterClient', () => {
expect(prompt).toContain('different opening words');
});
it('states that a comment-target recipient is not the person depicted', async () => {
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: 'That comment caught my attention.' } }] });
await client.generateMessage(
'Analysis', 'Title', 'Commenter', [], undefined, null, null, undefined, [],
'<<<UNTRUSTED PUBLIC COMMENT>>>\nI want that too\n<<<END COMMENT>>>',
);
const prompt = mockCreate.mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('The recipient commented on somebody else\'s post');
expect(prompt).toContain('Do not imply that the image depicts the recipient');
});
it('substitutes {rating} placeholder when score provided', async () => {
const cfg = getTestConfig({
openrouter: {
+21
View File
@@ -99,6 +99,27 @@ describe('ProcessorService', () => {
expect(result.scripted).toBe(false);
});
it('passes a commenter-specific context without treating the source image as theirs', async () => {
await processor.processPost({
...mockPost,
id: 't1_comment',
author: 'commenter',
targetComment: {
body: "I'd love that to be done to me",
permalink: '/r/test/comments/abc/test/comment/',
sourcePostId: 't3_test',
sourcePostAuthor: 'testuser',
expressedGender: 'unknown',
confidence: 0.95,
},
});
expect(aiClient.generateMessage).toHaveBeenCalledWith(
'Beautiful woman', 'Test Post', 'commenter', [], undefined, null, undefined, undefined, [],
expect.stringContaining("I'd love that to be done to me"),
);
});
it('returns null when no image URL', async () => {
redditApi = createMockRedditApi(null);
const config = getTestConfig();