Fix false autonomous safety handoffs
This commit is contained in:
1 parent
84a133451f
commit
c0cc661a2f
6 files changed
+144
-29
No files matched your search
@@ -12,10 +12,10 @@ import {
|
|||||||
chooseConversationIntent,
|
chooseConversationIntent,
|
||||||
conversationMessageFingerprint,
|
conversationMessageFingerprint,
|
||||||
createConversationAutomationState,
|
createConversationAutomationState,
|
||||||
isSafeHybridMediaAsk,
|
hybridMediaAskSafetyFailure,
|
||||||
isSafeR4RReply,
|
|
||||||
LEGACY_AUTOMATED_CONVERSATION_TURNS,
|
LEGACY_AUTOMATED_CONVERSATION_TURNS,
|
||||||
mergeConversationState,
|
mergeConversationState,
|
||||||
|
r4rReplySafetyFailure,
|
||||||
remainingAutomationTurns,
|
remainingAutomationTurns,
|
||||||
type ConversationState,
|
type ConversationState,
|
||||||
} from './conversation-state.js';
|
} from './conversation-state.js';
|
||||||
@@ -355,22 +355,32 @@ export class ConversationAutomationService {
|
|||||||
if (isLegacy && intent.kind !== 'media-ask') {
|
if (isLegacy && intent.kind !== 'media-ask') {
|
||||||
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason);
|
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason);
|
||||||
}
|
}
|
||||||
const reply = isLegacy
|
const generatePlannedReply = () => isLegacy
|
||||||
? await this.ai.generateMediaAsk(mapped, conversation.username, state, intent)
|
? this.ai.generateMediaAsk(mapped, conversation.username, state, intent)
|
||||||
: isHybridMediaAsk
|
: isHybridMediaAsk
|
||||||
? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state)
|
? this.ai.generateR4RMediaAsk(mapped, conversation.username, state)
|
||||||
: await this.ai.generateReply(mapped, conversation.username, state, intent);
|
: this.ai.generateReply(mapped, conversation.username, state, intent);
|
||||||
const safeReply = isLegacy || isHybridMediaAsk
|
let reply = await generatePlannedReply();
|
||||||
? isSafeHybridMediaAsk(reply)
|
let safetyFailure = isLegacy || isHybridMediaAsk
|
||||||
: isSafeR4RReply(reply);
|
? hybridMediaAskSafetyFailure(reply)
|
||||||
if (!safeReply) {
|
: 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(
|
return this.finish(
|
||||||
conversation,
|
conversation,
|
||||||
conversationId,
|
conversationId,
|
||||||
state,
|
state,
|
||||||
incomingKey,
|
incomingKey,
|
||||||
'human-required',
|
'human-required',
|
||||||
'The generated reply failed autonomous safety checks.',
|
`The generated ${outputKind} was rejected: ${safetyFailure}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this.dryRun) {
|
if (this.dryRun) {
|
||||||
|
|||||||
@@ -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_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 = [
|
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'),
|
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'),
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export function conversationMessageFingerprint(message: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function remainingAutomationTurns(state: ConversationState): number {
|
export function remainingAutomationTurns(state: ConversationState): number {
|
||||||
if (state.automation.status !== 'active') return 0;
|
|
||||||
return Math.max(0, state.automation.maxAutomatedTurns - state.automation.turnsUsed);
|
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`;
|
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. */
|
/** Reject obvious instruction leakage and outputs that disregard the reply length contract. */
|
||||||
export function isSafeConversationReply(message: string): boolean {
|
export function isSafeConversationReply(message: string): boolean {
|
||||||
const trimmed = message.trim();
|
return conversationReplySafetyFailure(message) === null;
|
||||||
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;
|
export function r4rReplySafetyFailure(message: string): ConversationReplySafetyFailure | null {
|
||||||
const sentences = trimmed.match(/[^.!?]+[.!?]+/g) ?? [];
|
const sharedFailure = conversationReplySafetyFailure(message);
|
||||||
return sentences.length <= 3;
|
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. */
|
/** R4R automation may converse and flirt, but it must never solicit sexual media. */
|
||||||
export function isSafeR4RReply(message: string): boolean {
|
export function isSafeR4RReply(message: string): boolean {
|
||||||
if (!isSafeConversationReply(message)) return false;
|
return r4rReplySafetyFailure(message) === null;
|
||||||
return !containsMediaRequest(message);
|
}
|
||||||
|
|
||||||
|
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. */
|
/** The hybrid policy gets one optional, non-explicit image request after rapport. */
|
||||||
export function isSafeHybridMediaAsk(message: string): boolean {
|
export function isSafeHybridMediaAsk(message: string): boolean {
|
||||||
if (!isSafeConversationReply(message)) return false;
|
return hybridMediaAskSafetyFailure(message) === null;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeConversationState(state: ConversationState): string {
|
export function serializeConversationState(state: ConversationState): string {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('conversation automation', () => {
|
|||||||
expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2);
|
expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2);
|
||||||
const second = await service.processConversation(incoming(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(chat.sendChatMessage).toHaveBeenCalledOnce();
|
||||||
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 1, status: 'human-required' });
|
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 1, status: 'human-required' });
|
||||||
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(1);
|
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(1);
|
||||||
@@ -135,7 +135,7 @@ describe('conversation automation', () => {
|
|||||||
|
|
||||||
const result = await fixture.service.processConversation(conversation);
|
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.ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||||
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
||||||
expect(fixture.getSaved()?.automation.handoffReason).toContain('media request has already been made');
|
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 { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']);
|
||||||
const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example'));
|
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(ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||||
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
||||||
expect(getSaved()?.automation).toMatchObject({
|
expect(getSaved()?.automation).toMatchObject({
|
||||||
@@ -550,7 +550,39 @@ describe('conversation automation', () => {
|
|||||||
|
|
||||||
expect(result?.kind).toBe('human-required');
|
expect(result?.kind).toBe('human-required');
|
||||||
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
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 () => {
|
it('builds rapport before making exactly one optional media ask in hybrid mode', async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildConversationState,
|
buildConversationState,
|
||||||
chooseConversationIntent,
|
chooseConversationIntent,
|
||||||
formatConversationAsUntrustedData,
|
formatConversationAsUntrustedData,
|
||||||
|
hybridMediaAskSafetyFailure,
|
||||||
mergeConversationState,
|
mergeConversationState,
|
||||||
isSafeConversationReply,
|
isSafeConversationReply,
|
||||||
isSafeHybridMediaAsk,
|
isSafeHybridMediaAsk,
|
||||||
@@ -88,6 +89,15 @@ describe('conversation state', () => {
|
|||||||
expect(chooseConversationIntent([], state, 'media-ask').kind).toBe('follow-up');
|
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', () => {
|
it('migrates legacy saved state with a fresh automation counter', () => {
|
||||||
const state = parseConversationState(JSON.stringify({
|
const state = parseConversationState(JSON.stringify({
|
||||||
persona: { age: null, location: null, relationshipStatus: 'unspecified' },
|
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', () => {
|
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('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('Would you send me a nude?')).toBe(false);
|
||||||
expect(isSafeHybridMediaAsk('You have to prove it by sending a photo.')).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(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', () => {
|
it('rejects replies that imply shared location or encourage meeting in person', () => {
|
||||||
|
|||||||
@@ -313,6 +313,29 @@ describe('ConversationsPage', () => {
|
|||||||
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Would you like to share a photo?');
|
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 () => {
|
it('starts a conversation and preserves both fields after a recoverable failure', async () => {
|
||||||
installConversationList();
|
installConversationList();
|
||||||
let startRequests = 0;
|
let startRequests = 0;
|
||||||
|
|||||||
Reference in new issue
Block a user