Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9ecd0b8d | ||
|
|
751d9d63cd | ||
|
|
4902cc632f |
@@ -5,6 +5,7 @@
|
|||||||
"www": {
|
"www": {
|
||||||
"site": "www",
|
"site": "www",
|
||||||
"tenantId": 2,
|
"tenantId": 2,
|
||||||
|
"currencyCode": "EUR",
|
||||||
"searchUrl": "https://www.yehwang.com/search.html?q=",
|
"searchUrl": "https://www.yehwang.com/search.html?q=",
|
||||||
"siteMap": "site-map.json",
|
"siteMap": "site-map.json",
|
||||||
"language": "en"
|
"language": "en"
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
"us": {
|
"us": {
|
||||||
"site": "us",
|
"site": "us",
|
||||||
"tenantId": 2,
|
"tenantId": 2,
|
||||||
|
"currencyCode": "USD",
|
||||||
"searchUrl": "https://us.yehwang.com/search.html?q=",
|
"searchUrl": "https://us.yehwang.com/search.html?q=",
|
||||||
"siteMap": "site-map-us.json",
|
"siteMap": "site-map-us.json",
|
||||||
"language": "en"
|
"language": "en"
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"eu": {
|
"eu": {
|
||||||
"site": "www",
|
"site": "www",
|
||||||
"tenantId": 2,
|
"tenantId": 2,
|
||||||
|
"currencyCode": "EUR",
|
||||||
"searchUrl": "https://www.yehwang.com/search.html?q=",
|
"searchUrl": "https://www.yehwang.com/search.html?q=",
|
||||||
"siteMap": "site-map.json",
|
"siteMap": "site-map.json",
|
||||||
"language": "en"
|
"language": "en"
|
||||||
|
|||||||
+6
-2
@@ -15,7 +15,7 @@ const { quickClassify, detectEmotion, INTENTS } = require('./intent');
|
|||||||
const { createContextManager } = require('./context');
|
const { createContextManager } = require('./context');
|
||||||
const { createReranker } = require('./reranker');
|
const { createReranker } = require('./reranker');
|
||||||
const { detectInjection, markToolResult, INJECTION_CAUTION_PROMPT } = require('./security');
|
const { detectInjection, markToolResult, INJECTION_CAUTION_PROMPT } = require('./security');
|
||||||
const { hasBuyingIntent, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('./lead-intent');
|
const { hasBuyingIntent, looksLikeLead, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('./lead-intent');
|
||||||
const { createLlmCache } = require('./llm-cache');
|
const { createLlmCache } = require('./llm-cache');
|
||||||
const { checkCitation } = require('./citation-checker');
|
const { checkCitation } = require('./citation-checker');
|
||||||
const { createFlywheelWriter } = require('./flywheel');
|
const { createFlywheelWriter } = require('./flywheel');
|
||||||
@@ -142,10 +142,14 @@ function createAgent(cfg, deps = {}) {
|
|||||||
}
|
}
|
||||||
const preSlots = (leadState && leadState.slots) || {};
|
const preSlots = (leadState && leadState.slots) || {};
|
||||||
const buyingIntent = hasBuyingIntent(currentText);
|
const buyingIntent = hasBuyingIntent(currentText);
|
||||||
|
const leadSignal = looksLikeLead(currentText);
|
||||||
const preMissing = missingLeadContacts(preSlots, ctx.email);
|
const preMissing = missingLeadContacts(preSlots, ctx.email);
|
||||||
if (buyingIntent && preMissing.length) {
|
if (buyingIntent && preMissing.length) {
|
||||||
systemContent += `\n\nLEAD COMPLETION (hard rule): the customer shows buying intent but we still miss ${preMissing.join(', ')}. At the END of your reply, ask naturally for the missing piece(s) in the customer's language. Do not end without asking.`;
|
systemContent += `\n\nLEAD COMPLETION (hard rule): the customer shows buying intent but we still miss ${preMissing.join(', ')}. At the END of your reply, ask naturally for the missing piece(s) in the customer's language. Do not end without asking.`;
|
||||||
}
|
}
|
||||||
|
if (leadSignal) {
|
||||||
|
systemContent += '\n\nLEAD CAPTURE (hard rule): the customer message already contains lead details (company and/or contact and/or product need). You MUST call collect_info with those exact values BEFORE writing the reply; do not ask again for details the customer already gave. After collect_info returns ready=true, call create_opportunity with the same values.';
|
||||||
|
}
|
||||||
messages.unshift({ role: 'system', content: systemContent });
|
messages.unshift({ role: 'system', content: systemContent });
|
||||||
if (injection.level === 'high') {
|
if (injection.level === 'high') {
|
||||||
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'injection_detected', conversation: conv.uuid, matches: injection.matches.length }));
|
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'injection_detected', conversation: conv.uuid, matches: injection.matches.length }));
|
||||||
@@ -310,7 +314,7 @@ function createAgent(cfg, deps = {}) {
|
|||||||
console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `link backfill failed: ${err.message}` }));
|
console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `link backfill failed: ${err.message}` }));
|
||||||
}
|
}
|
||||||
// collect_info 兜底:消息含明显 lead 信息但 LLM 没调 collect_info 时,补抽槽位
|
// collect_info 兜底:消息含明显 lead 信息但 LLM 没调 collect_info 时,补抽槽位
|
||||||
if (buyingIntent && leadStore && !(stats.toolCalls || []).some((t) => t.name === 'collect_info')) {
|
if ((buyingIntent || leadSignal) && leadStore && !(stats.toolCalls || []).some((t) => t.name === 'collect_info')) {
|
||||||
try {
|
try {
|
||||||
const latest = await leadStore.get(conv.uuid);
|
const latest = await leadStore.get(conv.uuid);
|
||||||
if (!latest || !latest.opportunityId) {
|
if (!latest || !latest.opportunityId) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function loadSites(env) {
|
|||||||
const fallback = {
|
const fallback = {
|
||||||
defaultSite: 'www',
|
defaultSite: 'www',
|
||||||
profiles: {
|
profiles: {
|
||||||
www: { site: 'www', tenantId: null, languageId: null, currencyCode: 'USD', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
|
www: { site: 'www', tenantId: null, languageId: null, currencyCode: 'EUR', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
|
||||||
},
|
},
|
||||||
inboxes: {},
|
inboxes: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,11 +14,21 @@ const ASK = {
|
|||||||
en: '\n\nBy the way, could you share your company name and a contact email? That way we can set up your wholesale account and follow up on your request.',
|
en: '\n\nBy the way, could you share your company name and a contact email? That way we can set up your wholesale account and follow up on your request.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 公司/联系人信号:客户主动报公司名(含法律后缀)、邮箱、电话、联系人等。
|
||||||
|
// 命中它但数量单位不规整(如 "500 stainless steel rings")时,也要触发 lead 抽取兜底,
|
||||||
|
// 否则 LLM 漏调 collect_info 时槽位会为空,商机就建不出来。
|
||||||
|
const LEAD_SIGNAL_RE = /(@|\+?\d[\d\s().-]{6,}\d|gmbh|b\.?\s?v\.?\b|ltd\b|llc\b|inc\b|sarl|srl\b|limited\b|company|corporation|we are|we're|our company|contact\s*(?:name|person)?\s*[::]|\u516c\u53f8|\u8054\u7cfb\u4eba|\u91c7\u8d2d|wholesale|bulk)/i;
|
||||||
|
|
||||||
function hasBuyingIntent(text) {
|
function hasBuyingIntent(text) {
|
||||||
const value = String(text || '');
|
const value = String(text || '');
|
||||||
return PRODUCT_RE.test(value) && QUANTITY_RE.test(value);
|
return PRODUCT_RE.test(value) && QUANTITY_RE.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeLead(text) {
|
||||||
|
const value = String(text || '');
|
||||||
|
return PRODUCT_RE.test(value) && LEAD_SIGNAL_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
function missingLeadContacts(slots = {}, knownEmail = '') {
|
function missingLeadContacts(slots = {}, knownEmail = '') {
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!slots.company) missing.push('company');
|
if (!slots.company) missing.push('company');
|
||||||
@@ -51,4 +61,4 @@ function appendLeadFollowup(reply, lang) {
|
|||||||
return text + buildLeadFollowup(lang);
|
return text + buildLeadFollowup(lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { hasBuyingIntent, missingLeadContacts, detectLanguage, buildLeadFollowup, alreadyAsksForLead, appendLeadFollowup };
|
module.exports = { hasBuyingIntent, looksLikeLead, LEAD_SIGNAL_RE, missingLeadContacts, detectLanguage, buildLeadFollowup, alreadyAsksForLead, appendLeadFollowup };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ACTION_RULES = [
|
|||||||
{ type: 'pdf_invoice', re: /(?:PDF|预发票|proforma|invoice).{0,15}(?:已生成|已发送|attached|generated)|(?:attached|generated).{0,15}(?:pdf|invoice|proforma)/i },
|
{ type: 'pdf_invoice', re: /(?:PDF|预发票|proforma|invoice).{0,15}(?:已生成|已发送|attached|generated)|(?:attached|generated).{0,15}(?:pdf|invoice|proforma)/i },
|
||||||
{ type: 'team_notify', re: /(?:团队|销售|同事).{0,12}(?:已通知|已联系|会联系|已收到|已转)|team\s+(?:has\s+been|was)\s+notified|notified\s+(?:the\s+)?team|sales\s+team\s+has\s+been\s+notified/i },
|
{ type: 'team_notify', re: /(?:团队|销售|同事).{0,12}(?:已通知|已联系|会联系|已收到|已转)|team\s+(?:has\s+been|was)\s+notified|notified\s+(?:the\s+)?team|sales\s+team\s+has\s+been\s+notified/i },
|
||||||
{ type: 'opportunity_create', re: /opportunity\s+(?:created|has been created)|商机(?:已|已经)?(?:创建|建立)|opportunity\s+id/i, requiresTool: 'create_opportunity' },
|
{ type: 'opportunity_create', re: /opportunity\s+(?:created|has been created)|商机(?:已|已经)?(?:创建|建立)|opportunity\s+id/i, requiresTool: 'create_opportunity' },
|
||||||
{ type: 'internal_crm', re: /\bCRM\b|商机|opportunity\s+(?:id|created|recorded|has been)|created\s+an?\s+opportunity/i },
|
{ type: 'internal_crm', re: /\bCRM\b|商机|opportunity\s+(?:id|created|recorded|has been|reference|name|number|no\.?|#)|created\s+an?\s+opportunity|\bopportunity\b\s*[::]/i },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STOCK_RE = /(?:有货|库存|现货|缺货|in stock|out of stock|available|backorder|restock|pre-?order)/i;
|
const STOCK_RE = /(?:有货|库存|现货|缺货|in stock|out of stock|available|backorder|restock|pre-?order)/i;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ TOOL DISCIPLINE:
|
|||||||
- 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.
|
- 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: 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.
|
- 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 use the words CRM or opportunity in a customer reply (say 'your request' / 'your inquiry' instead), never mention opportunity IDs, internal records, assigned users, or tool results, and never quote an opportunity name/reference. 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 requires a verified customer. Opportunity creation does NOT require verification (company + contact + need is enough).
|
- Order lookup requires a verified customer. Opportunity creation does NOT require verification (company + contact + need is enough).
|
||||||
|
|||||||
@@ -321,3 +321,39 @@ test('agent: does not auto-create when the LLM already created the opportunity',
|
|||||||
await agent.run(conv, { uuid: 'msg-once', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces.', content: '...' });
|
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);
|
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'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { hasBuyingIntent, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('../src/lead-intent');
|
const { hasBuyingIntent, looksLikeLead, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('../src/lead-intent');
|
||||||
|
|
||||||
test('lead-intent: product + quantity is buying intent', () => {
|
test('lead-intent: product + quantity is buying intent', () => {
|
||||||
assert.equal(hasBuyingIntent('I want 500 pieces of stainless steel rings'), true);
|
assert.equal(hasBuyingIntent('I want 500 pieces of stainless steel rings'), true);
|
||||||
@@ -26,3 +26,11 @@ test('lead-intent: detects existing ask in reply', () => {
|
|||||||
assert.equal(alreadyAsksForLead('Could you share your company name and email?'), true);
|
assert.equal(alreadyAsksForLead('Could you share your company name and email?'), true);
|
||||||
assert.equal(alreadyAsksForLead('Here are some rings.'), false);
|
assert.equal(alreadyAsksForLead('Here are some rings.'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lead-intent: company/contact signal counts as a lead even without unit-quantity', () => {
|
||||||
|
// 单位不规整(500 stainless steel rings)时 hasBuyingIntent 为 false,但已是明确线索
|
||||||
|
assert.equal(hasBuyingIntent('We are Acme GmbH, contact Anna Klein. We need 500 stainless steel rings.'), false);
|
||||||
|
assert.equal(looksLikeLead('We are Acme GmbH, contact Anna Klein. We need 500 stainless steel rings.'), true);
|
||||||
|
assert.equal(looksLikeLead('Our company is Test Buyer BV, contact name Jan Jansen.'), false); // 没有商品词
|
||||||
|
assert.equal(looksLikeLead('Do you have rings?'), false); // 没有公司/联系人信号
|
||||||
|
});
|
||||||
|
|||||||
@@ -194,3 +194,17 @@ test('guard: CRM wording is replaced with a colleague follow-up, not a cart note
|
|||||||
assert.match(r.content, /request has been noted/i);
|
assert.match(r.content, /request has been noted/i);
|
||||||
assert.doesNotMatch(r.content, /account cart/i);
|
assert.doesNotMatch(r.content, /account cart/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('guard: strips leaked opportunity reference labels', () => {
|
||||||
|
const r = guardReply({
|
||||||
|
content: '🎯\n**Opportunity reference**: *Acme GmbH — 500 rings*\nOur sales team will contact you shortly.',
|
||||||
|
toolResults: [{ created: true, opportunity_id: 'opp-1' }],
|
||||||
|
toolCalls: [{ name: 'create_opportunity', ok: true }],
|
||||||
|
lang: 'en',
|
||||||
|
});
|
||||||
|
assert.equal(r.blocked, true);
|
||||||
|
assert.ok(r.reasons.includes('internal_crm'));
|
||||||
|
assert.doesNotMatch(r.content, /opportunity/i);
|
||||||
|
assert.match(r.content, /sales team will contact you/i);
|
||||||
|
assert.doesNotMatch(r.content, /account cart/i);
|
||||||
|
});
|
||||||
|
|||||||
@@ -569,3 +569,30 @@ test('create_opportunity: creates opportunity + note + follow-up task', async ()
|
|||||||
const personIdx = twenty.calls.findIndex((c) => c[0] === 'createPerson');
|
const personIdx = twenty.calls.findIndex((c) => c[0] === 'createPerson');
|
||||||
assert.ok(companyIdx >= 0 && personIdx > companyIdx, 'company must be created before person');
|
assert.ok(companyIdx >= 0 && personIdx > companyIdx, 'company must be created before person');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sites.json: www/eu -> EUR, us -> USD', () => {
|
||||||
|
const sites = require('../config/sites.json');
|
||||||
|
assert.equal(sites.profiles.www.currencyCode, 'EUR');
|
||||||
|
assert.equal(sites.profiles.eu.currencyCode, 'EUR');
|
||||||
|
assert.equal(sites.profiles.us.currencyCode, 'USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search_products: passes the site currency to the product RPC', async () => {
|
||||||
|
const rpc = fakeRpc(() => []);
|
||||||
|
const cfg = {
|
||||||
|
...baseCfg,
|
||||||
|
sites: {
|
||||||
|
defaultSite: 'www',
|
||||||
|
profiles: { www: { site: 'www', tenantId: 2, languageId: 1, currencyCode: 'EUR', searchUrl: 'https://www.yehwang.com/search.html?q=' } },
|
||||||
|
inboxes: { '1': 'www' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const tools = createTools(cfg, { rpc, twenty: fakeTwenty() });
|
||||||
|
const tool = tools.find((t) => t.name === 'search_products');
|
||||||
|
await tool.handler({ query: 'rings' }, { inboxId: '1' });
|
||||||
|
const call = rpc.calls[0];
|
||||||
|
assert.equal(call.method, '/product/list');
|
||||||
|
assert.equal(call.params[0].currencyCode, 'EUR');
|
||||||
|
assert.equal(call.params[0].tenantBaseCurrency, 'EUR');
|
||||||
|
assert.equal(call.params[0].reqContext.currencyCode, 'EUR');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user