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:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 13:42:55 +01:00
1 parent 0a4e0064f2
commit 827eb3a1e0
15 files changed
+1169 -1

No files matched your search

+44
View File
@@ -604,4 +604,48 @@ describe('ConversationsPage', () => {
await user.click(within(dialog).getByRole('button', { name: 'Reveal image' }));
expect(dialogImage).not.toHaveClass('is-blurred');
});
it('runs a forced seller scan and reports what it cost', async () => {
let scanned = 0;
installConversationList();
server.use(
http.post('/api/conversations/seller-scan', () => {
scanned += 1;
return HttpResponse.json({
success: true, scanned: 12, alreadyFlagged: 2, unchanged: 6, deterministic: 1,
aiScreened: 3, aiRequests: 1, budgetSkipped: 0, emptySkipped: 0, degraded: false,
flagged: [{
conversationId: 'room-alice', username: 'alice', source: 'ai',
reason: 'AI screening classified this conversation as seller (confidence 0.90)',
confidence: 0.9, killed: true,
}],
});
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: 'Scan for sellers' }));
const summary = await screen.findByRole('status');
expect(summary).toHaveTextContent('Scanned 12 conversations');
expect(summary).toHaveTextContent('1 flagged as sellers (1 ended)');
expect(summary).toHaveTextContent('3 needed AI over 1 request');
expect(summary).toHaveTextContent('6 unchanged since the last scan');
expect(scanned).toBe(1);
});
it('surfaces a failed seller scan without disturbing the list', async () => {
installConversationList();
server.use(
http.post('/api/conversations/seller-scan', () =>
HttpResponse.json({ error: 'Failed to scan conversations for sellers' }, { status: 500 })),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: 'Scan for sellers' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Seller scan failed');
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
});
});