Harden conversation replies and prioritize newest candidates
This commit is contained in:
1 parent
f98c5c732d
commit
b8e4a1bb88
23 files changed
+598
-113
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationAutomationService } from '../src/services/conversation-automation.js';
|
||||
import type { ConversationState } from '../src/services/conversation-state.js';
|
||||
import type { ActiveChat } from '../src/services/chat.js';
|
||||
|
||||
function harness(
|
||||
sellerKeywords: string[] = [],
|
||||
@@ -11,6 +12,7 @@ function harness(
|
||||
maxAutomatedTurns: number;
|
||||
rapportTurnsBeforeMediaAsk?: number;
|
||||
},
|
||||
beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise<ActiveChat | undefined>,
|
||||
) {
|
||||
const saved = new Map<string, ConversationState>();
|
||||
let lastSaved: ConversationState | undefined;
|
||||
@@ -43,6 +45,7 @@ function harness(
|
||||
markAttempt: vi.fn(() => true),
|
||||
releasePending: vi.fn(() => true),
|
||||
markSent: vi.fn(() => true),
|
||||
markSuperseded: vi.fn(() => true),
|
||||
};
|
||||
const rateLimiter = {
|
||||
throttle: vi.fn(async (operation: () => Promise<boolean>) => operation()),
|
||||
@@ -59,6 +62,7 @@ function harness(
|
||||
}] : [],
|
||||
() => 1_000,
|
||||
0,
|
||||
beforeSendLoader ?? (async (_conversationId, observed) => observed),
|
||||
);
|
||||
return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved };
|
||||
}
|
||||
@@ -75,18 +79,17 @@ function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ original
|
||||
}
|
||||
|
||||
describe('conversation automation', () => {
|
||||
it('sends once per inbound turn and flags human handoff after the third send', async () => {
|
||||
it('allows only one autonomous media request and then hands off', async () => {
|
||||
const { service, chat, getSaved } = harness();
|
||||
|
||||
expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2);
|
||||
expect((await service.processConversation(incoming(2)))?.remainingTurns).toBe(1);
|
||||
const third = await service.processConversation(incoming(3));
|
||||
const second = await service.processConversation(incoming(2));
|
||||
|
||||
expect(third).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledTimes(3);
|
||||
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 3, status: 'human-required' });
|
||||
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(3);
|
||||
expect(await service.processConversation(incoming(3))).toBeNull();
|
||||
expect(second).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
|
||||
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 1, status: 'human-required' });
|
||||
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(1);
|
||||
expect(await service.processConversation(incoming(2))).toBeNull();
|
||||
});
|
||||
|
||||
it('stops without sending when the user declines images', async () => {
|
||||
@@ -98,6 +101,63 @@ describe('conversation automation', () => {
|
||||
expect(getSaved()?.automation.status).toBe('boundary');
|
||||
});
|
||||
|
||||
it('hands off immediately when the opener already made the conversation media request', async () => {
|
||||
const fixture = harness();
|
||||
const conversation = incoming(1, 'Sure');
|
||||
conversation.messages[0].text = 'If you feel like it, send a pic where you are smiling.';
|
||||
|
||||
const result = await fixture.service.processConversation(conversation);
|
||||
|
||||
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
||||
expect(fixture.ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
expect(fixture.getSaved()?.automation.handoffReason).toContain('media request has already been made');
|
||||
});
|
||||
|
||||
it('supersedes a queued reply when a refusal arrives during the rate-limit wait', async () => {
|
||||
const tattooEvent = {
|
||||
eventId: 'tattoo-event', sender: 'alice',
|
||||
text: 'I edit out my tattoos for the sake of anonymity but that is the only filtering done.',
|
||||
timestamp: 30, isSelf: false,
|
||||
};
|
||||
const observed: ActiveChat = {
|
||||
username: 'JustProwling', roomName: 'JustProwling', roomId: 'room-alice', url: '/room/room-alice',
|
||||
lastMessageTime: 30, lastMessageSnippet: tattooEvent.text, isFromUs: false, unread: true,
|
||||
messages: [
|
||||
{ eventId: 'opener', sender: 'me', text: 'Send a pic where you are smiling.', timestamp: 10, isSelf: true },
|
||||
tattooEvent,
|
||||
],
|
||||
};
|
||||
const latest: ActiveChat = {
|
||||
...observed,
|
||||
lastMessageTime: 40,
|
||||
lastMessageSnippet: 'Zero interest in sending you anything lol',
|
||||
messages: [
|
||||
...observed.messages!,
|
||||
{
|
||||
eventId: 'refusal-event', sender: 'alice',
|
||||
text: 'Zero interest in sending you anything lol', timestamp: 40, isSelf: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fixture = harness([], [], 0, undefined, async () => latest);
|
||||
fixture.claims.getClaim.mockReturnValue({
|
||||
conversationId: 'room-alice', incomingKey: 'tattoo-event', status: 'pending',
|
||||
replyText: 'If you ever feel like sharing a quick snap, I am all eyes.', incomingAt: 30,
|
||||
claimedAt: 0, lastAttemptAt: null, attemptCount: 0, sentAt: null, supersededAt: null,
|
||||
} as never);
|
||||
|
||||
const result = await fixture.service.processConversation(observed);
|
||||
|
||||
expect(result?.kind).toBe('boundary');
|
||||
expect(fixture.claims.markSuperseded).toHaveBeenCalledWith('room-alice', 'tattoo-event');
|
||||
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
expect(fixture.getSaved()).toMatchObject({
|
||||
boundaries: ['does not want to send photos or videos'],
|
||||
automation: { status: 'boundary', lastIncomingKey: 'refusal-event' },
|
||||
});
|
||||
});
|
||||
|
||||
it('flags a potential upsell for human handoff without generating a reply', async () => {
|
||||
const { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']);
|
||||
const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example'));
|
||||
@@ -323,7 +383,7 @@ describe('conversation automation', () => {
|
||||
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledTimes(2);
|
||||
expect(getSaved()?.automation).toMatchObject({
|
||||
policyMode: 'r4r', policyVersion: 2, maxAutomatedTurns: 2, turnsUsed: 2,
|
||||
policyMode: 'r4r', policyVersion: 3, maxAutomatedTurns: 2, turnsUsed: 2,
|
||||
});
|
||||
expect(getSaved()?.askedForMedia).toBe(false);
|
||||
});
|
||||
@@ -396,7 +456,7 @@ describe('conversation automation', () => {
|
||||
expect(fixture.getSaved()?.askedForMedia).toBe(true);
|
||||
expect(fixture.getSaved()?.automation).toMatchObject({
|
||||
policyMode: 'r4r-media',
|
||||
policyVersion: 2,
|
||||
policyVersion: 3,
|
||||
rapportTurnsBeforeMediaAsk: 2,
|
||||
hybridPhase: 'post-media-ask',
|
||||
turnsUsed: 4,
|
||||
@@ -445,4 +505,14 @@ describe('conversation automation', () => {
|
||||
expect(result?.kind).toBe('human-required');
|
||||
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a coercive legacy media request', async () => {
|
||||
const fixture = harness();
|
||||
fixture.ai.generateMediaAsk.mockResolvedValue('Prove me wrong and send a pic.');
|
||||
|
||||
const result = await fixture.service.processConversation(incoming(1));
|
||||
|
||||
expect(result?.kind).toBe('human-required');
|
||||
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user