fix(bridge): 商机漏建兜底与匿名线索
This commit is contained in:
@@ -328,6 +328,46 @@ function createAgent(cfg, deps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 商机兜底(旧 bridge leadPipeline.maybeCreateLead 同款):槽位齐全但 LLM 没调 create_opportunity 时,
|
||||||
|
// 由代码直接建商机,避免“回复说已记录/会创建、CRM 里却没有”的漏建。
|
||||||
|
// 不要求邮箱验证(政策 A):公司 + 联系人(姓名/邮箱/电话)+ 需求即可。
|
||||||
|
try {
|
||||||
|
const createTool = byName.get('create_opportunity');
|
||||||
|
const calledThisTurn = (stats.toolCalls || []).some((t) => t.name === 'create_opportunity');
|
||||||
|
if (leadStore && createTool && !calledThisTurn) {
|
||||||
|
const latest = await leadStore.get(conv.uuid);
|
||||||
|
const slots = (latest && latest.slots) || {};
|
||||||
|
const hasContact = Boolean(slots.contactName || slots.email || slots.phone);
|
||||||
|
const ready = Boolean(slots.company && slots.productNeed && hasContact);
|
||||||
|
if (ready && !(latest && latest.opportunityId)) {
|
||||||
|
const started = Date.now();
|
||||||
|
const created = await createTool.handler({
|
||||||
|
company: slots.company,
|
||||||
|
contactName: slots.contactName,
|
||||||
|
email: slots.email,
|
||||||
|
productNeed: slots.productNeed,
|
||||||
|
quantity: slots.quantity,
|
||||||
|
budget: slots.budget,
|
||||||
|
closeDate: slots.closeDate,
|
||||||
|
phone: slots.phone,
|
||||||
|
}, ctx);
|
||||||
|
stats.toolCalls.push({ name: 'create_opportunity', ms: Date.now() - started, ok: true, auto: true });
|
||||||
|
stats.toolResults.push(created);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
agent: 'lead_auto_create',
|
||||||
|
conversation: conv.uuid,
|
||||||
|
created: Boolean(created && created.created),
|
||||||
|
reason: created && created.reason,
|
||||||
|
opportunity: created && created.opportunity_id,
|
||||||
|
verified: ctx.verified,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `lead auto create failed: ${err.message}` }));
|
||||||
|
}
|
||||||
|
|
||||||
// post-LLM 兜底:LLM 没问公司/联系方式时,代码强制追加多语言追问
|
// post-LLM 兜底:LLM 没问公司/联系方式时,代码强制追加多语言追问
|
||||||
let leadFollowup = false;
|
let leadFollowup = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function createOpportunityService({ cfg, twenty, leadStore, client }) {
|
|||||||
function summaryFor({ ctx, slots, email, profile }) {
|
function summaryFor({ ctx, slots, email, profile }) {
|
||||||
return [
|
return [
|
||||||
`Company: ${slots.company}`,
|
`Company: ${slots.company}`,
|
||||||
`Contact: ${slots.contactName || '(not provided)'} <${email}>`,
|
`Contact: ${slots.contactName || '(not provided)'}${email ? ` <${email}>` : ''}`,
|
||||||
`Need: ${slots.productNeed}`,
|
`Need: ${slots.productNeed}`,
|
||||||
slots.quantity ? `Quantity: ${slots.quantity}` : null,
|
slots.quantity ? `Quantity: ${slots.quantity}` : null,
|
||||||
slots.budget ? `Budget: ${slots.budget}` : null,
|
slots.budget ? `Budget: ${slots.budget}` : null,
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ TOOL DISCIPLINE:
|
|||||||
- search_products: any product-finding request (recommend/show/find/buy products). A bare product/category word or short phrase is a product request too (e.g. 戒指, 项链, rings, necklace, earrings): call search_products before answering. Use exact product names from results; include prices, stock and full URLs when available. If a category link is returned, include it as a full clickable URL. If results are empty, say so honestly. Never reuse product names, prices or links from earlier turns unless they are in THIS turn's tool results.
|
- search_products: any product-finding request (recommend/show/find/buy products). A bare product/category word or short phrase is a product request too (e.g. 戒指, 项链, rings, necklace, earrings): call search_products before answering. Use exact product names from results; include prices, stock and full URLs when available. If a category link is returned, include it as a full clickable URL. If results are empty, say so honestly. Never reuse product names, prices or links from earlier turns unless they are in THIS turn's tool results.
|
||||||
- search_orders: only for the verified customer's own orders. If the customer gives an order number, pass it to get full detail (status, items, totals, address, shipping). Never accept another person's email; the identity comes from the platform.
|
- search_orders: only for the verified customer's own orders. If the customer gives an order number, pass it to get full detail (status, items, totals, address, shipping). Never accept another person's email; the identity comes from the platform.
|
||||||
- search_inventory: real-time stock/ETA for a specific product; prefer product_id returned by search_products.
|
- search_inventory: real-time stock/ETA for a specific product; prefer product_id returned by search_products.
|
||||||
- collect_info: whenever you learn any lead details (company, contact name, phone, product need, quantity, budget, close date), call it immediately so nothing is lost. Record productNeed exactly as the customer stated, never invent or substitute.
|
- collect_info: whenever you learn any lead details (company, contact name, email, phone, product need, quantity, budget, close date), call it immediately so nothing is lost. Record productNeed exactly as the customer stated, never invent or substitute.
|
||||||
- search_email_history: only for a verified customer asking about their own past emails (quotes, previous inquiries, "I emailed you before"). Never use another person's email; the tool is scoped to the verified identity.
|
- search_email_history: only for a verified customer asking about their own past emails (quotes, previous inquiries, "I emailed you before"). Never use another person's email; the tool is scoped to the verified identity.
|
||||||
- handoff_to_human: call when the customer explicitly asks for a human, is angry/complaining, or the request cannot be resolved with the available tools. Use it instead of only saying "I'll transfer you".
|
- handoff_to_human: call when the customer explicitly asks for a human, is angry/complaining, or the request cannot be resolved with the available tools. Use it instead of only saying "I'll transfer you".
|
||||||
- create_opportunity: only after company + contact name + product need are all known; call once per customer. If it returns created=false, do not create another. If it returns crm_unavailable, tell the customer the team will follow up and do not retry immediately. NEVER tell the customer about CRM, opportunity IDs, internal records, assigned users, or tool results. If it was created, only say their request has been noted and a relevant colleague will contact them.
|
- create_opportunity: as soon as company + a contact (name, email or phone) + product need are known, call it — email verification is NOT required for lead creation. Call it exactly once per customer. If it returns created=false, do not create another. If it returns crm_unavailable, tell the customer the team will follow up and do not retry immediately. NEVER tell the customer about CRM, opportunity IDs, internal records, assigned users, or tool results. Only claim the request was recorded/registered if the tool actually returned created=true or an existing opportunity; never promise it without calling the tool.
|
||||||
|
|
||||||
VERIFICATION (important):
|
VERIFICATION (important):
|
||||||
- Order lookup and opportunity creation require a verified customer.
|
- Order lookup requires a verified customer. Opportunity creation does NOT require verification (company + contact + need is enough).
|
||||||
- If the customer is not verified and asks for private order data: ask for their email, call send_verification_code({email}), tell them a code was sent, then wait for the code and call verify_email_code({code}).
|
- If the customer is not verified and asks for private order data: ask for their email, call send_verification_code({email}), tell them a code was sent, then wait for the code and call verify_email_code({code}).
|
||||||
- Do not call send_verification_code until the customer provides an email. Never invent an email address.
|
- Do not call send_verification_code until the customer provides an email. Never invent an email address.
|
||||||
- The code expires in 10 minutes and allows 5 attempts. If verification fails, ask them to check the code or request a new one.
|
- The code expires in 10 minutes and allows 5 attempts. If verification fails, ask them to check the code or request a new one.
|
||||||
@@ -31,7 +31,7 @@ VERIFICATION (important):
|
|||||||
|
|
||||||
SALES FLOW:
|
SALES FLOW:
|
||||||
- For clear buying intent (product + quantity/budget) but missing company or contact, ask for the missing pieces naturally in the same reply.
|
- For clear buying intent (product + quantity/budget) but missing company or contact, ask for the missing pieces naturally in the same reply.
|
||||||
- Do not create an opportunity until the required three fields exist.
|
- Create the opportunity only after company + contact + product need exist; do not create it for a vague inquiry.
|
||||||
- Do not proactively push discounts; if asked, volume/membership tiers are available (mention only when asked).
|
- Do not proactively push discounts; if asked, volume/membership tiers are available (mention only when asked).
|
||||||
|
|
||||||
ANTI-HALLUCINATION:
|
ANTI-HALLUCINATION:
|
||||||
|
|||||||
+10
-10
@@ -221,12 +221,13 @@ const TOOL_DEFS = [
|
|||||||
{
|
{
|
||||||
name: 'collect_info',
|
name: 'collect_info',
|
||||||
description:
|
description:
|
||||||
'Record customer lead details incrementally across turns (company, contact name, phone, product need, quantity, budget, close date). Call this whenever the customer provides any of these; it persists them for the conversation so the final create_opportunity does not lose earlier facts. Does not create a CRM record.',
|
'Record customer lead details incrementally across turns (company, contact name, email, phone, product need, quantity, budget, close date). Call this whenever the customer provides any of these; it persists them for the conversation so the final create_opportunity does not lose earlier facts. Does not create a CRM record.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
company: { type: 'string', description: 'customer company name' },
|
company: { type: 'string', description: 'customer company name' },
|
||||||
contactName: { type: 'string', description: 'contact person full name' },
|
contactName: { type: 'string', description: 'contact person full name' },
|
||||||
|
email: { type: 'string', description: 'contact email if the customer provided one' },
|
||||||
phone: { type: 'string', description: 'phone number if stated' },
|
phone: { type: 'string', description: 'phone number if stated' },
|
||||||
productNeed: { type: 'string', description: 'what the customer asked for, in their own words' },
|
productNeed: { type: 'string', description: 'what the customer asked for, in their own words' },
|
||||||
quantity: { type: 'string', description: 'quantity if stated' },
|
quantity: { type: 'string', description: 'quantity if stated' },
|
||||||
@@ -240,7 +241,7 @@ const TOOL_DEFS = [
|
|||||||
{
|
{
|
||||||
name: 'send_verification_code',
|
name: 'send_verification_code',
|
||||||
description:
|
description:
|
||||||
'Send a 6-digit email verification code to the customer. Only call after the customer provides their email address and private data (orders/opportunities) requires verification. Never invent an email address.',
|
'Send a 6-digit email verification code to the customer. Only call after the customer provides their email address and private data (orders) requires verification. Never invent an email address.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -253,7 +254,7 @@ const TOOL_DEFS = [
|
|||||||
{
|
{
|
||||||
name: 'verify_email_code',
|
name: 'verify_email_code',
|
||||||
description:
|
description:
|
||||||
'Verify the 6-digit code the customer received by email. Call after the customer replies with the code. On success the conversation becomes verified for private order/opportunity tools.',
|
'Verify the 6-digit code the customer received by email. Call after the customer replies with the code. On success the conversation becomes verified for private order tools.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -279,12 +280,13 @@ const TOOL_DEFS = [
|
|||||||
{
|
{
|
||||||
name: 'create_opportunity',
|
name: 'create_opportunity',
|
||||||
description:
|
description:
|
||||||
'Create a CRM opportunity in Twenty after you have collected the 3 required elements: company, contact name, and product/need. Call it exactly once per customer; if it returns created=false an opportunity already exists. Requires verified contact.',
|
'Create a CRM opportunity in Twenty once you know the company, a contact (name, email or phone) and the product/need. Call it exactly once per customer; if it returns created=false an opportunity already exists. Email verification is NOT required for lead creation.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
company: { type: 'string', description: 'customer company name' },
|
company: { type: 'string', description: 'customer company name' },
|
||||||
contactName: { type: 'string', description: 'contact person full name' },
|
contactName: { type: 'string', description: 'contact person full name' },
|
||||||
|
email: { type: 'string', description: 'contact email if the customer provided one' },
|
||||||
productNeed: { type: 'string', description: 'what the customer asked for, in their own words' },
|
productNeed: { type: 'string', description: 'what the customer asked for, in their own words' },
|
||||||
quantity: { type: 'string', description: 'quantity if stated' },
|
quantity: { type: 'string', description: 'quantity if stated' },
|
||||||
budget: { type: 'string', description: 'budget if stated, e.g. "€500"' },
|
budget: { type: 'string', description: 'budget if stated, e.g. "€500"' },
|
||||||
@@ -293,7 +295,7 @@ const TOOL_DEFS = [
|
|||||||
},
|
},
|
||||||
required: ['company', 'contactName', 'productNeed'],
|
required: ['company', 'contactName', 'productNeed'],
|
||||||
},
|
},
|
||||||
requiresVerification: true,
|
requiresVerification: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -341,7 +343,7 @@ function createTools(cfg, deps = {}) {
|
|||||||
if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required');
|
if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required');
|
||||||
const existing = await leadStore.get(conversationUuid);
|
const existing = await leadStore.get(conversationUuid);
|
||||||
const slots = { ...(existing ? existing.slots : {}) };
|
const slots = { ...(existing ? existing.slots : {}) };
|
||||||
for (const key of ['company', 'contactName', 'phone', 'productNeed', 'quantity', 'budget', 'closeDate']) {
|
for (const key of ['company', 'contactName', 'email', 'phone', 'productNeed', 'quantity', 'budget', 'closeDate']) {
|
||||||
const value = args && args[key] != null ? String(args[key]).trim() : '';
|
const value = args && args[key] != null ? String(args[key]).trim() : '';
|
||||||
if (value) slots[key] = value;
|
if (value) slots[key] = value;
|
||||||
}
|
}
|
||||||
@@ -673,11 +675,9 @@ function createTools(cfg, deps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createOpportunity(args, ctx) {
|
async function createOpportunity(args, ctx) {
|
||||||
if (!ctx.verified) {
|
// 线索建商机不要求邮箱验证(对齐旧 bridge leadPipeline):公司 + 联系人 + 需求即可。
|
||||||
throw new ToolError(403, 'verification_required', 'Creating an opportunity requires a verified contact. Ask the customer to verify their email first.');
|
// 订单/邮箱历史仍要求 verified,邮箱只从身份头取;商机允许用客户在对话里给的 email 做去重/联系人。
|
||||||
}
|
|
||||||
const email = String(ctx.email || '').trim().toLowerCase();
|
const email = String(ctx.email || '').trim().toLowerCase();
|
||||||
if (!email) throw new ToolError(400, 'missing_email', 'The verified contact has no email on file.');
|
|
||||||
if (!cfg.twenty.apiKey) throw new ToolError(503, 'not_configured', 'TWENTY_API_KEY is not configured');
|
if (!cfg.twenty.apiKey) throw new ToolError(503, 'not_configured', 'TWENTY_API_KEY is not configured');
|
||||||
const conversationUuid = String(ctx.conversationUuid || '');
|
const conversationUuid = String(ctx.conversationUuid || '');
|
||||||
if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required');
|
if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { createAgent } = require('../src/agent');
|
const { createAgent } = require('../src/agent');
|
||||||
|
const { createTools } = require('../src/tools');
|
||||||
|
|
||||||
function fakeTools() {
|
function fakeTools() {
|
||||||
const tools = [{
|
const tools = [{
|
||||||
@@ -180,3 +181,143 @@ test('agent: forces search_products when the LLM answers without tools', async (
|
|||||||
assert.doesNotMatch(sent, /fake-ring/);
|
assert.doesNotMatch(sent, /fake-ring/);
|
||||||
assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -487,15 +487,54 @@ test('search_email_history: scopes to verified email, ignores args.email', async
|
|||||||
assert.equal(out.count, 0);
|
assert.equal(out.count, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create_opportunity: requires verified contact', async () => {
|
test('create_opportunity: unverified lead is created from contact name (policy A)', async () => {
|
||||||
|
const twenty = fakeTwenty();
|
||||||
|
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||||
|
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||||
|
const out = await tool.handler(
|
||||||
|
{ company: 'Anon Co', contactName: 'Anna Klein', productNeed: '500 rings' },
|
||||||
|
{ verified: false, conversationUuid: `conv-anon-${Date.now()}` }
|
||||||
|
);
|
||||||
|
assert.equal(out.created, true);
|
||||||
|
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
|
||||||
|
const note = twenty.calls.find((c) => c[0] === 'createNote');
|
||||||
|
assert.match(note[1], /Contact: Anna Klein/);
|
||||||
|
assert.doesNotMatch(note[1], /<>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create_opportunity: unverified lead keeps the email provided in chat', async () => {
|
||||||
|
const twenty = fakeTwenty();
|
||||||
|
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||||
|
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||||
|
const out = await tool.handler(
|
||||||
|
{ company: 'Anon Co', contactName: 'Anna Klein', email: 'anna@anon.test', productNeed: '500 rings' },
|
||||||
|
{ verified: false, conversationUuid: `conv-anon-mail-${Date.now()}` }
|
||||||
|
);
|
||||||
|
assert.equal(out.created, true);
|
||||||
|
const person = twenty.calls.find((c) => c[0] === 'createPerson');
|
||||||
|
assert.equal(person[1].email, 'anna@anon.test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create_opportunity: still requires company + contact + need', async () => {
|
||||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
|
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
|
||||||
const tool = tools.find((t) => t.name === 'create_opportunity');
|
const tool = tools.find((t) => t.name === 'create_opportunity');
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => tool.handler({ company: 'A', contactName: 'B', productNeed: 'C' }, { verified: false }),
|
() => tool.handler({ company: 'A', productNeed: 'C' }, { verified: false, conversationUuid: 'conv-missing' }),
|
||||||
(err) => err instanceof ToolError && err.status === 403
|
(err) => err instanceof ToolError && err.status === 400
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collect_info: stores the email slot', async () => {
|
||||||
|
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
|
||||||
|
const tool = tools.find((t) => t.name === 'collect_info');
|
||||||
|
const out = await tool.handler(
|
||||||
|
{ company: 'Acme BV', contactName: 'John Doe', email: 'john@acme.test', productNeed: 'gold hoops' },
|
||||||
|
{ conversationUuid: 'conv-collect-email', verified: false }
|
||||||
|
);
|
||||||
|
assert.equal(out.slots.email, 'john@acme.test');
|
||||||
|
assert.deepEqual(out.missing, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('create_opportunity: dedupes by existing opportunity', async () => {
|
test('create_opportunity: dedupes by existing opportunity', async () => {
|
||||||
const twenty = fakeTwenty({ personId: 'person-1', existingOpps: [{ id: 'opp-old', name: 'Old', stage: 'NEW' }] });
|
const twenty = fakeTwenty({ personId: 'person-1', existingOpps: [{ id: 'opp-old', name: 'Old', stage: 'NEW' }] });
|
||||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||||
|
|||||||
Reference in New Issue
Block a user