Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9ecd0b8d | ||
|
|
751d9d63cd | ||
|
|
4902cc632f | ||
|
|
22de20e718 | ||
|
|
9ce3120648 | ||
|
|
22d96edee6 | ||
|
|
4d7b89492b | ||
|
|
ad1576588e | ||
|
|
888612924f | ||
|
|
51c62715a5 | ||
|
|
c6669b236a | ||
|
|
5c1d2b9129 | ||
|
|
cdeb30f5c4 | ||
|
|
589e5f9349 | ||
|
|
d1bd344b96 | ||
|
|
a1d1de03c3 | ||
|
|
5b08bcfc7c | ||
|
|
3b8997b565 | ||
|
|
696916ad50 | ||
|
|
a7a5b4e85c |
@@ -8,7 +8,10 @@ libredesk/backups/
|
||||
node_modules/
|
||||
*.log
|
||||
tmp/
|
||||
var/
|
||||
.DS_Store
|
||||
|
||||
# 子项目自带的 git 历史
|
||||
libredesk/.git/
|
||||
|
||||
.agent-credentials
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"www": {
|
||||
"site": "www",
|
||||
"tenantId": 2,
|
||||
"currencyCode": "EUR",
|
||||
"searchUrl": "https://www.yehwang.com/search.html?q=",
|
||||
"siteMap": "site-map.json",
|
||||
"language": "en"
|
||||
@@ -12,6 +13,7 @@
|
||||
"us": {
|
||||
"site": "us",
|
||||
"tenantId": 2,
|
||||
"currencyCode": "USD",
|
||||
"searchUrl": "https://us.yehwang.com/search.html?q=",
|
||||
"siteMap": "site-map-us.json",
|
||||
"language": "en"
|
||||
@@ -19,6 +21,7 @@
|
||||
"eu": {
|
||||
"site": "www",
|
||||
"tenantId": 2,
|
||||
"currencyCode": "EUR",
|
||||
"searchUrl": "https://www.yehwang.com/search.html?q=",
|
||||
"siteMap": "site-map.json",
|
||||
"language": "en"
|
||||
|
||||
+83
-2
@@ -10,11 +10,12 @@ const { createMetrics } = require('./metrics');
|
||||
const { auditAnswer } = require('./audit');
|
||||
const { markdownToHtml } = require('./markdown');
|
||||
const { guardReply } = require('./output-guard');
|
||||
const { appendMissingProductLinks } = require('./reply-links');
|
||||
const { quickClassify, detectEmotion, INTENTS } = require('./intent');
|
||||
const { createContextManager } = require('./context');
|
||||
const { createReranker } = require('./reranker');
|
||||
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 { checkCitation } = require('./citation-checker');
|
||||
const { createFlywheelWriter } = require('./flywheel');
|
||||
@@ -141,10 +142,14 @@ function createAgent(cfg, deps = {}) {
|
||||
}
|
||||
const preSlots = (leadState && leadState.slots) || {};
|
||||
const buyingIntent = hasBuyingIntent(currentText);
|
||||
const leadSignal = looksLikeLead(currentText);
|
||||
const preMissing = missingLeadContacts(preSlots, ctx.email);
|
||||
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.`;
|
||||
}
|
||||
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 });
|
||||
if (injection.level === 'high') {
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'injection_detected', conversation: conv.uuid, matches: injection.matches.length }));
|
||||
@@ -230,6 +235,26 @@ function createAgent(cfg, deps = {}) {
|
||||
stats.completionTokens += Number(response.usage.completion_tokens) || 0;
|
||||
stats.totalTokens += Number(response.usage.total_tokens) || 0;
|
||||
}
|
||||
// 兜底(旧 bridge 同款):product_search 意图下首轮 LLM 一个工具都没调时,
|
||||
// 丢弃它凭记忆编的回答,强制查一次真实商品结果再让它重答,避免幻觉商品/链接进入回复。
|
||||
if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && !stats.toolCalls.length) {
|
||||
const searchTool = byName.get('search_products');
|
||||
if (searchTool) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const forced = await searchTool.handler({ query: currentText, limit: 5 }, ctx);
|
||||
stats.toolCalls.push({ name: 'search_products', ms: Date.now() - started, ok: true, forced: true });
|
||||
stats.toolResults.push(forced);
|
||||
traceSteps.push({ step_type: 'tool', turn: step, tool_name: 'search_products', duration_ms: Date.now() - started });
|
||||
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'forced-search-products', type: 'function', function: { name: 'search_products', arguments: JSON.stringify({ query: currentText, limit: 5 }) } }] });
|
||||
messages.push({ role: 'tool', tool_call_id: 'forced-search-products', content: markToolResult('search_products', JSON.stringify(forced).slice(0, 6000)) });
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'forced_search_products', conversation: conv.uuid, ok: true }));
|
||||
continue;
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'forced_search_products', conversation: conv.uuid, ok: false, error: err.message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.toolCalls && response.toolCalls.length) {
|
||||
messages.push({ role: 'assistant', content: response.content || '', tool_calls: response.toolCalls });
|
||||
for (const call of response.toolCalls) {
|
||||
@@ -272,8 +297,24 @@ function createAgent(cfg, deps = {}) {
|
||||
if (!finalContent) {
|
||||
finalContent = '抱歉,我这边暂时无法处理,请您稍后再试。';
|
||||
}
|
||||
// 确定性链接兜底(旧 bridge buildProductListText 同款):LLM 漏写工具返回的真实商品/类目 URL 时,
|
||||
// 由代码在回复末尾补齐,保证客户至少拿到真实可点链接(放在护栏前,补的链接同样过白名单校验)。
|
||||
try {
|
||||
const backfill = appendMissingProductLinks({
|
||||
content: finalContent,
|
||||
toolResults: stats.toolResults,
|
||||
lang: detectLanguage(currentText),
|
||||
html: /<(p|br|a|ul|ol|strong|div)\b/i.test(finalContent),
|
||||
});
|
||||
if (backfill.appended) {
|
||||
finalContent = backfill.content;
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'link_backfill', conversation: conv.uuid, appended: backfill.appended }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `link backfill failed: ${err.message}` }));
|
||||
}
|
||||
// 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 {
|
||||
const latest = await leadStore.get(conv.uuid);
|
||||
if (!latest || !latest.opportunityId) {
|
||||
@@ -291,6 +332,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 没问公司/联系方式时,代码强制追加多语言追问
|
||||
let leadFollowup = false;
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,7 @@ function loadSites(env) {
|
||||
const fallback = {
|
||||
defaultSite: 'www',
|
||||
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: {},
|
||||
};
|
||||
|
||||
@@ -10,12 +10,17 @@ const INTENTS = {
|
||||
GENERAL: 'general',
|
||||
};
|
||||
|
||||
// 商品类目名词:只发一个类目词(如“戒指”)也算商品检索。
|
||||
// 不能只靠动词(想买/推荐)判断,否则模型会拿上一轮上下文里的商品编造名称和链接。
|
||||
const PRODUCT_NOUN_RE = /戒指|项链|手链|手镯|耳环|耳钉|耳坠|吊坠|饰品|首饰|珠宝|发饰|发夹|脚链|胸针|\brings?\b|\bnecklaces?\b|\bbracelets?\b|\bbangles?\b|\bearrings?\b|\bpendants?\b|\bjewell?ery\b|\bketting(?:en)?\b|\boorbellen\b|\barmband(?:en)?\b|\bhalsketten?\b|\bohrringe(?:n)?\b|\bschmuck\b|\bcolliers?\b|\bbagues?\b|\bbijoux\b|\banillos?\b|\bpulseras?\b|\bpendientes\b/i;
|
||||
|
||||
function quickClassify(query) {
|
||||
const q = String(query || '').toLowerCase();
|
||||
if (/转人工|真人|人工客服|speak\s+(to|with)\s+(a\s+)?(human|person|agent)|talk\s+(to|with)\s+(a\s+)?(human|person|agent)|real person|mit einem menschen|parler.*humain|persoon spreken/i.test(q)) return INTENTS.ESCALATE;
|
||||
if (/订单|物流|tracking|order status|where is my order|发货|到货|快递|bestelling|levering|volgen/i.test(q)) return INTENTS.ORDER_TRACKING;
|
||||
if (/库存|stock|in stock|available|有货|库存量|can you fulfill|voorraad|beschikbaar/i.test(q)) return INTENTS.INVENTORY_CHECK;
|
||||
if (/想买|找.*产品|show me|do you have|do you carry|looking for|推荐|recommend|catalog|browse|查找|看看|suchen|finden|aanbevelen/i.test(q)) return INTENTS.PRODUCT_SEARCH;
|
||||
if (PRODUCT_NOUN_RE.test(q)) return INTENTS.PRODUCT_SEARCH;
|
||||
if (/退货|refund|return|取消|cancel|vat|invoice|运费|shipping cost|交付|delivery time|会员|membership|折扣|discount|注册|register|账号|account|支付|payment|预购|pre.?order|一件代发|dropship|起订|minimum order|retour|btw|factuur|verzendkosten/i.test(q)) return INTENTS.POLICY_FAQ;
|
||||
if (/采购|bulk|wholesale|批发|quote|报价|großhandel|groothandel/i.test(q)) return INTENTS.SALES_LEAD;
|
||||
return INTENTS.GENERAL;
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
// 公司/联系人信号:客户主动报公司名(含法律后缀)、邮箱、电话、联系人等。
|
||||
// 命中它但数量单位不规整(如 "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) {
|
||||
const value = String(text || '');
|
||||
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 = '') {
|
||||
const missing = [];
|
||||
if (!slots.company) missing.push('company');
|
||||
@@ -51,4 +61,4 @@ function appendLeadFollowup(reply, 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 };
|
||||
|
||||
@@ -57,7 +57,7 @@ function createOpportunityService({ cfg, twenty, leadStore, client }) {
|
||||
function summaryFor({ ctx, slots, email, profile }) {
|
||||
return [
|
||||
`Company: ${slots.company}`,
|
||||
`Contact: ${slots.contactName || '(not provided)'} <${email}>`,
|
||||
`Contact: ${slots.contactName || '(not provided)'}${email ? ` <${email}>` : ''}`,
|
||||
`Need: ${slots.productNeed}`,
|
||||
slots.quantity ? `Quantity: ${slots.quantity}` : null,
|
||||
slots.budget ? `Budget: ${slots.budget}` : null,
|
||||
|
||||
@@ -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: '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: '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;
|
||||
@@ -98,6 +98,13 @@ function actionClarification(lang, hasOpportunity) {
|
||||
return `${hasOpportunity ? 'Your request has been noted; a relevant colleague will follow up.\n\n' : ''}Note: I can't place orders, create order numbers, or send emails/PDFs from this chat; please complete the order in your account cart: https://www.yehwang.com/cart.`;
|
||||
}
|
||||
|
||||
// CRM/商机类措辞被拦时:只把真实发生了的事(需求已记录、同事跟进)告诉客户,
|
||||
// 不要拼接“无法下单/购物车付款”这类误导性的订单话术。
|
||||
function crmClarification(lang, hasOpportunity) {
|
||||
if (lang === 'zh') return hasOpportunity ? '您的需求已记录,相关同事会与您联系。' : '相关同事会与您联系。';
|
||||
return hasOpportunity ? 'Your request has been noted; a relevant colleague will follow up.' : 'A relevant colleague will follow up.';
|
||||
}
|
||||
|
||||
function replaceUnsupported(content, amounts, stockNumbers, lang) {
|
||||
let out = String(content || '');
|
||||
const priceText = lang === 'zh' ? '价格以商品页为准' : 'price shown on the product page';
|
||||
@@ -120,14 +127,23 @@ function escapeRe(s) {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// 链接被删后不允许留下“点不动的链接文字”:通用 CTA(查看详情/View details…)整段去掉。
|
||||
const CTA_LABEL_RE = /^(?:[👉➡️→]\s*)?(?:查看详情|查看商品|查看产品|点击查看|点此查看|立即查看|了解更多|详情|view\s+details?|see\s+details?|view\s+product|learn\s+more|shop\s+now|click\s+here)\s*[::]?\s*$/i;
|
||||
const CTA_LABEL_SOURCE = '(?:[👉➡️→]\\s*)?(?:查看详情|查看商品|查看产品|点击查看|点此查看|立即查看|了解更多|view\\s+details?|see\\s+details?|view\\s+product|learn\\s+more|shop\\s+now|click\\s+here)\\s*[::]\\s*';
|
||||
|
||||
function removeUrls(content, urls) {
|
||||
let out = String(content || '');
|
||||
const labelOrNothing = (text) => (CTA_LABEL_RE.test(String(text).trim()) ? '' : text);
|
||||
for (const url of urls) {
|
||||
const e = escapeRe(url);
|
||||
out = out.replace(new RegExp('\\[([^\\]]*)\\]\\(' + e + '\\)', 'g'), '$1');
|
||||
out = out.replace(new RegExp('<a\\b[^>]*href=["\\\']' + e + '["\\\'][^>]*>([\\s\\S]*?)<\\/a>', 'gi'), '$1');
|
||||
out = out.replace(new RegExp('\\[([^\\]]*)\\]\\(' + e + '\\)', 'g'), (_, text) => labelOrNothing(text));
|
||||
out = out.replace(new RegExp('<a\\b[^>]*href=["\\\']' + e + '["\\\'][^>]*>([\\s\\S]*?)<\\/a>', 'gi'), (_, text) => labelOrNothing(text));
|
||||
out = out.split(url).join('');
|
||||
}
|
||||
// 删除链接后残留的 “→ 查看详情:” 片段(连同前面的 <br>,避免出现空行)
|
||||
out = out.replace(new RegExp('(?:<br\\s*\\/?>)?\\s*' + CTA_LABEL_SOURCE + '(?=<br\\s*\\/?>|<\\/p>|\\n|$)', 'gi'), '');
|
||||
out = out.replace(/(?:<br\s*\/?>)?\s*[👉➡️→]\s*(?=<br\s*\/?>|<\/p>|\n|$)/gi, '');
|
||||
out = out.replace(/<p>\s*<\/p>/gi, '').replace(/<p>\s*(?:<br\s*\/?>\s*)+/gi, '<p>');
|
||||
return out.replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
@@ -146,6 +162,7 @@ function guardReply({ content, toolResults, toolCalls, lang = 'en' }) {
|
||||
blocked = true;
|
||||
rewritten = true;
|
||||
reasons.push(...violations.map((v) => v.type));
|
||||
const crmOnly = violations.every((v) => v.type === 'internal_crm' || v.type === 'opportunity_create');
|
||||
// HTML 回复不做句子切割,避免破坏标签,直接安全话术
|
||||
if (/<[a-z][^>]*>/i.test(raw)) {
|
||||
out = safeReply(lang, { hasOpportunity });
|
||||
@@ -154,7 +171,8 @@ function guardReply({ content, toolResults, toolCalls, lang = 'en' }) {
|
||||
.filter((sentence) => !detectActionClaims(sentence).some((h) => !h.requiresTool || !hasTool(toolCalls, h.requiresTool)))
|
||||
.join('')
|
||||
.trim();
|
||||
out = isMeaningful(kept) ? `${kept}\n\n${actionClarification(lang, hasOpportunity)}` : safeReply(lang, { hasOpportunity });
|
||||
const clarification = crmOnly ? crmClarification(lang, hasOpportunity) : actionClarification(lang, hasOpportunity);
|
||||
out = isMeaningful(kept) ? `${kept}\n\n${clarification}` : (crmOnly ? crmClarification(lang, hasOpportunity) : safeReply(lang, { hasOpportunity }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ LANGUAGE (most important): ALWAYS reply in the same language as the customer's l
|
||||
|
||||
TOOL DISCIPLINE:
|
||||
- search_kb: call this FIRST for ANY question about policies/FAQ — registration, account, password, minimum order, cancel, pre-order, payment, VAT, invoice, shipping cost, delivery time, returns, refunds, defective items, membership, discounts, dropshipping, factory-direct, materials, discolor, jewelry care. Answer only from the returned content; do not answer policies from memory. If nothing is returned, say you need to check with the team.
|
||||
- search_products: any product-finding request (recommend/show/find/buy products). 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.
|
||||
- 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_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.
|
||||
- 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 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):
|
||||
- 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}).
|
||||
- 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.
|
||||
@@ -31,7 +31,7 @@ VERIFICATION (important):
|
||||
|
||||
SALES FLOW:
|
||||
- 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).
|
||||
|
||||
ANTI-HALLUCINATION:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
// 旧 bridge(crm-ai-demo)的确定性商品链接兜底:LLM 回答后面由代码追加真实商品/类目链接。
|
||||
// 新 bridge 把 Markdown 转成 HTML 发给 LibreDesk,所以这里同时支持 Markdown 与 HTML 两种模式。
|
||||
|
||||
const NO_MATCH_RE = /(?:no exact match|no matching|don'?t carry|do not carry|not available|out of stock|nicht verfügbar|pas disponible|niet beschikbaar|未找到|没有找到|暂无|无货|缺货)/i;
|
||||
|
||||
const LABELS = {
|
||||
Chinese: { title: '可直接查看:', details: '商品详情', stock: (n) => `${n} 件` },
|
||||
English: { title: 'Click to view:', details: 'Product details', stock: (n) => `${n} pcs` },
|
||||
German: { title: 'Hier ansehen:', details: 'Produktdetails', stock: (n) => `${n} Stk.` },
|
||||
French: { title: 'À voir ici :', details: 'Détails du produit', stock: (n) => `${n} pièces` },
|
||||
Dutch: { title: 'Hier bekijken:', details: 'Productdetails', stock: (n) => `${n} stuks` },
|
||||
Spanish: { title: 'Ver aquí:', details: 'Detalles del producto', stock: (n) => `${n} piezas` },
|
||||
Italian: { title: 'Vedi qui:', details: 'Dettagli prodotto', stock: (n) => `${n} pezzi` },
|
||||
};
|
||||
|
||||
const LANG_ALIASES = {
|
||||
zh: 'Chinese', cn: 'Chinese', chinese: 'Chinese',
|
||||
en: 'English', english: 'English',
|
||||
de: 'German', german: 'German',
|
||||
fr: 'French', french: 'French',
|
||||
nl: 'Dutch', dutch: 'Dutch',
|
||||
es: 'Spanish', spanish: 'Spanish',
|
||||
it: 'Italian', italian: 'Italian',
|
||||
};
|
||||
|
||||
function labelsFor(lang) {
|
||||
const key = LANG_ALIASES[String(lang || '').toLowerCase()];
|
||||
return LABELS[key] || LABELS.English;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeMarkdownLabel(text) {
|
||||
return String(text || '').replace(/[[\]\\]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** 从工具结果里收集真实商品/类目链接(保持出现顺序,按 URL 去重)。 */
|
||||
function collectProductLinks(toolResults) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const push = (item) => {
|
||||
if (!item || typeof item !== 'object') return;
|
||||
const url = String(item.url || item.product_url || '').trim();
|
||||
if (!/^https?:\/\//i.test(url) || seen.has(url)) return;
|
||||
seen.add(url);
|
||||
out.push({
|
||||
name: String(item.name || item.title || '').trim() || null,
|
||||
url,
|
||||
price: item.price != null && item.price !== '' ? String(item.price) : null,
|
||||
stock: item.stock != null && item.stock !== '' ? item.stock : null,
|
||||
});
|
||||
};
|
||||
for (const result of toolResults || []) {
|
||||
if (!result || typeof result !== 'object') continue;
|
||||
if (Array.isArray(result.products)) result.products.forEach(push);
|
||||
if (Array.isArray(result.category_links)) result.category_links.forEach(push);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把工具结果里有、但回复里没写的真实链接补到末尾。
|
||||
* - 回复已明确说“没有/缺货”时不补(避免自相矛盾,沿用旧 bridge 行为);
|
||||
* - 已包含的 URL 自动去重;
|
||||
* - html=true 时输出 <a>,否则输出 Markdown 链接(会再经 markdownToHtml 转 HTML)。
|
||||
*/
|
||||
function appendMissingProductLinks({ content, toolResults, lang = 'English', html = false, limit = 3 }) {
|
||||
const base = String(content || '');
|
||||
if (!base.trim() || NO_MATCH_RE.test(base)) return { content: base, appended: 0 };
|
||||
const missing = collectProductLinks(toolResults).filter((item) => !base.includes(item.url));
|
||||
if (!missing.length) return { content: base, appended: 0 };
|
||||
|
||||
const labels = labelsFor(lang);
|
||||
// 有货的排前面(stock=0 的真实商品只在没有别的可选时才补),保持工具返回的相对顺序。
|
||||
const stockRank = (item) => {
|
||||
if (item.stock == null) return 1;
|
||||
const n = Number(item.stock);
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
return n > 0 ? 0 : 2;
|
||||
};
|
||||
const ordered = missing
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => stockRank(a.item) - stockRank(b.item) || a.index - b.index)
|
||||
.map((x) => x.item);
|
||||
const top = ordered.slice(0, Math.max(1, Number(limit) || 3));
|
||||
const lines = top.map((item) => {
|
||||
const text = item.name || labels.details;
|
||||
const meta = [item.price, item.stock != null ? labels.stock(item.stock) : null].filter(Boolean).join(' | ');
|
||||
if (html) {
|
||||
const anchor = `<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
|
||||
return `<li>${anchor}${meta ? ` — ${escapeHtml(meta)}` : ''}</li>`;
|
||||
}
|
||||
return `- [${escapeMarkdownLabel(text)}](${item.url})${meta ? ` — ${meta}` : ''}`;
|
||||
});
|
||||
const block = html
|
||||
? `<p>${escapeHtml(labels.title)}</p><ul>${lines.join('')}</ul>`
|
||||
: `${labels.title}\n${lines.join('\n')}`;
|
||||
return { content: `${base.trimEnd()}\n\n${block}`, appended: top.length };
|
||||
}
|
||||
|
||||
module.exports = { appendMissingProductLinks, collectProductLinks, labelsFor, NO_MATCH_RE };
|
||||
+10
-10
@@ -221,12 +221,13 @@ const TOOL_DEFS = [
|
||||
{
|
||||
name: 'collect_info',
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
company: { type: 'string', description: 'customer company 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' },
|
||||
productNeed: { type: 'string', description: 'what the customer asked for, in their own words' },
|
||||
quantity: { type: 'string', description: 'quantity if stated' },
|
||||
@@ -240,7 +241,7 @@ const TOOL_DEFS = [
|
||||
{
|
||||
name: 'send_verification_code',
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -253,7 +254,7 @@ const TOOL_DEFS = [
|
||||
{
|
||||
name: 'verify_email_code',
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -279,12 +280,13 @@ const TOOL_DEFS = [
|
||||
{
|
||||
name: 'create_opportunity',
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
company: { type: 'string', description: 'customer company 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' },
|
||||
quantity: { type: 'string', description: 'quantity if stated' },
|
||||
budget: { type: 'string', description: 'budget if stated, e.g. "€500"' },
|
||||
@@ -293,7 +295,7 @@ const TOOL_DEFS = [
|
||||
},
|
||||
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');
|
||||
const existing = await leadStore.get(conversationUuid);
|
||||
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() : '';
|
||||
if (value) slots[key] = value;
|
||||
}
|
||||
@@ -673,11 +675,9 @@ function createTools(cfg, deps = {}) {
|
||||
}
|
||||
|
||||
async function createOpportunity(args, ctx) {
|
||||
if (!ctx.verified) {
|
||||
throw new ToolError(403, 'verification_required', 'Creating an opportunity requires a verified contact. Ask the customer to verify their email first.');
|
||||
}
|
||||
// 线索建商机不要求邮箱验证(对齐旧 bridge leadPipeline):公司 + 联系人 + 需求即可。
|
||||
// 订单/邮箱历史仍要求 verified,邮箱只从身份头取;商机允许用客户在对话里给的 email 做去重/联系人。
|
||||
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');
|
||||
const conversationUuid = String(ctx.conversationUuid || '');
|
||||
if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required');
|
||||
|
||||
@@ -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 = [{
|
||||
@@ -61,3 +62,298 @@ test('agent: output guard blocks fabricated order claim before send', async () =
|
||||
assert.match(sent, /无法在聊天中直接下单/);
|
||||
assert.doesNotMatch(sent, /已为您创建正式订单草稿/);
|
||||
});
|
||||
|
||||
test('agent: bare Chinese product word forces search_products before replying', async () => {
|
||||
const queries = [];
|
||||
const tools = [{
|
||||
name: 'search_products',
|
||||
description: 'fake',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
handler: async (args) => {
|
||||
queries.push(args.query);
|
||||
return { products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }], count: 1 };
|
||||
},
|
||||
}];
|
||||
tools.leadStore = { get: async () => null, save: async () => null };
|
||||
let sent = '';
|
||||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||||
llm: {
|
||||
enabled: true,
|
||||
model: 'fake',
|
||||
chat: async () => ({ content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null }),
|
||||
},
|
||||
client: {
|
||||
getMessages: async () => [{ id: 1, text_content: '戒指', sender_type: 'contact', private: false }],
|
||||
sendMessage: async (_uuid, content) => { sent = content; },
|
||||
},
|
||||
tools,
|
||||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||||
metrics: { record: async () => {}, enabled: false },
|
||||
contextManager: { compressHistory: async (_c, h) => h },
|
||||
});
|
||||
const conv = { uuid: 'conv-ring-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||||
await agent.run(conv, { uuid: 'msg-ring-1', text_content: '戒指', content: '戒指' });
|
||||
assert.deepEqual(queries, ['戒指']);
|
||||
assert.match(sent, /Triple Rings Ring/);
|
||||
});
|
||||
|
||||
test('agent: backfills real product links when the LLM omits them', async () => {
|
||||
const tools = [{
|
||||
name: 'search_products',
|
||||
description: 'fake',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
handler: async () => ({
|
||||
products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }],
|
||||
count: 1,
|
||||
}),
|
||||
}];
|
||||
tools.leadStore = { get: async () => null, save: async () => null };
|
||||
let sent = '';
|
||||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||||
llm: {
|
||||
enabled: true,
|
||||
model: 'fake',
|
||||
chat: async () => ({ content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null }),
|
||||
},
|
||||
client: {
|
||||
getMessages: async () => [{ id: 1, text_content: '戒指', 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-backfill-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||||
await agent.run(conv, { uuid: 'msg-backfill-1', text_content: '戒指', content: '戒指' });
|
||||
assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/);
|
||||
assert.match(sent, /可直接查看/);
|
||||
});
|
||||
|
||||
test('agent: forces search_products when the LLM answers without tools', async () => {
|
||||
const queries = [];
|
||||
let calls = 0;
|
||||
const tools = [{
|
||||
name: 'search_products',
|
||||
description: 'fake',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
handler: async (args) => {
|
||||
calls += 1;
|
||||
queries.push(args.query);
|
||||
if (calls === 1) throw new Error('rpc_timeout'); // 预检索失败 → 走事后强制注入
|
||||
return {
|
||||
products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }],
|
||||
count: 1,
|
||||
};
|
||||
},
|
||||
}];
|
||||
tools.leadStore = { get: async () => null, save: async () => null };
|
||||
let llmCalls = 0;
|
||||
let sent = '';
|
||||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||||
llm: {
|
||||
enabled: true,
|
||||
model: 'fake',
|
||||
chat: async () => {
|
||||
llmCalls += 1;
|
||||
if (llmCalls === 1) {
|
||||
return { content: 'Stainless Steel Ring Set — $9.99 → 查看详情:https://www.yehwang.com/fake-ring', toolCalls: [], usage: null };
|
||||
}
|
||||
return { content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null };
|
||||
},
|
||||
},
|
||||
client: {
|
||||
getMessages: async () => [{ id: 1, text_content: '戒指', 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-force-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||||
await agent.run(conv, { uuid: 'msg-force-1', text_content: '戒指', content: '戒指' });
|
||||
assert.equal(calls, 2, 'pre-tool fails once, forced injection retries');
|
||||
assert.deepEqual(queries, ['戒指', '戒指']);
|
||||
assert.doesNotMatch(sent, /Stainless Steel Ring Set/);
|
||||
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);
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { quickClassify, INTENTS } = require('../src/intent');
|
||||
|
||||
test('intent: bare product category word is a product search', () => {
|
||||
for (const q of ['戒指', '项链', '耳环', '手链', '珠宝', 'rings', 'necklace', 'earrings', 'oorbellen', 'halskette']) {
|
||||
assert.equal(quickClassify(q), INTENTS.PRODUCT_SEARCH, q);
|
||||
}
|
||||
});
|
||||
|
||||
test('intent: product noun inside a sentence is a product search', () => {
|
||||
assert.equal(quickClassify('有没有不锈钢戒指'), INTENTS.PRODUCT_SEARCH);
|
||||
assert.equal(quickClassify('do you sell stainless steel rings'), INTENTS.PRODUCT_SEARCH);
|
||||
});
|
||||
|
||||
test('intent: other intents keep priority', () => {
|
||||
assert.equal(quickClassify('我的订单到哪了'), INTENTS.ORDER_TRACKING);
|
||||
assert.equal(quickClassify('我要转人工'), INTENTS.ESCALATE);
|
||||
assert.equal(quickClassify('怎么退货'), INTENTS.POLICY_FAQ);
|
||||
assert.equal(quickClassify('你好'), INTENTS.GENERAL);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const test = require('node:test');
|
||||
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', () => {
|
||||
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('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); // 没有公司/联系人信号
|
||||
});
|
||||
|
||||
@@ -140,3 +140,71 @@ test('guard: rewrites unsupported price but keeps product name', () => {
|
||||
assert.doesNotMatch(r.content, /fake-a/);
|
||||
assert.match(r.content, /价格以商品页为准/);
|
||||
});
|
||||
|
||||
test('guard: drops dangling CTA text when a hallucinated product link is removed', () => {
|
||||
const r = guardReply({
|
||||
content: '<p>🔹 <strong>Stainless Steel Ring Set</strong> — $2.88<br>✅ 库存:充足<br>→ 查看详情:https://www.yehwang.com/stainless-steel-ring-set-12-pcs</p>',
|
||||
toolResults: [],
|
||||
toolCalls: [],
|
||||
lang: 'zh',
|
||||
});
|
||||
assert.equal(r.blocked, true);
|
||||
assert.ok(r.reasons.includes('stock_price_without_tool'));
|
||||
assert.deepEqual(r.removed_urls, ['https://www.yehwang.com/stainless-steel-ring-set-12-pcs']);
|
||||
assert.doesNotMatch(r.content, /查看详情/);
|
||||
assert.doesNotMatch(r.content, /stainless-steel-ring-set-12-pcs/);
|
||||
assert.match(r.content, /价格以商品页为准/);
|
||||
});
|
||||
|
||||
test('guard: generic CTA label is removed together with the unverified URL', () => {
|
||||
const r = guardReply({
|
||||
content: '商品A — [查看详情](https://www.yehwang.com/fake-a)',
|
||||
toolResults: [{ products: [{ name: '商品A' }] }],
|
||||
toolCalls: [{ name: 'search_products', ok: true }],
|
||||
lang: 'zh',
|
||||
});
|
||||
assert.equal(r.blocked, true);
|
||||
assert.deepEqual(r.removed_urls, ['https://www.yehwang.com/fake-a']);
|
||||
assert.doesNotMatch(r.content, /查看详情/);
|
||||
assert.match(r.content, /商品A/);
|
||||
});
|
||||
|
||||
test('guard: keeps meaningful anchor text when the URL is removed', () => {
|
||||
const r = guardReply({
|
||||
content: 'You can read [the product care guide](https://www.yehwang.com/fake-guide) for details.',
|
||||
toolResults: [],
|
||||
toolCalls: [],
|
||||
lang: 'en',
|
||||
});
|
||||
assert.equal(r.blocked, true);
|
||||
assert.doesNotMatch(r.content, /fake-guide/);
|
||||
assert.match(r.content, /the product care guide/);
|
||||
});
|
||||
|
||||
test('guard: CRM wording is replaced with a colleague follow-up, not a cart note', () => {
|
||||
const r = guardReply({
|
||||
content: 'Great — 500 stainless steel rings for your shops.\nI have created an opportunity for your request.',
|
||||
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.match(r.content, /stainless steel rings/i);
|
||||
assert.match(r.content, /request has been noted/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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { appendMissingProductLinks, collectProductLinks } = require('../src/reply-links');
|
||||
|
||||
const TOOL_RESULTS = [{
|
||||
products: [
|
||||
{ name: 'Triple Rings Ring', url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html', price: '$2.88', stock: 26 },
|
||||
{ name: 'Aurora Rings Ring', url: 'https://www.yehwang.com/aurora-rings-ring-silver-color-color-17549.html', price: '$3.17', stock: 63 },
|
||||
],
|
||||
category_links: [{ name: 'Rings', url: 'https://www.yehwang.com/jewelry/rings' }],
|
||||
}];
|
||||
|
||||
test('reply-links: appends real product/category links as markdown', () => {
|
||||
const r = appendMissingProductLinks({ content: 'Here are some rings.', toolResults: TOOL_RESULTS, lang: 'English' });
|
||||
assert.equal(r.appended, 3);
|
||||
assert.match(r.content, /\[Triple Rings Ring\]\(https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html\)/);
|
||||
assert.match(r.content, /\[Rings\]\(https:\/\/www\.yehwang\.com\/jewelry\/rings\)/);
|
||||
assert.match(r.content, /Click to view:/);
|
||||
assert.match(r.content, /26 pcs/);
|
||||
});
|
||||
|
||||
test('reply-links: skips links already written by the model', () => {
|
||||
const content = 'See https://www.yehwang.com/triple-rings-ring-gold-color-17590.html for details.';
|
||||
const r = appendMissingProductLinks({ content, toolResults: TOOL_RESULTS, lang: 'English' });
|
||||
assert.equal(r.appended, 2);
|
||||
assert.equal((r.content.match(/triple-rings-ring-gold-color-17590/g) || []).length, 1);
|
||||
});
|
||||
|
||||
test('reply-links: respects the limit', () => {
|
||||
const r = appendMissingProductLinks({ content: 'Rings:', toolResults: TOOL_RESULTS, lang: 'English', limit: 1 });
|
||||
assert.equal(r.appended, 1);
|
||||
assert.doesNotMatch(r.content, /Aurora Rings Ring/);
|
||||
});
|
||||
|
||||
test('reply-links: html mode emits clickable anchors', () => {
|
||||
const r = appendMissingProductLinks({ content: '<p>Rings:</p>', toolResults: TOOL_RESULTS, lang: 'Chinese', html: true });
|
||||
assert.equal(r.appended, 3);
|
||||
assert.match(r.content, /<a href="https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html"/);
|
||||
assert.match(r.content, /可直接查看:/);
|
||||
assert.doesNotMatch(r.content, /\[Triple Rings Ring\]\(/);
|
||||
});
|
||||
|
||||
test('reply-links: does not backfill when the reply already says there is no match', () => {
|
||||
const r = appendMissingProductLinks({ content: 'No exact matches found.', toolResults: TOOL_RESULTS, lang: 'English' });
|
||||
assert.equal(r.appended, 0);
|
||||
assert.equal(r.content, 'No exact matches found.');
|
||||
});
|
||||
|
||||
test('reply-links: collects only http(s) urls and dedupes', () => {
|
||||
const links = collectProductLinks([
|
||||
{ products: [{ name: 'A', url: 'https://a.example/1' }, { name: 'B', url: 'https://a.example/1' }] },
|
||||
{ category_links: [{ name: 'C', url: 'https://c.example/2' }, { name: 'bad', url: '/relative/3' }] },
|
||||
]);
|
||||
assert.deepEqual(links.map((l) => l.url), ['https://a.example/1', 'https://c.example/2']);
|
||||
});
|
||||
|
||||
test('reply-links: prefers in-stock products over out-of-stock ones', () => {
|
||||
const toolResults = [{ products: [
|
||||
{ name: 'Out', url: 'https://a.example/out', price: '$1.00', stock: 0 },
|
||||
{ name: 'In', url: 'https://a.example/in', price: '$2.00', stock: 26 },
|
||||
{ name: 'Backorder', url: 'https://a.example/bo', price: '$3.00', stock: 0 },
|
||||
] }];
|
||||
const r = appendMissingProductLinks({ content: 'Rings:', toolResults, lang: 'English', limit: 2 });
|
||||
assert.equal(r.appended, 2);
|
||||
assert.match(r.content, /\[In\]\(https:\/\/a\.example\/in\)/);
|
||||
assert.ok(r.content.indexOf('a.example/in') < r.content.indexOf('a.example/out'), 'in-stock link comes first');
|
||||
});
|
||||
@@ -487,15 +487,54 @@ test('search_email_history: scopes to verified email, ignores args.email', async
|
||||
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 tool = tools.find((t) => t.name === 'create_opportunity');
|
||||
await assert.rejects(
|
||||
() => tool.handler({ company: 'A', contactName: 'B', productNeed: 'C' }, { verified: false }),
|
||||
(err) => err instanceof ToolError && err.status === 403
|
||||
() => tool.handler({ company: 'A', productNeed: 'C' }, { verified: false, conversationUuid: 'conv-missing' }),
|
||||
(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 () => {
|
||||
const twenty = fakeTwenty({ personId: 'person-1', existingOpps: [{ id: 'opp-old', name: 'Old', stage: 'NEW' }] });
|
||||
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
|
||||
@@ -530,3 +569,30 @@ test('create_opportunity: creates opportunity + note + follow-up task', async ()
|
||||
const personIdx = twenty.calls.findIndex((c) => c[0] === 'createPerson');
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -229,6 +229,9 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- default
|
||||
- spug-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:4100/healthz"]
|
||||
interval: 10s
|
||||
@@ -238,6 +241,9 @@ services:
|
||||
networks:
|
||||
default:
|
||||
name: crm-new_default
|
||||
# 与本工作区的 wangliang-dev 同网络,bridge 可直接按容器名调用 9503/9505/9506/9509
|
||||
spug-net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
pg-data:
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
# crm-ai 在 wangliang 工作区的部署记录
|
||||
|
||||
> 部署时间:2026-10-23 | 部署位置:`/data/dev/wangliang/crm-ai` | 代码版本:`main @ 1ae25f1`
|
||||
>
|
||||
> 本文件记录这套栈**实际跑成什么样**、和仓库默认配置差在哪、怎么运维、以及还缺哪些凭据。
|
||||
> 环境相关的临时信息写在这里,通用约定见 `/data/dev/_shared/NOTES.md`。
|
||||
|
||||
## 1. 交付摘要
|
||||
|
||||
一套自包含的客服 + CRM 栈(与仓库 `docker-compose.yml` 一致,Compose 项目名 `crm-new`):
|
||||
|
||||
| 组件 | 容器 | 宿主端口 | 状态 |
|
||||
|---|---|---|---|
|
||||
| LibreDesk v2.9.0-rc.10(渠道/工作台) | `crmnew-libredesk` | `9000` | ✅ healthy |
|
||||
| Twenty CRM v2.30.1(server) | `crmnew-twenty-server` | `3100` | ✅ healthy |
|
||||
| Twenty worker | `crmnew-twenty-worker` | — | ✅ running |
|
||||
| bridge(业务工具 / RPC 集成) | `crmnew-bridge` | `4100` | ✅ healthy |
|
||||
| PostgreSQL 18 + pgvector | `crmnew-postgres` | `127.0.0.1:5440` | ✅ healthy |
|
||||
| Redis 8.8 | `crmnew-redis` | `127.0.0.1:6390` | ✅ healthy |
|
||||
| IMAP ID 注入代理(163 专用) | `crmnew-imap-proxy` | `127.0.0.1:1993` | ✅ running |
|
||||
|
||||
数据卷:`crm-new_pg-data`、`crm-new_redis-data`、`crm-new_twenty-data`(`docker compose down` 不会删)。
|
||||
|
||||
## 2. 访问地址与账号
|
||||
|
||||
| 入口 | 地址 | 账号 |
|
||||
|---|---|---|
|
||||
| LibreDesk(工作台 / 帮助中心 / widget) | https://wangliang-crm.dev.gaia888.com | `System` /密码见下方命令 |
|
||||
| Twenty CRM | https://wangliang-twenty.dev.gaia888.com | `admin@yehwang.com` /密码见下方命令 |
|
||||
| bridge(调试/集成) | https://wangliang-crmbridge.dev.gaia888.com | 请求头 `X-Tools-Key`,值见下方命令 |
|
||||
|
||||
```bash
|
||||
cd /data/dev/wangliang/crm-ai
|
||||
grep '^LIBREDESK_SYSTEM_USER_PASSWORD=' .env # LibreDesk System 密码
|
||||
grep '^TWENTY_ADMIN_PASSWORD=' .env # Twenty 管理员密码
|
||||
grep '^TOOLS_SHARED_KEY=' .env # bridge 工具密钥
|
||||
```
|
||||
|
||||
> 密钥只存在 `.env`(600)与 `libredesk/config/config.toml`(600),两者都已被 `.gitignore` 排除,**不要提交**。
|
||||
|
||||
## 3. 与仓库默认配置的差异(重要)
|
||||
|
||||
| 项 | 仓库默认 | 本环境 | 原因 |
|
||||
|---|---|---|---|
|
||||
| `LIBREDESK_IMAGE` | `libredesk/libredesk:v2.9.0-rc.10` | `libredesk/libredesk:v2.9.0-rc.10-**arm64**` | 宿主是 ARM64,`v2.9.0-rc.10` 只有 amd64;官方另发了 `-arm64` 同版本 tag,实测原生 `linux/arm64` 运行 |
|
||||
| `LIBREDESK_BASE_URL` | `http://localhost:9000` | `https://wangliang-crm.dev.gaia888.com` | 走宿主 nginx 的 HTTPS |
|
||||
| `TWENTY_SERVER_URL` | `http://192.168.31.61:3100` | `https://wangliang-twenty.dev.gaia888.com` | 前端 `REACT_APP_SERVER_BASE_URL` 用的就是它(已核对页面内联配置) |
|
||||
| `RECOMMEND_URL` | 测试服地址 | `jsonrpc://wangliang-dev:9503` | 用 **本工作区** 的 product-service |
|
||||
| `ORDER_API_URL` | 测试服地址 | `jsonrpc://wangliang-dev:9505` | trading-service |
|
||||
| `INVENTORY_API_URL` | 测试服地址 | `jsonrpc://wangliang-dev:9509` | eta-service |
|
||||
| `MEMBER_RPC_URL` | 测试服地址 | `jsonrpc://wangliang-dev:9506` | member-service |
|
||||
| `IDENTITY_DEV_MODE` | `false` | `true` | dev 环境方便 widget 联调;**上生产前改回 `false`** |
|
||||
| `PRODUCT_TIMEOUT_MS` | 未透传(bridge 默认 15s) | `30000`(compose 增加透传) | 冷门长查询会偶发 >15s,超时会降级成无价格的站点地图结果,模型易编价 |
|
||||
| `public-gateway` / `cloudflared` | compose 里默认启动 | **未启动** | 已有宿主 nginx 直出 HTTPS,不需要 Cloudflare 临时隧道 |
|
||||
| bridge 网络 | 只挂 `crm-new_default` | 额外接入 `spug-net` | 直连 `wangliang-dev:9503/9505/9506/9509`,去掉对宿主 socat 转发的依赖 |
|
||||
|
||||
RPC 走 **容器网络直连**:compose 把 `bridge` 同时接入外部网络 `spug-net`(与 `wangliang-dev` 同网络),
|
||||
直接按容器名解析到 `wangliang-dev`,不再依赖宿主 `socat` 24503-24509 转发。容器重建后 DNS 名不变,
|
||||
比 socat 需要按 IP 重建更稳。宿主侧的 `conf/dev/rpc-forward.sh` 仍保留(供宿主/不在 spug-net 的容器使用),
|
||||
但本栈已不依赖它。
|
||||
|
||||
## 4. 已完成初始化
|
||||
|
||||
### LibreDesk
|
||||
- `System` 账号可登录(浏览器实测落到 `/inboxes/assigned`)。
|
||||
- **AI Provider 已配置**:DashScope `qwen-plus-latest`(completion)+ `text-embedding-v4` 1024 维(embedding),
|
||||
`configure-ai.js` 的两项连通性测试均通过。密钥只存在 LibreDesk 库内与 `.env`(600),未写入文档。
|
||||
- 助理指令已加固(见第 9 节):`search_products` 必须用 2-5 个词的关键词、不得编造价格/商品,`price=null` 时明确说“暂不可用”。
|
||||
- **知识库已接入**:`bridge` 库 `kb_docs` 594 行(523 parent / 71 child,来自 `deploy/seeds/kb_docs.sql.gz`),
|
||||
`RAG_PG_URL` 已配置,`search_kb` 实测命中最低起订量/退换货等 FAQ,AI 能据此回答。
|
||||
- `app.root_url = https://wangliang-crm.dev.gaia888.com`(`scripts/set-public-url.js`)。
|
||||
- 10 个 bridge 工具已注册(`scripts/register-tools.js`):`search_products / search_orders / search_inventory /
|
||||
search_kb / search_email_history / collect_info / send_verification_code / verify_email_code / handoff_to_human /
|
||||
create_opportunity`。
|
||||
- 默认助理 `Yehwang Assistant`(#1,10 个工具、引用与 handoff 开启)。
|
||||
- Live chat Inbox `Website`(#1,uuid `ad25d7a4-722d-4561-aebf-9d8f60952d09`)。
|
||||
- Automation:Live chat 规则 1 已关闭(AI Assistant);新会话由 bridge webhook 接管。
|
||||
- **2026-10-23 切流完成**:
|
||||
- bridge webhook `Bridge AI` 已创建并启用(`message.created` → `http://bridge:4100/libredesk/webhook`)
|
||||
- LibreDesk API key 已生成并写入 `.env`
|
||||
- `Wholesale Support` 团队(id=1,Round robin,System 为成员)
|
||||
- WhatsApp 规则改为 `assign_team=Wholesale Support`,启用
|
||||
- `IDENTITY_CORS_ORIGIN` 改为 `https://wangliang-crm.dev.gaia888.com`
|
||||
- bridge AI 端到端已验证,`bridge_ai_metrics` 开始有数据
|
||||
|
||||
### Twenty
|
||||
- Workspace `Yehwang`:`2ff5a56b-95d8-40e1-85cf-529ad3a70ed7`(`ACTIVE`)。
|
||||
- API key 已生成并写入 `.env` 的 `TWENTY_API_KEY`;GraphQL `companies` 查询验证通过。
|
||||
- 浏览器实测可登录,首次进入 `/install-apps`(Twenty 的新工作区引导页,正常现象)。
|
||||
|
||||
### bridge
|
||||
- `/healthz` 正常,10 个工具就绪。
|
||||
- 实测 `search_products` 直接命中**本工作区真实商品数据**(如 `Stainless Steel Y-Chain Necklace` / `$6.73` /
|
||||
`product_id=11079`),说明 `wangliang-dev:9503` 的 JSONRPC 直连链路是通的(工具调用 925ms,source=catalog)。
|
||||
- `search_orders` 未验证身份时按设计返回 `verification_required`。
|
||||
|
||||
## 5. 运维命令
|
||||
|
||||
```bash
|
||||
cd /data/dev/wangliang/crm-ai
|
||||
|
||||
docker compose ps # 状态
|
||||
docker compose logs -f --tail=100 libredesk
|
||||
docker compose logs -f --tail=100 twenty-server
|
||||
docker compose logs -f --tail=100 bridge
|
||||
|
||||
docker compose start # 启动已有容器
|
||||
docker compose stop # 停止(保留数据)
|
||||
docker compose restart libredesk # 重启单个服务
|
||||
|
||||
# 改 .env 后(bridge 必做;libredesk 也要重建才会读到新 env)
|
||||
docker compose up -d --force-recreate bridge
|
||||
docker compose up -d --force-recreate libredesk
|
||||
|
||||
# 改 LibreDesk 配置模板后
|
||||
node scripts/render-config.js && docker compose up -d --force-recreate libredesk
|
||||
|
||||
# 备份(DB + Twenty 自带数据都在 PG 里;卷里是上传文件)
|
||||
mkdir -p backups
|
||||
docker exec crmnew-postgres pg_dump -U crmnew -Fc libredesk > backups/libredesk-$(date +%F).dump
|
||||
docker exec crmnew-postgres pg_dump -U crmnew -Fc twenty > backups/twenty-$(date +%F).dump
|
||||
docker exec crmnew-postgres pg_dump -U crmnew -Fc bridge > backups/bridge-$(date +%F).dump
|
||||
|
||||
# 恢复
|
||||
docker exec -i crmnew-postgres pg_restore -U crmnew -d libredesk --clean --if-exists < backups/libredesk-YYYY-MM-DD.dump
|
||||
```
|
||||
|
||||
所有容器都是 `restart: unless-stopped`,Docker 守护进程重启后会自己拉起来。
|
||||
|
||||
## 6. 域名反向代理
|
||||
|
||||
宿主 nginx 新增了**独立**配置文件 `/etc/nginx/conf.d/zz-wangliang-crm.conf`:
|
||||
|
||||
- `wangliang-crm.dev.gaia888.com` → `127.0.0.1:9000`
|
||||
- `wangliang-twenty.dev.gaia888.com` → `127.0.0.1:3100`
|
||||
- `wangliang-crmbridge.dev.gaia888.com` → `127.0.0.1:4100`
|
||||
- 80 → 308 跳 HTTPS;证书复用 `*.dev.gaia888.com`(通配一级域名,三个域名都被覆盖)。
|
||||
|
||||
它**没有改** `devstack.conf` 的 map/正则,因此不影响其它工作区。
|
||||
|
||||
改完后的自检 / 重载(query 用户没有 root,用容器代跑):
|
||||
|
||||
```bash
|
||||
docker run --rm --privileged --pid=host redis:7-alpine \
|
||||
sh -c 'nsenter -t 1 -m -u -i -n -p /usr/sbin/nginx -t'
|
||||
docker run --rm --privileged --pid=host redis:7-alpine \
|
||||
sh -c 'nsenter -t 1 -m -u -i -n -p /usr/sbin/nginx -s reload'
|
||||
```
|
||||
|
||||
回滚(只需删掉这个文件并 reload,其它站点不受影响):
|
||||
|
||||
```bash
|
||||
docker run --rm -v /etc/nginx/conf.d:/c redis:7-alpine rm -f /c/zz-wangliang-crm.conf
|
||||
# 再执行上面的 nginx -t / -s reload
|
||||
```
|
||||
|
||||
## 7. 尚未配置(需要真实凭据,不是代码问题)
|
||||
|
||||
| 项 | 现状 | 补法 |
|
||||
|---|---|---|
|
||||
| ~~AI Provider(DashScope)~~ | ✅ **已配置**(qwen-plus-latest / text-embedding-v4),端到端已验证 | 换 key:`DASHSCOPE_API_KEY=sk-xxx node scripts/configure-ai.js` |
|
||||
| **SMTP / 163 邮件** | `SMTP_*` 为空,OTP 与邮件渠道不可用 | 填 `.env` 后 `node scripts/configure-email.js` + `node scripts/add-email-inbox.js` |
|
||||
| ~~知识库 RAG(search_kb)~~ | ✅ **已配置**:快照导入 `bridge` 库(594 行),`RAG_PG_URL` 已指向,`search_kb` 可用 | 更新:`FORCE=1 bash scripts/import-kb-snapshot.sh` 或 `node scripts/sync-kb-online.js --local-only --force` |
|
||||
| **WhatsApp 渠道** | `WA_*` 已存在,inbox 已存在;规则已指向人工团队 | 仍需确认 Meta callback 是否指向测试域名,并补测试号/模板 |
|
||||
| **public-gateway / cloudflared** | 未启动 | 需要 Cloudflare 临时隧道时再 `docker compose up -d public-gateway cloudflared` |
|
||||
|
||||
> `IDENTITY_DEV_MODE=true` 只适合 dev;上生产前改回 `false`。
|
||||
|
||||
## 8. 验证记录(2026-10-23)
|
||||
|
||||
```text
|
||||
$ curl -o /dev/null -w '%{http_code}' https://wangliang-crm.dev.gaia888.com/health # 200
|
||||
$ curl -o /dev/null -w '%{http_code}' https://wangliang-twenty.dev.gaia888.com/healthz # 200
|
||||
$ curl -o /dev/null -w '%{http_code}' https://wangliang-crmbridge.dev.gaia888.com/healthz # 200
|
||||
$ curl -s https://wangliang-crmbridge.dev.gaia888.com/healthz
|
||||
{"ok":true,"service":"crm-tools","tools":[10 个],"sites":["www","us","eu"]}
|
||||
$ curl -s -X POST http://127.0.0.1:4100/tools/search_products -H 'X-Tools-Key: …' -d '{"query":"stainless steel necklace"}'
|
||||
{"ok":true,"products":[{"name":"Stainless Steel Y-Chain Necklace","price":"$6.73","product_id":11079}]}
|
||||
```
|
||||
|
||||
AI 端到端(`node scripts/e2e-widget.js "Show me 3 stainless steel necklaces with prices and stock."`):
|
||||
助手调用 `search_products`(612ms,source=catalog)并返回真实数据 —— Y-Chain `$6.73`/237、Necklace `$6.85`/186、
|
||||
Necklace Star `$5.70`/110,与 RPC 直查一致(首次调试时的编价问题见第 9 节)。
|
||||
|
||||
浏览器(Playwright 真 Chrome,1440×900):
|
||||
|
||||
- LibreDesk 登录成功 → `/inboxes/assigned`(截图 `docs/screenshots/wangliang-libredesk-inbox.png`)
|
||||
- Twenty 登录成功 → `/install-apps` 引导页(截图 `docs/screenshots/wangliang-twenty-onboarding.png`)
|
||||
- 回归:`wangliang-www` 仍 200,`wangliang-agent` 仍 302(宿主 nginx 未被改坏)
|
||||
|
||||
资源占用:约 1.9 GB 内存(Twenty worker 约 1.0 GB + server 0.6 GB + PG/Redis/bridge/LibreDesk 约 0.3 GB)。
|
||||
|
||||
## 9. 上线当天修的一个 AI 质量问题(编造价格)
|
||||
|
||||
首次用 `e2e-widget.js` 问“3 条不锈钢项链的价格和库存”时,助手返回了三款**不存在的商品**和虚高价格。
|
||||
排查(临时把 LibreDesk `log_level` 调到 `debug`)定位到:
|
||||
|
||||
1. 模型把用户**整句**(含 "under 10 dollars" 等)直接当成 `search_products` 的 query;
|
||||
2. 这类长查询偶发 >15s(bridge 的 `PRODUCT_TIMEOUT_MS` 默认 15s),超时后走降级:只返回名字、`price=null`;
|
||||
3. 模型面对 `price=null` 时自行编了价格(LibreDesk 的 guardrails 只是提示词,不是硬拦截)。
|
||||
|
||||
修复(已落地并验证):
|
||||
|
||||
- `docker-compose.yml` 给 bridge 透传 `PRODUCT_TIMEOUT_MS`,`.env` 设为 `30000`;
|
||||
- 助理的 instructions 追加严格规则(`scripts/setup-defaults.js` 同步):短关键词查询、工具输出是唯一事实来源、
|
||||
`price=null`/`stock=null` 时必须说“暂不可用”并建议转人工,禁止估算/换算币种。
|
||||
- 复测:同一条问题,工具调用 612ms 返回真实商品,回复价格/库存与 RPC 完全一致。
|
||||
|
||||
已改为 bridge agent 回复,输出护栏、Citation、飞轮、质量指标都生效;LibreDesk 内置 AI 仅保留作回滚。
|
||||
|
||||
验证完已把 `log_level` 恢复为 `info`。
|
||||
|
||||
## 10. 数据面初始化(5.1 KB / 5.2 sitemap / 5.3 质量表)
|
||||
|
||||
### 5.3 质量表 ✅
|
||||
`node scripts/quality-report.js 1`:自动建表成功。
|
||||
|
||||
```text
|
||||
table_exists = bridge_ai_metrics;切流后已有数据
|
||||
{"summary":{"runs":0,"errors":0,...},"tools":[],"hours":1}
|
||||
```
|
||||
|
||||
✅ 切流后 `bridge_ai_metrics` 由 bridge agent 写入;当前已有 Live chat AI 运行记录。
|
||||
|
||||
### 5.2 全量 sitemap ✅
|
||||
官网 `sitemap.xml` 受阿里云 WAF 的 JS 挑战保护(直接 fetch 返回 4KB 挑战 HTML、0 个 `<loc>`)。
|
||||
处理:用真 Chrome(Playwright)过挑战后抓取,缓存到 `var/sitemap-{www,us}.xml`(已 gitignore),
|
||||
并给 `scripts/build-site-map.js` 增加本地 XML 回退(`var/sitemap-<site>.xml` 或 `SITEMAP_XML_<SITE>`)。
|
||||
|
||||
```text
|
||||
[site-map] www: total=32710, products=32070, nav=640
|
||||
[site-map] us: total=22644, products=22324, nav=320
|
||||
```
|
||||
|
||||
表 `bridge_site_map_urls` 已核对:
|
||||
|
||||
```text
|
||||
site | total | products
|
||||
us | 22644 | 22324
|
||||
www | 32710 | 32070
|
||||
```
|
||||
|
||||
刷新方式(sitemap 会变):先用浏览器重新抓 `var/sitemap-{www,us}.xml`,再 `node scripts/build-site-map.js www us`。
|
||||
直接跑脚本在 WAF 下会明确报错「返回 0 个 <loc>」,不会静默写空表。
|
||||
|
||||
### 5.1 旧 KB(products.kb_docs)✅ 已用最新代码解决
|
||||
|
||||
拉取 `origin/main` 之前,本机确实没有旧 `products.kb_docs`:`upgrade-kb-schema.js` 报
|
||||
`password authentication failed for user "postgres"`,`rebuild-kb-index.sh` 报 `No such container: crm-postgres`
|
||||
(这两个脚本是旧栈专用,新版仓库已不依赖它们)。
|
||||
|
||||
最新代码(`f526696` / `81b49c9`)提供了「无旧 bridge」的标准路径,已按 `docs/go-live.md` 6.3 执行:
|
||||
|
||||
1. 导入仓库内置快照:`bash scripts/import-kb-snapshot.sh`
|
||||
→ 导入到 `crmnew-postgres/bridge`,实测 **367 行(296 parent / 71 child)**,与文档预期一致;
|
||||
后续第二批上游把快照升级到 **594 chunks(523 parent / 71 child)**,用 `FORCE=1 bash scripts/import-kb-snapshot.sh` 重导;
|
||||
2. `.env` 增加 `RAG_PG_URL=postgres://crmnew:***@postgres:5432/bridge`,`docker compose up -d --build --force-recreate bridge`;
|
||||
3. 修了一个上游接线 bug:`server.js` 调 `createTools(cfg)` 时没传 `retriever/verification/client`,
|
||||
导致 HTTP 工具入口的 `search_kb` / `send_verification_code` / `handoff_to_human` 永远返回 `not_configured`。
|
||||
现已按 `agent.js` 的方式创建并传入(本地提交 `fix(bridge): HTTP 工具入口注入 retriever/verification/client`)。
|
||||
4. 验证:
|
||||
- `search_kb "minimum order value"` → 命中 `What is the minimum order value?`(score 0.733,`source_urls` 含 `order.html`);
|
||||
- AI 端到端 → 回答 `The minimum order value is €30`,内容与 `kb_docs` parent 记录一致;
|
||||
5. 新索引器实测:`RAG_PG_URL=postgres://...@127.0.0.1:5440/bridge node scripts/sync-kb-online.js --local-only --force`
|
||||
→ `parents=296 children=71`,自动备份 `kb_docs_v20261023033455`,`search_kb` 仍正常。
|
||||
|
||||
维护方式:
|
||||
|
||||
```bash
|
||||
# 从 bridge/kb/*.md 重建(宿主机执行,RAG 用 127.0.0.1:5440)
|
||||
RAG_PG_URL=postgres://crmnew:<password>@127.0.0.1:5440/bridge \
|
||||
node scripts/sync-kb-online.js --local-only --force
|
||||
|
||||
# 在线同步官网 FAQ(建议每周一次;官网有 WAF,失败就退回 --local-only)
|
||||
RAG_PG_URL=postgres://crmnew:<password>@127.0.0.1:5440/bridge \
|
||||
node scripts/sync-kb-online.js
|
||||
```
|
||||
|
||||
## 11. 最新代码(origin/main merge `a1d1de0` + `589e5f9`)怎么运行
|
||||
|
||||
最新 `main` 新增了 KB 快照/在线同步、环境差异文档,并去掉了 5.1 对旧栈的依赖。本机已合并并落地。
|
||||
|
||||
### 日常更新
|
||||
|
||||
```bash
|
||||
cd /data/dev/wangliang/crm-ai
|
||||
git pull --ff-only # 或 merge origin/main
|
||||
docker compose config -q
|
||||
docker compose up -d
|
||||
docker compose up -d --build --force-recreate bridge # bridge 代码变更时必须
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
`bootstrap.sh` 只用于全新环境;已有环境重跑会覆盖 `.env`/配置,**不要跑**。
|
||||
|
||||
### 数据准备(对应 `docs/go-live.md` 6.3)
|
||||
|
||||
| 任务 | 命令 | 本机结果 |
|
||||
|---|---|---|
|
||||
| KB 快照导入 | `FORCE=1 bash scripts/import-kb-snapshot.sh` | ✅ 594 行(523/71) |
|
||||
| KB 本地重建 | `RAG_PG_URL=...127.0.0.1:5440/bridge node scripts/sync-kb-online.js --local-only --force` | ✅ 296/71 + 备份 |
|
||||
| KB 在线同步 | `RAG_PG_URL=... node scripts/sync-kb-online.js` | 可选(官网 WAF,失败用 `--local-only`) |
|
||||
| 全量 sitemap | `node scripts/build-site-map.js www us` | ✅ 55,354 行(见 5.2) |
|
||||
| 质量报表 | `node scripts/quality-report.js 1` | ✅ 表已建(0 行,原因见 5.3) |
|
||||
|
||||
### 环境差异(`docs/environments.md`)
|
||||
|
||||
最新文档明确:`rpc-tunnel.sh` / `public-https.sh` 只用于本地联调;测试/生产直接用内网 RPC 地址 + 正式域名 HTTPS。
|
||||
本机现状正好符合:
|
||||
- RPC 走 `spug-net` 直连 `wangliang-dev:950x`,**没有**运行 `rpc-tunnel.sh`;
|
||||
- HTTPS 由宿主 nginx + `*.dev.gaia888.com` 证书提供,**没有**用 Cloudflare Quick Tunnel。
|
||||
|
||||
### 第二批(merge `589e5f9`):质量/观测/KB 版本
|
||||
|
||||
新增:Prometheus `/metrics`、RAG reranker、citation checker、三级升级、A/B 实验、`agent_trace_steps`(`TRACE_ENABLED`)、
|
||||
`flywheel_samples`、`chat-log`,以及 `kb-audit.js` / `kb-version.js` / `eval-retrieval.js` / `build-site-kb.js`。
|
||||
KB 快照同步升级到 594 chunks。
|
||||
|
||||
```bash
|
||||
# 重导新版 KB 快照
|
||||
FORCE=1 bash scripts/import-kb-snapshot.sh
|
||||
|
||||
# 观测与质量
|
||||
curl -s http://127.0.0.1:4100/metrics | head
|
||||
PW=$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2)
|
||||
RAG_PG_URL="postgres://crmnew:${PW}@127.0.0.1:5440/bridge" node scripts/kb-audit.js
|
||||
RAG_PG_URL="postgres://crmnew:${PW}@127.0.0.1:5440/bridge" node scripts/eval-retrieval.js
|
||||
RAG_PG_URL="postgres://crmnew:${PW}@127.0.0.1:5440/bridge" node scripts/kb-version.js list
|
||||
```
|
||||
|
||||
本机实测:
|
||||
|
||||
- `kb-audit`:594 行(523/71)、`empty_answers=0`、`missing_tsv=0`;
|
||||
- `eval-retrieval`:**9/9 PASS**;
|
||||
- `kb-version list`:能看到 `kb_docs_v20261023033455` 备份与 `kb_sync_runs` 记录;
|
||||
- AI 端到端:退款/VAT 问题均按 KB 内容回答(如 VAT refund 给出 `helpdesk@yehwang.com` 与 proof of export 流程)。
|
||||
|
||||
⚠️ 两点范围说明:
|
||||
|
||||
1. `/metrics` 的 `bridge_ai_*` 计数只在 **bridge agent 路径**(webhook 编排)里累加;LibreDesk 内置 AI 不走这条路径,
|
||||
所以当前 `/metrics` 主要只有 uptime/RSS,`bridge_ai_metrics` 也仍是 0 行。
|
||||
2. `build-site-kb.js` 直接 fetch 官网 sitemap,和 `build-site-map.js` 一样会被 WAF 挑战页挡住;
|
||||
如需从本地 sitemap 重新生成 `bridge/kb/11-site-map.md`,要先给脚本加本地 XML 回退(`build-site-map.js` 已有同款逻辑)。
|
||||
|
||||
本地在这一批上补了两个小修复(未推送):
|
||||
|
||||
- `search_kb` 在没有 reranker 时会忽略 `limit`(上游改为先取 8 条给 reranker)→ 现在无 reranker 时尊重 `limit`;
|
||||
- parent/child 可能命中同一篇 → 按 `question+answer` 去重后再重排;
|
||||
- 同时把 `createLLM + createReranker` 接进 HTTP 工具入口,让 LibreDesk 直接调 `search_kb` 也能享受重排。
|
||||
|
||||
## 11. 2026-10-23 后续配置
|
||||
|
||||
### Live chat / bridge AI
|
||||
|
||||
- bridge webhook `Bridge AI`:`message.created` → `http://bridge:4100/libredesk/webhook`(启用)
|
||||
- LibreDesk 规则 1(AI Assistant):关闭
|
||||
- bridge API key:已生成并写入 `.env`
|
||||
- bridge AI e2e:最低起订量问题回复正常,`bridge_ai_metrics` 已有数据
|
||||
- `IDENTITY_CORS_ORIGIN`:已从 `*` 改为 `https://wangliang-crm.dev.gaia888.com`
|
||||
|
||||
### 人工团队
|
||||
|
||||
- `Wholesale Support`(id=1,Round robin)
|
||||
- 成员:
|
||||
- System(id=1)
|
||||
- agent1@yehwang.com(id=11)
|
||||
- agent2@yehwang.com(id=12)
|
||||
- 两个 agent 的临时密码写在测试服 `.agent-credentials`(600,已在 `.gitignore` 忽略)
|
||||
- 登录验证:两个 agent 均 200
|
||||
|
||||
### Email
|
||||
|
||||
- SMTP 已使用本地 163 凭据配置
|
||||
- Email Inbox `Email 163`(id=3,IMAP 走 `imap-proxy:1993`)
|
||||
- Automation #3:email → `assign_team=Wholesale Support`
|
||||
- ⚠️ 本机测试服和本地共用同一个 163 邮箱,两个 LibreDesk 会同时轮询,可能重复/抢占邮件;长期测试建议用独立测试邮箱
|
||||
|
||||
### WhatsApp
|
||||
|
||||
- `WA_INBOX_ID=2`
|
||||
- `PUBLIC_BASE_URL=https://wangliang-crm.dev.gaia888.com`
|
||||
- 规则 #2:WhatsApp → `assign_team=Wholesale Support`
|
||||
- **Meta callback 已改到测试域名**:`https://wangliang-crm.dev.gaia888.com/webhooks/whatsapp/2`,GET 验证 challenge 200。
|
||||
- 因为一个 Meta App 只能有一个 callback,本机 WhatsApp 已暂停接收;
|
||||
- 如需本地和测试同时接收,需要独立 Meta App / 测试号。
|
||||
|
||||
### 坐席邮件通知
|
||||
|
||||
- agent1/agent2 的**邮件通知已关闭**(assignment/new_reply/SLA 等 email 渠道全部 `false`),仅保留站内通知。
|
||||
- 原因:`agent1@yehwang.com` / `agent2@yehwang.com` 在 `yehwang.com` 邮件服务器上被 550 拒绝,导致“New conversation assigned to you”退信。
|
||||
- OTP 邮件不受影响,bridge OTP 已实测可发送并送达 163 收件箱。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -78,7 +78,17 @@ const data = (j) => (j && j.data !== undefined ? j.data : j);
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv(path.join(ROOT, '.env'));
|
||||
const old = JSON.parse(fs.readFileSync(OLD_MAIL_CFG, 'utf8'));
|
||||
const old = fs.existsSync(OLD_MAIL_CFG)
|
||||
? JSON.parse(fs.readFileSync(OLD_MAIL_CFG, 'utf8'))
|
||||
: {
|
||||
imap: { auth: { user: env.SMTP_USER, pass: env.SMTP_PASS } },
|
||||
smtp: {
|
||||
host: env.SMTP_HOST || 'smtp.163.com',
|
||||
port: Number(env.SMTP_PORT || 465),
|
||||
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
|
||||
},
|
||||
};
|
||||
if (!old.imap?.auth?.user || !old.imap?.auth?.pass) throw new Error('缺少 IMAP/SMTP 凭据(设置 SMTP_USER/SMTP_PASS 或提供旧 mail.config.json)');
|
||||
const base = (env.LIBREDESK_BASE_URL || 'http://localhost:9000').replace(/\/+$/, '');
|
||||
const c = createClient(base);
|
||||
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });
|
||||
|
||||
@@ -33,19 +33,41 @@ function tokensFor(pathPart) {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
async function fetchSitemap(url) {
|
||||
function parseLocs(xml) {
|
||||
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// 官网 sitemap 受阿里云 WAF JS 挑战保护,直接 fetch 会返回挑战 HTML(0 个 <loc>)。
|
||||
// 支持从本地缓存的 XML 读取:SITEMAP_XML_<SITE> 环境变量,或 var/sitemap-<site>.xml。
|
||||
function localSitemapFile(name) {
|
||||
const fromEnv = process.env['SITEMAP_XML_' + name.toUpperCase()];
|
||||
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
||||
const p = path.join(ROOT, 'var', `sitemap-${name}.xml`);
|
||||
return fs.existsSync(p) ? p : null;
|
||||
}
|
||||
|
||||
async function fetchSitemap(name, url) {
|
||||
const local = localSitemapFile(name);
|
||||
if (local) {
|
||||
console.log(`[site-map] ${name}: 使用本地 sitemap ${local}`);
|
||||
return parseLocs(await fs.promises.readFile(local, 'utf8'));
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; YehwangSiteMapBot/1.0)' },
|
||||
signal: AbortSignal.timeout(90000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`sitemap HTTP ${res.status}: ${url}`);
|
||||
const xml = await res.text();
|
||||
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim()).filter(Boolean);
|
||||
const locs = parseLocs(xml);
|
||||
if (!locs.length) {
|
||||
throw new Error(`sitemap 返回 0 个 <loc>(可能被 WAF 挑战):先下载 XML 到 var/sitemap-${name}.xml,或设置 SITEMAP_XML_${name.toUpperCase()}`);
|
||||
}
|
||||
return locs;
|
||||
}
|
||||
|
||||
async function buildSite(client, name) {
|
||||
const cfg = SITES[name];
|
||||
const urls = await fetchSitemap(cfg.sitemap);
|
||||
const urls = await fetchSitemap(name, cfg.sitemap);
|
||||
const rows = [];
|
||||
const seenPaths = new Set();
|
||||
for (const url of urls) {
|
||||
|
||||
@@ -81,7 +81,13 @@ const INSTRUCTIONS = `You are the Yehwang B2B customer assistant for stainless-s
|
||||
- For stock / pre-order questions, call search_inventory with the SKU digits.
|
||||
- When you know company + contact name + product/need, call create_opportunity once.
|
||||
- If you cannot help, or the customer asks for a human, hand off to a human agent.
|
||||
- Never reveal another customer's data.`;
|
||||
- Never reveal another customer's data.
|
||||
|
||||
Tool usage rules (strict):
|
||||
- When calling search_products, pass a SHORT keyword phrase of 2-5 words (e.g. "stainless steel necklace"). Never pass the customer's full sentence, price limits ("under $10"), quantities or filler words ("show me", "please"). This keeps catalog search fast and accurate.
|
||||
- The tool output is the ONLY source of product names, prices, stock and URLs. Never invent or "fill in" a product, price, SKU or link that is not in the tool output.
|
||||
- If a returned product has price=null or stock=null, tell the customer that price/stock is unavailable for now; do NOT estimate, convert currency or guess. Offer to connect a human for an exact quote.
|
||||
- If search_products returns note/catalog_error saying prices are unavailable, list only the returned names/links and explicitly say prices need confirmation.`;
|
||||
|
||||
const GUARDRAILS = `Never invent prices, discounts, stock or delivery dates.
|
||||
Never share another customer's order or personal data.
|
||||
|
||||
Reference in New Issue
Block a user