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

+10 -2
View File
@@ -43,12 +43,19 @@ export const CONVERSATION_POLICY_VERSION = 3;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
/**
* `killed` is terminal and distinct from `human-required`: the conversation was
* ended because the other party is selling or pulling the conversation off
* Reddit, so there is nothing for an operator to pick up. Everything else is a
* handoff that still expects a human.
*/
export type ConversationAutomationStatus =
| 'active'
| 'image-received'
| 'human-required'
| 'boundary'
| 'user-overridden';
| 'user-overridden'
| 'killed';
export interface ConversationAutomationState {
policyMode: ConversationPolicyMode;
@@ -296,7 +303,8 @@ function parseAutomationState(value: unknown): ConversationAutomationState {
? Math.max(1, Math.min(20, Math.trunc(parsed.maxAutomatedTurns)))
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
const status: ConversationAutomationStatus =
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary' || parsed.status === 'user-overridden'
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary'
|| parsed.status === 'user-overridden' || parsed.status === 'killed'
? parsed.status
: 'active';
const rapportTurnsBeforeMediaAsk = typeof parsed.rapportTurnsBeforeMediaAsk === 'number'