chore: CRM-NEW baseline
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
'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)|created\s+an?\s+opportunity/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.`;
|
||||
}
|
||||
|
||||
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, '\\$&');
|
||||
}
|
||||
|
||||
function removeUrls(content, urls) {
|
||||
let out = String(content || '');
|
||||
for (const url of urls) {
|
||||
const e = escapeRe(url);
|
||||
out = out.replace(new RegExp('\\[([^\\]]*)\\]\\(' + e + '\\)', 'g'), '$1');
|
||||
out = out.replace(new RegExp('<a\\b[^>]*href=["\\\']' + e + '["\\\'][^>]*>([\\s\\S]*?)<\\/a>', 'gi'), '$1');
|
||||
out = out.split(url).join('');
|
||||
}
|
||||
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));
|
||||
// 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();
|
||||
out = isMeaningful(kept) ? `${kept}\n\n${actionClarification(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 };
|
||||
Reference in New Issue
Block a user