'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(']*href=["\\\']' + e + '["\\\'][^>]*>([\\s\\S]*?)<\\/a>', 'gi'), (_, text) => labelOrNothing(text)); out = out.split(url).join(''); } // 删除链接后残留的 “→ 查看详情:” 片段(连同前面的
,避免出现空行) out = out.replace(new RegExp('(?:)?\\s*' + CTA_LABEL_SOURCE + '(?=|<\\/p>|\\n|$)', 'gi'), ''); out = out.replace(/(?:)?\s*[👉➡️→]\s*(?=|<\/p>|\n|$)/gi, ''); out = out.replace(/

\s*<\/p>/gi, '').replace(/

\s*(?:\s*)+/gi, '

'); 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 };