response-professional-score.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. 'use strict';
  2. const DIMENSION_IDS = Object.freeze([
  3. 'direct_answer',
  4. 'judgment_value',
  5. 'continuity',
  6. 'next_step',
  7. 'naturalness',
  8. 'non_repetition',
  9. ]);
  10. const FILLER_PATTERN = /^(?:好的|收到|了解|明白|行|可以)[,,。\s]*(?:我看看|我来处理|这边处理|稍后回复|正在处理|帮您关注)?[。!!\s]*$/;
  11. const INTERNAL_PATTERN = /qualityScore|qualityChecks|requiresHuman|fallbackReason|系统提示|内部审核|机器评分/i;
  12. const JUDGMENT_PATTERN = /因为|所以|取决于|主要看|关键是|区别在|相比|更适合|优先|不建议|建议|重点|要结合|需要结合|不能只看|暂时不能|目前不确定|还不能确定|不能直接下结论/;
  13. const NEXT_STEP_PATTERN = /(?:您|你)(?:先)?(?:发|说|告诉|确认|选)|可以先|先看|先按|先把|接下来|下一步|再比较|再判断|再定|不用急|不着急|有需要再|方便时再|[??]/;
  14. const PRESSURE_PATTERN = /马上定|现在就定|赶紧|尽快交|直接签|先交定金|今天必须|错过就没/;
  15. const MECHANICAL_PATTERN = /(?:建议由人工(?:专员|顾问).{0,10}(?:复核|核验)){2,}|(?:需要核验|待核验).{0,12}(?:需要核验|待核验)|^(?:根据您提供的信息|感谢您的咨询)/;
  16. function normalize(value = '') {
  17. return String(value || '').toLowerCase().replace(/[\s,。!?!?,;;::'"“”‘’()()【】\[\]-]+/g, '');
  18. }
  19. function roleOf(message = {}) {
  20. return message.role || (message.direction === 'outbound' ? 'assistant' : 'user');
  21. }
  22. function contentOf(message = {}) {
  23. return String(message.content || message.text || '').trim();
  24. }
  25. function questionFields(text = '') {
  26. const value = String(text || '');
  27. return [
  28. ['budget', /预算|总价|首付|月供/],
  29. ['region', /区域|片区|地段|小区|哪里|哪边/],
  30. ['roomCount', /几房|几室|户型|房间数/],
  31. ['area', /面积|多大|多少平/],
  32. ['school', /学校|学区|入学|户籍|划片/],
  33. ['schedule', /哪天|什么时候|几点|上午|下午|时段/],
  34. ].filter(([, pattern]) => pattern.test(value)).map(([id]) => id);
  35. }
  36. function makeDimension(score, detail) {
  37. return { score: Math.max(0, Math.min(2, Number(score) || 0)), detail: String(detail || '') };
  38. }
  39. function scoreDirectAnswer(reply = '') {
  40. const content = String(reply || '').trim();
  41. if (!content || FILLER_PATTERN.test(content)) return makeDimension(0, '空回复或空泛承接');
  42. const first = content.replace(/^(?:好的|收到|了解|明白)[,,::\s]*/, '').split(/[。!?!?\n]/)[0];
  43. if (!first || /^(?:我看看|我来处理|这边处理|稍后回复|正在处理)/.test(first)) return makeDimension(0, '首句是处理过程');
  44. if (/[??]$/.test(first) && !/还不能|不确定|要看|取决于/.test(first)) return makeDimension(1, '首句直接进入澄清,但没有先给方向回答');
  45. return makeDimension(2, '首句给出实质回应或诚实边界');
  46. }
  47. function scoreJudgmentValue(reply = '') {
  48. const content = String(reply || '').trim();
  49. if (!content || FILLER_PATTERN.test(content)) return makeDimension(0, '没有判断信息');
  50. if (JUDGMENT_PATTERN.test(content)) return makeDimension(2, '包含判断、取舍依据或明确边界');
  51. if (/^[^。!?!?]*[??]$/.test(content)) return makeDimension(0, '只有追问,没有判断');
  52. return makeDimension(1, '有回应,但判断依据较弱');
  53. }
  54. function scoreContinuity(reply = '', messages = [], inbound = '') {
  55. const userHistory = messages.filter(message => roleOf(message) === 'user').map(contentOf).filter(Boolean);
  56. if (!userHistory.length) return makeDimension(2, '当前没有需要承接的历史条件');
  57. const knownText = [...userHistory, inbound].join('\n');
  58. const asked = questionFields(reply);
  59. const alreadyKnown = questionFields(knownText);
  60. if (asked.some(field => alreadyKnown.includes(field)) && /[??]|请问|告诉我|说下/.test(reply)) {
  61. return makeDimension(0, '再次采集历史中已有字段');
  62. }
  63. const knownNumbers = knownText.match(/\d+(?:\.\d+)?/g) || [];
  64. if (knownNumbers.length && knownNumbers.some(number => String(reply).includes(number))) return makeDimension(2, '复用了历史中的明确条件');
  65. if (/按您|您刚|您说|之前|继续保留|先按|记下/.test(reply)) return makeDimension(2, '显式承接上一轮条件');
  66. return makeDimension(1, '未冲突,但对历史条件承接不明显');
  67. }
  68. function scoreNextStep(reply = '', plan = {}) {
  69. const content = String(reply || '').trim();
  70. if (PRESSURE_PATTERN.test(content)) return makeDimension(0, '下一步对当前阶段施压过强');
  71. const lowPressure = plan.generationStrategy?.signals?.lowPressure;
  72. if (lowPressure && /什么时候|哪天|几点/.test(content) && !/不急|方便时|定下来再/.test(content)) {
  73. return makeDimension(0, '客户低压力时仍追问推进时间');
  74. }
  75. if (NEXT_STEP_PATTERN.test(content)) return makeDimension(2, '下一步明确且与当前阶段兼容');
  76. return makeDimension(1, '完成回答,但下一步不够清晰');
  77. }
  78. function scoreNaturalness(reply = '') {
  79. const content = String(reply || '').trim();
  80. if (!content || INTERNAL_PATTERN.test(content) || FILLER_PATTERN.test(content)) return makeDimension(0, '存在内部状态、空正文或空泛承接');
  81. if (MECHANICAL_PATTERN.test(content) || content.length > 240) return makeDimension(1, '表达偏机械或过长');
  82. return makeDimension(2, '表达简洁、客户可读');
  83. }
  84. function scoreNonRepetition(reply = '', messages = []) {
  85. const current = normalize(reply);
  86. const previous = messages.filter(message => roleOf(message) === 'assistant').map(message => normalize(contentOf(message))).filter(Boolean);
  87. if (previous.some(item => item === current && current.length >= 6)) return makeDimension(0, '逐字复读历史回复');
  88. if (previous.some(item => current.length >= 12 && item.length >= 12 && (item.includes(current) || current.includes(item)))) {
  89. return makeDimension(1, '与历史回复存在明显复用');
  90. }
  91. return makeDimension(2, '未发现明显复读');
  92. }
  93. function scoreProfessionalResponse(input = {}) {
  94. const reply = String(input.reply ?? input.content ?? '').trim();
  95. const messages = Array.isArray(input.messages) ? input.messages : [];
  96. const plan = input.plan || {};
  97. const dimensions = {
  98. direct_answer: scoreDirectAnswer(reply),
  99. judgment_value: scoreJudgmentValue(reply),
  100. continuity: scoreContinuity(reply, messages, String(input.inboundContent || input.inbound || '')),
  101. next_step: scoreNextStep(reply, plan),
  102. naturalness: scoreNaturalness(reply),
  103. non_repetition: scoreNonRepetition(reply, messages),
  104. };
  105. const total = DIMENSION_IDS.reduce((sum, id) => sum + dimensions[id].score, 0);
  106. return {
  107. version: '1.0',
  108. total,
  109. max: DIMENSION_IDS.length * 2,
  110. dimensions,
  111. };
  112. }
  113. module.exports = {
  114. DIMENSION_IDS,
  115. scoreProfessionalResponse,
  116. };