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

+43
View File
@@ -42,6 +42,49 @@ describe('OpenRouterClient', () => {
});
});
describe('screenConversationReply', () => {
const history = [
{ sender: 'me', text: 'how has your week been?', isSelf: true },
{ sender: 'alice', text: 'good! everything else is on my page though', isSelf: false },
];
it('parses a screening verdict and sends the transcript as untrusted data', async () => {
mockCreate.mockResolvedValueOnce({
model: 'chat-model',
choices: [{ message: { content: '{"classification":"seller","confidence":0.91,"evidence":"points at a paid page"}' } }],
usage: {},
});
await expect(client.screenConversationReply(history, 'alice')).resolves.toEqual({
classification: 'seller', confidence: 0.91, evidence: 'points at a paid page',
});
const request = mockCreate.mock.calls[0][0];
expect(request.model).toBe('chat-model');
expect(request.temperature).toBe(0);
expect(request.messages[1].content).toContain('BEGIN UNTRUSTED CONVERSATION DATA');
});
it('rejects a verdict outside the allowed classifications', async () => {
mockCreate.mockResolvedValue({
model: 'chat-model',
choices: [{ message: { content: '{"classification":"definitely-a-seller","confidence":0.9,"evidence":"x"}' } }],
usage: {},
});
await expect(client.screenConversationReply(history, 'alice')).rejects.toThrow();
});
it('throws when no model returns usable JSON, so the caller can fail open', async () => {
mockCreate.mockResolvedValue({
model: 'chat-model',
choices: [{ message: { content: 'sure, here is my take on it' } }],
usage: {},
});
await expect(client.screenConversationReply(history, 'alice')).rejects.toThrow('no JSON object');
});
});
describe('isRefusal (internal)', () => {
// We test refusal detection by checking the analyzeImage behavior
// since isRefusal is private. We do this by mocking responses that