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

+50
View File
@@ -85,6 +85,56 @@ describe('OpenRouterClient', () => {
});
});
describe('screenConversationsForSellers', () => {
const batch = [
{ id: '1', username: 'alice', messages: [{ sender: 'alice', text: 'my menu is on my page', isSelf: false }] },
{ id: '2', username: 'bea', messages: [{ sender: 'bea', text: 'what are you up to today?', isSelf: false }] },
];
it('classifies a whole batch in one request and keys verdicts back by id', async () => {
mockCreate.mockResolvedValueOnce({
model: 'chat-model',
choices: [{ message: { content: '{"results":[{"id":"1","seller":true,"confidence":0.93,"evidence":"a menu"},{"id":"2","seller":false,"confidence":0.05,"evidence":"small talk"}]}' } }],
usage: {},
});
await expect(client.screenConversationsForSellers(batch)).resolves.toEqual([
{ id: '1', seller: true, confidence: 0.93, evidence: 'a menu' },
{ id: '2', seller: false, confidence: 0.05, evidence: 'small talk' },
]);
expect(mockCreate).toHaveBeenCalledTimes(1);
const request = mockCreate.mock.calls[0][0];
expect(request.temperature).toBe(0);
expect(request.messages[1].content).toContain('CONVERSATION id=1');
expect(request.messages[1].content).toContain('BEGIN UNTRUSTED CONVERSATION DATA');
});
it('drops verdicts for ids it never asked about', async () => {
mockCreate.mockResolvedValueOnce({
model: 'chat-model',
choices: [{ message: { content: '{"results":[{"id":"9","seller":true,"confidence":0.99,"evidence":"invented"}]}' } }],
usage: {},
});
await expect(client.screenConversationsForSellers(batch)).resolves.toEqual([]);
});
it('makes no request at all for an empty batch', async () => {
await expect(client.screenConversationsForSellers([])).resolves.toEqual([]);
expect(mockCreate).not.toHaveBeenCalled();
});
it('throws when no model returns usable JSON', async () => {
mockCreate.mockResolvedValue({
model: 'chat-model',
choices: [{ message: { content: 'they all look like sellers to me' } }],
usage: {},
});
await expect(client.screenConversationsForSellers(batch)).rejects.toThrow('no JSON object');
});
});
describe('isRefusal (internal)', () => {
// We test refusal detection by checking the analyzeImage behavior
// since isRefusal is private. We do this by mocking responses that