response-human-style.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. 'use strict';
  2. const VAGUE_ACKNOWLEDGEMENT_PATTERN = /^(?:好的?|行|可以|没问题|明白|收到|嗯)[,,。!!\s]*(?:[^。!?]{0,14})?[。!]?$/;
  3. const REPLY_TOPIC_MATCHERS = [
  4. { topic: 'price', inbound: /什么价|多少钱|怎么收费|报价|费用|单价|总价/, reply: /价|费|万|元|预算|报价|核价/ },
  5. { topic: 'appointment', inbound: /预约|约时间|哪天方便|几点|到店|上门/, reply: /约|时间|上午|下午|哪天|档期|方便/ },
  6. { topic: 'availability', inbound: /还有没有|有没有名额|有货吗|档期还有/, reply: /有|没有|名额|库存|档期|名额/ },
  7. { topic: 'policy', inbound: /资质|资格|政策|合同|退款|发票/, reply: /资质|资格|政策|合同|退款|发票|人工|核验/ },
  8. { topic: 'complaint', inbound: /投诉|不处理|太差|骗人/, reply: /抱歉|记录|主管|处理|跟进|接管/ },
  9. { topic: 'timeline', inbound: /什么时候|多久|周期|交付|上线/, reply: /时间|周期|天|周|月|交付|确认/ },
  10. { topic: 'spec', inbound: /规格|型号|版本|套餐|配置/, reply: /规格|型号|版本|套餐|配置|方案/ },
  11. ];
  12. const TABOO_PHRASES = Object.freeze([
  13. '我先帮您梳理一下需求',
  14. '请您耐心等待',
  15. '我们一直致力于提供优质服务',
  16. '为了更好地为您服务',
  17. '系统提示',
  18. '当前缺少判断依据',
  19. '已降级为人工核验',
  20. ]);
  21. function clauseSimilarity(a = '', b = '') {
  22. const strip = value => String(value || '').replace(/[\s,。!?、;:""''()()]/g, '');
  23. const x = strip(a);
  24. const y = strip(b);
  25. if (x.length < 6 || y.length < 6) return 0;
  26. const grams = text => {
  27. const map = new Map();
  28. for (let index = 0; index < text.length - 1; index += 1) {
  29. const gram = text.slice(index, index + 2);
  30. map.set(gram, (map.get(gram) || 0) + 1);
  31. }
  32. return map;
  33. };
  34. const gx = grams(x);
  35. const gy = grams(y);
  36. let overlap = 0;
  37. for (const [gram, count] of gx) overlap += Math.min(count, gy.get(gram) || 0);
  38. const total = [...gx.values()].reduce((sum, count) => sum + count, 0)
  39. + [...gy.values()].reduce((sum, count) => sum + count, 0);
  40. return total ? (2 * overlap) / total : 0;
  41. }
  42. function deduplicateResponseClauses(reply = '') {
  43. const clauses = String(reply || '').match(/[^。!?\n]+[。!?]?/g) || [];
  44. const kept = [];
  45. for (const clause of clauses) {
  46. if (kept.some(previous => clauseSimilarity(previous, clause) >= 0.5)) continue;
  47. kept.push(clause);
  48. }
  49. return kept.join('');
  50. }
  51. function normalizeWeChatReply(reply = '') {
  52. const normalized = deduplicateResponseClauses(String(reply || ''))
  53. .trim()
  54. .replace(/,/g, ',')
  55. .replace(/\?/g, '?')
  56. .replace(/!/g, '!');
  57. if (!normalized) return '';
  58. const sentences = normalized.match(/[^。!?\n]+[。!?]?/g) || [normalized];
  59. const kept = [];
  60. let questions = 0;
  61. for (const sentence of sentences) {
  62. const question = /?/.test(sentence);
  63. if (question && questions >= 1) continue;
  64. if (question) questions += 1;
  65. kept.push(sentence.trim());
  66. if (kept.length >= 3) break;
  67. }
  68. return kept.join('');
  69. }
  70. function pickReplyVariant(seed = '', variants = []) {
  71. if (!variants.length) return '';
  72. let value = 2166136261;
  73. for (const char of String(seed || '')) {
  74. value ^= char.charCodeAt(0);
  75. value = Math.imul(value, 16777619);
  76. }
  77. return variants[(value >>> 0) % variants.length];
  78. }
  79. function replyStyleViolations(reply = '', inbound = '', _generationStrategy = {}, styleContext = {}) {
  80. const text = String(reply || '').trim();
  81. const ask = String(inbound || '');
  82. if (!text) return [];
  83. const violations = [];
  84. if (/(?:帮|替|给)(?:您|你).{0,6}(?:查|问|找|确认|核实|核对|安排|转达)|(?:确认|核实)(?:好|完|后).{0,8}(?:回|发|告诉|答复)|稍等.{0,8}(?:我|回)/.test(text)) {
  85. violations.push('unverified_commitment');
  86. }
  87. if (/(?:肯定|保证|一定|绝对)(?:能|会|有|可以|给|没问题|不会)|包(?:您|你)|百分百|准没错/.test(text)) {
  88. violations.push('absolute_guarantee');
  89. }
  90. if (styleContext.hasAppointmentEvidence !== true) {
  91. const acceptsNamedDate = (/(?:今天|明天|后天|周[一二三四五六日天末])/.test(text)
  92. && /(?:我(?:有空|可以|能)|可以安排|能安排|没问题|都行|随时)/.test(text))
  93. || /(?:今天|明天|后天|周[一二三四五六日天末])(?:是|就)?(?:可以|行|没问题|方便)/.test(text);
  94. const promisesArrangement = /(?:预约|到店|上门).{0,6}(?:没问题|都行|随时|好说)/.test(text)
  95. || /(?:按|依|照)(?:您|你)(?:的)?(?:时间|档期|节奏)(?:来)?(?:安排|定|走|排)/.test(text)
  96. || /(?:帮|替|给)(?:您|你).{0,8}(?:约|安排|排).{0,6}(?:时间|档期|上门|到店)/.test(text);
  97. if (acceptsNamedDate || promisesArrangement) violations.push('schedule_confirmation');
  98. }
  99. if (/(?:尽快|抓紧|赶紧|趁早|要快)|(?:再不|错过).{0,10}(?:就没|可惜)/.test(text)) {
  100. violations.push('customer_pressure');
  101. }
  102. if (/(?:资料|系统|数据库|后台|记录)(?:里|中)?.{0,8}(?:没有|查不到|看不出|未收录)|缺少.{0,12}判断依据|没有新的核验结果|当前(?:还)?缺少|需要(?:顾问|专员|同事).{0,6}确认/.test(text)) {
  103. violations.push('system_speak');
  104. }
  105. const customerText = [ask, ...(styleContext.messages || [])
  106. .filter(message => (message?.role === 'user' || message?.direction === 'inbound'))
  107. .map(message => String(message?.content || ''))].join('\n');
  108. const numbers = text.match(/\d+(?:\.\d+)?/g) || [];
  109. const quotesUnsourcedNumber = numbers.some(number => !customerText.includes(number))
  110. && /(?:%|%|万|元|年|折)/.test(text)
  111. && styleContext.hasCatalogEvidence !== true;
  112. if (quotesUnsourcedNumber) violations.push('unsourced_number');
  113. if (/税/.test(ask) && /(?:免税|不用交税|全免)|(?:税率|税额).{0,8}\d/.test(text)) {
  114. violations.push('tax_conclusion');
  115. }
  116. if (/投诉|退款|赔偿|合同纠纷|隐私泄露/.test(ask)
  117. && !/(?:人工|主管|负责人).{0,10}(?:复核|核验|确认|接管)/.test(text)) {
  118. violations.push('risk_handoff_missing');
  119. }
  120. if (VAGUE_ACKNOWLEDGEMENT_PATTERN.test(text)) violations.push('vague_acknowledgement');
  121. for (const matcher of REPLY_TOPIC_MATCHERS) {
  122. if (matcher.inbound.test(ask) && !matcher.reply.test(text)) {
  123. violations.push(`off_topic:${matcher.topic}`);
  124. break;
  125. }
  126. }
  127. if (text.length <= 24
  128. && !/[??]/.test(text)
  129. && /(?:要看|得看|取决于|看具体|看情况)/.test(text)
  130. && !/(?:一般|通常|大多|因为|比如|差别|不一样)/.test(text)) {
  131. violations.push('deflection_only');
  132. }
  133. if ((text.match(/[??]/g) || []).length >= 3) violations.push('question_overload');
  134. const recentOutbound = (styleContext.messages || [])
  135. .filter(message => (message?.role === 'assistant' || message?.direction === 'outbound'))
  136. .slice(-4)
  137. .map(message => String(message?.content || ''))
  138. .filter(Boolean);
  139. if (recentOutbound.some(previous => clauseSimilarity(previous, text) >= 0.82)) {
  140. violations.push('repeat_previous');
  141. }
  142. return violations;
  143. }
  144. function fallbackForViolations(violations = [], inbound = '', seed = '') {
  145. const codes = new Set(violations.map(item => String(item).split(':')[0]));
  146. const pick = (key, variants) => pickReplyVariant(`${seed}\n${key}`, variants);
  147. if (codes.has('risk_handoff_missing')) {
  148. return pick('handoff', [
  149. '这件事需要主管复核,我先记下您的诉求,结论出来前先按未处理完成看待。',
  150. '投诉和责任判断要转主管跟进。您最希望先解决哪一项?',
  151. ]);
  152. }
  153. if (codes.has('schedule_confirmation')) {
  154. return pick('schedule', [
  155. '具体时间还要再核对档期,我先记下您方便的时段。您更想上午还是下午?',
  156. '预约还没落到日历上,先不确定已经约好。您哪天更方便?',
  157. ]);
  158. }
  159. if (codes.has('absolute_guarantee') || codes.has('unsourced_number')) {
  160. return pick('boundary', [
  161. '这一项我现在不能打包票,得按核验结果说。您最想先确认价格、时间还是范围?',
  162. '现有信息还不够下确定结论。您把最关键的一项条件再说清楚,我按这个往下核。',
  163. ]);
  164. }
  165. if (codes.has('unverified_commitment') || codes.has('system_speak')) {
  166. return pick('no-process', [
  167. '我先按现有信息回答;还没核到的部分不先说满。您最想先确认哪一项?',
  168. '能确定的我直接说,拿不准的不替工具下结论。您更在意进度还是具体条件?',
  169. ]);
  170. }
  171. if (codes.has('vague_acknowledgement') || codes.has('deflection_only') || codes.has('off_topic')) {
  172. return pick('answer', [
  173. '您这个问题我先直接说:当前还要结合具体条件确认。您把最关键的一项发我。',
  174. '我先回应您刚问的点。把具体对象或条件给我一个,我好对上再给下一步。',
  175. ]);
  176. }
  177. if (codes.has('repeat_previous') || codes.has('question_overload') || codes.has('customer_pressure')) {
  178. return pick('pace', [
  179. '刚才那句说绕了。您按自己的节奏来,有明确问题我再补一句。',
  180. '不催您决定。您先看当前这条是否把问题说清,不清楚再问一个点就行。',
  181. ]);
  182. }
  183. return pick('generic', [
  184. '我先按能确定的说,拿不准的不替您承诺。您最想先确认哪一项?',
  185. ]);
  186. }
  187. function enforceHumanReplyStyle(final = {}, inboundContent = '', generationStrategy = {}, styleContext = {}) {
  188. const inbound = String(inboundContent || '');
  189. const original = String(final.reply || '');
  190. if (!original.trim()) return final;
  191. const violations = replyStyleViolations(original, inbound, generationStrategy, styleContext);
  192. if (!violations.length) {
  193. const clean = normalizeWeChatReply(original.replace(/您们/g, '你们'));
  194. if (!clean.trim() || clean === original) return final;
  195. return {
  196. ...final,
  197. reply: clean,
  198. reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端仅做微信节奏归一,未改写模型语气。`,
  199. };
  200. }
  201. const seed = [
  202. styleContext.variantKey,
  203. generationStrategy.stage,
  204. generationStrategy.scenario,
  205. inbound,
  206. original,
  207. ].filter(Boolean).join('\n');
  208. return {
  209. ...final,
  210. reply: fallbackForViolations(violations, inbound, seed),
  211. reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端因回复越线已改写:${violations.join('、')}。`,
  212. styleViolations: violations,
  213. };
  214. }
  215. function enforceTabooFreeReply(final = {}) {
  216. let reply = String(final.reply || '').trim();
  217. if (!reply) return final;
  218. const hits = [];
  219. for (const phrase of TABOO_PHRASES) {
  220. if (reply.includes(phrase)) {
  221. hits.push(phrase);
  222. reply = reply.split(phrase).join('');
  223. }
  224. }
  225. if (!hits.length) return final;
  226. reply = reply
  227. .replace(/[。!?,、\s]+(?=[。!?,、])/g, '')
  228. .replace(/^(?:[,,、。;:]+|[了得的]?[,,、。;:])/, '')
  229. .replace(/\s{2,}/g, ' ')
  230. .trim();
  231. if (!reply || !/[^\s,。!?;:、]/u.test(reply)) return final;
  232. return {
  233. ...final,
  234. reply,
  235. reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端已删除聊天风格禁忌语:${hits.join('、')}。`,
  236. };
  237. }
  238. function enforceSingleFinalReply(final = {}) {
  239. const text = String(final.reply || '').trim();
  240. if (!text) return final;
  241. const markers = [
  242. /(?:^|[。!?\n;]|[((【\[]\s*)(?:以下是|这是|上面是|我的最终回复是)?\s*(?:按(?:聊天|沟通)?风格处理(?:后|过)?的?(?:最终)?(?:回复|消息|版本)|检查后(?:生成的)?(?:的)?(?:最终)?(?:回复|消息)|最终(?:回复|消息|版本))[))】\]]?\s*[::]?\s*/g,
  243. /(?:^|[。!?\n;])\s*[((【\[]\s*(?:按(?:聊天|沟通)?风格处理(?:后|过)?|检查后|风格化(?:处理)?)\s*[))】\]]\s*[::]?\s*/g,
  244. ];
  245. let cut = -1;
  246. for (const rx of markers) {
  247. let match;
  248. while ((match = rx.exec(text))) {
  249. const next = match.index + match[0].length;
  250. if (text.slice(next).trim() && next > cut) cut = next;
  251. }
  252. }
  253. if (cut < 0) return final;
  254. const finalPart = text.slice(cut).trim();
  255. if (!finalPart) return final;
  256. return {
  257. ...final,
  258. reply: finalPart,
  259. reason: `${String(final.reason || '')}${final.reason ? ';' : ''}服务端检测到多版本回复,已按“最终版本”收敛为单条。`,
  260. };
  261. }
  262. module.exports = {
  263. TABOO_PHRASES,
  264. clauseSimilarity,
  265. deduplicateResponseClauses,
  266. enforceHumanReplyStyle,
  267. enforceSingleFinalReply,
  268. enforceTabooFreeReply,
  269. normalizeWeChatReply,
  270. replyStyleViolations,
  271. };