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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user