feat: kill conversations on off-platform and seller pushes

Seller detection on replies was a case-insensitive substring match over the
configured keyword list, and every hit landed in `human-required` alongside
genuine prospects that need a person. Off-platform pushes were only checked
for r4r policy modes, as were the under-18 signals.

Three layers now:

- New `off-platform-signals` module matches on normalized text (unicode
  folding, zero-width stripping, leet mapping, separator removal), so
  `0nlyf4ns`, `t3l3gr4m` and `t e l e g r a m` are caught. Ambiguous terms
  need context: `snap` on its own stays a photo request, never a kill.
- New terminal `killed` automation status. It records the evidence, sets the
  durable seller flag, supersedes the pending claim, and resolves triage to
  `done`, so killed conversations leave every actionable queue. Off-platform
  kills are independent of `reddit.filterSellers`.
- Optional AI screening of inbound replies behind
  `conversations.aiReplyScreening` (off by default). Runs only after the
  deterministic gates pass, memoizes per inbound event, and fails open.

Under-18 and in-person/contact-detail signals now apply to every policy mode
rather than r4r only, and remain human handoffs rather than kills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 12:53:00 +01:00
1 parent ac12a3146a
commit 4404ae75ff
18 files changed
+907 -31

No files matched your search

+163 -18
View File
@@ -17,16 +17,20 @@ import {
mergeConversationState,
r4rReplySafetyFailure,
remainingAutomationTurns,
type ConversationMessage,
type ConversationState,
} from './conversation-state.js';
import { containsMediaRequest } from './conversation-boundaries.js';
import { findOffPlatformSignal, offPlatformSignalReason } from './off-platform-signals.js';
import type { MediaLibraryExchangeService } from './media-library-exchange.js';
import type { ConversationReplyScreening } from '../ai/openrouter.js';
export type ConversationAutomationResultKind =
| 'sent'
| 'human-required'
| 'boundary'
| 'image-received';
| 'image-received'
| 'killed';
export interface ConversationAutomationResult {
conversationId: string;
@@ -54,6 +58,37 @@ export type ConversationBeforeSendLoader = (
observed: ActiveChat,
) => Promise<ActiveChat | undefined>;
/** Writes the operator-facing triage status, so killed conversations leave the inbox. */
export interface ConversationTriageWriter {
setStatus(conversationId: string, status: 'open' | 'snoozed' | 'done', snoozedUntil?: number | null, now?: number): void;
}
/** Narrow view of the AI client used for second-stage reply screening. */
export interface ConversationReplyScreener {
screenConversationReply(
conversationHistory: { sender: string; text: string; isSelf: boolean }[],
username: string,
): Promise<ConversationReplyScreening>;
}
export interface ConversationScreeningOptions {
enabled: boolean;
/** At or above this confidence, a seller/off-platform/scam verdict kills the conversation. */
killConfidence: number;
/** At or above this confidence, the same verdict hands off to a human instead. */
flagConfidence: number;
}
export interface ConversationAutomationOptions {
/** Off-platform kills are a safety control, so they default on regardless of `filterSellers`. */
offPlatformKill?: boolean;
/** Whether a configured seller keyword or domain also ends the conversation. */
killOnSellerKeyword?: boolean;
triage?: ConversationTriageWriter;
screener?: ConversationReplyScreener;
screening?: ConversationScreeningOptions;
}
export interface ReplyPriorityStatus {
processing: boolean;
actionableBacklog: number;
@@ -63,6 +98,9 @@ export interface ReplyPriorityStatus {
lastReplyLatencyMs: number | null;
}
const SCREENING_CACHE_LIMIT = 500;
const SCREENING_REJECT_CLASSIFICATIONS = new Set(['seller', 'off-platform', 'scam', 'bot']);
const REPLY_REFRESH_LIMIT = 25;
const REPLY_SEND_PRIORITY = 100;
const PENDING_REPLY_RETRY_DELAY_MS = 2 * 60_000;
@@ -96,8 +134,12 @@ export class ConversationAutomationService {
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
private readonly mediaExchange?: MediaLibraryExchangeService,
private readonly options: ConversationAutomationOptions = {},
) {}
/** In-process memo so a claim retry does not pay for the same verdict twice. */
private readonly screeningCache = new Map<string, ConversationReplyScreening>();
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
this.processing = true;
let conversations: ActiveChat[] = [];
@@ -254,17 +296,28 @@ export class ConversationAutomationService {
);
}
// Off-platform pushes end the conversation outright: there is nothing for an
// operator to salvage, and leaving them in the manual queue only buries the
// conversations that do need a human.
if (this.options.offPlatformKill !== false) {
const offPlatform = findOffPlatformSignal(latest.text);
if (offPlatform) {
return this.kill(conversation, conversationId, state, incomingKey, offPlatformSignalReason(offPlatform));
}
}
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
if (sellerSignal) {
state.sellerDetected = true;
return this.finish(
conversation,
conversationId,
state,
incomingKey,
'human-required',
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
);
const reason = `Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`;
return this.options.killOnSellerKeyword === false
? this.finish(conversation, conversationId, state, incomingKey, 'human-required', reason)
: this.kill(conversation, conversationId, state, incomingKey, reason);
}
// Under-18 and in-person signals are safety handoffs rather than kills, and
// they apply to every policy mode, not just the R4R ones.
const safetyReason = safetyHandoffReason(latest.text);
if (safetyReason) {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
}
if (hasReceivedImage(messages)) {
return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.');
@@ -272,12 +325,6 @@ export class ConversationAutomationService {
if (state.boundaries.length > 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
}
if (state.automation.policyMode === 'r4r' || state.automation.policyMode === 'r4r-media') {
const safetyReason = r4rSafetyHandoffReason(latest.text);
if (safetyReason) {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
}
}
if (this.mediaExchange?.detect(conversationId, latest.text)) {
const exchangeAttempt = await this.rateLimiter.throttle(
async () => {
@@ -291,13 +338,19 @@ export class ConversationAutomationService {
if (refreshedState.boundaries.length > 0 || hasReceivedImage(refreshed.messages ?? [])) {
return { kind: 'human-required' as const, reason: 'The conversation became ineligible before the image could be sent.' };
}
const refreshedOffPlatform = this.options.offPlatformKill !== false
? findOffPlatformSignal(refreshedLatest.text)
: null;
if (refreshedOffPlatform) {
return { kind: 'killed' as const, reason: offPlatformSignalReason(refreshedOffPlatform) };
}
const refreshedSellerSignal = findSellerSignal(
refreshedLatest.text,
[...this.sellerKeywords],
[...this.sellerDomains],
);
if (refreshedSellerSignal) {
return { kind: 'human-required' as const, reason: 'Potential upsell appeared before the image could be sent.' };
return { kind: 'killed' as const, reason: 'Potential upsell appeared before the image could be sent.' };
}
if (messageKey(refreshedLatest) !== incomingKey) return { kind: 'superseded' as const };
return this.mediaExchange!.handle(conversationId, incomingKey, refreshedLatest.text);
@@ -318,6 +371,9 @@ export class ConversationAutomationService {
remainingTurns: remainingAutomationTurns(state),
};
}
if (exchange.kind === 'killed') {
return this.kill(conversation, conversationId, state, incomingKey, exchange.reason);
}
if (exchange.kind === 'human-required') {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', exchange.reason);
}
@@ -335,6 +391,14 @@ export class ConversationAutomationService {
);
}
const screeningOutcome = await this.screenReply(conversationId, incomingKey, mapped, conversation.username);
if (screeningOutcome) {
state.sellerDetected = true;
return screeningOutcome.kind === 'killed'
? this.kill(conversation, conversationId, state, incomingKey, screeningOutcome.reason)
: this.finish(conversation, conversationId, state, incomingKey, 'human-required', screeningOutcome.reason);
}
const { isLegacy, isHybridMediaAsk } = replyPlan(state);
if (isLegacy && state.askedForMedia) {
return this.finish(
@@ -626,6 +690,82 @@ export class ConversationAutomationService {
};
}
/**
* Runs the optional second-stage classifier. Returns null when the reply
* should proceed — including on any classifier failure, because an
* OpenRouter outage must not stall the reply path.
*/
private async screenReply(
conversationId: string,
incomingKey: string,
messages: ConversationMessage[],
username: string,
): Promise<{ kind: 'killed' | 'human-required'; reason: string } | null> {
const screening = this.options.screening;
const screener = this.options.screener;
if (!screening?.enabled || !screener) return null;
const cacheKey = `${conversationId}:${incomingKey}`;
let verdict = this.screeningCache.get(cacheKey);
if (!verdict) {
try {
verdict = await screener.screenConversationReply(messages, username);
} catch (error) {
this.logger.warn(
`[automation] Reply screening unavailable for u/${username}; proceeding: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
if (this.screeningCache.size >= SCREENING_CACHE_LIMIT) {
const oldest = this.screeningCache.keys().next().value;
if (oldest !== undefined) this.screeningCache.delete(oldest);
}
this.screeningCache.set(cacheKey, verdict);
}
if (!SCREENING_REJECT_CLASSIFICATIONS.has(verdict.classification)) return null;
const reason = `AI screening classified this conversation as ${verdict.classification}`
+ ` (confidence ${verdict.confidence.toFixed(2)})`
+ (verdict.evidence ? `: ${verdict.evidence}` : '.');
if (verdict.confidence >= screening.killConfidence) return { kind: 'killed', reason };
if (verdict.confidence >= screening.flagConfidence) return { kind: 'human-required', reason };
return null;
}
/**
* Ends a conversation permanently. Unlike a handoff this also resolves the
* operator's triage state, so a killed conversation drops out of every
* actionable queue while staying reachable through the seller and done views.
*/
private kill(
conversation: ActiveChat,
conversationId: string,
state: ConversationState,
incomingKey: string,
reason: string,
): ConversationAutomationResult {
state.sellerDetected = true;
state.automation.status = 'killed';
state.automation.lastIncomingKey = incomingKey;
state.automation.handoffReason = reason;
this.states.save(conversationId, state);
this.claims.markSuperseded?.(conversationId, incomingKey);
try {
this.options.triage?.setStatus(conversationId, 'done', null, this.now());
} catch (error) {
this.logger.warn(
`[automation] Killed u/${conversation.username} but could not resolve triage state: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.logger.info(`[automation] Killed conversation with u/${conversation.username}: ${reason}`);
return {
conversationId,
username: conversation.username,
kind: 'killed',
remainingTurns: 0,
};
}
private finish(
conversation: ActiveChat,
conversationId: string,
@@ -701,13 +841,18 @@ function replyPlan(state: ConversationState): { isLegacy: boolean; isHybridMedia
return { isLegacy, isHybridMediaAsk };
}
function r4rSafetyHandoffReason(message: string): string | null {
/**
* Handoffs that need a person rather than a kill, and that apply to every
* policy mode. Messaging-app names are deliberately absent: those are handled
* earlier by the off-platform kill, which runs before this check.
*/
function safetyHandoffReason(message: string): string | null {
if (/\b(?:i(?:'m| am)|im|age(?:d)?|turning)\s*(?:1[0-7]|[0-9])\b/i.test(message)
|| /\b(?:1[0-7]|[0-9])\s*[fm]\b/i.test(message)
|| /\b(?:minor|underage)\b/i.test(message)) {
return 'The participant may be under 18.';
}
if (/\b(?:meet|meetup|meet up|come over|address|phone number|whatsapp|telegram|snapchat)\b/i.test(message)) {
if (/\b(?:meet|meetup|meet up|come over|address|phone number)\b/i.test(message)) {
return 'The conversation reached contact details or an in-person meeting.';
}
return null;