9 changed files with 90 additions and 8 deletions
+6 -2
View File
@@ -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) {
+9 -2
View File
@@ -17,17 +17,24 @@ function createEscalation({ client, handoffUserId = 1 }) {
const text = String(reply || '').replace(/\s+$/, ''); const text = String(reply || '').replace(/\s+$/, '');
const hints = { const hints = {
zh: '您想转人工客服吗?', zh: '您想转人工客服吗?',
en: 'Would you like to speak to a human agent?',
de: 'Möchten Sie mit einem Mitarbeiter sprechen?', de: 'Möchten Sie mit einem Mitarbeiter sprechen?',
fr: 'Souhaitez-vous parler à un conseiller ?', fr: 'Souhaitez-vous parler à un conseiller ?',
it: 'Vuole parlare con un operatore?', it: 'Vuole parlare con un operatore?',
nl: 'Wilt u met een medewerker spreken?', nl: 'Wilt u met een medewerker spreken?',
es: '¿Desea hablar con un agente humano?',
tr: 'Bir insan temsilcisiyle görüşmek ister misiniz?',
}; };
// 只输出一种语言:按用户输入判断语言,识别不到时用英文;不再拼英文兜底。
let lang = null; let lang = null;
if (/[\u4e00-\u9fff]/.test(query)) lang = 'zh'; if (/[\u4e00-\u9fff]/.test(query)) lang = 'zh';
else if (/[ğışç]/i.test(query) || /\b(merhaba|istiyorum|konuşmak|temsilci|fiyat|ürün|sipariş)\b/i.test(query)) lang = 'tr';
else if (/[äöüß]/i.test(query)) lang = 'de'; else if (/[äöüß]/i.test(query)) lang = 'de';
else if (/[éèêëàâùûîïôç]/i.test(query)) lang = 'fr'; else if (/[ñ¿¡]/i.test(query) || /\b(hola|quiero|hablar|agente|puedo|gracias|precio|pedido)\b/i.test(query)) lang = 'es';
else if (/\b(vorrei|parlare|operatore|desidera|posso|prezzo|ordine)\b/i.test(query)) lang = 'it';
else if (/[éèêëàâùûîïôç]/i.test(query) || /\b(bonjour|merci|conseiller|commande|livraison|prix|voudrais)\b/i.test(query)) lang = 'fr';
else if (/\b(hoe|voor|wilt|bent|geen|verzending|levering|bestelling)\b/i.test(query)) lang = 'nl'; else if (/\b(hoe|voor|wilt|bent|geen|verzending|levering|bestelling)\b/i.test(query)) lang = 'nl';
const option = lang ? `\n\n${hints[lang]} / Would you like to speak to a human agent?` : '\n\nWould you like to speak to a human agent?'; const option = `\n\n${hints[lang] || hints.en}`;
return text + option; return text + option;
} }
+11 -1
View File
@@ -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 };
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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).
+36
View File
@@ -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'));
});
+9 -1
View File
@@ -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); // 没有公司/联系人信号
});
+14
View File
@@ -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);
});
+3
View File
@@ -199,3 +199,6 @@ ss -ltnp | grep -E ':(9000|3100|4100|5440|6390)\s'
| WhatsApp webhook 收不到 | 没有公网 HTTPS / Meta 回调地址不对 | 本地联调跑 `bash scripts/public-https.sh`;测试/生产使用正式域名,不要用 Quick Tunnel | | WhatsApp webhook 收不到 | 没有公网 HTTPS / Meta 回调地址不对 | 本地联调跑 `bash scripts/public-https.sh`;测试/生产使用正式域名,不要用 Quick Tunnel |
| 公网访问到管理后台 | 网关白名单配置 | `services/public-gateway` 只放行 `/webhooks/whatsapp/*`、`/hc/*`、`/widget*`、`/static/public/*`,其余 404 | | 公网访问到管理后台 | 网关白名单配置 | `services/public-gateway` 只放行 `/webhooks/whatsapp/*`、`/hc/*`、`/widget*`、`/static/public/*`,其余 404 |
| AI 回复"系统暂时无法查询订单" | RPC 不可达,bridge 8s 超时 | 本地开发跑 `bash scripts/rpc-tunnel.sh --apply`;测试/生产检查内网 RPC 地址/网络策略,不要用开发机隧道 | | AI 回复"系统暂时无法查询订单" | RPC 不可达,bridge 8s 超时 | 本地开发跑 `bash scripts/rpc-tunnel.sh --apply`;测试/生产检查内网 RPC 地址/网络策略,不要用开发机隧道 |
| WhatsApp 真实消息收不到,但 Meta "Send to My Server" 测试事件能到 | WABA 级 `override_callback_uri` 优先于 App 级 callback,且 Meta 对 override 的验证常返回 403(与目标 URL 无关) | `GET /<WABA_ID>/subscribed_apps` 确认 override;`DELETE /<WABA_ID>/subscribed_apps` 后重新 `POST`(不带 override)清掉,确认 App 级 callback 指向目标域名,再发真实消息验证 |
| 多个栈共用同一个 WhatsApp 号,消息被旧环境接走(旧环境 AI 回复、新环境无日志) | crm-new 与旧 crm-ai-demo 配置了同一 `WA_PHONE_NUMBER_ID` / WABA,旧栈 tunnel 仍在接收 | 停旧栈 WhatsApp 接收(停旧 tunnel / 禁用旧 inbox),或给新环境申请独立测试号;不要让两套同时接同一个号 |
| 新建 WhatsApp inbox 后 webhook 验证 403 `verify token mismatch` | 创建接口传入的 `webhook_verify_token` 可能未真正生效,inbox 仍是默认值;`GET /api/v1/inboxes/<id>` 返回的是掩码值,不能用来核对 | 用 `PUT /api/v1/inboxes/<id>` 带完整 config 重新保存一次,再请求 `GET /webhooks/whatsapp/<id>?hub.mode=subscribe&hub.verify_token=...&hub.challenge=...` 确认返回 200 |