fix(bridge): 商机漏建兜底与匿名线索

This commit is contained in:
wangliang
2026-10-23 14:22:14 +08:00
parent 22d96edee6
commit 9ce3120648
6 changed files with 238 additions and 18 deletions
+141
View File
@@ -3,6 +3,7 @@
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 = [{
@@ -180,3 +181,143 @@ test('agent: forces search_products when the LLM answers without tools', async (
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);
});