feat: force a seller scan over active conversations from the inbox
Adds a "Scan for sellers" button to the conversations header, backed by POST /api/conversations/seller-scan. The sweep reuses the existing seller detection rather than inventing a second one: the configured keyword/domain list, the seller-link funnel patterns, and the aiReplyScreening confidences all decide the verdict, so a swept conversation lands in the state the live reply loop would have given it. Being operator-forced, it deliberately ignores filterSellers and aiReplyScreening.enabled. AI is the last resort. Conversations are skipped when already flagged or killed, settled for free by the deterministic rules, or skipped against a durable sellerScreenedKey watermark when nothing new has arrived since the last clean sweep. Only the remainder reaches the model, batched eight at a time through a new screenConversationsForSellers, so the system prompt and round trip are paid per batch instead of per conversation. A classifier outage degrades the sweep instead of failing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a4e0064f2
commit
827eb3a1e0
15 files changed
+1169
-1
No files matched your search
@@ -537,6 +537,136 @@ describe('conversations route', () => {
|
||||
expect(await response.json()).toEqual({ success: true });
|
||||
});
|
||||
|
||||
describe('forced seller sweep', () => {
|
||||
const sweepConfig = {
|
||||
reddit: { sellerKeywords: ['onlyfans'], sellerDomains: ['fansly.com'] },
|
||||
conversations: {
|
||||
killOnSellerKeyword: true,
|
||||
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
|
||||
},
|
||||
};
|
||||
|
||||
function chat(username: string, roomId: string, snippet: string) {
|
||||
return {
|
||||
username, roomName: username, roomId, lastMessageTime: 500,
|
||||
lastMessageSnippet: snippet, isFromUs: false, unread: false,
|
||||
messages: [{ sender: username, text: snippet, timestamp: 500, isSelf: false }],
|
||||
};
|
||||
}
|
||||
|
||||
function sweepContext(conversations: unknown[], ai?: unknown, states = new Map<string, unknown>()) {
|
||||
const runtime = {
|
||||
session: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' },
|
||||
snapshot: () => ({ state: 'running' }),
|
||||
resources: {
|
||||
chatService: { getCachedConversations: vi.fn().mockResolvedValue(conversations) },
|
||||
conversationStateRepo: {
|
||||
get: (id: string) => states.get(id),
|
||||
save: vi.fn((id: string, state: unknown) => { states.set(id, state); }),
|
||||
},
|
||||
conversationReadStateRepo: { setStatus: vi.fn() },
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||
broadcaster: { push: vi.fn() },
|
||||
config: sweepConfig,
|
||||
services: {
|
||||
chat: { getCachedConversations: vi.fn().mockResolvedValue([]) },
|
||||
ai,
|
||||
accountRuntimes: {
|
||||
list: () => [{ accountId: '@t2_one:reddit.com', redditUsername: 'owner-one', state: 'running' }],
|
||||
get: () => runtime,
|
||||
},
|
||||
},
|
||||
} as unknown as WebContext;
|
||||
return { context, runtime, states };
|
||||
}
|
||||
|
||||
it('flags keyword hits for free and sends only the unsettled conversations to the classifier', async () => {
|
||||
const ai = { screenConversationsForSellers: vi.fn().mockResolvedValue([]) };
|
||||
const { context, runtime } = sweepContext(
|
||||
[chat('alice', 'room-alice', 'everything else is on my onlyfans'), chat('bob', 'room-bob', 'how was your day?')],
|
||||
ai,
|
||||
);
|
||||
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
|
||||
|
||||
const response = await request(app, '/api/conversations/seller-scan', { method: 'POST' });
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toMatchObject({ success: true, scanned: 2, deterministic: 1, aiRequests: 1 });
|
||||
expect(body.flagged).toEqual([expect.objectContaining({
|
||||
// Flags carry the owned id so the dashboard can match its list rows.
|
||||
conversationId: `${encodeURIComponent('@t2_one:reddit.com')}~room-alice`,
|
||||
username: 'alice', source: 'keyword', killed: true,
|
||||
})]);
|
||||
// The keyword hit never reaches the model; only the clean row is screened.
|
||||
expect(ai.screenConversationsForSellers.mock.calls[0][0]).toEqual([
|
||||
expect.objectContaining({ username: 'bob' }),
|
||||
]);
|
||||
expect(runtime.resources.conversationReadStateRepo.setStatus).toHaveBeenCalledWith('room-alice', 'done', null);
|
||||
});
|
||||
|
||||
it('batches the conversations the rules could not settle into one classifier request', async () => {
|
||||
const ai = {
|
||||
screenConversationsForSellers: vi.fn().mockResolvedValue([
|
||||
{ id: '1', seller: true, confidence: 0.9, evidence: 'sells customs' },
|
||||
{ id: '2', seller: false, confidence: 0.05, evidence: 'ordinary chat' },
|
||||
]),
|
||||
};
|
||||
const { context } = sweepContext(
|
||||
[chat('alice', 'room-alice', 'dm me about customs'), chat('bob', 'room-bob', 'how was your day?')],
|
||||
ai,
|
||||
);
|
||||
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
|
||||
|
||||
const body = await (await request(app, '/api/conversations/seller-scan', { method: 'POST' })).json();
|
||||
|
||||
expect(ai.screenConversationsForSellers).toHaveBeenCalledTimes(1);
|
||||
expect(body).toMatchObject({ scanned: 2, aiScreened: 2, aiRequests: 1 });
|
||||
expect(body.flagged).toEqual([expect.objectContaining({ username: 'alice', source: 'ai', confidence: 0.9 })]);
|
||||
});
|
||||
|
||||
it('screens conversations whose transcript was never cached, using the summary snippet', async () => {
|
||||
const ai = {
|
||||
screenConversationsForSellers: vi.fn().mockResolvedValue([
|
||||
{ id: '1', seller: true, confidence: 0.95, evidence: 'links to a paid page' },
|
||||
]),
|
||||
};
|
||||
const { context } = sweepContext([{
|
||||
username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 500,
|
||||
lastMessageSnippet: 'come see my page for the good stuff', isFromUs: false, unread: false,
|
||||
}], ai);
|
||||
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
|
||||
|
||||
const body = await (await request(app, '/api/conversations/seller-scan', { method: 'POST' })).json();
|
||||
|
||||
expect(ai.screenConversationsForSellers.mock.calls[0][0][0].messages).toEqual([
|
||||
{ sender: 'alice', text: 'come see my page for the good stuff', isSelf: false },
|
||||
]);
|
||||
expect(body.flagged).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports a degraded sweep instead of failing when screening is unavailable', async () => {
|
||||
const ai = { screenConversationsForSellers: vi.fn().mockRejectedValue(new Error('router down')) };
|
||||
const { context } = sweepContext([chat('alice', 'room-alice', 'hey there stranger')], ai);
|
||||
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
|
||||
|
||||
const response = await request(app, '/api/conversations/seller-scan', { method: 'POST' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ success: true, degraded: true, flagged: [] });
|
||||
});
|
||||
|
||||
it('refuses to scan when no chat service is initialized', async () => {
|
||||
const context = { logger: { error: vi.fn() }, services: {} } as unknown as WebContext;
|
||||
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
|
||||
|
||||
expect((await request(app, '/api/conversations/seller-scan', { method: 'POST' })).status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operator read state', () => {
|
||||
const conversation = {
|
||||
username: 'alice', roomName: 'alice', roomId: 'room-alice',
|
||||
|
||||
Reference in new issue
Block a user