Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ba7cc95e0 | ||
|
|
32bdaf3f23 | ||
|
|
751d9d63cd |
+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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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); // 没有公司/联系人信号
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user