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

@@ -13,6 +13,7 @@ import {
SuggestedReplyResponseSchema,
BulkMarkConversationsReadRequestSchema,
BulkMarkConversationsReadResponseSchema,
SellerScanResponseSchema,
MarkConversationReadRequestSchema,
MarkConversationUnreadRequestSchema,
SetConversationStatusRequestSchema,
@@ -22,6 +23,7 @@ import {
type ConversationMediaShareOptions,
type ConversationTriageStatus,
type ProcessedPost,
type SellerScanResponse,
type SuccessResponse,
} from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js';
@@ -53,6 +55,7 @@ export interface ConversationApi {
markUnread(id: string, beforeTimestamp?: number): Promise<SuccessResponse>;
setStatus(id: string, status: ConversationTriageStatus, snoozeUntil?: number | null): Promise<SuccessResponse>;
bulkMarkRead(ids: string[]): Promise<BulkMarkConversationsReadResponse>;
sellerScan(): Promise<SellerScanResponse>;
}
function jsonPost(body: unknown): RequestInit {
@@ -174,6 +177,12 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
BulkMarkConversationsReadResponseSchema,
);
},
async sellerScan() {
return parseResponse(
await request('/api/conversations/seller-scan', { method: 'POST' }),
SellerScanResponseSchema,
);
},
};
}
@@ -182,6 +182,20 @@ export function useBulkMarkConversationsReadMutation() {
});
}
/**
* Forces a seller pass over every active conversation. The list is refetched
* on completion because a sweep can flag, kill and resolve conversations.
*/
export function useSellerScanMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => conversationApi.sellerScan(),
async onSuccess() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
export function useStartConversationMutation() {
const queryClient = useQueryClient();
return useMutation({
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
import type { ActiveChat, SellerScanResponse } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { ConversationDetail } from './conversation-detail.js';
import {
@@ -7,6 +7,7 @@ import {
useConversationsQuery,
useMarkConversationReadMutation,
useMarkConversationUnreadMutation,
useSellerScanMutation,
useSetConversationStatusMutation,
} from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
@@ -64,6 +65,28 @@ interface ConversationChip {
tooltip?: string;
}
/**
* Plain-English outcome of a forced seller sweep. The AI counts are shown
* because the sweep is deliberately frugal with them — most conversations are
* settled by keyword rules or skipped as unchanged, and the operator should be
* able to see that.
*/
function sellerScanSummary(result: SellerScanResponse): string {
const flagged = result.flagged.length;
const killed = result.flagged.filter((flag) => flag.killed).length;
const parts = [
`Scanned ${result.scanned} ${result.scanned === 1 ? 'conversation' : 'conversations'}`,
flagged === 0 ? 'no new sellers found' : `${flagged} flagged as sellers${killed > 0 ? ` (${killed} ended)` : ''}`,
result.aiScreened === 0
? 'no AI needed'
: `${result.aiScreened} needed AI over ${result.aiRequests} ${result.aiRequests === 1 ? 'request' : 'requests'}`,
];
if (result.unchanged > 0) parts.push(`${result.unchanged} unchanged since the last scan`);
if (result.budgetSkipped > 0) parts.push(`${result.budgetSkipped} left for the next scan`);
if (result.degraded) parts.push('some conversations were skipped because screening failed');
return `${parts.join(' · ')}.`;
}
/** Independent state chips. A conversation can be several of these at once. */
function conversationChips(conversation: ActiveChat, now: number): ConversationChip[] {
const chips: ConversationChip[] = [];
@@ -182,6 +205,7 @@ export function ConversationsPage({
const markUnread = useMarkConversationUnreadMutation();
const setStatus = useSetConversationStatusMutation();
const bulkMarkRead = useBulkMarkConversationsReadMutation();
const sellerScan = useSellerScanMutation();
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 60_000);
@@ -389,12 +413,32 @@ export function ConversationsPage({
{lastSyncedLabel ? `Updated ${lastSyncedLabel} · auto every 30s` : 'Auto-refreshes every 30s'}
</span>
</div>
<button
aria-busy={sellerScan.isPending || undefined}
className="conversation-seller-scan"
disabled={sellerScan.isPending}
onClick={() => sellerScan.mutate()}
title="Re-check every active conversation for content sellers"
type="button"
>
{sellerScan.isPending ? 'Scanning for sellers…' : 'Scan for sellers'}
</button>
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
</button>
</div>
</header>
{sellerScan.isError ? (
<p className="conversation-seller-scan-result is-error" role="alert">
Seller scan failed: {sellerScan.error.message}
</p>
) : sellerScan.data ? (
<p className="conversation-seller-scan-result" role="status">
{sellerScanSummary(sellerScan.data)}
</p>
) : null}
{isStartingConversation ? (
<section aria-labelledby="new-conversation-heading" className="new-conversation-panel">
<h2 id="new-conversation-heading">Start a conversation</h2>
@@ -20,6 +20,8 @@
.conversations-header p { margin-bottom: 0; color: var(--muted); }
.conversation-eyebrow { display: block; margin-bottom: .4rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.conversation-primary { border-color: transparent !important; color: #06140b !important; background: var(--accent) !important; font-weight: 750; }
.conversation-seller-scan-result { margin: 0 0 1rem; color: var(--muted); font-size: .8rem; }
.conversation-seller-scan-result.is-error { color: var(--danger, #ff8f8f); }
.conversations-page button, .conversations-page input, .conversations-page select, .conversations-page textarea { font: inherit; }
.conversations-page button { min-height: 2.5rem; padding: .5rem .8rem; border: 1px solid var(--border); border-radius: .6rem; color: #e9f1ec; background: var(--surface-raised); }
.conversations-page button:disabled { cursor: wait; opacity: .58; }