223 lines
12 KiB
JavaScript
223 lines
12 KiB
JavaScript
'use strict';
|
||
|
||
const { extractUrls, collectToolUrls } = require('./audit');
|
||
|
||
// 发送前硬护栏:禁止伪造下单/邮件/订单号/PDF/团队通知;URL 只允许来自工具结果;
|
||
// 库存/价格必须有本轮工具证据。
|
||
const STATIC_ALLOW = new Set([
|
||
'https://www.yehwang.com',
|
||
'https://www.yehwang.com/cart',
|
||
'https://www.yehwang.com/order.html',
|
||
]);
|
||
|
||
const ACTION_RULES = [
|
||
{ type: 'order_create', re: /(?:已(?:经)?(?:为您|帮你|给您)?\s*(?:创建|生成|下|提交)).{0,14}(?:订单|复购|草稿)|(?:i'?ve|i have|we'?ve|we have)\s+(?:created|placed|submitted)\s+(?:your\s+)?(?:order|reorder)|(?:order|reorder)\s+(?:draft|has been created|is created)|created\s+(?:a\s+)?(?:formal\s+)?order\s+draft/i },
|
||
{ type: 'email_send', re: /(?:已|已经|同步).{0,6}(?:发送|发出|寄出).{0,12}(?:邮件|邮箱|e-?mail|报价|quote|invoice)|(?:sent|emailed|delivered).{0,24}(?:email|e-?mail|quote|invoice|pdf)|(?:email|quote)\s+(?:has been|was)\s+sent/i },
|
||
{ type: 'order_number', re: /订单(?:确认)?号\s*[::]?\s*[A-Z0-9][A-Z0-9-]{5,}|order\s+(?:number|confirmation|id)\s*[::]?\s*[A-Z0-9][A-Z0-9-]{5,}/i },
|
||
{ type: 'pdf_invoice', re: /(?:PDF|预发票|proforma|invoice).{0,15}(?:已生成|已发送|attached|generated)|(?:attached|generated).{0,15}(?:pdf|invoice|proforma)/i },
|
||
{ type: 'team_notify', re: /(?:团队|销售|同事).{0,12}(?:已通知|已联系|会联系|已收到|已转)|team\s+(?:has\s+been|was)\s+notified|notified\s+(?:the\s+)?team|sales\s+team\s+has\s+been\s+notified/i },
|
||
{ type: '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|reference|name|number|no\.?|#)|created\s+an?\s+opportunity|\bopportunity\b\s*[::]/i },
|
||
];
|
||
|
||
const STOCK_RE = /(?:有货|库存|现货|缺货|in stock|out of stock|available|backorder|restock|pre-?order)/i;
|
||
const PRICE_RE = /(?:[$€£¥]\s?\d|\b\d+(?:[.,]\d+)?\s?(?:eur|usd|gbp|cny|rmb|€|\$|£|¥)\b|价格|单价|price)/i;
|
||
const EVIDENCE_TOOLS = new Set(['search_products', 'search_inventory', 'search_orders', 'search_kb']);
|
||
|
||
function normalizeAmount(raw) {
|
||
return String(raw || '').replace(/[.,](?=\d{3}\b)/g, '').replace(',', '.');
|
||
}
|
||
|
||
function extractAmounts(text) {
|
||
const out = new Set();
|
||
const value = String(text || '');
|
||
const re = /(?:[$€£¥]\s?)(\d+(?:[.,]\d+)?)|\b(\d+(?:[.,]\d+)?)\s?(?:eur|usd|gbp|cny|rmb|euros?|dollars?)\b/gi;
|
||
let m;
|
||
while ((m = re.exec(value))) out.add(normalizeAmount(m[1] || m[2]));
|
||
return [...out];
|
||
}
|
||
|
||
function extractStockNumbers(text) {
|
||
const out = new Set();
|
||
const value = String(text || '');
|
||
const re = /(\d+)\s*(?:pcs|pieces?|units?|in stock|件|库存)/gi;
|
||
let m;
|
||
while ((m = re.exec(value))) out.add(String(Number(m[1])));
|
||
return [...out];
|
||
}
|
||
|
||
function ungroundedNumbers(numbers, toolResults) {
|
||
if (!numbers.length) return [];
|
||
const blob = JSON.stringify(toolResults || []);
|
||
return numbers.filter((n) => {
|
||
const variants = new Set([n, n.replace('.', ','), n.replace(/,/g, '')]);
|
||
return ![...variants].some((v) => v && blob.includes(v));
|
||
});
|
||
}
|
||
|
||
function hasTool(toolCalls, name) {
|
||
return (toolCalls || []).some((t) => t && t.name === name);
|
||
}
|
||
|
||
function detectActionClaims(text) {
|
||
const value = String(text || '');
|
||
const hits = [];
|
||
for (const rule of ACTION_RULES) {
|
||
if (rule.re.test(value)) hits.push(rule);
|
||
}
|
||
return hits;
|
||
}
|
||
|
||
function safeReply(lang, { hasOpportunity }) {
|
||
// 对客户不暴露 CRM / opportunity 概念;只说明需求已记录、会有同事跟进。
|
||
const recordedZh = hasOpportunity ? '您的需求已记录,相关同事会与您联系。\n\n' : '';
|
||
const recordedEn = hasOpportunity ? 'Your request has been noted; a relevant colleague will follow up.\n\n' : '';
|
||
if (lang === 'zh') {
|
||
return `${recordedZh}我无法在聊天中直接下单、生成订单号、发送邮件/PDF。请您登录账户在购物车确认并付款:https://www.yehwang.com/cart。`;
|
||
}
|
||
return `${recordedEn}I can't place orders, create order numbers, or send emails/PDFs from this chat. Please confirm and pay in your account cart: https://www.yehwang.com/cart.`;
|
||
}
|
||
|
||
function safeEvidenceReply(lang) {
|
||
if (lang === 'zh') return '我目前无法在聊天中确认实时库存/价格,请以商品页或购物车显示为准。我可以帮您搜索商品,或转人工确认。';
|
||
return "I can't confirm live stock or prices from this chat. Please check the product page or cart; I can help you search for products or hand you to a human colleague.";
|
||
}
|
||
|
||
function splitSentences(text) {
|
||
return String(text || '').match(/[^。!?.!?\n]+[。!?.!?]?|\n+/g) || [String(text || '')];
|
||
}
|
||
|
||
function isMeaningful(text) {
|
||
return String(text || '').replace(/https?:\/\/\S+/g, '').replace(/[\s\p{P}\p{S}]/gu, '').length >= 6;
|
||
}
|
||
|
||
function actionClarification(lang, hasOpportunity) {
|
||
if (lang === 'zh') {
|
||
return `${hasOpportunity ? '您的需求已记录,相关同事会与您联系。\n\n' : ''}补充:我无法在聊天中直接下单、生成订单号或发送邮件/PDF;如需下单请在账户购物车完成:https://www.yehwang.com/cart。`;
|
||
}
|
||
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';
|
||
const stockText = lang === 'zh' ? '库存以商品页为准' : 'stock shown on the product page';
|
||
for (const n of amounts) {
|
||
const num = String(n).split('.').map((part) => escapeRe(part)).join('[.,]');
|
||
out = out.replace(new RegExp('(?:[$€£¥]\s?)?' + num + '(?:\s?(?:eur|usd|gbp|cny|rmb|euros?|dollars?))?', 'gi'), priceText);
|
||
}
|
||
for (const n of stockNumbers) {
|
||
out = out.replace(new RegExp('\\b' + escapeRe(String(n)) + '\\s*(?:pcs|pieces?|units?|in stock|件|库存)', 'gi'), stockText);
|
||
}
|
||
// 无数字的库存断言也去掉
|
||
out = out.replace(/有货|现货|库存实时更新|in stock|out of stock/gi, stockText);
|
||
// 清理 "单价 价格以商品页为准" 这类重复前缀
|
||
out = out.replace(/(?:单价|价格|price)\s*(价格以商品页为准|price shown on the product page)/gi, '$1');
|
||
return out.replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
|
||
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'), (_, 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();
|
||
}
|
||
|
||
function guardReply({ content, toolResults, toolCalls, lang = 'en' }) {
|
||
const raw = String(content || '');
|
||
const hasOpportunity = hasTool(toolCalls, 'create_opportunity');
|
||
const claims = detectActionClaims(raw);
|
||
const violations = claims.filter((c) => !c.requiresTool || !hasTool(toolCalls, c.requiresTool));
|
||
let out = raw;
|
||
let blocked = false;
|
||
let rewritten = false;
|
||
const reasons = [];
|
||
|
||
// 1) 动作类:句子级删除,保留商品/订单等有效内容
|
||
if (violations.length) {
|
||
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 });
|
||
} else {
|
||
const kept = splitSentences(raw)
|
||
.filter((sentence) => !detectActionClaims(sentence).some((h) => !h.requiresTool || !hasTool(toolCalls, h.requiresTool)))
|
||
.join('')
|
||
.trim();
|
||
const clarification = crmOnly ? crmClarification(lang, hasOpportunity) : actionClarification(lang, hasOpportunity);
|
||
out = isMeaningful(kept) ? `${kept}\n\n${clarification}` : (crmOnly ? crmClarification(lang, hasOpportunity) : safeReply(lang, { hasOpportunity }));
|
||
}
|
||
}
|
||
|
||
// 2) 价格/库存:必须有工具证据,且出现的数字必须在工具结果中
|
||
const evidence = (toolCalls || []).some((t) => t && EVIDENCE_TOOLS.has(t.name));
|
||
const amounts = extractAmounts(out);
|
||
const stockNumbers = extractStockNumbers(out);
|
||
const ungroundedAmounts = ungroundedNumbers(amounts, toolResults);
|
||
const ungroundedStock = ungroundedNumbers(stockNumbers, toolResults);
|
||
const hasStockOrPrice = STOCK_RE.test(out) || PRICE_RE.test(out);
|
||
if (hasStockOrPrice && !evidence) {
|
||
blocked = true;
|
||
rewritten = true;
|
||
reasons.push('stock_price_without_tool');
|
||
const rewrittenText = replaceUnsupported(out, amounts, stockNumbers, lang);
|
||
out = isMeaningful(rewrittenText) ? rewrittenText : safeEvidenceReply(lang);
|
||
} else if (ungroundedAmounts.length || ungroundedStock.length) {
|
||
blocked = true;
|
||
rewritten = true;
|
||
reasons.push('ungrounded_numbers');
|
||
const rewrittenText = replaceUnsupported(out, ungroundedAmounts, ungroundedStock, lang);
|
||
out = isMeaningful(rewrittenText) ? rewrittenText : safeEvidenceReply(lang);
|
||
}
|
||
|
||
// 3) URL 白名单
|
||
const allowed = collectToolUrls(toolResults);
|
||
for (const u of STATIC_ALLOW) allowed.add(u);
|
||
const cited = extractUrls(out);
|
||
const unverified = cited.filter((u) => !allowed.has(u));
|
||
if (unverified.length) {
|
||
blocked = true;
|
||
rewritten = true;
|
||
out = removeUrls(out, unverified);
|
||
}
|
||
|
||
return {
|
||
content: out || safeReply(lang, { hasOpportunity }),
|
||
blocked,
|
||
rewritten,
|
||
reasons,
|
||
removed_urls: unverified,
|
||
evidence_missing: Boolean((hasStockOrPrice && !evidence) || ungroundedAmounts.length || ungroundedStock.length),
|
||
...(ungroundedAmounts.length || ungroundedStock.length ? { ungrounded: [...ungroundedAmounts.map((n) => `amount:${n}`), ...ungroundedStock.map((n) => `stock:${n}`)] } : {}),
|
||
};
|
||
}
|
||
|
||
module.exports = { guardReply, detectActionClaims, removeUrls, STATIC_ALLOW };
|