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>
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
sweepConversationsForSellers,
|
|
type SellerSweepConversation,
|
|
type SellerSweepOptions,
|
|
} from '../src/services/seller-sweep.js';
|
|
import {
|
|
buildConversationState,
|
|
type ConversationState,
|
|
} from '../src/services/conversation-state.js';
|
|
|
|
const OPTIONS: SellerSweepOptions = {
|
|
sellerKeywords: ['onlyfans'],
|
|
sellerDomains: ['fansly.com'],
|
|
killConfidence: 0.8,
|
|
flagConfidence: 0.6,
|
|
killOnSellerKeyword: true,
|
|
};
|
|
|
|
function conversation(overrides: Partial<SellerSweepConversation> = {}): SellerSweepConversation {
|
|
return {
|
|
conversationId: 'room-1',
|
|
username: 'alice',
|
|
messages: [
|
|
{ sender: 'me', text: 'how has your week been?', isSelf: true },
|
|
{ sender: 'alice', text: 'busy, but good', isSelf: false },
|
|
],
|
|
watermark: 'w1',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** In-memory stand-in for the durable conversation state repository. */
|
|
function stateStore(initial: Record<string, ConversationState> = {}) {
|
|
const saved = new Map<string, ConversationState>(Object.entries(initial));
|
|
return {
|
|
saved,
|
|
get: (id: string) => saved.get(id),
|
|
save: vi.fn((id: string, state: ConversationState) => { saved.set(id, structuredClone(state)); }),
|
|
};
|
|
}
|
|
|
|
function screener(verdicts: Array<{ id: string; seller: boolean; confidence: number; evidence: string }>) {
|
|
return { screenConversationsForSellers: vi.fn().mockResolvedValue(verdicts) };
|
|
}
|
|
|
|
describe('sweepConversationsForSellers', () => {
|
|
it('flags configured keywords and domains without spending an AI call', async () => {
|
|
const states = stateStore();
|
|
const ai = screener([]);
|
|
|
|
const summary = await sweepConversationsForSellers(
|
|
[
|
|
conversation({
|
|
conversationId: 'room-keyword',
|
|
messages: [{ sender: 'alice', text: 'everything is on my onlyfans', isSelf: false }],
|
|
}),
|
|
conversation({
|
|
conversationId: 'room-domain',
|
|
username: 'bea',
|
|
messages: [{ sender: 'bea', text: 'check fansly.com/bea for more', isSelf: false }],
|
|
}),
|
|
],
|
|
OPTIONS,
|
|
{ states, screener: ai },
|
|
);
|
|
|
|
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
|
|
expect(summary.deterministic).toBe(2);
|
|
expect(summary.aiRequests).toBe(0);
|
|
expect(summary.flagged.map((flag) => flag.source)).toEqual(['keyword', 'domain']);
|
|
expect(summary.flagged.every((flag) => flag.killed)).toBe(true);
|
|
expect(states.saved.get('room-keyword')?.sellerDetected).toBe(true);
|
|
expect(states.saved.get('room-keyword')?.automation.status).toBe('killed');
|
|
});
|
|
|
|
it('catches obfuscated funnel language the keyword list does not enumerate', async () => {
|
|
const states = stateStore();
|
|
|
|
const summary = await sweepConversationsForSellers(
|
|
[conversation({ messages: [{ sender: 'alice', text: 'my links are in my bio babe', isSelf: false }] })],
|
|
OPTIONS,
|
|
{ states },
|
|
);
|
|
|
|
expect(summary.flagged).toHaveLength(1);
|
|
expect(summary.flagged[0].source).toBe('funnel');
|
|
});
|
|
|
|
it('hands the deterministic kill decision to the configured policy', async () => {
|
|
const states = stateStore();
|
|
const triage = { setStatus: vi.fn() };
|
|
|
|
const summary = await sweepConversationsForSellers(
|
|
[conversation({ messages: [{ sender: 'alice', text: 'see my onlyfans', isSelf: false }] })],
|
|
{ ...OPTIONS, killOnSellerKeyword: false },
|
|
{ states, triage },
|
|
);
|
|
|
|
expect(summary.flagged[0].killed).toBe(false);
|
|
expect(triage.setStatus).not.toHaveBeenCalled();
|
|
expect(states.saved.get('room-1')?.sellerDetected).toBe(true);
|
|
expect(states.saved.get('room-1')?.automation.status).toBe('human-required');
|
|
});
|
|
|
|
it('batches the remaining conversations into a single classifier request', async () => {
|
|
const states = stateStore();
|
|
const ai = screener([
|
|
{ id: '1', seller: true, confidence: 0.92, evidence: 'points at a paid page' },
|
|
{ id: '2', seller: false, confidence: 0.1, evidence: 'ordinary chat' },
|
|
]);
|
|
const triage = { setStatus: vi.fn() };
|
|
|
|
const summary = await sweepConversationsForSellers(
|
|
[
|
|
conversation({ conversationId: 'room-1', username: 'alice' }),
|
|
conversation({ conversationId: 'room-2', username: 'bea' }),
|
|
],
|
|
OPTIONS,
|
|
{ states, screener: ai, triage },
|
|
);
|
|
|
|
expect(ai.screenConversationsForSellers).toHaveBeenCalledTimes(1);
|
|
expect(summary.aiRequests).toBe(1);
|
|
expect(summary.aiScreened).toBe(2);
|
|
expect(summary.flagged).toEqual([expect.objectContaining({
|
|
conversationId: 'room-1', username: 'alice', source: 'ai', confidence: 0.92, killed: true,
|
|
})]);
|
|
expect(triage.setStatus).toHaveBeenCalledWith('room-1', 'done', null);
|
|
expect(states.saved.get('room-2')?.sellerDetected).toBe(false);
|
|
});
|
|
|
|
it('flags without killing between the flag and kill confidence thresholds', async () => {
|
|
const states = stateStore();
|
|
const ai = screener([{ id: '1', seller: true, confidence: 0.7, evidence: 'hints at a menu' }]);
|
|
const triage = { setStatus: vi.fn() };
|
|
|
|
const summary = await sweepConversationsForSellers([conversation()], OPTIONS, { states, screener: ai, triage });
|
|
|
|
expect(summary.flagged[0].killed).toBe(false);
|
|
expect(triage.setStatus).not.toHaveBeenCalled();
|
|
expect(states.saved.get('room-1')?.automation.status).toBe('human-required');
|
|
});
|
|
|
|
it('treats a low-confidence seller verdict as clean', async () => {
|
|
const states = stateStore();
|
|
const ai = screener([{ id: '1', seller: true, confidence: 0.2, evidence: 'weak' }]);
|
|
|
|
const summary = await sweepConversationsForSellers([conversation()], OPTIONS, { states, screener: ai });
|
|
|
|
expect(summary.flagged).toHaveLength(0);
|
|
expect(states.saved.get('room-1')?.sellerScreenedKey).toBe('w1');
|
|
});
|
|
|
|
it('skips conversations that are unchanged since the last clean sweep', async () => {
|
|
const cleared = { ...buildConversationState([]), sellerScreenedKey: 'w1' };
|
|
const states = stateStore({ 'room-1': cleared });
|
|
const ai = screener([]);
|
|
|
|
const summary = await sweepConversationsForSellers([conversation()], OPTIONS, { states, screener: ai });
|
|
|
|
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
|
|
expect(summary.unchanged).toBe(1);
|
|
expect(summary.aiScreened).toBe(0);
|
|
});
|
|
|
|
it('re-screens a conversation once a newer inbound message arrives', async () => {
|
|
const cleared = { ...buildConversationState([]), sellerScreenedKey: 'w1' };
|
|
const states = stateStore({ 'room-1': cleared });
|
|
const ai = screener([{ id: '1', seller: false, confidence: 0.1, evidence: '' }]);
|
|
|
|
const summary = await sweepConversationsForSellers(
|
|
[conversation({ watermark: 'w2' })],
|
|
OPTIONS,
|
|
{ states, screener: ai },
|
|
);
|
|
|
|
expect(ai.screenConversationsForSellers).toHaveBeenCalledTimes(1);
|
|
expect(summary.aiScreened).toBe(1);
|
|
expect(states.saved.get('room-1')?.sellerScreenedKey).toBe('w2');
|
|
});
|
|
|
|
it('never re-screens conversations already flagged or killed', async () => {
|
|
const flagged = { ...buildConversationState([]), sellerDetected: true };
|
|
const killed = buildConversationState([]);
|
|
killed.automation.status = 'killed';
|
|
const states = stateStore({ 'room-1': flagged, 'room-2': killed });
|
|
const ai = screener([]);
|
|
|
|
const summary = await sweepConversationsForSellers(
|
|
[conversation(), conversation({ conversationId: 'room-2', username: 'bea' })],
|
|
OPTIONS,
|
|
{ states, screener: ai },
|
|
);
|
|
|
|
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
|
|
expect(summary.alreadyFlagged).toBe(2);
|
|
expect(summary.scanned).toBe(2);
|
|
});
|
|
|
|
it('skips conversations with nothing inbound to judge', async () => {
|
|
const states = stateStore();
|
|
const ai = screener([]);
|
|
|
|
const summary = await sweepConversationsForSellers(
|
|
[conversation({ messages: [{ sender: 'me', text: 'hey there', isSelf: true }] })],
|
|
OPTIONS,
|
|
{ states, screener: ai },
|
|
);
|
|
|
|
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
|
|
expect(summary.emptySkipped).toBe(1);
|
|
});
|
|
|
|
it('honours the per-sweep AI budget and reports what it left behind', async () => {
|
|
const states = stateStore();
|
|
const ai = screener([{ id: '1', seller: false, confidence: 0.1, evidence: '' }]);
|
|
|
|
const summary = await sweepConversationsForSellers(
|
|
[conversation({ conversationId: 'room-1' }), conversation({ conversationId: 'room-2' })],
|
|
{ ...OPTIONS, maxAiConversations: 1 },
|
|
{ states, screener: ai },
|
|
);
|
|
|
|
expect(summary.aiScreened).toBe(1);
|
|
expect(summary.budgetSkipped).toBe(1);
|
|
expect(states.saved.has('room-2')).toBe(false);
|
|
});
|
|
|
|
it('degrades rather than fails when the classifier is unavailable, keeping the old watermark', async () => {
|
|
const states = stateStore();
|
|
const ai = { screenConversationsForSellers: vi.fn().mockRejectedValue(new Error('router down')) };
|
|
const logger = { info: vi.fn(), warn: vi.fn() };
|
|
|
|
const summary = await sweepConversationsForSellers([conversation()], OPTIONS, { states, screener: ai, logger });
|
|
|
|
expect(summary.degraded).toBe(true);
|
|
expect(summary.flagged).toHaveLength(0);
|
|
expect(states.save).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('leaves a conversation the model omitted for the next sweep', async () => {
|
|
const states = stateStore();
|
|
const ai = screener([{ id: '1', seller: false, confidence: 0.1, evidence: '' }]);
|
|
|
|
const summary = await sweepConversationsForSellers(
|
|
[conversation({ conversationId: 'room-1' }), conversation({ conversationId: 'room-2' })],
|
|
OPTIONS,
|
|
{ states, screener: ai },
|
|
);
|
|
|
|
expect(summary.aiScreened).toBe(1);
|
|
expect(states.saved.has('room-2')).toBe(false);
|
|
});
|
|
|
|
it('splits large sweeps into fixed-size batches', async () => {
|
|
const states = stateStore();
|
|
const ai = { screenConversationsForSellers: vi.fn().mockResolvedValue([]) };
|
|
|
|
await sweepConversationsForSellers(
|
|
Array.from({ length: 5 }, (_, index) => conversation({ conversationId: `room-${index}` })),
|
|
{ ...OPTIONS, batchSize: 2 },
|
|
{ states, screener: ai },
|
|
);
|
|
|
|
expect(ai.screenConversationsForSellers).toHaveBeenCalledTimes(3);
|
|
expect(ai.screenConversationsForSellers.mock.calls[0][0]).toHaveLength(2);
|
|
expect(ai.screenConversationsForSellers.mock.calls[2][0]).toHaveLength(1);
|
|
});
|
|
});
|