chore: CRM-NEW baseline

This commit is contained in:
wangliang
2026-10-23 10:35:28 +08:00
commit aa499f6dbf
111 changed files with 15126 additions and 0 deletions
+204
View File
@@ -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 };