| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- 'use strict';
- const VAGUE_ACKNOWLEDGEMENT_PATTERN = /^(?:好的?|行|可以|没问题|明白|收到|嗯)[,,。!!\s]*(?:[^。!?]{0,14})?[。!]?$/;
- const REPLY_TOPIC_MATCHERS = [
- { topic: 'price', inbound: /什么价|多少钱|怎么收费|报价|费用|单价|总价/, reply: /价|费|万|元|预算|报价|核价/ },
- { topic: 'appointment', inbound: /预约|约时间|哪天方便|几点|到店|上门/, reply: /约|时间|上午|下午|哪天|档期|方便/ },
- { topic: 'availability', inbound: /还有没有|有没有名额|有货吗|档期还有/, reply: /有|没有|名额|库存|档期|名额/ },
- { topic: 'policy', inbound: /资质|资格|政策|合同|退款|发票/, reply: /资质|资格|政策|合同|退款|发票|人工|核验/ },
- { topic: 'complaint', inbound: /投诉|不处理|太差|骗人/, reply: /抱歉|记录|主管|处理|跟进|接管/ },
- { topic: 'timeline', inbound: /什么时候|多久|周期|交付|上线/, reply: /时间|周期|天|周|月|交付|确认/ },
- { topic: 'spec', inbound: /规格|型号|版本|套餐|配置/, reply: /规格|型号|版本|套餐|配置|方案/ },
- ];
- const TABOO_PHRASES = Object.freeze([
- '我先帮您梳理一下需求',
- '请您耐心等待',
- '我们一直致力于提供优质服务',
- '为了更好地为您服务',
- '系统提示',
- '当前缺少判断依据',
- '已降级为人工核验',
- ]);
- function clauseSimilarity(a = '', b = '') {
- const strip = value => String(value || '').replace(/[\s,。!?、;:""''()()]/g, '');
- const x = strip(a);
- const y = strip(b);
- if (x.length < 6 || y.length < 6) return 0;
- const grams = text => {
- const map = new Map();
- for (let index = 0; index < text.length - 1; index += 1) {
- const gram = text.slice(index, index + 2);
- map.set(gram, (map.get(gram) || 0) + 1);
- }
- return map;
- };
- const gx = grams(x);
- const gy = grams(y);
- let overlap = 0;
- for (const [gram, count] of gx) overlap += Math.min(count, gy.get(gram) || 0);
- const total = [...gx.values()].reduce((sum, count) => sum + count, 0)
- + [...gy.values()].reduce((sum, count) => sum + count, 0);
- return total ? (2 * overlap) / total : 0;
- }
- function deduplicateResponseClauses(reply = '') {
- const clauses = String(reply || '').match(/[^。!?\n]+[。!?]?/g) || [];
- const kept = [];
- for (const clause of clauses) {
- if (kept.some(previous => clauseSimilarity(previous, clause) >= 0.5)) continue;
- kept.push(clause);
- }
- return kept.join('');
- }
- function normalizeWeChatReply(reply = '') {
- const normalized = deduplicateResponseClauses(String(reply || ''))
- .trim()
- .replace(/,/g, ',')
- .replace(/\?/g, '?')
- .replace(/!/g, '!');
- if (!normalized) return '';
- const sentences = normalized.match(/[^。!?\n]+[。!?]?/g) || [normalized];
- const kept = [];
- let questions = 0;
- for (const sentence of sentences) {
- const question = /?/.test(sentence);
- if (question && questions >= 1) continue;
- if (question) questions += 1;
- kept.push(sentence.trim());
- if (kept.length >= 3) break;
- }
- return kept.join('');
- }
- function pickReplyVariant(seed = '', variants = []) {
- if (!variants.length) return '';
- let value = 2166136261;
- for (const char of String(seed || '')) {
- value ^= char.charCodeAt(0);
- value = Math.imul(value, 16777619);
- }
- return variants[(value >>> 0) % variants.length];
- }
- function replyStyleViolations(reply = '', inbound = '', _generationStrategy = {}, styleContext = {}) {
- const text = String(reply || '').trim();
- const ask = String(inbound || '');
- if (!text) return [];
- const violations = [];
- if (/(?:帮|替|给)(?:您|你).{0,6}(?:查|问|找|确认|核实|核对|安排|转达)|(?:确认|核实)(?:好|完|后).{0,8}(?:回|发|告诉|答复)|稍等.{0,8}(?:我|回)/.test(text)) {
- violations.push('unverified_commitment');
- }
- if (/(?:肯定|保证|一定|绝对)(?:能|会|有|可以|给|没问题|不会)|包(?:您|你)|百分百|准没错/.test(text)) {
- violations.push('absolute_guarantee');
- }
- if (styleContext.hasAppointmentEvidence !== true) {
- const acceptsNamedDate = (/(?:今天|明天|后天|周[一二三四五六日天末])/.test(text)
- && /(?:我(?:有空|可以|能)|可以安排|能安排|没问题|都行|随时)/.test(text))
- || /(?:今天|明天|后天|周[一二三四五六日天末])(?:是|就)?(?:可以|行|没问题|方便)/.test(text);
- const promisesArrangement = /(?:预约|到店|上门).{0,6}(?:没问题|都行|随时|好说)/.test(text)
- || /(?:按|依|照)(?:您|你)(?:的)?(?:时间|档期|节奏)(?:来)?(?:安排|定|走|排)/.test(text)
- || /(?:帮|替|给)(?:您|你).{0,8}(?:约|安排|排).{0,6}(?:时间|档期|上门|到店)/.test(text);
- if (acceptsNamedDate || promisesArrangement) violations.push('schedule_confirmation');
- }
- if (/(?:尽快|抓紧|赶紧|趁早|要快)|(?:再不|错过).{0,10}(?:就没|可惜)/.test(text)) {
- violations.push('customer_pressure');
- }
- if (/(?:资料|系统|数据库|后台|记录)(?:里|中)?.{0,8}(?:没有|查不到|看不出|未收录)|缺少.{0,12}判断依据|没有新的核验结果|当前(?:还)?缺少|需要(?:顾问|专员|同事).{0,6}确认/.test(text)) {
- violations.push('system_speak');
- }
- const customerText = [ask, ...(styleContext.messages || [])
- .filter(message => (message?.role === 'user' || message?.direction === 'inbound'))
- .map(message => String(message?.content || ''))].join('\n');
- const numbers = text.match(/\d+(?:\.\d+)?/g) || [];
- const quotesUnsourcedNumber = numbers.some(number => !customerText.includes(number))
- && /(?:%|%|万|元|年|折)/.test(text)
- && styleContext.hasCatalogEvidence !== true;
- if (quotesUnsourcedNumber) violations.push('unsourced_number');
- if (/税/.test(ask) && /(?:免税|不用交税|全免)|(?:税率|税额).{0,8}\d/.test(text)) {
- violations.push('tax_conclusion');
- }
- if (/投诉|退款|赔偿|合同纠纷|隐私泄露/.test(ask)
- && !/(?:人工|主管|负责人).{0,10}(?:复核|核验|确认|接管)/.test(text)) {
- violations.push('risk_handoff_missing');
- }
- if (VAGUE_ACKNOWLEDGEMENT_PATTERN.test(text)) violations.push('vague_acknowledgement');
- for (const matcher of REPLY_TOPIC_MATCHERS) {
- if (matcher.inbound.test(ask) && !matcher.reply.test(text)) {
- violations.push(`off_topic:${matcher.topic}`);
- break;
- }
- }
- if (text.length <= 24
- && !/[??]/.test(text)
- && /(?:要看|得看|取决于|看具体|看情况)/.test(text)
- && !/(?:一般|通常|大多|因为|比如|差别|不一样)/.test(text)) {
- violations.push('deflection_only');
- }
- if ((text.match(/[??]/g) || []).length >= 3) violations.push('question_overload');
- const recentOutbound = (styleContext.messages || [])
- .filter(message => (message?.role === 'assistant' || message?.direction === 'outbound'))
- .slice(-4)
- .map(message => String(message?.content || ''))
- .filter(Boolean);
- if (recentOutbound.some(previous => clauseSimilarity(previous, text) >= 0.82)) {
- violations.push('repeat_previous');
- }
- return violations;
- }
- function fallbackForViolations(violations = [], inbound = '', seed = '') {
- const codes = new Set(violations.map(item => String(item).split(':')[0]));
- const pick = (key, variants) => pickReplyVariant(`${seed}\n${key}`, variants);
- if (codes.has('risk_handoff_missing')) {
- return pick('handoff', [
- '这件事需要主管复核,我先记下您的诉求,结论出来前先按未处理完成看待。',
- '投诉和责任判断要转主管跟进。您最希望先解决哪一项?',
- ]);
- }
- if (codes.has('schedule_confirmation')) {
- return pick('schedule', [
- '具体时间还要再核对档期,我先记下您方便的时段。您更想上午还是下午?',
- '预约还没落到日历上,先不确定已经约好。您哪天更方便?',
- ]);
- }
- if (codes.has('absolute_guarantee') || codes.has('unsourced_number')) {
- return pick('boundary', [
- '这一项我现在不能打包票,得按核验结果说。您最想先确认价格、时间还是范围?',
- '现有信息还不够下确定结论。您把最关键的一项条件再说清楚,我按这个往下核。',
- ]);
- }
- if (codes.has('unverified_commitment') || codes.has('system_speak')) {
- return pick('no-process', [
- '我先按现有信息回答;还没核到的部分不先说满。您最想先确认哪一项?',
- '能确定的我直接说,拿不准的不替工具下结论。您更在意进度还是具体条件?',
- ]);
- }
- if (codes.has('vague_acknowledgement') || codes.has('deflection_only') || codes.has('off_topic')) {
- return pick('answer', [
- '您这个问题我先直接说:当前还要结合具体条件确认。您把最关键的一项发我。',
- '我先回应您刚问的点。把具体对象或条件给我一个,我好对上再给下一步。',
- ]);
- }
- if (codes.has('repeat_previous') || codes.has('question_overload') || codes.has('customer_pressure')) {
- return pick('pace', [
- '刚才那句说绕了。您按自己的节奏来,有明确问题我再补一句。',
- '不催您决定。您先看当前这条是否把问题说清,不清楚再问一个点就行。',
- ]);
- }
- return pick('generic', [
- '我先按能确定的说,拿不准的不替您承诺。您最想先确认哪一项?',
- ]);
- }
- function enforceHumanReplyStyle(final = {}, inboundContent = '', generationStrategy = {}, styleContext = {}) {
- const inbound = String(inboundContent || '');
- const original = String(final.reply || '');
- if (!original.trim()) return final;
- const violations = replyStyleViolations(original, inbound, generationStrategy, styleContext);
- if (!violations.length) {
- const clean = normalizeWeChatReply(original.replace(/您们/g, '你们'));
- if (!clean.trim() || clean === original) return final;
- return {
- ...final,
- reply: clean,
- reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端仅做微信节奏归一,未改写模型语气。`,
- };
- }
- const seed = [
- styleContext.variantKey,
- generationStrategy.stage,
- generationStrategy.scenario,
- inbound,
- original,
- ].filter(Boolean).join('\n');
- return {
- ...final,
- reply: fallbackForViolations(violations, inbound, seed),
- reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端因回复越线已改写:${violations.join('、')}。`,
- styleViolations: violations,
- };
- }
- function enforceTabooFreeReply(final = {}) {
- let reply = String(final.reply || '').trim();
- if (!reply) return final;
- const hits = [];
- for (const phrase of TABOO_PHRASES) {
- if (reply.includes(phrase)) {
- hits.push(phrase);
- reply = reply.split(phrase).join('');
- }
- }
- if (!hits.length) return final;
- reply = reply
- .replace(/[。!?,、\s]+(?=[。!?,、])/g, '')
- .replace(/^(?:[,,、。;:]+|[了得的]?[,,、。;:])/, '')
- .replace(/\s{2,}/g, ' ')
- .trim();
- if (!reply || !/[^\s,。!?;:、]/u.test(reply)) return final;
- return {
- ...final,
- reply,
- reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端已删除聊天风格禁忌语:${hits.join('、')}。`,
- };
- }
- function enforceSingleFinalReply(final = {}) {
- const text = String(final.reply || '').trim();
- if (!text) return final;
- const markers = [
- /(?:^|[。!?\n;]|[((【\[]\s*)(?:以下是|这是|上面是|我的最终回复是)?\s*(?:按(?:聊天|沟通)?风格处理(?:后|过)?的?(?:最终)?(?:回复|消息|版本)|检查后(?:生成的)?(?:的)?(?:最终)?(?:回复|消息)|最终(?:回复|消息|版本))[))】\]]?\s*[::]?\s*/g,
- /(?:^|[。!?\n;])\s*[((【\[]\s*(?:按(?:聊天|沟通)?风格处理(?:后|过)?|检查后|风格化(?:处理)?)\s*[))】\]]\s*[::]?\s*/g,
- ];
- let cut = -1;
- for (const rx of markers) {
- let match;
- while ((match = rx.exec(text))) {
- const next = match.index + match[0].length;
- if (text.slice(next).trim() && next > cut) cut = next;
- }
- }
- if (cut < 0) return final;
- const finalPart = text.slice(cut).trim();
- if (!finalPart) return final;
- return {
- ...final,
- reply: finalPart,
- reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端检测到多版本回复,已按“最终版本”收敛为单条。`,
- };
- }
- module.exports = {
- TABOO_PHRASES,
- clauseSimilarity,
- deduplicateResponseClauses,
- enforceHumanReplyStyle,
- enforceSingleFinalReply,
- enforceTabooFreeReply,
- normalizeWeChatReply,
- replyStyleViolations,
- };
|