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':
|
||||
return 'Image received. Automation is complete.';
|
||||
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':
|
||||
return 'Automation stopped because the user set a boundary.';
|
||||
case 'user-overridden':
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js';
|
||||
import type { ConversationStateRepository } from '../database/repositories.js';
|
||||
import type { Logger } from '../utils/logger.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 { ChatService } from './chat/service.js';
|
||||
import {
|
||||
@@ -40,6 +41,8 @@ export class ConversationAutomationService {
|
||||
private readonly rateLimiter: RateLimiter,
|
||||
private readonly logger: Logger,
|
||||
private readonly dryRun = false,
|
||||
private readonly sellerKeywords: readonly string[] = [],
|
||||
private readonly sellerDomains: readonly string[] = [],
|
||||
) {}
|
||||
|
||||
async processUnreadConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
|
||||
@@ -72,6 +75,17 @@ export class ConversationAutomationService {
|
||||
if (state.automation.lastIncomingKey === incomingKey) 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)) {
|
||||
return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.');
|
||||
}
|
||||
|
||||
@@ -161,6 +161,8 @@ async function buildRuntime(
|
||||
rateLimiter,
|
||||
logger,
|
||||
accountConfig.browser.dryRun,
|
||||
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [],
|
||||
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [],
|
||||
);
|
||||
|
||||
const resources: RedditAccountRuntimeResources = {
|
||||
|
||||
@@ -2,7 +2,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';
|
||||
|
||||
function harness() {
|
||||
function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) {
|
||||
let saved: ConversationState | undefined;
|
||||
const chat = {
|
||||
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -20,6 +20,7 @@ function harness() {
|
||||
const logger = { info: vi.fn() };
|
||||
const service = new ConversationAutomationService(
|
||||
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 };
|
||||
}
|
||||
@@ -59,6 +60,19 @@ describe('conversation automation', () => {
|
||||
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 () => {
|
||||
const { service, chat, getSaved } = harness();
|
||||
const result = await service.processConversation(incoming(1, '[Image]', [{
|
||||
|
||||
Reference in new issue
Block a user