fix(bridge): 商品链接兜底与强制检索

This commit is contained in:
wangliang
2026-10-23 13:15:37 +08:00
parent 4d7b89492b
commit 22d96edee6
9 changed files with 414 additions and 3 deletions
+109
View File
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 };