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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user