feat: hand off upsell conversations to humans
This commit is contained in:
1 parent
5ad7f1bf77
commit
e8f56b42e6
4 files changed
+32
-2
No files matched your search
@@ -163,7 +163,7 @@ function automationStatusMessage(conversation: ConversationDetailResponse): stri
|
|||||||
case 'image-received':
|
case 'image-received':
|
||||||
return 'Image received. Automation is complete.';
|
return 'Image received. Automation is complete.';
|
||||||
case 'human-required':
|
case 'human-required':
|
||||||
return 'Automated turns are exhausted. A human should take over.';
|
return conversation.automation.handoffReason ?? 'Automated turns are exhausted. A human should take over.';
|
||||||
case 'boundary':
|
case 'boundary':
|
||||||
return 'Automation stopped because the user set a boundary.';
|
return 'Automation stopped because the user set a boundary.';
|
||||||
case 'user-overridden':
|
case 'user-overridden':
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js';
|
|||||||
import type { ConversationStateRepository } from '../database/repositories.js';
|
import type { ConversationStateRepository } from '../database/repositories.js';
|
||||||
import type { Logger } from '../utils/logger.js';
|
import type { Logger } from '../utils/logger.js';
|
||||||
import type { RateLimiter } from '../utils/rate-limiter.js';
|
import type { RateLimiter } from '../utils/rate-limiter.js';
|
||||||
|
import { findSellerSignal } from '../reddit/api.js';
|
||||||
import type { ActiveChat, ChatMessage } from './chat/types.js';
|
import type { ActiveChat, ChatMessage } from './chat/types.js';
|
||||||
import type { ChatService } from './chat/service.js';
|
import type { ChatService } from './chat/service.js';
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +41,8 @@ export class ConversationAutomationService {
|
|||||||
private readonly rateLimiter: RateLimiter,
|
private readonly rateLimiter: RateLimiter,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly dryRun = false,
|
private readonly dryRun = false,
|
||||||
|
private readonly sellerKeywords: readonly string[] = [],
|
||||||
|
private readonly sellerDomains: readonly string[] = [],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processUnreadConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
|
async processUnreadConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
|
||||||
@@ -72,6 +75,17 @@ export class ConversationAutomationService {
|
|||||||
if (state.automation.lastIncomingKey === incomingKey) return null;
|
if (state.automation.lastIncomingKey === incomingKey) return null;
|
||||||
if (state.automation.status !== 'active') return null;
|
if (state.automation.status !== 'active') return null;
|
||||||
|
|
||||||
|
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
|
||||||
|
if (sellerSignal) {
|
||||||
|
return this.finish(
|
||||||
|
conversation,
|
||||||
|
conversationId,
|
||||||
|
state,
|
||||||
|
incomingKey,
|
||||||
|
'human-required',
|
||||||
|
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (hasReceivedImage(messages)) {
|
if (hasReceivedImage(messages)) {
|
||||||
return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.');
|
return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ async function buildRuntime(
|
|||||||
rateLimiter,
|
rateLimiter,
|
||||||
logger,
|
logger,
|
||||||
accountConfig.browser.dryRun,
|
accountConfig.browser.dryRun,
|
||||||
|
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [],
|
||||||
|
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resources: RedditAccountRuntimeResources = {
|
const resources: RedditAccountRuntimeResources = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { ConversationAutomationService } from '../src/services/conversation-automation.js';
|
import { ConversationAutomationService } from '../src/services/conversation-automation.js';
|
||||||
import type { ConversationState } from '../src/services/conversation-state.js';
|
import type { ConversationState } from '../src/services/conversation-state.js';
|
||||||
|
|
||||||
function harness() {
|
function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) {
|
||||||
let saved: ConversationState | undefined;
|
let saved: ConversationState | undefined;
|
||||||
const chat = {
|
const chat = {
|
||||||
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -20,6 +20,7 @@ function harness() {
|
|||||||
const logger = { info: vi.fn() };
|
const logger = { info: vi.fn() };
|
||||||
const service = new ConversationAutomationService(
|
const service = new ConversationAutomationService(
|
||||||
chat as never, ai as never, states as never, rateLimiter as never, logger as never,
|
chat as never, ai as never, states as never, rateLimiter as never, logger as never,
|
||||||
|
false, sellerKeywords, sellerDomains,
|
||||||
);
|
);
|
||||||
return { service, chat, ai, states, getSaved: () => saved };
|
return { service, chat, ai, states, getSaved: () => saved };
|
||||||
}
|
}
|
||||||
@@ -59,6 +60,19 @@ describe('conversation automation', () => {
|
|||||||
expect(getSaved()?.automation.status).toBe('boundary');
|
expect(getSaved()?.automation.status).toBe('boundary');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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'));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
||||||
|
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||||
|
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
||||||
|
expect(getSaved()?.automation).toMatchObject({
|
||||||
|
status: 'human-required',
|
||||||
|
handoffReason: 'Potential upsell detected (domain: fansly.com).',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('completes when an inbound image is received', async () => {
|
it('completes when an inbound image is received', async () => {
|
||||||
const { service, chat, getSaved } = harness();
|
const { service, chat, getSaved } = harness();
|
||||||
const result = await service.processConversation(incoming(1, '[Image]', [{
|
const result = await service.processConversation(incoming(1, '[Image]', [{
|
||||||
|
|||||||
Reference in new issue
Block a user