Add commenter targeting mode
This commit is contained in:
1 parent
0dfdda61fd
commit
e9a4c965ee
23 files changed
+374
-10
No files matched your search
+84
-2
@@ -3,7 +3,9 @@ import {
|
||||
RedditSubredditUnavailableError,
|
||||
type RedditApiClient,
|
||||
type RedditPost,
|
||||
type BrowsingThing,
|
||||
} from '../reddit/api.js';
|
||||
import type { CommentReactionCandidate, CommentReactionVerdict } from '../ai/openrouter.js';
|
||||
import type { PostsRepository } from '../database/repositories.js';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
@@ -33,11 +35,20 @@ export interface SubredditPollingControl {
|
||||
isSubredditEnabled(subreddit: string): boolean;
|
||||
}
|
||||
|
||||
export interface CommentReactionScreener {
|
||||
screenCommentReactions(comments: readonly CommentReactionCandidate[]): Promise<CommentReactionVerdict[]>;
|
||||
}
|
||||
|
||||
const COMMENT_REACTION_CONFIDENCE = 0.8;
|
||||
const COMMENT_POST_SCAN_LIMIT = 5;
|
||||
const COMMENTS_PER_POST_LIMIT = 20;
|
||||
|
||||
export class MonitorService {
|
||||
// Flips on each call so that when both pools have candidates the bot
|
||||
// alternates between image and chat instead of always favouring whichever
|
||||
// type the random shuffle put first.
|
||||
private chatGoesFirst = false;
|
||||
private readonly commentReactionCache = new Map<string, CommentReactionVerdict>();
|
||||
|
||||
constructor(
|
||||
private redditApi: RedditApiClient,
|
||||
@@ -47,6 +58,7 @@ export class MonitorService {
|
||||
private logger: Logger,
|
||||
private delayBetweenFetchesMs: number = 1000,
|
||||
private readonly pollingControl?: SubredditPollingControl,
|
||||
private readonly commentReactionScreener?: CommentReactionScreener,
|
||||
) { }
|
||||
|
||||
async getUnprocessedPosts(): Promise<RedditPost[]> {
|
||||
@@ -65,7 +77,10 @@ export class MonitorService {
|
||||
for (const subreddit of subreddits) {
|
||||
try {
|
||||
this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`);
|
||||
const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
|
||||
const listedPosts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
|
||||
const posts = subreddit.targetingMode === 'comment-author'
|
||||
? await this.findCommentCandidates(listedPosts)
|
||||
: listedPosts;
|
||||
const summary: ListingScreeningSummary = {
|
||||
scanned: 0,
|
||||
alreadyProcessed: 0,
|
||||
@@ -211,7 +226,9 @@ export class MonitorService {
|
||||
if (!this.isSubredditEnabled(subreddit.name)) return [];
|
||||
try {
|
||||
this.logger.debug(`Collecting candidate posts from r/${subreddit.name} (${subreddit.type})...`);
|
||||
return await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
|
||||
const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
|
||||
if (subreddit.targetingMode !== 'comment-author') return posts;
|
||||
return await this.findCommentCandidates(posts);
|
||||
} catch (error) {
|
||||
if (error instanceof RedditRateLimitError) throw error;
|
||||
if (error instanceof RedditSubredditUnavailableError) {
|
||||
@@ -225,6 +242,71 @@ export class MonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
private async findCommentCandidates(posts: readonly RedditPost[]): Promise<RedditPost[]> {
|
||||
if (!this.commentReactionScreener) {
|
||||
this.logger.warn('Comment-author targeting is configured but no reaction screener is available');
|
||||
return [];
|
||||
}
|
||||
const pairs: Array<{ post: RedditPost; comment: BrowsingThing }> = [];
|
||||
for (const post of posts.slice(0, COMMENT_POST_SCAN_LIMIT)) {
|
||||
const comments = await this.redditApi.getBrowsingComments(
|
||||
post.subreddit,
|
||||
post.id.replace(/^t3_/, ''),
|
||||
COMMENTS_PER_POST_LIMIT,
|
||||
);
|
||||
for (const comment of comments) {
|
||||
if (comment.removed || comment.author === '[deleted]' || comment.author === 'AutoModerator') continue;
|
||||
if (comment.author.toLowerCase() === post.author.toLowerCase()) continue;
|
||||
if (comment.text.trim().length < 8) continue;
|
||||
pairs.push({ post, comment });
|
||||
}
|
||||
}
|
||||
if (pairs.length === 0) return [];
|
||||
|
||||
const unseen = pairs.filter(({ comment }) => !this.commentReactionCache.has(comment.fullname));
|
||||
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) ?? {
|
||||
id: comment.fullname,
|
||||
qualifies: false,
|
||||
expressedGender: 'unknown',
|
||||
confidence: 0,
|
||||
evidence: 'Classifier returned no verdict',
|
||||
});
|
||||
}
|
||||
while (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 [];
|
||||
return [{
|
||||
...post,
|
||||
id: comment.fullname,
|
||||
name: comment.fullname,
|
||||
author: comment.author,
|
||||
author_fullname: undefined,
|
||||
permalink: comment.permalink,
|
||||
created_utc: comment.createdAt / 1_000,
|
||||
targetComment: {
|
||||
body: comment.text,
|
||||
permalink: comment.permalink,
|
||||
sourcePostId: post.id,
|
||||
sourcePostAuthor: post.author,
|
||||
expressedGender: verdict.expressedGender,
|
||||
confidence: verdict.confidence,
|
||||
},
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply cheap eligibility checks before any profile or AI work. */
|
||||
async screenCandidate(post: RedditPost, sourceType: 'image' | 'chat'): Promise<CandidateScreeningResult> {
|
||||
if (this.recipientHistory.hasSentTo(post.author)) {
|
||||
|
||||
@@ -198,7 +198,7 @@ export class ProcessorService {
|
||||
.replace('{author}', post.author)
|
||||
.replace('{flair}', post.link_flair_text || '(none)')
|
||||
: selectedStrategy?.promptOverride;
|
||||
const message = await this.aiClient.generateMessage(
|
||||
const messageArgs = [
|
||||
analysis.description,
|
||||
post.title,
|
||||
post.author,
|
||||
@@ -208,7 +208,13 @@ export class ProcessorService {
|
||||
post.link_flair_text ?? undefined,
|
||||
tone.scripted ? undefined : tone.profile.promptGuidance || undefined,
|
||||
this.recentMessageProvider('image'),
|
||||
);
|
||||
] as const;
|
||||
const message = post.targetComment
|
||||
? await this.aiClient.generateMessage(
|
||||
...messageArgs,
|
||||
`<<<UNTRUSTED PUBLIC COMMENT>>>\n${post.targetComment.body.slice(0, 500)}\n<<<END COMMENT>>>`,
|
||||
)
|
||||
: await this.aiClient.generateMessage(...messageArgs);
|
||||
|
||||
if (!message || message.length < 10) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -206,6 +206,7 @@ async function buildRuntime(
|
||||
const monitorService = new MonitorService(
|
||||
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
|
||||
1000, createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
|
||||
aiClient,
|
||||
);
|
||||
const processorService = new ProcessorService(
|
||||
redditApi,
|
||||
|
||||
Reference in new issue
Block a user