diff --git a/src/services/conversation-automation.ts b/src/services/conversation-automation.ts index 71c56a8..f6d8961 100644 --- a/src/services/conversation-automation.ts +++ b/src/services/conversation-automation.ts @@ -12,10 +12,10 @@ import { chooseConversationIntent, conversationMessageFingerprint, createConversationAutomationState, - isSafeHybridMediaAsk, - isSafeR4RReply, + hybridMediaAskSafetyFailure, LEGACY_AUTOMATED_CONVERSATION_TURNS, mergeConversationState, + r4rReplySafetyFailure, remainingAutomationTurns, type ConversationState, } from './conversation-state.js'; @@ -355,22 +355,32 @@ export class ConversationAutomationService { if (isLegacy && intent.kind !== 'media-ask') { return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason); } - const reply = isLegacy - ? await this.ai.generateMediaAsk(mapped, conversation.username, state, intent) + const generatePlannedReply = () => isLegacy + ? this.ai.generateMediaAsk(mapped, conversation.username, state, intent) : isHybridMediaAsk - ? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state) - : await this.ai.generateReply(mapped, conversation.username, state, intent); - const safeReply = isLegacy || isHybridMediaAsk - ? isSafeHybridMediaAsk(reply) - : isSafeR4RReply(reply); - if (!safeReply) { + ? this.ai.generateR4RMediaAsk(mapped, conversation.username, state) + : this.ai.generateReply(mapped, conversation.username, state, intent); + let reply = await generatePlannedReply(); + let safetyFailure = isLegacy || isHybridMediaAsk + ? hybridMediaAskSafetyFailure(reply) + : r4rReplySafetyFailure(reply); + if ((isLegacy || isHybridMediaAsk) + && safetyFailure === 'media ask does not contain a recognizable image or video request') { + this.logger.warn?.( + `[automation] Regenerating media ask for u/${conversation.username}; the first response did not contain a recognizable request`, + ); + reply = await generatePlannedReply(); + safetyFailure = hybridMediaAskSafetyFailure(reply); + } + if (safetyFailure) { + const outputKind = isLegacy || isHybridMediaAsk ? 'media ask' : 'reply'; return this.finish( conversation, conversationId, state, incomingKey, 'human-required', - 'The generated reply failed autonomous safety checks.', + `The generated ${outputKind} was rejected: ${safetyFailure}.`, ); } if (this.dryRun) { diff --git a/src/services/conversation-boundaries.ts b/src/services/conversation-boundaries.ts index ed71562..4003c3a 100644 --- a/src/services/conversation-boundaries.ts +++ b/src/services/conversation-boundaries.ts @@ -4,7 +4,7 @@ export interface BoundaryMessage { } const MEDIA_NOUN = String.raw`(?:pic|pics|picture|pictures|photo|photos|selfie|selfies|snap|snaps|image|images|video|videos|nude|nudes|content)`; -const MEDIA_VERB = String.raw`(?:send(?:ing)?|shar(?:e|ing)|show(?:ing)?|giv(?:e|ing)|drop(?:ping)?|swap(?:ping)?|trad(?:e|ing))`; +const MEDIA_VERB = String.raw`(?:send(?:ing)?|shar(?:e|ing)|show(?:ing)?|giv(?:e|ing)|drop(?:ping)?|swap(?:ping)?|trad(?:e|ing)|snap(?:ping)?|tak(?:e|ing)|attach(?:ed|ing)?|upload(?:ed|ing)?)`; const EXPLICIT_MEDIA_REFUSAL_PATTERNS = [ new RegExp(String.raw`\b(?:no|not|never|won't|wouldn't|don't|do not|can't|cannot)\b[^.\n]{0,60}\b${MEDIA_NOUN}\b`, 'i'), diff --git a/src/services/conversation-state.ts b/src/services/conversation-state.ts index d05ddf6..e28c78e 100644 --- a/src/services/conversation-state.ts +++ b/src/services/conversation-state.ts @@ -88,7 +88,6 @@ export function conversationMessageFingerprint(message: string): string { } export function remainingAutomationTurns(state: ConversationState): number { - if (state.automation.status !== 'active') return 0; return Math.max(0, state.automation.maxAutomatedTurns - state.automation.turnsUsed); } @@ -191,28 +190,65 @@ export function formatConversationAsUntrustedData(messages: ConversationMessage[ return `BEGIN UNTRUSTED CONVERSATION DATA\n${rows.join('\n')}\nEND UNTRUSTED CONVERSATION DATA`; } +export type ConversationReplySafetyFailure = + | 'empty reply' + | 'reply exceeds 600 characters' + | 'reply contains instruction or prompt leakage' + | 'reply implies shared location or an in-person meeting' + | 'reply exceeds the three-sentence limit' + | 'reply solicits media outside the configured media-ask phase' + | 'media ask does not contain a recognizable image or video request' + | 'media ask requests explicit or sexual content' + | 'media ask uses coercive or pressuring language'; + +/** Explain why generated text violates the shared autonomous reply contract. */ +export function conversationReplySafetyFailure(message: string): ConversationReplySafetyFailure | null { + const trimmed = message.trim(); + if (!trimmed) return 'empty reply'; + if (trimmed.length > 600) return 'reply exceeds 600 characters'; + if (/\b(as an ai|system prompt|begin untrusted|end untrusted|ignore (all|previous) instructions)\b/i.test(trimmed)) { + return 'reply contains instruction or prompt leakage'; + } + if (!isSafeOutboundMessage(trimmed)) return 'reply implies shared location or an in-person meeting'; + const sentences = trimmed.match(/[^.!?]+[.!?]+/g) ?? []; + if (sentences.length > 3) return 'reply exceeds the three-sentence limit'; + return null; +} + /** Reject obvious instruction leakage and outputs that disregard the reply length contract. */ export function isSafeConversationReply(message: string): boolean { - const trimmed = message.trim(); - if (!trimmed || trimmed.length > 600) return false; - if (/\b(as an ai|system prompt|begin untrusted|end untrusted|ignore (all|previous) instructions)\b/i.test(trimmed)) return false; - if (!isSafeOutboundMessage(trimmed)) return false; - const sentences = trimmed.match(/[^.!?]+[.!?]+/g) ?? []; - return sentences.length <= 3; + return conversationReplySafetyFailure(message) === null; +} + +export function r4rReplySafetyFailure(message: string): ConversationReplySafetyFailure | null { + const sharedFailure = conversationReplySafetyFailure(message); + if (sharedFailure) return sharedFailure; + return containsMediaRequest(message) + ? 'reply solicits media outside the configured media-ask phase' + : null; } /** R4R automation may converse and flirt, but it must never solicit sexual media. */ export function isSafeR4RReply(message: string): boolean { - if (!isSafeConversationReply(message)) return false; - return !containsMediaRequest(message); + return r4rReplySafetyFailure(message) === null; +} + +export function hybridMediaAskSafetyFailure(message: string): ConversationReplySafetyFailure | null { + const sharedFailure = conversationReplySafetyFailure(message); + if (sharedFailure) return sharedFailure; + if (!containsMediaRequest(message)) return 'media ask does not contain a recognizable image or video request'; + if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) { + return 'media ask requests explicit or sexual content'; + } + if (/\b(must|prove|dare|owe|have to|need to)\b/i.test(message)) { + return 'media ask uses coercive or pressuring language'; + } + return null; } /** The hybrid policy gets one optional, non-explicit image request after rapport. */ export function isSafeHybridMediaAsk(message: string): boolean { - if (!isSafeConversationReply(message)) return false; - if (!containsMediaRequest(message)) return false; - if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) return false; - return !/\b(must|prove|dare|owe|have to|need to)\b/i.test(message); + return hybridMediaAskSafetyFailure(message) === null; } export function serializeConversationState(state: ConversationState): string { diff --git a/tests/conversation-automation.test.ts b/tests/conversation-automation.test.ts index 7db16a9..289cc8f 100644 --- a/tests/conversation-automation.test.ts +++ b/tests/conversation-automation.test.ts @@ -107,7 +107,7 @@ describe('conversation automation', () => { expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2); const second = await service.processConversation(incoming(2)); - expect(second).toMatchObject({ kind: 'human-required', remainingTurns: 0 }); + expect(second).toMatchObject({ kind: 'human-required', remainingTurns: 2 }); expect(chat.sendChatMessage).toHaveBeenCalledOnce(); expect(getSaved()?.automation).toMatchObject({ turnsUsed: 1, status: 'human-required' }); expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(1); @@ -135,7 +135,7 @@ describe('conversation automation', () => { const result = await fixture.service.processConversation(conversation); - expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 }); + expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 3 }); expect(fixture.ai.generateMediaAsk).not.toHaveBeenCalled(); expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled(); expect(fixture.getSaved()?.automation.handoffReason).toContain('media request has already been made'); @@ -194,7 +194,7 @@ describe('conversation automation', () => { const { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']); const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example')); - expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 }); + expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 3 }); expect(ai.generateMediaAsk).not.toHaveBeenCalled(); expect(chat.sendChatMessage).not.toHaveBeenCalled(); expect(getSaved()?.automation).toMatchObject({ @@ -550,7 +550,39 @@ describe('conversation automation', () => { expect(result?.kind).toBe('human-required'); expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled(); - expect(fixture.getSaved()?.automation.handoffReason).toContain('failed autonomous safety checks'); + expect(fixture.getSaved()?.automation.handoffReason) + .toBe('The generated reply was rejected: reply solicits media outside the configured media-ask phase.'); + }); + + it('regenerates once when a legacy media ask contains no recognizable request', async () => { + const fixture = harness(); + fixture.ai.generateMediaAsk + .mockResolvedValueOnce('That sounds fun. I would love to see that smile.') + .mockResolvedValueOnce('If you are up for it, snap a cute pic of yourself?'); + + const result = await fixture.service.processConversation(incoming(1)); + + expect(result).toMatchObject({ kind: 'sent', remainingTurns: 2 }); + expect(fixture.ai.generateMediaAsk).toHaveBeenCalledTimes(2); + expect(fixture.chat.sendChatMessage).toHaveBeenCalledWith( + expect.any(String), + 'If you are up for it, snap a cute pic of yourself?', + ); + }); + + it('preserves unused turns and records the exact reason when regeneration is still invalid', async () => { + const fixture = harness(); + fixture.ai.generateMediaAsk.mockResolvedValue('That sounds fun. I would love to see that smile.'); + + const result = await fixture.service.processConversation(incoming(1)); + + expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 3 }); + expect(fixture.ai.generateMediaAsk).toHaveBeenCalledTimes(2); + expect(fixture.getSaved()?.automation).toMatchObject({ + turnsUsed: 0, + handoffReason: 'The generated media ask was rejected: media ask does not contain a recognizable image or video request.', + }); + expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled(); }); it('builds rapport before making exactly one optional media ask in hybrid mode', async () => { diff --git a/tests/conversation-state.test.ts b/tests/conversation-state.test.ts index 11596c7..7270bad 100644 --- a/tests/conversation-state.test.ts +++ b/tests/conversation-state.test.ts @@ -3,6 +3,7 @@ import { buildConversationState, chooseConversationIntent, formatConversationAsUntrustedData, + hybridMediaAskSafetyFailure, mergeConversationState, isSafeConversationReply, isSafeHybridMediaAsk, @@ -88,6 +89,15 @@ describe('conversation state', () => { expect(chooseConversationIntent([], state, 'media-ask').kind).toBe('follow-up'); }); + it('reports unused turns even when automation is paused for another reason', () => { + const state = buildConversationState([message('Hello', true), message('Hey')]); + state.automation.turnsUsed = 1; + state.automation.status = 'human-required'; + + expect(remainingAutomationTurns(state)).toBe(2); + expect(chooseConversationIntent([], state, 'media-ask').kind).toBe('follow-up'); + }); + it('migrates legacy saved state with a fresh automation counter', () => { const state = parseConversationState(JSON.stringify({ persona: { age: null, location: null, relationshipStatus: 'unspecified' }, @@ -146,9 +156,13 @@ describe('conversation state', () => { it('allows only optional non-explicit media asks in the hybrid phase', () => { expect(isSafeHybridMediaAsk('Would you be comfortable sharing a selfie?')).toBe(true); + expect(isSafeHybridMediaAsk('If you are up for it, snap a cute pic of yourself today?')).toBe(true); + expect(isSafeHybridMediaAsk('You can attach or upload a photo if you like.')).toBe(true); expect(isSafeHybridMediaAsk('Would you send me a nude?')).toBe(false); expect(isSafeHybridMediaAsk('You have to prove it by sending a photo.')).toBe(false); expect(isSafeHybridMediaAsk('What music are you into?')).toBe(false); + expect(hybridMediaAskSafetyFailure('What music are you into?')) + .toBe('media ask does not contain a recognizable image or video request'); }); it('rejects replies that imply shared location or encourage meeting in person', () => { diff --git a/tests/dashboard/conversations.test.tsx b/tests/dashboard/conversations.test.tsx index 6b4bf79..b4e7bb7 100644 --- a/tests/dashboard/conversations.test.tsx +++ b/tests/dashboard/conversations.test.tsx @@ -313,6 +313,29 @@ describe('ConversationsPage', () => { expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Would you like to share a photo?'); }); + it('shows unused turns separately from a safety handoff', async () => { + server.use( + http.get('/api/conversations', () => HttpResponse.json([alice])), + http.get('/api/conversations/room-alice', () => HttpResponse.json({ + ...detailFixture(), + automation: { + turnsUsed: 0, + remainingTurns: 3, + status: 'human-required', + handoffReason: 'The generated media ask was rejected: media ask does not contain a recognizable image or video request.', + }, + })), + ); + const user = userEvent.setup(); + renderConversations(); + + await user.click(await screen.findByRole('button', { name: /u\/alice/ })); + + expect(await screen.findByText('3 automated turns remaining')).toBeInTheDocument(); + expect(screen.getByText(/generated media ask was rejected/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Suggest media ask' })).toBeDisabled(); + }); + it('starts a conversation and preserves both fields after a recoverable failure', async () => { installConversationList(); let startRequests = 0;