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

@@ -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>