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
@@ -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,
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { findSellerSignal } from '../reddit/api.js';
|
||||
import { findOffPlatformSignal, offPlatformSignalReason } from './off-platform-signals.js';
|
||||
import {
|
||||
buildConversationState,
|
||||
mergeConversationState,
|
||||
type ConversationMessage,
|
||||
type ConversationState,
|
||||
} from './conversation-state.js';
|
||||
import type { SellerScreeningRequest, SellerScreeningVerdict } from '../ai/openrouter.js';
|
||||
|
||||
/**
|
||||
* Operator-forced seller sweep.
|
||||
*
|
||||
* The reply loop already screens conversations one message at a time as they
|
||||
* arrive. This is the bulk equivalent an operator can trigger by hand over the
|
||||
* whole inbox, and it exists alongside — never instead of — that flow: the same
|
||||
* keyword/domain list, the same off-platform funnel patterns, and the same
|
||||
* confidence thresholds decide the verdict, so a conversation swept here ends
|
||||
* up in exactly the state the live path would have put it in.
|
||||
*
|
||||
* Everything here is arranged so the AI is the last resort rather than the
|
||||
* first move. In order, a conversation is:
|
||||
* 1. skipped when it is already flagged as a seller or already killed,
|
||||
* 2. skipped when nothing inbound has arrived since the last clean sweep,
|
||||
* 3. settled for free by the deterministic keyword/domain/funnel rules,
|
||||
* 4. and only then batched into a shared classifier request.
|
||||
*/
|
||||
|
||||
export interface SellerSweepConversation {
|
||||
conversationId: string;
|
||||
username: string;
|
||||
/** Whatever transcript is cached. May be empty; the snippet is then used. */
|
||||
messages: ConversationMessage[];
|
||||
/**
|
||||
* Identifies the newest inbound message. A conversation whose watermark
|
||||
* matches the one recorded by the last clean sweep is not re-screened.
|
||||
*/
|
||||
watermark: string;
|
||||
}
|
||||
|
||||
export type SellerSweepSource = 'keyword' | 'domain' | 'funnel' | 'ai';
|
||||
|
||||
export interface SellerSweepFlag {
|
||||
conversationId: string;
|
||||
username: string;
|
||||
source: SellerSweepSource;
|
||||
reason: string;
|
||||
/** Present for AI verdicts only; the deterministic rules do not score. */
|
||||
confidence?: number;
|
||||
killed: boolean;
|
||||
}
|
||||
|
||||
export interface SellerSweepSummary {
|
||||
/** Conversations considered, including those resolved without any AI call. */
|
||||
scanned: number;
|
||||
flagged: SellerSweepFlag[];
|
||||
/** Already carried a seller flag or a kill, so there was nothing to decide. */
|
||||
alreadyFlagged: number;
|
||||
/** Unchanged since the last clean sweep, so no AI call was needed. */
|
||||
unchanged: number;
|
||||
/** Settled by the free keyword/domain/funnel rules. */
|
||||
deterministic: number;
|
||||
/** Conversations actually sent to the classifier. */
|
||||
aiScreened: number;
|
||||
/** Requests made to the classifier — several conversations share each one. */
|
||||
aiRequests: number;
|
||||
/** Conversations left unscreened because the per-sweep AI budget ran out. */
|
||||
budgetSkipped: number;
|
||||
/** Conversations with no inbound text to judge. */
|
||||
emptySkipped: number;
|
||||
/** True when at least one classifier request failed and was skipped. */
|
||||
degraded: boolean;
|
||||
}
|
||||
|
||||
export interface SellerSweepStateStore {
|
||||
get(conversationId: string): ConversationState | undefined;
|
||||
save(conversationId: string, state: ConversationState): void;
|
||||
}
|
||||
|
||||
export interface SellerSweepTriageStore {
|
||||
setStatus(conversationId: string, status: 'open' | 'snoozed' | 'done', snoozeUntil: number | null): void;
|
||||
}
|
||||
|
||||
export interface SellerSweepScreener {
|
||||
screenConversationsForSellers(
|
||||
requests: SellerScreeningRequest[],
|
||||
maxMessagesPerConversation?: number,
|
||||
): Promise<SellerScreeningVerdict[]>;
|
||||
}
|
||||
|
||||
export interface SellerSweepOptions {
|
||||
sellerKeywords: readonly string[];
|
||||
sellerDomains: readonly string[];
|
||||
/** At or above this confidence an AI seller verdict also ends the conversation. */
|
||||
killConfidence: number;
|
||||
/** At or above this confidence the conversation is flagged but left alive. */
|
||||
flagConfidence: number;
|
||||
/** Mirrors `conversations.killOnSellerKeyword` for deterministic hits. */
|
||||
killOnSellerKeyword: boolean;
|
||||
/** Conversations per classifier request. */
|
||||
batchSize?: number;
|
||||
/** Newest messages per conversation sent to the classifier. */
|
||||
maxMessagesPerConversation?: number;
|
||||
/** Upper bound on conversations sent to the classifier in one sweep. */
|
||||
maxAiConversations?: number;
|
||||
}
|
||||
|
||||
export interface SellerSweepDependencies {
|
||||
states: SellerSweepStateStore;
|
||||
triage?: SellerSweepTriageStore;
|
||||
screener?: SellerSweepScreener;
|
||||
logger?: { info(message: string): void; warn(message: string): void };
|
||||
}
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 8;
|
||||
const DEFAULT_MAX_MESSAGES = 6;
|
||||
const DEFAULT_MAX_AI_CONVERSATIONS = 120;
|
||||
|
||||
function emptySummary(): SellerSweepSummary {
|
||||
return {
|
||||
scanned: 0,
|
||||
flagged: [],
|
||||
alreadyFlagged: 0,
|
||||
unchanged: 0,
|
||||
deterministic: 0,
|
||||
aiScreened: 0,
|
||||
aiRequests: 0,
|
||||
budgetSkipped: 0,
|
||||
emptySkipped: 0,
|
||||
degraded: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Only the other person's words can incriminate them; ours are noise here. */
|
||||
function inboundText(messages: ConversationMessage[]): string {
|
||||
return messages.filter((message) => !message.isSelf).map((message) => message.text).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The deterministic half of the sweep. Runs over the whole inbound transcript
|
||||
* rather than just the newest message, because a sweep is retrospective — the
|
||||
* funnel may have been dropped several replies ago.
|
||||
*/
|
||||
function deterministicVerdict(
|
||||
text: string,
|
||||
options: SellerSweepOptions,
|
||||
): { source: SellerSweepSource; reason: string } | null {
|
||||
const configured = findSellerSignal(text, [...options.sellerKeywords], [...options.sellerDomains]);
|
||||
if (configured) {
|
||||
return {
|
||||
source: configured.kind,
|
||||
reason: `Potential upsell detected (${configured.kind}: ${configured.value}).`,
|
||||
};
|
||||
}
|
||||
const signal = findOffPlatformSignal(text);
|
||||
// Only the seller funnel patterns belong to a seller sweep. The other
|
||||
// off-platform kinds are the live reply loop's business, not this one's.
|
||||
if (signal?.kind === 'seller-link') {
|
||||
return { source: 'funnel', reason: offPlatformSignalReason(signal) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function sweepConversationsForSellers(
|
||||
conversations: readonly SellerSweepConversation[],
|
||||
options: SellerSweepOptions,
|
||||
dependencies: SellerSweepDependencies,
|
||||
): Promise<SellerSweepSummary> {
|
||||
const summary = emptySummary();
|
||||
const { states, triage, screener, logger } = dependencies;
|
||||
const batchSize = Math.max(1, options.batchSize ?? DEFAULT_BATCH_SIZE);
|
||||
const maxMessages = Math.max(1, options.maxMessagesPerConversation ?? DEFAULT_MAX_MESSAGES);
|
||||
const aiBudget = Math.max(0, options.maxAiConversations ?? DEFAULT_MAX_AI_CONVERSATIONS);
|
||||
|
||||
function flag(
|
||||
conversation: SellerSweepConversation,
|
||||
state: ConversationState,
|
||||
source: SellerSweepSource,
|
||||
reason: string,
|
||||
kill: boolean,
|
||||
confidence?: number,
|
||||
): void {
|
||||
state.sellerDetected = true;
|
||||
state.sellerScreenedKey = conversation.watermark;
|
||||
if (kill) {
|
||||
state.automation.status = 'killed';
|
||||
state.automation.handoffReason = reason;
|
||||
} else if (state.automation.status === 'active') {
|
||||
state.automation.status = 'human-required';
|
||||
state.automation.handoffReason = reason;
|
||||
}
|
||||
states.save(conversation.conversationId, state);
|
||||
if (kill && triage) {
|
||||
try {
|
||||
triage.setStatus(conversation.conversationId, 'done', null);
|
||||
} catch (error) {
|
||||
logger?.warn(
|
||||
`[seller-sweep] Flagged u/${conversation.username} but could not resolve triage state: `
|
||||
+ `${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
summary.flagged.push({
|
||||
conversationId: conversation.conversationId,
|
||||
username: conversation.username,
|
||||
source,
|
||||
reason,
|
||||
...(confidence === undefined ? {} : { confidence }),
|
||||
killed: kill,
|
||||
});
|
||||
}
|
||||
|
||||
function clear(conversation: SellerSweepConversation, state: ConversationState): void {
|
||||
state.sellerScreenedKey = conversation.watermark;
|
||||
states.save(conversation.conversationId, state);
|
||||
}
|
||||
|
||||
const pending: Array<{ conversation: SellerSweepConversation; state: ConversationState }> = [];
|
||||
|
||||
for (const conversation of conversations) {
|
||||
// A conversation the bot has never had to reason about has no stored
|
||||
// state yet; deriving one keeps it inside the sweep instead of silently
|
||||
// exempting exactly the conversations nobody has looked at.
|
||||
const state = mergeConversationState(
|
||||
states.get(conversation.conversationId),
|
||||
buildConversationState(conversation.messages),
|
||||
);
|
||||
summary.scanned += 1;
|
||||
if (state.sellerDetected || state.automation.status === 'killed') {
|
||||
summary.alreadyFlagged += 1;
|
||||
continue;
|
||||
}
|
||||
const text = inboundText(conversation.messages);
|
||||
if (!text.trim()) {
|
||||
summary.emptySkipped += 1;
|
||||
continue;
|
||||
}
|
||||
const deterministic = deterministicVerdict(text, options);
|
||||
if (deterministic) {
|
||||
summary.deterministic += 1;
|
||||
flag(conversation, state, deterministic.source, deterministic.reason, options.killOnSellerKeyword);
|
||||
continue;
|
||||
}
|
||||
// Deterministically clean and unchanged since the last sweep: the
|
||||
// classifier already saw this exact transcript and cleared it.
|
||||
if (state.sellerScreenedKey === conversation.watermark) {
|
||||
summary.unchanged += 1;
|
||||
continue;
|
||||
}
|
||||
if (!screener) {
|
||||
clear(conversation, state);
|
||||
continue;
|
||||
}
|
||||
if (pending.length >= aiBudget) {
|
||||
summary.budgetSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
pending.push({ conversation, state });
|
||||
}
|
||||
|
||||
for (let index = 0; index < pending.length; index += batchSize) {
|
||||
const batch = pending.slice(index, index + batchSize);
|
||||
const requests: SellerScreeningRequest[] = batch.map((item, position) => ({
|
||||
id: String(position + 1),
|
||||
username: item.conversation.username,
|
||||
messages: item.conversation.messages,
|
||||
}));
|
||||
let verdicts: SellerScreeningVerdict[];
|
||||
try {
|
||||
const result = await screener!.screenConversationsForSellers(requests, maxMessages);
|
||||
if (!Array.isArray(result)) throw new Error('Screener returned no verdict list');
|
||||
verdicts = result;
|
||||
summary.aiRequests += 1;
|
||||
} catch (error) {
|
||||
// A classifier outage degrades the sweep; it never fails the whole run,
|
||||
// and an unscreened conversation keeps its old watermark so the next
|
||||
// sweep retries it.
|
||||
summary.degraded = true;
|
||||
logger?.warn(
|
||||
`[seller-sweep] Screening batch failed and was skipped: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const byId = new Map(verdicts.map((verdict) => [verdict.id, verdict]));
|
||||
for (const [position, item] of batch.entries()) {
|
||||
const verdict = byId.get(String(position + 1));
|
||||
// No verdict means the model dropped the conversation, not that it
|
||||
// cleared it — leave the watermark alone so the next sweep retries.
|
||||
if (!verdict) continue;
|
||||
summary.aiScreened += 1;
|
||||
if (!verdict.seller || verdict.confidence < options.flagConfidence) {
|
||||
clear(item.conversation, item.state);
|
||||
continue;
|
||||
}
|
||||
const reason = `AI screening classified this conversation as seller (confidence ${verdict.confidence.toFixed(2)})`
|
||||
+ (verdict.evidence ? `: ${verdict.evidence}` : '.');
|
||||
flag(
|
||||
item.conversation,
|
||||
item.state,
|
||||
'ai',
|
||||
reason,
|
||||
verdict.confidence >= options.killConfidence,
|
||||
verdict.confidence,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger?.info(
|
||||
`[seller-sweep] Scanned ${summary.scanned} conversations: ${summary.flagged.length} flagged `
|
||||
+ `(${summary.deterministic} by rule, ${summary.aiScreened} screened over ${summary.aiRequests} AI requests, `
|
||||
+ `${summary.unchanged} unchanged, ${summary.alreadyFlagged} already flagged).`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
Reference in new issue
Block a user