Add durable candidate funnel
This commit is contained in:
1 parent
5abac31c28
commit
df35648920
20 files changed
+1128
-142
No files matched your search
@@ -0,0 +1,168 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../src/database/client.js';
|
||||
import {
|
||||
CandidateQueueRepository,
|
||||
RuntimeStateRepository,
|
||||
} from '../src/database/candidate-queue-repository.js';
|
||||
import { RecipientHistoryRepository } from '../src/database/repositories.js';
|
||||
import type { RedditPost } from '../src/reddit/api.js';
|
||||
import type { ProcessedPost } from '../src/services/processor.js';
|
||||
|
||||
function post(id: string, author: string, created = 100): RedditPost {
|
||||
return {
|
||||
id,
|
||||
subreddit: 'test',
|
||||
author,
|
||||
author_fullname: `t2_${author}`,
|
||||
title: `[25F] ${id}`,
|
||||
url: `https://example.com/${id}.jpg`,
|
||||
permalink: `/r/test/${id}`,
|
||||
thumbnail: '',
|
||||
is_self: false,
|
||||
created_utc: created,
|
||||
over_18: true,
|
||||
spoiler: false,
|
||||
};
|
||||
}
|
||||
|
||||
function prepared(candidate: RedditPost): ProcessedPost {
|
||||
return {
|
||||
post: candidate,
|
||||
imageAnalysis: 'analysis',
|
||||
detectedGender: 'female',
|
||||
generatedMessage: 'prepared message',
|
||||
strategyId: null,
|
||||
experimentId: null,
|
||||
promptVersion: null,
|
||||
selectionReason: null,
|
||||
modelId: 'model',
|
||||
sourceType: 'image',
|
||||
toneId: 'default',
|
||||
scripted: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CandidateQueueRepository', () => {
|
||||
let db: Database.Database;
|
||||
let queue: CandidateQueueRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
const insertAccount = db.prepare(`
|
||||
INSERT INTO reddit_accounts (
|
||||
account_id, reddit_username, matrix_user_id, status, is_active,
|
||||
cookies_json, matrix_credentials_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'verified', 1, '[]', '{}', 1, 1)
|
||||
`);
|
||||
insertAccount.run('account-a', 'account-a', '@account-a:reddit.com');
|
||||
insertAccount.run('account-b', 'account-b', '@account-b:reddit.com');
|
||||
queue = new CandidateQueueRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it('builds a broad pool while keeping only one active candidate per author', () => {
|
||||
const result = queue.enqueue([
|
||||
post('p1', 'alice'),
|
||||
post('p2', 'alice'),
|
||||
post('p3', 'bob'),
|
||||
], 'image', 2, 1_000);
|
||||
|
||||
expect(result).toEqual({
|
||||
discovered: 2,
|
||||
duplicatePost: 0,
|
||||
duplicateAuthor: 1,
|
||||
previouslyProcessed: 0,
|
||||
previouslyContacted: 0,
|
||||
});
|
||||
expect(queue.getStats().byStatus.discovered).toBe(2);
|
||||
});
|
||||
|
||||
it('excludes posts and recipients already handled anywhere', () => {
|
||||
db.prepare(`
|
||||
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
|
||||
VALUES ('__legacy_unassigned__', 'old-post', 'test', 'old', 'title', 1)
|
||||
`).run();
|
||||
db.prepare(`INSERT INTO recipient_suppressions (recipient, reason, created_at) VALUES ('contacted', 'sent', 1)`).run();
|
||||
|
||||
const result = queue.enqueue([
|
||||
post('old-post', 'different'),
|
||||
post('fresh', 'contacted'),
|
||||
], 'image', 1, 2_000);
|
||||
|
||||
expect(result.previouslyProcessed).toBe(1);
|
||||
expect(result.previouslyContacted).toBe(1);
|
||||
expect(queue.getStats().total).toBe(0);
|
||||
});
|
||||
|
||||
it('recovers screening and sending leases after an interrupted deployment', () => {
|
||||
queue.enqueue([post('screen', 'alice'), post('send', 'bob')], 'image', 1, 1_000);
|
||||
const screening = queue.claimForScreening('screen-worker', 100, 1_010)!;
|
||||
expect(queue.markReady(screening.postId, 'screen-worker', prepared(screening.post), 1_020)).toBe(true);
|
||||
const sending = queue.claimReady('account-a', 'send-worker', 100, 1_030)!;
|
||||
expect(sending.status).toBe('sending');
|
||||
expect(queue.claimForScreening('other-worker', 100, 1_040)?.status).toBe('screening');
|
||||
|
||||
expect(queue.recoverExpiredLeases(1_200)).toBe(2);
|
||||
expect(queue.getStats().byStatus.retryable).toBe(1);
|
||||
expect(queue.getStats().byStatus.discovered).toBe(1);
|
||||
});
|
||||
|
||||
it('retries a rate-limited prepared candidate and preserves its generated message', () => {
|
||||
queue.enqueue([post('p1', 'alice')], 'image', 1, 1_000);
|
||||
const screening = queue.claimForScreening('screen', 100, 1_001)!;
|
||||
queue.markReady('p1', 'screen', prepared(screening.post), 1_002);
|
||||
const sending = queue.claimReady('account-a', 'send-a', 100, 1_003)!;
|
||||
queue.deferDelivery('p1', 'send-a', 'Matrix rate limited', 1_100, 1_004);
|
||||
|
||||
expect(queue.claimReady('account-b', 'send-b', 100, 1_099)).toBeNull();
|
||||
expect(queue.promoteDueRetries(1_100)).toBe(1);
|
||||
expect(queue.claimReady('account-b', 'send-b', 100, 1_101)).toMatchObject({
|
||||
postId: 'p1',
|
||||
assignedAccountId: 'account-b',
|
||||
prepared: expect.objectContaining({ generatedMessage: 'prepared message' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuntimeStateRepository', () => {
|
||||
it('persists the operator run state across repository instances', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const first = new RuntimeStateRepository(db);
|
||||
expect(first.isBotRunning()).toBe(false);
|
||||
first.setBotRunning(true, 100);
|
||||
expect(new RuntimeStateRepository(db).isBotRunning()).toBe(true);
|
||||
first.setBotRunning(false, 200);
|
||||
expect(new RuntimeStateRepository(db).isBotRunning()).toBe(false);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecipientHistoryRepository recovery', () => {
|
||||
it('releases only stale pending claims that have no delivery evidence', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const history = new RecipientHistoryRepository(db);
|
||||
|
||||
expect(history.tryClaim('orphaned', 100)).toBe(true);
|
||||
expect(history.tryClaim('delivered', 100)).toBe(true);
|
||||
db.prepare(`
|
||||
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
|
||||
VALUES ('__legacy_unassigned__', 'post-1', 'test', 'delivered', 'title', 125)
|
||||
`).run();
|
||||
db.prepare(`
|
||||
INSERT INTO sent_messages (
|
||||
account_id, post_id, recipient, subject, message, sent_at
|
||||
) VALUES ('__legacy_unassigned__', 'post-1', 'delivered', 'subject', 'message', 150)
|
||||
`).run();
|
||||
|
||||
expect(history.recoverStaleClaims(200)).toBe(1);
|
||||
expect(history.hasSentTo('orphaned')).toBe(false);
|
||||
expect(history.hasSentTo('delivered')).toBe(true);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ function buildService() {
|
||||
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
||||
);
|
||||
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
||||
return { svc, logger };
|
||||
return { svc, logger, matrix };
|
||||
}
|
||||
|
||||
describe('ChatService invitation cooldown', () => {
|
||||
@@ -82,6 +82,30 @@ describe('ChatService invitation cooldown', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a retryable delivery result with the persisted cooldown deadline', async () => {
|
||||
const { svc } = buildService();
|
||||
svc.setInvitationCooldown(
|
||||
new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', 'rate.foo'),
|
||||
);
|
||||
const cooldown = svc.getInvitationCooldown()!;
|
||||
|
||||
await expect(svc.startConversationDetailed('someuser', 'hi')).resolves.toEqual({
|
||||
status: 'retryable',
|
||||
reason: cooldown.reason,
|
||||
retryAt: cooldown.until,
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies recipients that reject chat requests as terminal', async () => {
|
||||
const { svc, matrix } = buildService();
|
||||
matrix.sendInitialDirectMessage = async () => {
|
||||
throw new Error('some of the invited users do not accept chat requests');
|
||||
};
|
||||
|
||||
await expect(svc.startConversationDetailed('someuser', 'hi', 't2_someuser'))
|
||||
.resolves.toEqual({ status: 'terminal', reason: 'Recipient does not accept chat requests' });
|
||||
});
|
||||
|
||||
it('sendChatMessage short-circuits when a cooldown is active', async () => {
|
||||
const { svc } = buildService();
|
||||
svc.setInvitationCooldown(
|
||||
|
||||
@@ -107,7 +107,11 @@ describe('Database Client', () => {
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
||||
expect(tables.length).toBe(16);
|
||||
expect(tables.length).toBe(18);
|
||||
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
||||
'candidate_queue',
|
||||
'runtime_state',
|
||||
]));
|
||||
});
|
||||
|
||||
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
|
||||
@@ -156,7 +160,7 @@ describe('Database Client', () => {
|
||||
DROP TABLE conversation_automation_claims;
|
||||
DROP TABLE ai_usage;
|
||||
`);
|
||||
db.pragma(`user_version = ${latestVersion - 3}`);
|
||||
db.pragma(`user_version = ${latestVersion - 4}`);
|
||||
runMigrations(db);
|
||||
|
||||
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
|
||||
|
||||
+14
-6
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MonitorService } from '../src/services/monitor.js';
|
||||
import { MonitorService, parseSelfDeclaredAge } from '../src/services/monitor.js';
|
||||
import {
|
||||
RedditRateLimitError,
|
||||
RedditSubredditUnavailableError,
|
||||
@@ -24,6 +24,7 @@ function createMockPostsRepo(processedIds: Set<string> = new Set()): PostsReposi
|
||||
isProcessed: vi.fn((id: string) => processedIds.has(id)),
|
||||
isProcessedAnywhere: vi.fn((id: string) => processedIds.has(id)),
|
||||
insert: vi.fn(),
|
||||
insertIfAbsent: vi.fn(() => true),
|
||||
updateAnalysis: vi.fn(),
|
||||
markMessageSent: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
@@ -168,7 +169,7 @@ describe('MonitorService', () => {
|
||||
expect(redditApi.isSellerProfile).toHaveBeenCalledWith(
|
||||
'seller', config.reddit.sellerKeywords, config.reddit.sellerDomains, sellerPost,
|
||||
);
|
||||
expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_seller' }));
|
||||
expect(postsRepo.insertIfAbsent).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_seller' }));
|
||||
expect(postsRepo.setError).toHaveBeenCalledWith('t3_seller', 'Seller signals detected');
|
||||
});
|
||||
|
||||
@@ -192,7 +193,7 @@ describe('MonitorService', () => {
|
||||
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([selected]);
|
||||
expect(logger.logs).toContainEqual(expect.objectContaining({
|
||||
level: 'info',
|
||||
message: 'r/sub1 listing: 4 fetched; 3 screened; 0 already processed; 1 already messaged; 1 seller signals; 0 missing gender tag; 0 gender not targeted; 1 eligible (selected); 0 outside recent window; 1 not evaluated after selected recent candidate',
|
||||
message: 'r/sub1 listing: 4 fetched; 3 screened; 0 already processed; 1 already messaged; 1 seller signals; 0 missing gender tag; 0 gender not targeted; 0 missing age tag; 0 underage; 1 eligible (selected); 0 outside recent window; 1 not evaluated after selected recent candidate',
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -224,7 +225,7 @@ describe('MonitorService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('finishes the seller and gender checks for one candidate before moving to the next', async () => {
|
||||
it('runs free gender and age checks before seller profile requests', async () => {
|
||||
const newer: RedditPost = {
|
||||
id: 't3_newer', subreddit: 'chat-sub', author: 'newer', title: '[30M] hello',
|
||||
url: '', permalink: '/r/chat-sub/newer', thumbnail: '', is_self: true,
|
||||
@@ -249,11 +250,18 @@ describe('MonitorService', () => {
|
||||
|
||||
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([eligible]);
|
||||
expect(vi.mocked(redditApi.isSellerProfile).mock.calls.map(([username]) => username))
|
||||
.toEqual(['newer', 'eligible']);
|
||||
expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_newer' }));
|
||||
.toEqual(['eligible']);
|
||||
expect(postsRepo.insertIfAbsent).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_newer' }));
|
||||
expect(postsRepo.setError).toHaveBeenCalledWith('t3_newer', 'Gender tag "male" not targeted');
|
||||
});
|
||||
|
||||
it('parses adult subreddit age tags without treating unrelated numbers as ages', () => {
|
||||
expect(parseSelfDeclaredAge('28 [F4M] Online')).toBe(28);
|
||||
expect(parseSelfDeclaredAge('[25F] hello')).toBe(25);
|
||||
expect(parseSelfDeclaredAge('F 31 looking to chat')).toBe(31);
|
||||
expect(parseSelfDeclaredAge('F4M, 100 percent bored')).toBeNull();
|
||||
});
|
||||
|
||||
it('tries next subreddit when first has no valid posts', async () => {
|
||||
const post1: RedditPost = {
|
||||
id: 't3_1', subreddit: 'sub2', author: 'user1', title: 'Post',
|
||||
|
||||
Reference in new issue
Block a user