Files
crm-ai/bridge/test/agent-lead.test.js
T

360 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createAgent } = require('../src/agent');
const { createTools } = require('../src/tools');
function fakeTools() {
const tools = [{
name: 'search_products',
description: 'fake',
parameters: { type: 'object', properties: {} },
handler: async () => ({ products: [] }),
}];
tools.leadStore = { get: async () => null, save: async () => null };
return tools;
}
test('agent: appends lead follow-up when LLM omits company/contact ask', async () => {
let sent = '';
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => ({ content: 'Here are some rings you may like.', toolCalls: [], usage: null }),
},
client: {
getMessages: async () => [{ id: 1, text_content: 'I want 500 pieces of rings', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
},
tools: fakeTools(),
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
});
const conv = { uuid: 'conv-lead-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-1', text_content: 'I want 500 pieces of rings', content: 'I want 500 pieces of rings' });
assert.match(sent, /company name/i);
assert.match(sent, /contact email/i);
});
test('agent: output guard blocks fabricated order claim before send', async () => {
let sent = '';
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => ({ content: '已为您创建正式订单草稿,订单号:YH2024-REORDER-1,并同步发送至邮箱。', toolCalls: [], usage: null }),
},
client: {
getMessages: async () => [{ id: 1, text_content: '请下单', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
},
tools: fakeTools(),
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
});
const conv = { uuid: 'conv-guard-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-guard-1', text_content: '请下单', content: '请下单' });
assert.match(sent, /无法在聊天中直接下单/);
assert.doesNotMatch(sent, /已为您创建正式订单草稿/);
});
test('agent: bare Chinese product word forces search_products before replying', async () => {
const queries = [];
const tools = [{
name: 'search_products',
description: 'fake',
parameters: { type: 'object', properties: {} },
handler: async (args) => {
queries.push(args.query);
return { products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }], count: 1 };
},
}];
tools.leadStore = { get: async () => null, save: async () => null };
let sent = '';
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => ({ content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null }),
},
client: {
getMessages: async () => [{ id: 1, text_content: '戒指', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
},
tools,
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
});
const conv = { uuid: 'conv-ring-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-ring-1', text_content: '戒指', content: '戒指' });
assert.deepEqual(queries, ['戒指']);
assert.match(sent, /Triple Rings Ring/);
});
test('agent: backfills real product links when the LLM omits them', async () => {
const tools = [{
name: 'search_products',
description: 'fake',
parameters: { type: 'object', properties: {} },
handler: async () => ({
products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }],
count: 1,
}),
}];
tools.leadStore = { get: async () => null, save: async () => null };
let sent = '';
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => ({ content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null }),
},
client: {
getMessages: async () => [{ id: 1, text_content: '戒指', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
});
const conv = { uuid: 'conv-backfill-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-backfill-1', text_content: '戒指', content: '戒指' });
assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/);
assert.match(sent, /可直接查看/);
});
test('agent: forces search_products when the LLM answers without tools', async () => {
const queries = [];
let calls = 0;
const tools = [{
name: 'search_products',
description: 'fake',
parameters: { type: 'object', properties: {} },
handler: async (args) => {
calls += 1;
queries.push(args.query);
if (calls === 1) throw new Error('rpc_timeout'); // 预检索失败 → 走事后强制注入
return {
products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }],
count: 1,
};
},
}];
tools.leadStore = { get: async () => null, save: async () => null };
let llmCalls = 0;
let sent = '';
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => {
llmCalls += 1;
if (llmCalls === 1) {
return { content: 'Stainless Steel Ring Set — $9.99 → 查看详情:https://www.yehwang.com/fake-ring', toolCalls: [], usage: null };
}
return { content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: '戒指', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
});
const conv = { uuid: 'conv-force-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-force-1', text_content: '戒指', content: '戒指' });
assert.equal(calls, 2, 'pre-tool fails once, forced injection retries');
assert.deepEqual(queries, ['戒指', '戒指']);
assert.doesNotMatch(sent, /Stainless Steel Ring Set/);
assert.doesNotMatch(sent, /fake-ring/);
assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/);
});
// ---- 商机兜底:LLM 只调 collect_info 不调 create_opportunity 时,代码自动建商机 ----
function leadTools(twenty) {
const rpc = { async call() { return []; } };
return createTools(
{
recommendUrl: 'jsonrpc://127.0.0.1:1', orderApiUrl: '', inventoryApiUrl: '', rpcTimeoutMs: 50,
siteSearchUrl: 'https://www.yehwang.com/search.html?q=',
twenty: { baseURL: 'http://localhost:1', graphqlPath: '/graphql', workspaceId: 'w', apiKey: 'k' },
},
{ rpc, twenty, client: { sendPrivateNote: async () => {}, assignUser: async () => {} } }
);
}
function leadTwenty() {
const calls = [];
return {
calls,
async findPersonByEmail(email) { calls.push(['findPersonByEmail', email]); return null; },
async findOpportunitiesByPersonId() { return []; },
async createPerson(p) { calls.push(['createPerson', p]); return { id: 'person-1' }; },
async findCompanyByName() { return null; },
async createCompany(n) { calls.push(['createCompany', n]); return { id: 'company-1' }; },
async createOpportunity(o) { calls.push(['createOpportunity', o]); return { id: 'opp-auto-1', name: o.name }; },
async updateOpportunity(id, patch) { calls.push(['updateOpportunity', id, patch]); return { id }; },
async updateOpportunityStage(id, stage) { calls.push(['updateOpportunityStage', id, stage]); return { id, stage }; },
async createNote(c) { calls.push(['createNote', c]); return 'note-1'; },
async linkNoteToOpportunity(n, o) { calls.push(['linkNoteToOpportunity', n, o]); return { id: 'nt' }; },
async createTask(t, b, d, o) { calls.push(['createTask', t, o]); return 'task-1'; },
};
}
function leadAgentDeps(twenty, tools, contact) {
let llmCalls = 0;
let sent = '';
return {
get sent() { return sent; },
deps: {
llm: {
enabled: true,
model: 'fake',
chat: async () => {
llmCalls += 1;
if (llmCalls === 1) {
return {
content: '',
toolCalls: [{
id: 'tc-collect-1',
function: {
name: 'collect_info',
arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces for our shops', quantity: '500' }),
},
}],
usage: null,
};
}
return { content: 'Your request has been noted; a colleague will follow up.', toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => (contact.type === 'contact' ? contact.email : null), sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
},
conv: { uuid: 'conv-auto-opp-1', inbox_id: '1', channel: 'livechat', contact },
};
}
test('agent: auto-creates opportunity for a verified single-message lead', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
const ctx = leadAgentDeps(twenty, tools, { type: 'contact', email: 'buyer@test-customer.nl' });
ctx.conv.uuid = 'conv-auto-opp-verified';
const agent = createAgent({ sites: [], defaultSite: 'www' }, ctx.deps);
await agent.run(ctx.conv, { uuid: 'msg-auto-1', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', content: '...' });
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.match(twenty.calls.find((c) => c[0] === 'createOpportunity')[1].name, /Acme GmbH/);
assert.match(ctx.sent, /noted/i);
});
test('agent: auto-creates opportunity for an unverified single-message lead (policy A)', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
const ctx = leadAgentDeps(twenty, tools, { type: 'visitor' });
ctx.conv.uuid = 'conv-auto-opp-anon';
const agent = createAgent({ sites: [], defaultSite: 'www' }, ctx.deps);
await agent.run(ctx.conv, { uuid: 'msg-auto-2', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', content: '...' });
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Acme GmbH'));
});
test('agent: does not auto-create when the LLM already created the opportunity', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
let sent = '';
let llmCalls = 0;
const deps = {
llm: {
enabled: true,
model: 'fake',
chat: async () => {
llmCalls += 1;
if (llmCalls === 1) {
return {
content: '',
toolCalls: [{ id: 'tc-1', function: { name: 'collect_info', arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces' }) } }],
usage: null,
};
}
if (llmCalls === 2) {
return {
content: '',
toolCalls: [{ id: 'tc-2', function: { name: 'create_opportunity', arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces' }) } }],
usage: null,
};
}
return { content: 'Your request has been noted; a colleague will follow up.', toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => 'buyer@test-customer.nl', sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
};
const conv = { uuid: 'conv-opp-once', inbox_id: '1', channel: 'livechat', contact: { type: 'contact', email: 'buyer@test-customer.nl' } };
const agent = createAgent({ sites: [], defaultSite: 'www' }, deps);
await agent.run(conv, { uuid: 'msg-once', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces.', content: '...' });
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
});
test('agent: extracts lead fields when the LLM skips collect_info (company mentioned, unit-less quantity)', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
let sent = '';
let llmCalls = 0;
const deps = {
llm: {
enabled: true,
model: 'fake',
chat: async () => {
llmCalls += 1;
if (llmCalls === 1) {
return { content: 'Could you share your contact details so we can follow up?', toolCalls: [], usage: null };
}
// extractLeadFields 的调用:返回 JSON
return { content: JSON.stringify({ company: 'Leadfix GmbH', contactName: 'Anna Klein', productNeed: '500 stainless steel rings', quantity: '500' }), toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We are Leadfix GmbH, contact Anna Klein. We need 500 stainless steel rings.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
};
const conv = { uuid: 'conv-lead-extract', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
const agent = createAgent({ sites: [], defaultSite: 'www' }, deps);
await agent.run(conv, { uuid: 'msg-lead-extract', text_content: 'We are Leadfix GmbH, contact Anna Klein. We need 500 stainless steel rings.', content: '...' });
assert.equal(llmCalls >= 2, true);
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Leadfix GmbH'));
});