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
@@ -11,6 +11,12 @@ import {
|
||||
type ConversationAutomationStatus,
|
||||
type ConversationPolicyMode,
|
||||
} from '../../services/conversation-state.js';
|
||||
import {
|
||||
sweepConversationsForSellers,
|
||||
type SellerSweepConversation,
|
||||
type SellerSweepOptions,
|
||||
type SellerSweepSummary,
|
||||
} from '../../services/seller-sweep.js';
|
||||
import type { MediaLibraryAsset } from '../../services/media-library-types.js';
|
||||
import type {
|
||||
ConversationReadStateRecord,
|
||||
@@ -662,6 +668,135 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
res.json({ success: true, updated, failed });
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the sweep input for one account's conversations.
|
||||
*
|
||||
* Cached summaries frequently carry no message history — delta sync only
|
||||
* populates room summaries — so the newest inbound snippet stands in for the
|
||||
* transcript rather than forcing a full lazy load of every room, which would
|
||||
* turn one button press into hundreds of Matrix round trips.
|
||||
*/
|
||||
function sweepInput(conversations: ActiveChat[]): SellerSweepConversation[] {
|
||||
const input: SellerSweepConversation[] = [];
|
||||
for (const conversation of conversations) {
|
||||
if (!conversation.roomId) continue;
|
||||
const messages = (conversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }));
|
||||
const effective = messages.length > 0
|
||||
? messages
|
||||
: conversation.isFromUs
|
||||
? []
|
||||
: [{ sender: conversation.username, text: conversation.lastMessageSnippet, isSelf: false }];
|
||||
const latestInbound = [...effective].reverse().find((message) => !message.isSelf);
|
||||
input.push({
|
||||
conversationId: conversation.roomId,
|
||||
username: conversation.username,
|
||||
messages: effective,
|
||||
watermark: latestInbound
|
||||
? `${conversation.lastMessageTime}:${conversationMessageFingerprint(latestInbound.text)}`
|
||||
: `${conversation.lastMessageTime}`,
|
||||
});
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function sweepOptions(): SellerSweepOptions {
|
||||
return {
|
||||
// A forced sweep deliberately ignores `reddit.filterSellers` and
|
||||
// `conversations.aiReplyScreening.enabled`: the operator asked for this
|
||||
// pass explicitly. The thresholds and the kill policy still come from
|
||||
// configuration, so a swept conversation lands in the same state the
|
||||
// live reply loop would have put it in.
|
||||
sellerKeywords: ctx.config.reddit.sellerKeywords,
|
||||
sellerDomains: ctx.config.reddit.sellerDomains,
|
||||
killConfidence: ctx.config.conversations.aiReplyScreening.killConfidence,
|
||||
flagConfidence: ctx.config.conversations.aiReplyScreening.flagConfidence,
|
||||
killOnSellerKeyword: ctx.config.conversations.killOnSellerKeyword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a seller pass over every active conversation. Deterministic rules
|
||||
* settle what they can for free, unchanged conversations are skipped against
|
||||
* their last clean watermark, and only the remainder is batched into shared
|
||||
* classifier requests.
|
||||
*/
|
||||
router.post('/conversations/seller-scan', async (_req, res) => {
|
||||
const chatService = ctx.services.chat;
|
||||
if (!chatService) {
|
||||
res.status(503).json({ error: 'Chat service not initialized' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? [];
|
||||
const targets = runtimes.length > 0
|
||||
? runtimes.map((snapshot) => {
|
||||
const runtime = ctx.services.accountRuntimes!.get(snapshot.accountId)!;
|
||||
return {
|
||||
accountId: snapshot.accountId,
|
||||
chat: runtime.resources.chatService,
|
||||
states: runtime.resources.conversationStateRepo,
|
||||
triage: runtime.resources.conversationReadStateRepo,
|
||||
};
|
||||
})
|
||||
: [{
|
||||
accountId: currentOwner()?.accountId,
|
||||
chat: chatService,
|
||||
states: ctx.services.conversationState,
|
||||
triage: ctx.services.conversationReadState,
|
||||
}];
|
||||
|
||||
const totals: SellerSweepSummary = {
|
||||
scanned: 0, flagged: [], alreadyFlagged: 0, unchanged: 0, deterministic: 0,
|
||||
aiScreened: 0, aiRequests: 0, budgetSkipped: 0, emptySkipped: 0, degraded: false,
|
||||
};
|
||||
for (const target of targets) {
|
||||
if (!target.states) continue;
|
||||
const summary = await sweepConversationsForSellers(
|
||||
sweepInput(await target.chat.getCachedConversations()),
|
||||
sweepOptions(),
|
||||
{ states: target.states, triage: target.triage, screener: ctx.services.ai, logger: ctx.logger },
|
||||
);
|
||||
totals.scanned += summary.scanned;
|
||||
totals.alreadyFlagged += summary.alreadyFlagged;
|
||||
totals.unchanged += summary.unchanged;
|
||||
totals.deterministic += summary.deterministic;
|
||||
totals.aiScreened += summary.aiScreened;
|
||||
totals.aiRequests += summary.aiRequests;
|
||||
totals.budgetSkipped += summary.budgetSkipped;
|
||||
totals.emptySkipped += summary.emptySkipped;
|
||||
totals.degraded ||= summary.degraded;
|
||||
for (const flag of summary.flagged) {
|
||||
totals.flagged.push({
|
||||
...flag,
|
||||
conversationId: target.accountId
|
||||
? `${encodeURIComponent(target.accountId)}~${encodeURIComponent(flag.conversationId)}`
|
||||
: flag.conversationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (totals.flagged.length > 0) {
|
||||
ctx.broadcaster.push('info', `Seller sweep flagged ${totals.flagged.length} of ${totals.scanned} conversations`);
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
scanned: totals.scanned,
|
||||
flagged: totals.flagged,
|
||||
alreadyFlagged: totals.alreadyFlagged,
|
||||
unchanged: totals.unchanged,
|
||||
deterministic: totals.deterministic,
|
||||
aiScreened: totals.aiScreened,
|
||||
aiRequests: totals.aiRequests,
|
||||
budgetSkipped: totals.budgetSkipped,
|
||||
emptySkipped: totals.emptySkipped,
|
||||
degraded: totals.degraded,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to sweep conversations for sellers:', error);
|
||||
res.status(500).json({ error: 'Failed to scan conversations for sellers' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/conversations/:id/shareable-media', async (req, res) => {
|
||||
try {
|
||||
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
||||
|
||||
Reference in new issue
Block a user