Persist comment source monitoring
This commit is contained in:
1 parent
5e1fc5def9
commit
8d932fcf36
16 files changed
+376
-24
No files matched your search
+82
-14
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user