Persist comment source monitoring
This commit is contained in:
1 parent
5e1fc5def9
commit
8d932fcf36
16 files changed
+376
-24
No files matched your search
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../src/database/client.js';
|
||||
import { CommentSourceWatchRepository } from '../src/database/comment-source-watch-repository.js';
|
||||
import type { BrowsingThing, RedditPost } from '../src/reddit/api.js';
|
||||
|
||||
function post(id: string, createdAtMs: number): RedditPost {
|
||||
return {
|
||||
id, name: id, subreddit: 'scene', author: `author-${id}`, title: id,
|
||||
url: `https://example.test/${id}.jpg`, permalink: `/r/scene/comments/${id}/`, thumbnail: '',
|
||||
is_self: false, created_utc: createdAtMs / 1_000, over_18: true, spoiler: false,
|
||||
};
|
||||
}
|
||||
|
||||
function comment(text: string): BrowsingThing {
|
||||
return {
|
||||
fullname: 't1_comment', kind: 'comment', subreddit: 'scene', author: 'reader',
|
||||
permalink: '/r/scene/comments/source/post/comment/', createdAt: 2_000, score: 1,
|
||||
text, nsfw: true, parentPostId: 'source', distinguished: null, removed: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CommentSourceWatchRepository', () => {
|
||||
let db: Database.Database;
|
||||
let repo: CommentSourceWatchRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new CommentSourceWatchRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it('durably rotates source posts until their configured watch window expires', () => {
|
||||
expect(repo.refreshSources([post('older', 1_000), post('newer', 2_000)], 10_000, 3_000))
|
||||
.toEqual({ added: 2, active: 2, expired: 0 });
|
||||
expect(repo.nextSource('SCENE', 3_000)?.id).toBe('newer');
|
||||
|
||||
repo.markChecked('newer', 3_100);
|
||||
expect(repo.nextSource('scene', 3_200)?.id).toBe('older');
|
||||
|
||||
const restored = new CommentSourceWatchRepository(db);
|
||||
expect(restored.nextSource('scene', 3_200)?.id).toBe('older');
|
||||
expect(restored.refreshSources([], 10_000, 12_001)).toEqual({ added: 0, active: 0, expired: 2 });
|
||||
});
|
||||
|
||||
it('reuses unchanged verdicts across restarts and reclassifies edited bodies', () => {
|
||||
const original = comment('I would love that done to me');
|
||||
const verdict = {
|
||||
id: original.fullname, qualifies: true, expressedGender: 'female' as const,
|
||||
confidence: 0.94, evidence: 'recipient role',
|
||||
};
|
||||
repo.recordVerdict(original, verdict, 4_000);
|
||||
|
||||
expect(new CommentSourceWatchRepository(db).getVerdict(original)).toEqual({ ...verdict, evidence: '' });
|
||||
const stored = db.prepare(`
|
||||
SELECT body_sha256, verdict_json FROM comment_source_comment_observations WHERE comment_id = ?
|
||||
`).get(original.fullname);
|
||||
expect(JSON.stringify(stored)).not.toContain(original.text);
|
||||
expect(JSON.stringify(stored)).not.toContain(verdict.evidence);
|
||||
expect(repo.getVerdict(comment('Edited: I would really love that done to me'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -108,9 +108,11 @@ describe('Database Client', () => {
|
||||
.all() as { name: string }[];
|
||||
// Existing domain/config tables plus account, conversation, sync, media
|
||||
// ownership and account-growth stores.
|
||||
expect(tables.length).toBe(33);
|
||||
expect(tables.length).toBe(35);
|
||||
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
||||
'candidate_queue',
|
||||
'comment_source_watches',
|
||||
'comment_source_comment_observations',
|
||||
'conversation_events',
|
||||
'conversation_learning_trajectories',
|
||||
'conversation_learning_decisions',
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
import type { PostsRepository, MessagesRepository } from '../src/database/repositories.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../src/database/client.js';
|
||||
import { CommentSourceWatchRepository } from '../src/database/comment-source-watch-repository.js';
|
||||
|
||||
function createMockRedditApi(posts: RedditPost[] = []): RedditApiClient {
|
||||
return {
|
||||
@@ -677,6 +680,60 @@ describe('MonitorService', () => {
|
||||
});
|
||||
|
||||
describe('comment-author targeting', () => {
|
||||
it('keeps watching a durable source and classifies only new comments newest-first', async () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
try {
|
||||
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/', thumbnail: '',
|
||||
is_self: false, created_utc: Date.now() / 1_000, over_18: true, spoiler: false,
|
||||
};
|
||||
const first = {
|
||||
fullname: 't1_first', kind: 'comment' as const, subreddit: 'scene', author: 'first',
|
||||
permalink: '/first', createdAt: Date.now(), score: 1, text: 'As a woman I would love that done to me',
|
||||
nsfw: true, parentPostId: 'post1', distinguished: null, removed: false,
|
||||
};
|
||||
const second = { ...first, fullname: 't1_second', author: 'second', permalink: '/second' };
|
||||
const redditApi = createMockRedditApi([source]);
|
||||
redditApi.getNewPosts = vi.fn()
|
||||
.mockResolvedValueOnce([source])
|
||||
.mockResolvedValueOnce([]);
|
||||
redditApi.getBrowsingComments = vi.fn()
|
||||
.mockResolvedValueOnce([first])
|
||||
.mockResolvedValueOnce([second, first]);
|
||||
const screener = { screenCommentReactions: vi.fn(async (comments) => comments.map((item) => ({
|
||||
id: item.id, qualifies: true, expressedGender: 'female' as const, confidence: 0.95, evidence: 'recipient role',
|
||||
}))) };
|
||||
const config = getTestConfig({ reddit: {
|
||||
subreddits: [{ name: 'scene', type: 'image', targetingMode: 'comment-author' }],
|
||||
targetGenders: ['female'], recentWindowMinutes: 600,
|
||||
} });
|
||||
monitor = new MonitorService(
|
||||
redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, undefined, screener,
|
||||
new CommentSourceWatchRepository(db),
|
||||
);
|
||||
|
||||
await expect(monitor.fetchCandidates(config.reddit.subreddits[0]))
|
||||
.resolves.toEqual([expect.objectContaining({ id: 't1_first' })]);
|
||||
await expect(monitor.fetchCandidates(config.reddit.subreddits[0]))
|
||||
.resolves.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 't1_first' }),
|
||||
expect.objectContaining({ id: 't1_second' }),
|
||||
]));
|
||||
expect(screener.screenCommentReactions).toHaveBeenNthCalledWith(1, [
|
||||
{ id: 't1_first', text: first.text },
|
||||
]);
|
||||
expect(screener.screenCommentReactions).toHaveBeenNthCalledWith(2, [
|
||||
{ id: 't1_second', text: second.text },
|
||||
]);
|
||||
expect(redditApi.getNewPosts).toHaveBeenCalledWith('scene', 100, 'image');
|
||||
expect(redditApi.getBrowsingComments).toHaveBeenCalledWith('scene', 'post1', 100, 'new');
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
|
||||
@@ -54,6 +54,20 @@ describe('RedditApiClient', () => {
|
||||
params: { limit: 10, depth: 1, sort: 'hot', raw_json: 1 }, headers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('can request newest comments for a durable source-post watch', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: [{ data: { children: [] } }, { data: { children: [] } }],
|
||||
});
|
||||
vi.mocked(axios.create).mockReturnValue({ get } as unknown as AxiosInstance);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
|
||||
await client2.getBrowsingComments('scene', 'source', 100, 'new');
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/r/scene/comments/source.json', {
|
||||
params: { limit: 100, depth: 1, sort: 'new', raw_json: 1 }, headers: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasImage', () => {
|
||||
|
||||
Reference in new issue
Block a user