Files
crm-ai/bridge/src/output-guard.js
T

223 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };