Add durable candidate funnel
This commit is contained in:
1 parent
5abac31c28
commit
df35648920
20 files changed
+1128
-142
No files matched your search
@@ -17,7 +17,7 @@ import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddi
|
||||
import { RedditRequestCoordinator } from '../reddit/request-coordinator.js';
|
||||
import { OpenRouterClient } from '../ai/openrouter.js';
|
||||
import { MonitorService } from './monitor.js';
|
||||
import { ProcessorService, isSkip } from './processor.js';
|
||||
import { ProcessorService, isSkip, type ProcessResult, type ProcessedPost } from './processor.js';
|
||||
import { ResponseCheckerService } from './response-checker.js';
|
||||
import { StrategySelector } from './strategy-selector.js';
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ import { RateLimiter } from '../utils/rate-limiter.js';
|
||||
import {
|
||||
RedditAccountRuntime, RedditAccountRuntimeFactory,
|
||||
accountBrowserProfileDir, type RedditAccountRuntimeResources,
|
||||
type VerifiedAccountSession,
|
||||
type VerifiedAccountSession, type PreparedDeliveryResult,
|
||||
} from './reddit-account-runtime.js';
|
||||
import { RecipientDeduplicator } from './recipient-deduplicator.js';
|
||||
import { ConfigStore } from '../config/persistence.js';
|
||||
@@ -291,80 +291,89 @@ class AccountRuntimeLoop {
|
||||
}
|
||||
|
||||
async dispatchPost(post: RedditPost, signal?: AbortSignal): Promise<void> {
|
||||
const { postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources;
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase());
|
||||
const isChat = subreddit?.type === 'chat';
|
||||
const imageUrl = isChat ? null : redditApi.getImageUrl(post);
|
||||
postsRepo.insert({
|
||||
id: post.id, subreddit: post.subreddit, author: post.author, title: post.title,
|
||||
postUrl: redditApi.getPostUrl(post), imageUrl,
|
||||
postCreatedAt: post.created_utc * 1000, processedAt: Date.now(),
|
||||
const processed = await this.preparePost(post, signal);
|
||||
if (isSkip(processed)) return;
|
||||
await this.dispatchPreparedPost(processed, signal);
|
||||
}
|
||||
|
||||
async preparePost(post: RedditPost, signal?: AbortSignal): Promise<ProcessResult> {
|
||||
const { postsRepo, redditApi, processorService, responseChecker } = this.resources;
|
||||
if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError');
|
||||
const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase());
|
||||
const isChat = subreddit?.type === 'chat';
|
||||
postsRepo.insertIfAbsent({
|
||||
id: post.id, subreddit: post.subreddit, author: post.author, title: post.title,
|
||||
postUrl: redditApi.getPostUrl(post), imageUrl: isChat ? null : redditApi.getImageUrl(post),
|
||||
postCreatedAt: post.created_utc * 1_000, processedAt: Date.now(),
|
||||
});
|
||||
processorService.setSuccessfulExamples(responseChecker.getSuccessfulExamples(3, post.subreddit));
|
||||
const processed = await processorService.processPost(post);
|
||||
if (isSkip(processed)) {
|
||||
postsRepo.setError(post.id, processed.skipReason);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'skipped',
|
||||
message: `Skipped post by u/${post.author} (${processed.skipReason})`, recipient: post.author,
|
||||
});
|
||||
// Seller screening is performed once by the shared MonitorService before
|
||||
// assignment. Do not repeat its profile request for every recipient.
|
||||
processorService.setSuccessfulExamples(responseChecker.getSuccessfulExamples(3, post.subreddit));
|
||||
const processed = await processorService.processPost(post);
|
||||
if (isSkip(processed)) {
|
||||
postsRepo.setError(post.id, processed.skipReason);
|
||||
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (${processed.skipReason})`, recipient: post.author });
|
||||
return;
|
||||
}
|
||||
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
|
||||
await rateLimiter.throttle(async () => {
|
||||
if (this.config.browser.dryRun) {
|
||||
const reason = 'Dry run: message prepared but not sent';
|
||||
postsRepo.setError(post.id, reason);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'skipped',
|
||||
message: `${reason} to u/${post.author}`, recipient: post.author,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (!this.recipientDeduplicator.tryReserve(post.author)) {
|
||||
postsRepo.setError(post.id, 'User already contacted across accounts');
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'skipped',
|
||||
message: `Skipped u/${post.author} (already contacted across accounts)`,
|
||||
recipient: post.author,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
let chat;
|
||||
try {
|
||||
chat = await chatService.startConversation(post.author, processed.generatedMessage, post.author_fullname);
|
||||
} catch (error) {
|
||||
this.recipientDeduplicator.release(post.author);
|
||||
throw error;
|
||||
}
|
||||
if (!chat) {
|
||||
this.recipientDeduplicator.release(post.author);
|
||||
return false;
|
||||
}
|
||||
postsRepo.markMessageSent(post.id);
|
||||
messagesRepo.insert({
|
||||
postId: post.id, recipient: post.author, subject: post.author,
|
||||
message: processed.generatedMessage, sentAt: Date.now(),
|
||||
strategyId: processed.strategyId, chatRoomId: chat.roomId,
|
||||
experimentId: processed.experimentId, promptVersion: processed.promptVersion,
|
||||
modelId: processed.modelId, selectionReason: processed.selectionReason,
|
||||
sourceType: processed.sourceType,
|
||||
});
|
||||
this.recipientDeduplicator.commit(post.author);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`,
|
||||
recipient: post.author, imageUrl: imageUrl ?? undefined, postTitle: post.title, postUrl: redditApi.getPostUrl(post),
|
||||
});
|
||||
void chatService.refreshTopChats(5);
|
||||
return true;
|
||||
}, (success) => success, signal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return;
|
||||
postsRepo.setError(post.id, error instanceof Error ? error.message : String(error));
|
||||
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'error', message: `Failed processing post by u/${post.author}`, recipient: post.author });
|
||||
this.logger.error(`Account runtime failed processing post ${post.id}:`, error);
|
||||
return processed;
|
||||
}
|
||||
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
|
||||
return processed;
|
||||
}
|
||||
|
||||
async dispatchPreparedPost(processed: ProcessedPost, signal?: AbortSignal): Promise<PreparedDeliveryResult> {
|
||||
const { postsRepo, messagesRepo, redditApi, rateLimiter, chatService } = this.resources;
|
||||
const { post } = processed;
|
||||
if (signal?.aborted) {
|
||||
return { status: 'retryable', reason: 'Delivery interrupted by shutdown', retryAt: Date.now() + 5_000 };
|
||||
}
|
||||
return rateLimiter.throttle(async (): Promise<PreparedDeliveryResult> => {
|
||||
if (this.config.browser.dryRun) {
|
||||
const reason = 'Dry run: message prepared but not sent';
|
||||
postsRepo.setError(post.id, reason);
|
||||
return { status: 'terminal', reason };
|
||||
}
|
||||
if (!this.recipientDeduplicator.tryReserve(post.author)) {
|
||||
const reason = 'User already contacted across accounts';
|
||||
postsRepo.setError(post.id, reason);
|
||||
return { status: 'terminal', reason };
|
||||
}
|
||||
|
||||
const start = await chatService.startConversationDetailed(
|
||||
post.author,
|
||||
processed.generatedMessage,
|
||||
post.author_fullname,
|
||||
);
|
||||
if (start.status !== 'sent') {
|
||||
this.recipientDeduplicator.release(post.author);
|
||||
if (start.status === 'terminal') {
|
||||
postsRepo.setError(post.id, start.reason);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'skipped',
|
||||
message: `Skipped u/${post.author} (${start.reason})`, recipient: post.author,
|
||||
});
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
postsRepo.markMessageSent(post.id);
|
||||
messagesRepo.insert({
|
||||
postId: post.id, recipient: post.author, subject: post.author,
|
||||
message: processed.generatedMessage, sentAt: Date.now(),
|
||||
strategyId: processed.strategyId, chatRoomId: start.chat.roomId,
|
||||
experimentId: processed.experimentId, promptVersion: processed.promptVersion,
|
||||
modelId: processed.modelId, selectionReason: processed.selectionReason,
|
||||
sourceType: processed.sourceType,
|
||||
});
|
||||
this.recipientDeduplicator.commit(post.author);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`,
|
||||
recipient: post.author,
|
||||
imageUrl: processed.sourceType === 'image' ? redditApi.getImageUrl(post) ?? undefined : undefined,
|
||||
postTitle: post.title, postUrl: redditApi.getPostUrl(post),
|
||||
});
|
||||
void chatService.refreshTopChats(5);
|
||||
return { status: 'sent', conversationId: start.chat.roomId! };
|
||||
}, (result) => result.status === 'sent', signal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user