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
@@ -163,6 +163,32 @@ export const BulkMarkConversationsReadResponseSchema = z.strictObject({
|
||||
updated: z.number().int().nonnegative(),
|
||||
failed: z.array(z.string()),
|
||||
});
|
||||
/** One conversation an operator-forced seller sweep decided to flag. */
|
||||
export const SellerScanFlagSchema = z.strictObject({
|
||||
conversationId: z.string(),
|
||||
username: z.string(),
|
||||
/** Which rule settled it. Everything but `ai` costs no tokens. */
|
||||
source: z.enum(['keyword', 'domain', 'funnel', 'ai']),
|
||||
reason: z.string(),
|
||||
/** Present for AI verdicts only. */
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
/** True when the sweep also ended the conversation, per the kill policy. */
|
||||
killed: z.boolean(),
|
||||
});
|
||||
export const SellerScanResponseSchema = z.strictObject({
|
||||
success: z.literal(true),
|
||||
scanned: z.number().int().nonnegative(),
|
||||
flagged: z.array(SellerScanFlagSchema),
|
||||
alreadyFlagged: z.number().int().nonnegative(),
|
||||
unchanged: z.number().int().nonnegative(),
|
||||
deterministic: z.number().int().nonnegative(),
|
||||
aiScreened: z.number().int().nonnegative(),
|
||||
aiRequests: z.number().int().nonnegative(),
|
||||
budgetSkipped: z.number().int().nonnegative(),
|
||||
emptySkipped: z.number().int().nonnegative(),
|
||||
/** True when a classifier request failed and part of the sweep was skipped. */
|
||||
degraded: z.boolean(),
|
||||
});
|
||||
export const StartConversationRequestSchema = z.strictObject({
|
||||
accountId: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
@@ -188,6 +214,8 @@ export type ConversationTriageStatus = z.infer<typeof ConversationTriageStatusSc
|
||||
export type ConversationAutomationStatus = z.infer<typeof ConversationAutomationStatusSchema>;
|
||||
export type ConversationPolicyMode = z.infer<typeof ConversationPolicyModeSchema>;
|
||||
export type BulkMarkConversationsReadResponse = z.infer<typeof BulkMarkConversationsReadResponseSchema>;
|
||||
export type SellerScanFlag = z.infer<typeof SellerScanFlagSchema>;
|
||||
export type SellerScanResponse = z.infer<typeof SellerScanResponseSchema>;
|
||||
export type ChatSyncStatus = z.infer<typeof ChatSyncStatusSchema>;
|
||||
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;
|
||||
export type ConversationMediaShareOptions = z.infer<typeof ConversationMediaShareOptionsResponseSchema>;
|
||||
Reference in new issue
Block a user