Persist comment source monitoring

This commit is contained in:
jjbrunton committed 2026-08-14 13:40:55 +01:00
1 parent 5e1fc5def9
commit 8d932fcf36
16 files changed
+376 -24

No files matched your search

+82 -14
View File
@@ -7,6 +7,7 @@ import {
} from '../reddit/api.js';
import type { CommentReactionCandidate, CommentReactionVerdict } from '../ai/openrouter.js';
import type { PostsRepository } from '../database/repositories.js';
import type { CommentSourceWatchRepository } from '../database/comment-source-watch-repository.js';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
import { parseGenderTag } from '../utils/gender-tag.js';
@@ -42,6 +43,7 @@ export interface CommentReactionScreener {
const COMMENT_REACTION_CONFIDENCE = 0.8;
const COMMENT_POST_SCAN_LIMIT = 5;
const COMMENTS_PER_POST_LIMIT = 20;
const WATCHED_COMMENTS_PER_POST_LIMIT = 100;
export class MonitorService {
// Flips on each call so that when both pools have candidates the bot
@@ -59,6 +61,7 @@ export class MonitorService {
private delayBetweenFetchesMs: number = 1000,
private readonly pollingControl?: SubredditPollingControl,
private readonly commentReactionScreener?: CommentReactionScreener,
private readonly commentSourceWatches?: CommentSourceWatchRepository,
) { }
async getUnprocessedPosts(): Promise<RedditPost[]> {
@@ -79,7 +82,7 @@ export class MonitorService {
this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`);
const listedPosts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
const posts = subreddit.targetingMode === 'comment-author'
? await this.findCommentCandidates(listedPosts)
? await this.findCommentCandidates(listedPosts, subreddit.name)
: listedPosts;
const summary: ListingScreeningSummary = {
scanned: 0,
@@ -226,9 +229,13 @@ export class MonitorService {
if (!this.isSubredditEnabled(subreddit.name)) return [];
try {
this.logger.debug(`Collecting candidate posts from r/${subreddit.name} (${subreddit.type})...`);
const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
const posts = await this.redditApi.getNewPosts(
subreddit.name,
subreddit.targetingMode === 'comment-author' ? 100 : 25,
subreddit.type,
);
if (subreddit.targetingMode !== 'comment-author') return posts;
return await this.findCommentCandidates(posts);
return await this.findCommentCandidates(posts, subreddit.name);
} catch (error) {
if (error instanceof RedditRateLimitError) throw error;
if (error instanceof RedditSubredditUnavailableError) {
@@ -242,18 +249,34 @@ export class MonitorService {
}
}
private async findCommentCandidates(posts: readonly RedditPost[]): Promise<RedditPost[]> {
private async findCommentCandidates(posts: readonly RedditPost[], subreddit: string): Promise<RedditPost[]> {
if (!this.commentReactionScreener) {
this.logger.warn('Comment-author targeting is configured but no reaction screener is available');
return [];
}
let sourcePosts = posts.slice(0, COMMENT_POST_SCAN_LIMIT);
if (this.commentSourceWatches) {
const watch = this.commentSourceWatches.refreshSources(
posts,
this.config.reddit.recentWindowMinutes * 60_000,
);
const due = this.commentSourceWatches.nextSource(subreddit);
sourcePosts = due ? [due] : [];
this.logger.debug(
`Comment watch r/${subreddit}: ${watch.added} source(s) added; ${watch.expired} expired; ${watch.active} active`,
);
}
const pairs: Array<{ post: RedditPost; comment: BrowsingThing }> = [];
for (const post of posts.slice(0, COMMENT_POST_SCAN_LIMIT)) {
let commentsRead = 0;
for (const post of sourcePosts) {
const comments = await this.redditApi.getBrowsingComments(
post.subreddit,
post.id.replace(/^t3_/, ''),
COMMENTS_PER_POST_LIMIT,
this.commentSourceWatches ? WATCHED_COMMENTS_PER_POST_LIMIT : COMMENTS_PER_POST_LIMIT,
this.commentSourceWatches ? 'new' : 'hot',
);
commentsRead += comments.length;
this.commentSourceWatches?.markChecked(post.id);
for (const comment of comments) {
if (comment.removed || comment.author === '[deleted]' || comment.author === 'AutoModerator') continue;
if (comment.author.toLowerCase() === post.author.toLowerCase()) continue;
@@ -261,16 +284,23 @@ export class MonitorService {
pairs.push({ post, comment });
}
}
if (pairs.length === 0) return [];
if (pairs.length === 0) {
if (this.commentSourceWatches && sourcePosts.length > 0) {
this.logger.info(
`Comment watch r/${subreddit}: ${commentsRead} newest comment(s); 0 eligible for classification; 0 accepted`,
);
}
return [];
}
const unseen = pairs.filter(({ comment }) => !this.commentReactionCache.has(comment.fullname));
const unseen = pairs.filter(({ comment }) => this.getCommentReactionVerdict(comment) === null);
if (unseen.length > 0) {
const verdicts = await this.commentReactionScreener.screenCommentReactions(
unseen.map(({ comment }) => ({ id: comment.fullname, text: comment.text })),
);
const returned = new Map(verdicts.map((verdict) => [verdict.id, verdict]));
for (const { comment } of unseen) {
this.commentReactionCache.set(comment.fullname, returned.get(comment.fullname) ?? {
this.storeCommentReactionVerdict(comment, returned.get(comment.fullname) ?? {
id: comment.fullname,
qualifies: false,
expressedGender: 'unknown',
@@ -278,17 +308,32 @@ export class MonitorService {
evidence: 'Classifier returned no verdict',
});
}
while (this.commentReactionCache.size > 2_000) {
while (!this.commentSourceWatches && this.commentReactionCache.size > 2_000) {
const oldest = this.commentReactionCache.keys().next().value;
if (typeof oldest !== 'string') break;
this.commentReactionCache.delete(oldest);
}
}
return pairs.flatMap(({ post, comment }): RedditPost[] => {
const verdict = this.commentReactionCache.get(comment.fullname);
if (!verdict?.qualifies || verdict.confidence < COMMENT_REACTION_CONFIDENCE) return [];
let roleRejected = 0;
let lowConfidence = 0;
let genderRejected = 0;
const genderRejectedBy = { male: 0, female: 0, other: 0, unknown: 0 };
const candidates = pairs.flatMap(({ post, comment }): RedditPost[] => {
const verdict = this.getCommentReactionVerdict(comment);
if (!verdict?.qualifies) {
roleRejected += 1;
return [];
}
if (verdict.confidence < COMMENT_REACTION_CONFIDENCE) {
lowConfidence += 1;
return [];
}
const targetGenders = this.config.reddit.targetGenders;
if (targetGenders.length > 0 && !targetGenders.includes(verdict.expressedGender)) return [];
if (targetGenders.length > 0 && !targetGenders.includes(verdict.expressedGender)) {
genderRejected += 1;
genderRejectedBy[verdict.expressedGender] += 1;
return [];
}
return [{
...post,
id: comment.fullname,
@@ -308,6 +353,29 @@ export class MonitorService {
},
}];
});
if (this.commentSourceWatches && sourcePosts.length > 0) {
this.logger.info(
`Comment watch r/${subreddit}: ${commentsRead} newest comment(s); ${commentsRead - pairs.length} basic rejected; `
+ `${pairs.length} eligible for classification; ${unseen.length} newly classified; `
+ `${roleRejected} role rejected; ${lowConfidence} low confidence; ${genderRejected} gender rejected; `
+ `gender detail male=${genderRejectedBy.male}, female=${genderRejectedBy.female}, `
+ `other=${genderRejectedBy.other}, unknown=${genderRejectedBy.unknown}; ${candidates.length} accepted`,
);
}
return candidates;
}
private getCommentReactionVerdict(comment: BrowsingThing): CommentReactionVerdict | null {
if (this.commentSourceWatches) return this.commentSourceWatches.getVerdict(comment);
return this.commentReactionCache.get(comment.fullname) ?? null;
}
private storeCommentReactionVerdict(comment: BrowsingThing, verdict: CommentReactionVerdict): void {
if (this.commentSourceWatches) {
this.commentSourceWatches.recordVerdict(comment, verdict);
return;
}
this.commentReactionCache.set(comment.fullname, verdict);
}
/** Apply cheap eligibility checks before any profile or AI work. */
@@ -68,6 +68,7 @@ import { AccountEstablishmentService } from './account-establishment.js';
import { AccountGrowthService, type GrowthActivityEvent } from './account-growth.js';
import { AccountGrowthRepository } from '../database/growth-repository.js';
import { AccountBrowsingSimulationRepository } from '../database/browsing-simulation-repository.js';
import { CommentSourceWatchRepository } from '../database/comment-source-watch-repository.js';
import { AccountBrowsingSimulationService } from './account-browsing-simulation.js';
import { allowsConversationSync, allowsSending, type BotMode } from './bot-mode.js';
import {
@@ -207,6 +208,7 @@ async function buildRuntime(
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
1000, createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
aiClient,
new CommentSourceWatchRepository(db),
);
const processorService = new ProcessorService(
redditApi,