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

+8
View File
@@ -31,6 +31,12 @@ export interface ConversationState {
boundaries: string[];
/** Durable marker set when configured seller or upsell signals are detected. */
sellerDetected: boolean;
/**
* Watermark of the last inbound message an operator-forced seller sweep has
* already screened and cleared. Re-running the sweep skips a conversation
* whose watermark is unchanged, so repeated presses cost no AI calls.
*/
sellerScreenedKey?: string;
/** Kept for backwards compatibility with state written before automation. */
askedForMedia: boolean;
topics: string[];
@@ -178,6 +184,7 @@ export function mergeConversationState(
},
boundaries: [...new Set([...saved.boundaries, ...observed.boundaries])],
sellerDetected: saved.sellerDetected || observed.sellerDetected,
...(saved.sellerScreenedKey ? { sellerScreenedKey: saved.sellerScreenedKey } : {}),
askedForMedia: saved.askedForMedia || observed.askedForMedia,
topics: [...new Set([...saved.topics, ...observed.topics])],
automation: saved.automation,
@@ -282,6 +289,7 @@ export function parseConversationState(value: string | null | undefined): Conver
sellerDetected: typeof parsed.sellerDetected === 'boolean'
? parsed.sellerDetected
: automation.handoffReason?.startsWith('Potential upsell detected') === true,
...(typeof parsed.sellerScreenedKey === 'string' ? { sellerScreenedKey: parsed.sellerScreenedKey } : {}),
askedForMedia: parsed.askedForMedia,
topics: parsed.topics.filter((value): value is string => typeof value === 'string'),
automation,