fix(voice-kb): sync assistant profile and stabilize reply flow

This commit is contained in:
User
2026-03-23 13:58:41 +08:00
parent 93b8135d51
commit 57a03677a9
5 changed files with 867 additions and 141 deletions

View File

@@ -64,9 +64,10 @@ function estimateSpeechDurationMs(text) {
function normalizeKnowledgeAlias(text) {
return String(text || '')
.replace(/一成[,、。!?\s]+系统/g, '一成系统')
.replace(/X{2}系统/gi, '一成系统')
.replace(/一城系统|逸城系统|一程系统|易成系统|一诚系统|亦成系统|艺成系统|溢成系统|义成系统|毅成系统|怡成系统|以成系统|已成系统|亿成系统|忆成系统|益成系统|益生系统|易诚系统|义诚系统|忆诚系统|以诚系统|一声系统|亿生系统|易乘系统/g, '一成系统')
.replace(/(?<![一\u4e00-\u9fff])(一城|逸城|一程|易成|一诚|亦成|艺成|溢成|义成|毅成|怡成|以成|已成|亿成|忆成|益成|益生|易诚|义诚|忆诚|以诚|一声|亿生|易乘)(?=系统)/g, '一成')
.replace(/[\u4e00-\u9fff]{1,3}(?:成|城|程|诚|乘|声|生)[,、\s]*系统/g, '一成系统')
.replace(/(?:一城|逸城|一程|易成|一诚|亦成|艺成|溢成|义成|毅成|怡成|以成|已成|亿成|忆成|益成|益生|易诚|义诚|忆诚|以诚|一声|亿生|易乘)系统/g, '一成系统')
.replace(/大窝|大握|大我|大卧/g, '大沃')
.replace(/盛咖学院|圣咖学愿|盛咖学院|圣咖学院|盛卡学愿/g, '盛咖学愿')
.replace(/AI众享|Ai众享|爱众享|艾众享|哎众享/gi, 'Ai众享')
@@ -79,12 +80,62 @@ function hasKnowledgeKeyword(text) {
}
function isKnowledgeFollowUp(text) {
const normalized = String(text || '').trim().replace(/[,。!??~\s]+$/g, '').replace(/^(那你|那再|那|你再|再来|再|麻烦你|帮我)[,、\s]*/g, '');
const normalized = String(text || '').trim().replace(/[,。!??~\s]+$/g, '').replace(/^(那你|那再|那(?!个|种|款|些)|你再|再来|再|麻烦你|帮我)[,、\s]*/g, '');
if (!normalized) return false;
if (/^(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人)$/.test(normalized)) {
if (/^(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人|规格是什么|什么规格|包装是什么|什么包装|剂型是什么|什么剂型|形态是什么|什么形态|一天几次|每天几次|每日几次|一天吃几次|每天吃几次|一天服用几次|每日服用几次)$/.test(normalized)) {
return true;
}
return /^(这个|那个|它|该系统|这个系统|那个系统|这个功能|那个功能|这个产品|那个产品|这个公司|那家公司|这个政策|那个政策|这个培训|那个培训)(的)?(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人)$/.test(normalized);
// ========== 质疑/纠正/怀疑/复查类话术9大类全覆盖 ==========
// 当用户对AI回答产生异议时视为知识库追问结合上下文重新查询KB
// 1. 直接否定:"不是的"、"不是不是"、"才不是"、"没有啊"、"哪有"
if (/(不是的|不是啊|不是不是|才不是|没有啊|没有吧|哪有|哪里有|不是这么回事|不是这么说|不是这个意思)/.test(normalized)) {
return true;
}
// 2. 指出错误:"搞错了"、"搞混了"、"说反了"、"记岔了"、"张冠李戴"
if (/(搞错|说错|弄错|记错|讲错|答错|错了|搞混|搞反|记岔|说反|弄反|说混|记反|张冠李戴|答非所问)/.test(normalized)) {
return true;
}
// 3. 说AI不对"你说的不对"、"不对不对"、"说的不准"、"回答有误"
if (/(不对|不准确|不正确|有误|有问题|说的不对|讲的不对|说得不准|说得不对|回答的不对|回答得不对|回答有误|说的有问题|不太对|不太准|不太对劲)/.test(normalized)) {
return true;
}
// 4. 与自己认知矛盾:"跟我了解的不一样"、"我记得不是这样"、"前后矛盾"
if (/(不一样|不一致|我听说|我记得|我知道不是|我了解的|跟我说的|跟之前|前后矛盾|自相矛盾|前后不一|你刚才不是说|你前面说|你之前说|别人说的|别人告诉我)/.test(normalized)) {
return true;
}
// 5. 怀疑/不信:"我不信"、"骗人的吧"、"吹牛"、"太夸张"、"离谱"、"扯淡"
if (/(不信|难以置信|不太相信|骗人|忽悠|吹牛|吹的|太夸张|夸张了|不靠谱|扯淡|瞎扯|离谱|太离谱|有依据吗|有证据吗|有根据吗|可信吗|可靠吗|鬼才信|才怪)/.test(normalized)) {
return true;
}
// 6. 要求复查/重新确认:"你再查查"、"再确认一下"、"重新回答"、"核实一下"
if (/(再查|再看看|再确认|再核实|重新查|重新回答|重新说|核实一下|查清楚|搞清楚|再问一下|再问问|帮我确认|帮我核实|帮我再|你查一下|你看一下|你确认|确认一下|看看吧|查一下)/.test(normalized)) {
return true;
}
// 7. 委婉质疑:"好像不是这样吧"、"我觉得不太对"、"恐怕不是"、"感觉不对"
if (/(好像不是|好像不太对|好像不对|我觉得不|我觉得有问题|恐怕不是|似乎不对|感觉不对|感觉不太对|我怎么记得|我印象中|印象中不是)/.test(normalized)) {
return true;
}
// 8. 质问来源/权威:"谁说的"、"你从哪知道的"、"有什么根据"
if (/(谁说的|谁告诉你|你从哪|你怎么知道|有什么根据|有什么依据|哪里说的|什么地方说|你确定|确定吗|真的吗|当真|真的假的)/.test(normalized)) {
return true;
}
// 9. 不可能/反问/嘲讽:"怎么可能"、"不可能"、"不会吧"、"开玩笑"、"别逗了"
if (/(怎么可能|不可能|不会吧|不是吧|开玩笑|别逗了|少来|得了吧|算了吧|胡说|瞎说|乱说|别瞎说|别胡说|别乱说)/.test(normalized)) {
return true;
}
// 10. 产品形态/剂型纠正:"粉末来的"、"是胶囊"、"冲着喝的"、"直接吞的"
if (/(粉末|粉剂|粉状|冲剂|冲泡|片剂|药片|胶囊|软胶囊|颗粒|液体|口服液|喷雾剂|乳霜|乳液|凝胶|膏体|膏状|冲着喝|泡着喝|直接吞|是喝的|是吃的|是固体|是液体)/.test(normalized)) {
return true;
}
// 11. 强调/纠正句式:"到底是"、"应该是"、"明明是"、"怎么变成...了"
if (/(到底是|究竟是|应该是|明明是|本来是|实际上是|事实上|其实是|怎么变成|什么时候变|不应该是)/.test(normalized)) {
return true;
}
const subjectPattern = '这个|那个|它|它的|他|他的|该|这款|那款|该系统|这个系统|那个系统|这个功能|那个功能|这个产品|那个产品|这个公司|那家公司|这个政策|那个政策|这个培训|那个培训';
const actionPattern = '详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人|规格是什么|什么规格|包装是什么|什么包装|剂型是什么|什么剂型|形态是什么|什么形态|一天几次|每天几次|每日几次|一天吃几次|每天吃几次|一天服用几次|每日服用几次';
const subjectActionRegex = new RegExp('^(' + subjectPattern + ')(的)?(' + actionPattern + ')$');
return subjectActionRegex.test(normalized);
}
function shouldForceKnowledgeRoute(userText, context = []) {
@@ -208,7 +259,46 @@ function extractToolResultText(toolName, toolResult) {
}
async function resolveReply(sessionId, session, text) {
const recentMessages = await db.getRecentMessages(sessionId, 20).catch(() => []);
const _resolveStart = Date.now();
const originalText = text.trim();
// 快速路径知识库候选先尝试无context的热答案/缓存命中跳过DB查询省50-200ms
if (shouldForceKnowledgeRoute(originalText)) {
const fastResult = await ToolExecutor.execute('search_knowledge', { query: originalText, response_mode: 'answer', session_id: sessionId, _session: session }, []);
if (fastResult && fastResult.hit) {
const replyText = extractToolResultText('search_knowledge', fastResult);
const ragItems = fastResult.hit && Array.isArray(fastResult.results)
? fastResult.results.filter(i => i && i.content).map(i => ({ title: i.title || '知识库结果', content: i.content }))
: [];
console.log(`[resolveReply] fast-path hit in ${Date.now() - _resolveStart}ms session=${sessionId} source=${fastResult.hot_answer ? 'hot_answer' : (fastResult.cache_hit ? 'cache' : 'direct')}`);
if (ragItems.length > 0) {
const cleanedText = normalizeTextForSpeech(replyText).replace(/^(根据知识库信息[,:\s]*|根据.*?[,]\s*)/i, '');
session.handoffSummaryUsed = true;
return {
delivery: 'external_rag',
speechText: '',
ragItems: [{ title: '知识库结果', content: cleanedText || replyText }],
source: 'voice_tool',
toolName: 'search_knowledge',
routeDecision: { route: 'search_knowledge', args: { query: originalText } },
responseMeta: {
route: 'search_knowledge',
original_text: originalText,
tool_name: 'search_knowledge',
source: fastResult.source,
hit: fastResult.hit,
reason: fastResult.reason,
latency_ms: fastResult.latency_ms,
},
};
}
}
}
const _dbStart = Date.now();
const recentMessages = await db.getRecentMessages(sessionId, 10).catch(() => []);
const _dbMs = Date.now() - _dbStart;
if (_dbMs > 50) console.log(`[resolveReply] DB getRecentMessages took ${_dbMs}ms session=${sessionId}`);
const scopedMessages = session?.handoffSummaryUsed
? recentMessages.filter((item) => !/^chat_/i.test(String(item?.source || '')))
: recentMessages;
@@ -216,10 +306,9 @@ async function resolveReply(sessionId, session, text) {
.filter((item) => item && (item.role === 'user' || item.role === 'assistant'))
.map((item) => ({ role: item.role, content: item.content }));
const context = withHandoffSummary(session, baseContext);
const originalText = text.trim();
let routeDecision = getRuleBasedDirectRouteDecision(text.trim());
if (routeDecision.route === 'chat' && shouldForceKnowledgeRoute(text.trim(), context)) {
routeDecision = { route: 'search_knowledge', args: { query: text.trim() } };
let routeDecision = getRuleBasedDirectRouteDecision(originalText);
if (routeDecision.route === 'chat' && shouldForceKnowledgeRoute(originalText, context)) {
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
}
let replyText = '';
let source = 'voice_bot';
@@ -243,14 +332,17 @@ async function resolveReply(sessionId, session, text) {
toolName = routeDecision.route;
source = 'voice_tool';
const toolArgs = toolName === 'search_knowledge'
? { ...(routeDecision.args || {}), response_mode: 'answer', session_id: sessionId }
? { ...(routeDecision.args || {}), response_mode: 'answer', session_id: sessionId, _session: session }
: routeDecision.args;
const metaToolArgs = toolArgs && typeof toolArgs === 'object'
? Object.fromEntries(Object.entries(toolArgs).filter(([key]) => key !== '_session'))
: toolArgs;
const toolResult = await ToolExecutor.execute(routeDecision.route, toolArgs, context);
replyText = extractToolResultText(toolName, toolResult);
responseMeta = {
...responseMeta,
tool_name: toolName,
tool_args: toolArgs || {},
tool_args: metaToolArgs || {},
source: toolResult?.source || null,
original_query: toolResult?.original_query || routeDecision.args?.query || originalText,
rewritten_query: toolResult?.rewritten_query || null,
@@ -316,6 +408,21 @@ async function resolveReply(sessionId, session, text) {
responseMeta: { ...responseMeta, hit: true, reason: 'brand_protection' },
};
}
// KB保护窗口内的问题no-hit时走诚实兜底而非让S2S自由编造产品信息
// 防止用户质疑/纠正时S2S瞎说如"粉末来的呀你搞错了" → S2S编造"关节修复粉"
if (session._lastKbHitAt && (Date.now() - session._lastKbHitAt < 60000)) {
const honestReply = '这个问题我暂时不太确定具体细节,建议你咨询一下你的推荐人,或者换个更具体的问法再问我。';
console.log(`[resolveReply] KB no-hit in protection window, honest fallback session=${sessionId}`);
return {
delivery: 'external_rag',
speechText: '',
ragItems: [{ title: '知识库未命中', content: honestReply }],
source: 'voice_tool',
toolName: 'search_knowledge',
routeDecision,
responseMeta: { ...responseMeta, hit: false, reason: 'honest_fallback' },
};
}
return {
delivery: 'upstream_chat',
speechText: '',