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

+135
View File
@@ -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));