fix(bridge): 商品链接兜底与强制检索
This commit is contained in:
@@ -10,6 +10,7 @@ const { createMetrics } = require('./metrics');
|
|||||||
const { auditAnswer } = require('./audit');
|
const { auditAnswer } = require('./audit');
|
||||||
const { markdownToHtml } = require('./markdown');
|
const { markdownToHtml } = require('./markdown');
|
||||||
const { guardReply } = require('./output-guard');
|
const { guardReply } = require('./output-guard');
|
||||||
|
const { appendMissingProductLinks } = require('./reply-links');
|
||||||
const { quickClassify, detectEmotion, INTENTS } = require('./intent');
|
const { quickClassify, detectEmotion, INTENTS } = require('./intent');
|
||||||
const { createContextManager } = require('./context');
|
const { createContextManager } = require('./context');
|
||||||
const { createReranker } = require('./reranker');
|
const { createReranker } = require('./reranker');
|
||||||
@@ -230,6 +231,26 @@ function createAgent(cfg, deps = {}) {
|
|||||||
stats.completionTokens += Number(response.usage.completion_tokens) || 0;
|
stats.completionTokens += Number(response.usage.completion_tokens) || 0;
|
||||||
stats.totalTokens += Number(response.usage.total_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) {
|
if (response.toolCalls && response.toolCalls.length) {
|
||||||
messages.push({ role: 'assistant', content: response.content || '', tool_calls: response.toolCalls });
|
messages.push({ role: 'assistant', content: response.content || '', tool_calls: response.toolCalls });
|
||||||
for (const call of response.toolCalls) {
|
for (const call of response.toolCalls) {
|
||||||
@@ -272,6 +293,22 @@ function createAgent(cfg, deps = {}) {
|
|||||||
if (!finalContent) {
|
if (!finalContent) {
|
||||||
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 时,补抽槽位
|
// collect_info 兜底:消息含明显 lead 信息但 LLM 没调 collect_info 时,补抽槽位
|
||||||
if (buyingIntent && leadStore && !(stats.toolCalls || []).some((t) => t.name === 'collect_info')) {
|
if (buyingIntent && leadStore && !(stats.toolCalls || []).some((t) => t.name === 'collect_info')) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ const INTENTS = {
|
|||||||
GENERAL: 'general',
|
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) {
|
function quickClassify(query) {
|
||||||
const q = String(query || '').toLowerCase();
|
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 (/转人工|真人|人工客服|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 (/订单|物流|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 (/库存|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 (/想买|找.*产品|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 (/退货|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;
|
if (/采购|bulk|wholesale|批发|quote|报价|großhandel|groothandel/i.test(q)) return INTENTS.SALES_LEAD;
|
||||||
return INTENTS.GENERAL;
|
return INTENTS.GENERAL;
|
||||||
|
|||||||
@@ -120,14 +120,23 @@ function escapeRe(s) {
|
|||||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
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) {
|
function removeUrls(content, urls) {
|
||||||
let out = String(content || '');
|
let out = String(content || '');
|
||||||
|
const labelOrNothing = (text) => (CTA_LABEL_RE.test(String(text).trim()) ? '' : text);
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
const e = escapeRe(url);
|
const e = escapeRe(url);
|
||||||
out = out.replace(new RegExp('\\[([^\\]]*)\\]\\(' + e + '\\)', 'g'), '$1');
|
out = out.replace(new RegExp('\\[([^\\]]*)\\]\\(' + e + '\\)', 'g'), (_, text) => labelOrNothing(text));
|
||||||
out = out.replace(new RegExp('<a\\b[^>]*href=["\\\']' + e + '["\\\'][^>]*>([\\s\\S]*?)<\\/a>', 'gi'), '$1');
|
out = out.replace(new RegExp('<a\\b[^>]*href=["\\\']' + e + '["\\\'][^>]*>([\\s\\S]*?)<\\/a>', 'gi'), (_, text) => labelOrNothing(text));
|
||||||
out = out.split(url).join('');
|
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();
|
return out.replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ LANGUAGE (most important): ALWAYS reply in the same language as the customer's l
|
|||||||
|
|
||||||
TOOL DISCIPLINE:
|
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_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_orders: only for the verified customer's own orders. If the customer gives an order number, pass it to get full detail (status, items, totals, address, shipping). Never accept another person's email; the identity comes from the platform.
|
||||||
- search_inventory: real-time stock/ETA for a specific product; prefer product_id returned by search_products.
|
- search_inventory: real-time stock/ETA for a specific product; prefer product_id returned by search_products.
|
||||||
- collect_info: whenever you learn any lead details (company, contact name, phone, product need, quantity, budget, close date), call it immediately so nothing is lost. Record productNeed exactly as the customer stated, never invent or substitute.
|
- collect_info: whenever you learn any lead details (company, contact name, 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.
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -61,3 +61,122 @@ test('agent: output guard blocks fabricated order claim before send', async () =
|
|||||||
assert.match(sent, /无法在聊天中直接下单/);
|
assert.match(sent, /无法在聊天中直接下单/);
|
||||||
assert.doesNotMatch(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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -140,3 +140,43 @@ test('guard: rewrites unsupported price but keeps product name', () => {
|
|||||||
assert.doesNotMatch(r.content, /fake-a/);
|
assert.doesNotMatch(r.content, /fake-a/);
|
||||||
assert.match(r.content, /价格以商品页为准/);
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user