|
@@ -1,6 +1,7 @@
|
|
|
'use strict';
|
|
'use strict';
|
|
|
|
|
|
|
|
const { planResponse, plannerInstruction } = require('./response-quality-planner');
|
|
const { planResponse, plannerInstruction } = require('./response-quality-planner');
|
|
|
|
|
+const { qualityFallbackReason } = require('./response-quality-contract');
|
|
|
|
|
|
|
|
const GREETING_PATTERN = /^(?:您好|你好|哈喽|嗨|尊敬的|感谢您的?(?:咨询|关注|理解|反馈)?)[,,。!!\s]*/;
|
|
const GREETING_PATTERN = /^(?:您好|你好|哈喽|嗨|尊敬的|感谢您的?(?:咨询|关注|理解|反馈)?)[,,。!!\s]*/;
|
|
|
const FILLER_OPENING_PATTERN = /^(?:关于这个问题|针对您的问题|我来为您|我先帮您|我这边先|请您耐心等待|感谢您的耐心)/;
|
|
const FILLER_OPENING_PATTERN = /^(?:关于这个问题|针对您的问题|我来为您|我先帮您|我这边先|请您耐心等待|感谢您的耐心)/;
|
|
@@ -8,8 +9,49 @@ const EMPTY_HANDOFF_PATTERN = /^(?:好的|收到|知道了|了解了|我(?:先)?
|
|
|
const SENSITIVE_PROMISE_PATTERN = /(?:保证|承诺|绝对|百分之百|100%|一定(?:可以|能|会|不会)|全网最低|最低价|包过|稳赚|马上全部解决)/i;
|
|
const SENSITIVE_PROMISE_PATTERN = /(?:保证|承诺|绝对|百分之百|100%|一定(?:可以|能|会|不会)|全网最低|最低价|包过|稳赚|马上全部解决)/i;
|
|
|
const UNCERTAINTY_PATTERN = /(?:待确认|需要确认|需要核验|以.+为准|暂未找到|没有返回|未返回|当前数据(?:源|中)?.*没有|尚无|不确定|人工确认|核实后)/;
|
|
const UNCERTAINTY_PATTERN = /(?:待确认|需要确认|需要核验|以.+为准|暂未找到|没有返回|未返回|当前数据(?:源|中)?.*没有|尚无|不确定|人工确认|核实后)/;
|
|
|
const FACT_CLAIM_PATTERN = /\d|(?:价格|费用|优惠|库存|名额|政策|资格|合同|案例|数据|结果|当前有|已经找到|已确认|支持|最低|最高)/;
|
|
const FACT_CLAIM_PATTERN = /\d|(?:价格|费用|优惠|库存|名额|政策|资格|合同|案例|数据|结果|当前有|已经找到|已确认|支持|最低|最高)/;
|
|
|
-const NEXT_STEP_PATTERN = /(?:下一步|接下来|我(?:先|会|可以)|我们(?:先|会|可以)|建议|可以先|请您|麻烦|方便|确认后|核对后|安排|发给您|同步给您|转人工|人工(?:跟进|处理|确认))/;
|
|
|
|
|
|
|
+const SENSITIVE_FACT_PATTERN = /(?:价格|费用|优惠|库存|名额|档期|预约|安排|政策|资格|资质|合同|产权|权属|贷款|利率|税费|学区|入学|学位|房况|装修|媒体|照片|视频|案例|成交|最低|最高|保证|确定有|已经找到|已确认)/;
|
|
|
|
|
+const NEXT_STEP_PATTERN = /(?:下一步|接下来|我(?:先|会|可以)|我们(?:先|会|可以)|建议|可以先|请您|麻烦|方便|确认后|核对后|安排|发给您|同步给您|转人工|转交|转给|主管|负责人|人工(?:跟进|处理|确认))/;
|
|
|
const INTERNAL_LEAK_PATTERN = /(?:qualityScore|qualityChecks|rewriteCount|fallbackReason|内部评分|系统提示|JSON Schema|requiresHuman)/i;
|
|
const INTERNAL_LEAK_PATTERN = /(?:qualityScore|qualityChecks|rewriteCount|fallbackReason|内部评分|系统提示|JSON Schema|requiresHuman)/i;
|
|
|
|
|
+const HUMAN_HANDOFF_ACTION_PATTERN = /(?:已|现在|立即|马上|会|将|确认后|核对后).{0,6}(?:转交|转给|转至|转|安排|请|由).{0,10}(?:人工|专员|顾问|主管|负责人)/;
|
|
|
|
|
+const SUPERVISOR_HANDOFF_PATTERN = /(?:主管|负责人|投诉专员).{0,12}(?:跟进|处理|核验|联系|接管)|(?:转交|转给|安排).{0,8}(?:主管|负责人|投诉专员)/;
|
|
|
|
|
+
|
|
|
|
|
+const FIELD_RULES = {
|
|
|
|
|
+ budget: {
|
|
|
|
|
+ question: /预算|价格上限|费用上限|投入上限|总投入|总价|金额|首付|月供|付款能力/,
|
|
|
|
|
+ answer: /(?:预算|价格|费用|投入|总价|金额|首付|月供).{0,14}\d|\d+(?:\.\d+)?\s*(?:万|元)/,
|
|
|
|
|
+ profileKeys: /budget|price|maxamount|investment|funding|downpayment|monthlypayment|首付|月供|预算|金额/,
|
|
|
|
|
+ },
|
|
|
|
|
+ time: {
|
|
|
|
|
+ question: /时间|什么时候|何时|哪天|几号|多久|开始|截止|入住|入学|上线|交付|购置/,
|
|
|
|
|
+ answer: /(?:时间|开始|截止|计划|入住|入学|上线|交付|购置).{0,14}(?:今天|明天|本周|下周|月|年|季度|\d)/,
|
|
|
|
|
+ profileKeys: /timeline|startat|endat|deadline|purchase.*at|readyat|时间|日期|周期/,
|
|
|
|
|
+ },
|
|
|
|
|
+ people: {
|
|
|
|
|
+ question: /多少人|几人|人数|规模|参与人|团队人数/,
|
|
|
|
|
+ answer: /(?:人数|规模|参与|团队).{0,8}\d|\d.{0,4}人/,
|
|
|
|
|
+ profileKeys: /people|participant|headcount|人数|规模/,
|
|
|
|
|
+ },
|
|
|
|
|
+ goal: {
|
|
|
|
|
+ question: /目标|用途|目的|效果|解决什么|核心需求|主要诉求/,
|
|
|
|
|
+ answer: /(?:目标|用途|目的|需求|诉求)(?:是|为|:|:)|我(?:想|要|希望|主要)/,
|
|
|
|
|
+ profileKeys: /goal|purpose|intent|need|objective|目标|用途|需求|诉求/,
|
|
|
|
|
+ },
|
|
|
|
|
+ location: {
|
|
|
|
|
+ question: /地区|区域|地点|地址|哪里|哪儿|片区|城市|小区/,
|
|
|
|
|
+ answer: /(?:地区|区域|地点|地址|片区|城市)(?:是|在|选|为|:|:)|我(?:想|要|考虑).{0,8}(?:在|选)/,
|
|
|
|
|
+ profileKeys: /location|region|area|city|address|district|区域|地区|地址|城市/,
|
|
|
|
|
+ },
|
|
|
|
|
+ target: {
|
|
|
|
|
+ question: /目标(?:学校|初中|小学|机构|产品)|哪(?:所|个)(?:学校|初中|小学|产品|方案)|哪套|房源|候选|项目编号|方案编号/,
|
|
|
|
|
+ answer: /(?:目标学校|目标机构|目标产品|房源|候选|项目|方案)(?:是|为|选|编号|:|:)|(?:看中|选中|确定).{0,8}(?:这套|该|方案|产品|项目)/,
|
|
|
|
|
+ profileKeys: /target|school|property|listing|candidate|selected|projectid|productid|目标|学校|房源|候选|方案/,
|
|
|
|
|
+ },
|
|
|
|
|
+ feature: {
|
|
|
|
|
+ question: /户型|面积|楼层|电梯|规格|版本|功能偏好|配置偏好/,
|
|
|
|
|
+ answer: /(?:户型|面积|楼层|电梯|规格|版本|功能|配置)(?:是|要|不要|偏好|选择|:|:)|\d+\s*(?:平|㎡|层)/,
|
|
|
|
|
+ profileKeys: /layout|area|floor|elevator|feature|version|configuration|户型|面积|楼层|电梯|配置/,
|
|
|
|
|
+ },
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
function normalized(value = '') {
|
|
function normalized(value = '') {
|
|
|
return String(value || '').toLowerCase().replace(/[\s,。!?;:、,.!?;:'"“”‘’()()【】\[\]-]+/g, '');
|
|
return String(value || '').toLowerCase().replace(/[\s,。!?;:、,.!?;:'"“”‘’()()【】\[\]-]+/g, '');
|
|
@@ -30,14 +72,21 @@ function questionSegments(text = '') {
|
|
|
|
|
|
|
|
function questionFields(text = '') {
|
|
function questionFields(text = '') {
|
|
|
const value = String(text || '');
|
|
const value = String(text || '');
|
|
|
- const fields = [];
|
|
|
|
|
- if (/预算|价格|费用|投入|总价|上限|下限|金额/.test(value)) fields.push('budget');
|
|
|
|
|
- if (/时间|什么时候|何时|哪天|几号|多久|开始|截止/.test(value)) fields.push('time');
|
|
|
|
|
- if (/多少人|几人|人数|规模|参与人/.test(value)) fields.push('people');
|
|
|
|
|
- if (/目标|用途|目的|效果|解决什么|需求/.test(value)) fields.push('goal');
|
|
|
|
|
- if (/地区|区域|地点|地址|哪里|哪儿/.test(value)) fields.push('location');
|
|
|
|
|
- if (/数量|多少个|几个|套数/.test(value)) fields.push('quantity');
|
|
|
|
|
- return [...new Set(fields)];
|
|
|
|
|
|
|
+ return Object.entries(FIELD_RULES).filter(([, rule]) => rule.question.test(value)).map(([field]) => field);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function questionFieldText(text = '') {
|
|
|
|
|
+ const cue = /(?:请问|麻烦|方便|能否|是否|可否|怎么|如何|为什么|多少|什么时候|何时|哪|几|要不要|需不需要|更看重|更在意|偏好|倾向|吗$|么$)/;
|
|
|
|
|
+ return String(text || '')
|
|
|
|
|
+ .split(/[。!!\n]/)
|
|
|
|
|
+ .filter(sentence => /[??]/.test(sentence) || countQuestions(sentence) > 0)
|
|
|
|
|
+ .flatMap(sentence => {
|
|
|
|
|
+ const explicit = sentence.split(/[??]/).slice(0, -1);
|
|
|
|
|
+ return explicit.length ? explicit : [sentence];
|
|
|
|
|
+ })
|
|
|
|
|
+ .flatMap(segment => segment.split(/[,,;;]/).map(item => item.trim()).filter(Boolean))
|
|
|
|
|
+ .filter((clause, index, clauses) => cue.test(clause) || index === clauses.length - 1)
|
|
|
|
|
+ .join('\n');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function countQuestions(text = '') {
|
|
function countQuestions(text = '') {
|
|
@@ -45,11 +94,13 @@ function countQuestions(text = '') {
|
|
|
const clauses = value.split(/[;;。!!\n]/).map(item => item.trim()).filter(Boolean);
|
|
const clauses = value.split(/[;;。!!\n]/).map(item => item.trim()).filter(Boolean);
|
|
|
return clauses.reduce((total, clause) => {
|
|
return clauses.reduce((total, clause) => {
|
|
|
const punctuationCount = (clause.match(/[??]/g) || []).length;
|
|
const punctuationCount = (clause.match(/[??]/g) || []).length;
|
|
|
- const cueCount = (clause.match(/多少|什么时候|何时|哪(?:个|些|里|儿|天)|几(?:点|号|人|个)?|是否|能否|可否|怎么|如何|为什么/g) || []).length;
|
|
|
|
|
- const implicitQuestion = !punctuationCount && cueCount > 0 && (
|
|
|
|
|
|
|
+ const cueCount = (clause.match(/多少|什么时候|何时|哪(?:个|些|里|儿|天|一?所)|几(?:点|号|人|个)?|是否|能否|可否|怎么|如何|为什么/g) || []).length;
|
|
|
|
|
+ const collectionIntent = /(?:告诉|告知|提供|补充|发(?:一下)?|说(?:一下|下)?|说明).{0,20}(?:预算|价格上限|费用上限|投入上限|总投入|心理预算|时间|日期|人数|规模|地区|区域|地点|地址|目标|学校|初中|产品|方案|房源|小区|户型|面积|楼层|规格|版本)/.test(clause)
|
|
|
|
|
+ || /(?:您|客户).{0,8}(?:确认|选择).{0,20}(?:哪(?:一?所|个|套)|多少|什么|何时|什么时候)/.test(clause);
|
|
|
|
|
+ const implicitQuestion = !punctuationCount && ((cueCount > 0 && (
|
|
|
/^(?:请问|麻烦|方便|能否|是否|怎么|如何|为什么|多少|什么时候|何时|哪|几|预算|时间|人数|规模|地区|区域)/.test(clause)
|
|
/^(?:请问|麻烦|方便|能否|是否|怎么|如何|为什么|多少|什么时候|何时|哪|几|预算|时间|人数|规模|地区|区域)/.test(clause)
|
|
|
|| /(?:预算|时间|人数|规模|地区|区域).{0,10}(?:多少|什么时候|何时|哪|几|吗|么)$/.test(clause)
|
|
|| /(?:预算|时间|人数|规模|地区|区域).{0,10}(?:多少|什么时候|何时|哪|几|吗|么)$/.test(clause)
|
|
|
- );
|
|
|
|
|
|
|
+ )) || collectionIntent);
|
|
|
if (!punctuationCount && !implicitQuestion) return total;
|
|
if (!punctuationCount && !implicitQuestion) return total;
|
|
|
const fields = questionFields(clause);
|
|
const fields = questionFields(clause);
|
|
|
const multiFieldCount = fields.length > 1 && !/还是/.test(clause) ? fields.length : 0;
|
|
const multiFieldCount = fields.length > 1 && !/还是/.test(clause) ? fields.length : 0;
|
|
@@ -58,17 +109,23 @@ function countQuestions(text = '') {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function hasAnsweredFieldQuestion(reply, input = {}) {
|
|
function hasAnsweredFieldQuestion(reply, input = {}) {
|
|
|
- const asked = questionFields(reply);
|
|
|
|
|
|
|
+ const asked = questionFields(questionFieldText(reply));
|
|
|
if (!asked.length || countQuestions(reply) === 0) return false;
|
|
if (!asked.length || countQuestions(reply) === 0) return false;
|
|
|
const source = [input.inboundContent, ...(input.messages || []).filter(message => toRole(message) === 'user').map(messageContent)].join('\n');
|
|
const source = [input.inboundContent, ...(input.messages || []).filter(message => toRole(message) === 'user').map(messageContent)].join('\n');
|
|
|
- const profile = input.profile || {};
|
|
|
|
|
- const answered = new Set();
|
|
|
|
|
- if (/(?:预算|价格|费用|投入|总价|上限|金额).{0,12}\d|\d.{0,6}(?:万|元)/.test(source) || profile.budget || profile.budgetWan || profile.budgetMax) answered.add('budget');
|
|
|
|
|
- if (/(?:时间|开始|截止|计划).{0,12}(?:今天|明天|本周|下周|月|年|\d)/.test(source) || profile.timeline || profile.startAt || profile.deadline) answered.add('time');
|
|
|
|
|
- if (/(?:人数|规模|参与).{0,8}\d|\d.{0,4}人/.test(source) || profile.peopleCount || profile.participantCount) answered.add('people');
|
|
|
|
|
- if (/(?:目标|用途|目的|需求)(?:是|为|:|:)/.test(source) || profile.goal || profile.purpose || profile.intent || profile.need) answered.add('goal');
|
|
|
|
|
- if (/(?:地区|区域|地点|地址)(?:是|在|为|:|:)/.test(source) || profile.location || profile.region) answered.add('location');
|
|
|
|
|
- return asked.some(field => answered.has(field));
|
|
|
|
|
|
|
+ const profileEntries = [];
|
|
|
|
|
+ const visit = (value, prefix = '') => {
|
|
|
|
|
+ if (!value || typeof value !== 'object') return;
|
|
|
|
|
+ for (const [key, child] of Object.entries(value)) {
|
|
|
|
|
+ const path = `${prefix}.${key}`.toLowerCase();
|
|
|
|
|
+ if (child !== undefined && child !== null && child !== '' && (typeof child !== 'object' || Array.isArray(child))) profileEntries.push(path);
|
|
|
|
|
+ if (child && typeof child === 'object') visit(child, path);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ visit(input.profile || {});
|
|
|
|
|
+ return asked.some(field => {
|
|
|
|
|
+ const rule = FIELD_RULES[field];
|
|
|
|
|
+ return rule.answer.test(source) || profileEntries.some(key => rule.profileKeys.test(key));
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function hasRepeatedQuestion(reply, messages = [], input = {}) {
|
|
function hasRepeatedQuestion(reply, messages = [], input = {}) {
|
|
@@ -81,9 +138,8 @@ function hasRepeatedQuestion(reply, messages = [], input = {}) {
|
|
|
return current.some(question => previous.some(old => question === old || (question.length >= 8 && (question.includes(old) || old.includes(question)))));
|
|
return current.some(question => previous.some(old => question === old || (question.length >= 8 && (question.includes(old) || old.includes(question)))));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function evidencePayload(input = {}) {
|
|
|
|
|
|
|
+function externalEvidencePayload(input = {}) {
|
|
|
return [
|
|
return [
|
|
|
- JSON.stringify(input.profile || {}),
|
|
|
|
|
...(input.citations || []).map(item => JSON.stringify({
|
|
...(input.citations || []).map(item => JSON.stringify({
|
|
|
content: item?.content,
|
|
content: item?.content,
|
|
|
excerpt: item?.excerpt,
|
|
excerpt: item?.excerpt,
|
|
@@ -96,6 +152,23 @@ function evidencePayload(input = {}) {
|
|
|
].join('\n');
|
|
].join('\n');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function customerEvidencePayload(input = {}) {
|
|
|
|
|
+ return [
|
|
|
|
|
+ JSON.stringify(input.profile || {}),
|
|
|
|
|
+ input.inboundContent,
|
|
|
|
|
+ ...(input.messages || []).filter(message => toRole(message) === 'user').map(messageContent),
|
|
|
|
|
+ ].join('\n');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function claimHasScopedUncertainty(claim = '') {
|
|
|
|
|
+ const clause = String(claim || '').trim();
|
|
|
|
|
+ if (/^(?:当前|目前|暂时|现阶段)?(?:尚未|还没|没有|未|暂无|暂未|不确定|待|需要|需先|要先)/.test(clause)) return true;
|
|
|
|
|
+ if (/^(?:当前|目前|暂时|现阶段).{0,12}(?:没有返回|未返回|未找到|暂无|尚无)/.test(clause)) return true;
|
|
|
|
|
+ if (/(?:档期|安排).{0,10}(?:需要看|需看|需要确认|待确认|需要核对|需核对)/.test(clause)) return true;
|
|
|
|
|
+ if (/(?:价格|费用|报价|优惠|最低|最高|库存|名额|档期|预约|安排|政策|资格|资质|合同|产权|权属|贷款|利率|税费|学区|入学|学位|房况|装修|媒体|照片|视频|案例|成交).{0,10}(?:待确认|待核验|需要确认|需要.{0,6}核验|需先核验|以.+为准|不确定)/.test(clause)) return true;
|
|
|
|
|
+ return /(?:取决于|需要根据|需按|要按).{0,18}(?:确认|核验|评估|测算)/.test(clause);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function claimNgrams(claim = '') {
|
|
function claimNgrams(claim = '') {
|
|
|
const value = normalized(claim)
|
|
const value = normalized(claim)
|
|
|
.replace(/(?:当前|已经|我们|这边|可以|给您|核验|确认|结果|数据)/g, '');
|
|
.replace(/(?:当前|已经|我们|这边|可以|给您|核验|确认|结果|数据)/g, '');
|
|
@@ -109,28 +182,34 @@ function claimNgrams(claim = '') {
|
|
|
|
|
|
|
|
function claimIsSupported(claim, input = {}, plan = {}) {
|
|
function claimIsSupported(claim, input = {}, plan = {}) {
|
|
|
const clause = String(claim || '').trim();
|
|
const clause = String(claim || '').trim();
|
|
|
- if (!FACT_CLAIM_PATTERN.test(clause)) return true;
|
|
|
|
|
- if (UNCERTAINTY_PATTERN.test(clause)) return true;
|
|
|
|
|
|
|
+ if (HUMAN_HANDOFF_ACTION_PATTERN.test(clause)) return true;
|
|
|
|
|
+ const sensitiveFact = SENSITIVE_FACT_PATTERN.test(clause);
|
|
|
|
|
+ const assertiveFact = /(?:已确认|已经确认|已有|已经找到|当前有|确定有|完全匹配|可以直接|不会影响|一定存在|明确支持)/.test(clause);
|
|
|
|
|
+ if (!FACT_CLAIM_PATTERN.test(clause) && !sensitiveFact && !assertiveFact) return true;
|
|
|
|
|
+ if (claimHasScopedUncertainty(clause)) return true;
|
|
|
|
|
|
|
|
- const evidence = normalized(evidencePayload(input));
|
|
|
|
|
- const conversation = normalized([
|
|
|
|
|
- input.inboundContent,
|
|
|
|
|
- ...(input.messages || []).map(messageContent),
|
|
|
|
|
- ].join('\n'));
|
|
|
|
|
|
|
+ const externalEvidence = normalized(externalEvidencePayload(input));
|
|
|
|
|
+ const customerEvidence = normalized(customerEvidencePayload(input));
|
|
|
const numbers = clause.match(/\d+(?:\.\d+)?/g) || [];
|
|
const numbers = clause.match(/\d+(?:\.\d+)?/g) || [];
|
|
|
- const terms = clause.match(/价格|费用|优惠|库存|名额|政策|资格|合同|案例|数据|结果|最低|最高|现货|匹配|预算/g) || [];
|
|
|
|
|
- const evidenceHasNumbers = numbers.every(value => evidence.includes(normalized(value)));
|
|
|
|
|
- const conversationHasNumbers = numbers.length > 0 && numbers.every(value => conversation.includes(normalized(value)));
|
|
|
|
|
- const evidenceHasTerms = terms.length > 0 && terms.every(term => evidence.includes(normalized(term)));
|
|
|
|
|
- const conversationHasTerms = terms.length > 0 && terms.every(term => conversation.includes(normalized(term)));
|
|
|
|
|
- const exactEvidence = claimNgrams(clause).some(gram => evidence.includes(gram));
|
|
|
|
|
- const explicitRestatement = /(?:您|客户)(?:的|提到|说|确认)|已记录/.test(clause)
|
|
|
|
|
- && conversationHasNumbers
|
|
|
|
|
- && conversationHasTerms;
|
|
|
|
|
|
|
+ const terms = clause.match(/价格|费用|投入|总价|金额|首付|月供|优惠|库存|名额|档期|预约|安排|政策|资格|资质|合同|产权|权属|贷款|利率|税费|学区|入学|学位|房况|装修|媒体|照片|视频|案例|数据|结果|最低|最高|现货|匹配|预算/g) || [];
|
|
|
|
|
+ const externalHasNumbers = numbers.every(value => externalEvidence.includes(normalized(value)));
|
|
|
|
|
+ const customerHasNumbers = numbers.every(value => customerEvidence.includes(normalized(value)));
|
|
|
|
|
+ const externalHasTerms = terms.length > 0 && terms.every(term => externalEvidence.includes(normalized(term)));
|
|
|
|
|
+ const customerHasTerms = terms.length > 0 && terms.every(term => customerEvidence.includes(normalized(term)));
|
|
|
|
|
+ const grams = claimNgrams(clause);
|
|
|
|
|
+ const exactExternal = grams.some(gram => externalEvidence.includes(gram));
|
|
|
|
|
+ const exactCustomer = grams.some(gram => customerEvidence.includes(gram));
|
|
|
|
|
+ const explicitCustomerAttribution = /(?:您|客户)(?:的|提到|说|确认|希望|选择)|按您|已记录/.test(clause);
|
|
|
|
|
+ const externalSupported = externalEvidence.length > 0
|
|
|
|
|
+ && externalHasNumbers
|
|
|
|
|
+ && ((terms.length > 0 && externalHasTerms && (numbers.length > 0 || exactExternal)) || exactExternal);
|
|
|
|
|
+ const customerSupported = explicitCustomerAttribution
|
|
|
|
|
+ && customerEvidence.length > 0
|
|
|
|
|
+ && customerHasNumbers
|
|
|
|
|
+ && ((terms.length > 0 && customerHasTerms && (numbers.length > 0 || exactCustomer)) || exactCustomer);
|
|
|
|
|
|
|
|
- if (numbers.length) return (evidenceHasNumbers && (evidenceHasTerms || exactEvidence)) || explicitRestatement;
|
|
|
|
|
- if (!plan.requireEvidence && !/(?:已确认|已经找到|当前有|库存|名额|资格|政策|最低|最高)/.test(clause)) return true;
|
|
|
|
|
- return exactEvidence && evidenceHasTerms;
|
|
|
|
|
|
|
+ if (numbers.length || sensitiveFact || assertiveFact || plan.requireEvidence) return externalSupported || customerSupported;
|
|
|
|
|
+ return true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function factsAreSupported(reply, input = {}, plan = {}) {
|
|
function factsAreSupported(reply, input = {}, plan = {}) {
|
|
@@ -163,9 +242,20 @@ function stageNextStepPresent(reply, plan = {}) {
|
|
|
return NEXT_STEP_PATTERN.test(lastSentence) || countQuestions(lastSentence) === 1;
|
|
return NEXT_STEP_PATTERN.test(lastSentence) || countQuestions(lastSentence) === 1;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function riskHandoffPresent(reply = '', plan = {}) {
|
|
|
|
|
+ if (!plan.requireHumanHandoff) return true;
|
|
|
|
|
+ const content = String(reply || '');
|
|
|
|
|
+ if (!HUMAN_HANDOFF_ACTION_PATTERN.test(content)) return false;
|
|
|
|
|
+ return plan.requiredHumanRole !== 'supervisor' || SUPERVISOR_HANDOFF_PATTERN.test(content);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function humanBoundaryPresent(input = {}, plan = {}) {
|
|
|
|
|
+ return !plan.requireHumanHandoff || input.requiresHuman === true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function commitmentsAreSafe(reply = '') {
|
|
function commitmentsAreSafe(reply = '') {
|
|
|
const claims = String(reply || '').split(/[,,;;。!?!?\n]/).map(item => item.trim()).filter(Boolean);
|
|
const claims = String(reply || '').split(/[,,;;。!?!?\n]/).map(item => item.trim()).filter(Boolean);
|
|
|
- return claims.every(claim => !SENSITIVE_PROMISE_PATTERN.test(claim) || UNCERTAINTY_PATTERN.test(claim));
|
|
|
|
|
|
|
+ return claims.every(claim => !SENSITIVE_PROMISE_PATTERN.test(claim) || claimHasScopedUncertainty(claim));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function makeCheck(id, passed, weight, detail, hard = false) {
|
|
function makeCheck(id, passed, weight, detail, hard = false) {
|
|
@@ -180,13 +270,15 @@ function verifyResponseQuality(input = {}) {
|
|
|
const shouldBeEmpty = !plan.shouldReply;
|
|
const shouldBeEmpty = !plan.shouldReply;
|
|
|
const checks = [
|
|
const checks = [
|
|
|
makeCheck('direct_answer', directAnswerPresent(reply, plan), 12, '首句直接回应当前问题', plan.requireDirectAnswer),
|
|
makeCheck('direct_answer', directAnswerPresent(reply, plan), 12, '首句直接回应当前问题', plan.requireDirectAnswer),
|
|
|
- makeCheck('fact_evidence', factsAreSupported(reply, input, plan), 14, '事实有会话、画像、规则或工具依据;未知项明确待确认', plan.requireEvidence),
|
|
|
|
|
|
|
+ makeCheck('fact_evidence', factsAreSupported(reply, input, plan), 14, '事实有可定位的客户归因、引用或工具依据;未知项在同一断言内明确待确认', true),
|
|
|
makeCheck('continuity', continuityPreserved(reply, input.messages || []), 12, '没有复读上一轮完整回复', true),
|
|
makeCheck('continuity', continuityPreserved(reply, input.messages || []), 12, '没有复读上一轮完整回复', true),
|
|
|
makeCheck('question_count', questionCount <= plan.maxQuestions, 12, `问号数 ${questionCount}/${plan.maxQuestions}`, true),
|
|
makeCheck('question_count', questionCount <= plan.maxQuestions, 12, `问号数 ${questionCount}/${plan.maxQuestions}`, true),
|
|
|
makeCheck('length', shouldBeEmpty ? reply.length === 0 : reply.length > 0 && reply.length <= plan.maxChars, 8, `正文长度 ${reply.length}/${plan.maxChars}`, true),
|
|
makeCheck('length', shouldBeEmpty ? reply.length === 0 : reply.length > 0 && reply.length <= plan.maxChars, 8, `正文长度 ${reply.length}/${plan.maxChars}`, true),
|
|
|
makeCheck('repeated_greeting', !(priorAssistantExists && GREETING_PATTERN.test(reply)), 8, '多轮对话不重复问候', false),
|
|
makeCheck('repeated_greeting', !(priorAssistantExists && GREETING_PATTERN.test(reply)), 8, '多轮对话不重复问候', false),
|
|
|
makeCheck('repeated_question', !hasRepeatedQuestion(reply, input.messages || [], input), 10, '没有重复追问已问过或已回答的信息', true),
|
|
makeCheck('repeated_question', !hasRepeatedQuestion(reply, input.messages || [], input), 10, '没有重复追问已问过或已回答的信息', true),
|
|
|
makeCheck('stage_next_step', stageNextStepPresent(reply, plan), 12, '末句包含阶段适配的下一步', plan.requireNextStep),
|
|
makeCheck('stage_next_step', stageNextStepPresent(reply, plan), 12, '末句包含阶段适配的下一步', plan.requireNextStep),
|
|
|
|
|
+ makeCheck('risk_handoff', riskHandoffPresent(reply, plan), 12, '风险或高意向场景包含已执行的人工接管动作', plan.requireHumanHandoff),
|
|
|
|
|
+ makeCheck('human_boundary', humanBoundaryPresent(input, plan), 8, '风险或高意向场景设置 requiresHuman=true', plan.requireHumanHandoff),
|
|
|
makeCheck('sensitive_commitment', commitmentsAreSafe(reply), 8, '没有未经证实的保证或绝对承诺', true),
|
|
makeCheck('sensitive_commitment', commitmentsAreSafe(reply), 8, '没有未经证实的保证或绝对承诺', true),
|
|
|
makeCheck('internal_leakage', !INTERNAL_LEAK_PATTERN.test(reply), 4, '没有外发内部评分或运行字段', true),
|
|
makeCheck('internal_leakage', !INTERNAL_LEAK_PATTERN.test(reply), 4, '没有外发内部评分或运行字段', true),
|
|
|
];
|
|
];
|
|
@@ -196,12 +288,16 @@ function verifyResponseQuality(input = {}) {
|
|
|
const failed = checks.filter(check => !check.passed);
|
|
const failed = checks.filter(check => !check.passed);
|
|
|
const passed = failed.every(check => !check.hard) && score >= Number(input.passScore || 82);
|
|
const passed = failed.every(check => !check.hard) && score >= Number(input.passScore || 82);
|
|
|
return {
|
|
return {
|
|
|
- version: '1.0',
|
|
|
|
|
|
|
+ version: '1.1',
|
|
|
passed,
|
|
passed,
|
|
|
score,
|
|
score,
|
|
|
checks,
|
|
checks,
|
|
|
failedCheckIds: failed.map(check => check.id),
|
|
failedCheckIds: failed.map(check => check.id),
|
|
|
- fallbackReason: passed ? '' : failed.map(check => check.id).join(','),
|
|
|
|
|
|
|
+ hardFailureIds: failed.filter(check => check.hard).map(check => check.id),
|
|
|
|
|
+ fallbackReason: qualityFallbackReason({
|
|
|
|
|
+ qualityPassed: passed,
|
|
|
|
|
+ requiresHuman: Boolean(input.requiresHuman && plan.requireHumanHandoff),
|
|
|
|
|
+ }),
|
|
|
plan,
|
|
plan,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
@@ -209,7 +305,7 @@ function verifyResponseQuality(input = {}) {
|
|
|
function buildRewriteInstruction(assessment = {}) {
|
|
function buildRewriteInstruction(assessment = {}) {
|
|
|
const failed = (assessment.checks || []).filter(check => !check.passed).map(check => `${check.id}: ${check.detail}`);
|
|
const failed = (assessment.checks || []).filter(check => !check.passed).map(check => `${check.id}: ${check.detail}`);
|
|
|
return [
|
|
return [
|
|
|
- '上一版 reply 未通过发送前质量校验。只修订 reply,并保持已验证事实、profileUpdates、tasks 和 alerts 不变。',
|
|
|
|
|
|
|
+ `上一版 reply 未通过发送前质量校验。修订 reply,并保持已验证事实、profileUpdates、tasks 和 alerts 不变。${assessment.plan?.requireHumanHandoff ? '该场景同时把 requiresHuman 设为 true。' : ''}`,
|
|
|
plannerInstruction(assessment.plan || {}),
|
|
plannerInstruction(assessment.plan || {}),
|
|
|
`需要修复:${failed.join(';') || '按回复契约精简并提高直接性'}`,
|
|
`需要修复:${failed.join(';') || '按回复契约精简并提高直接性'}`,
|
|
|
'不要输出质量分数、检查项名称或修订说明;仍只返回既定 JSON 结构。',
|
|
'不要输出质量分数、检查项名称或修订说明;仍只返回既定 JSON 结构。',
|