Files
bigwo/test2/server/services/realtimeDialogRouting.js

481 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const ToolExecutor = require('./toolExecutor');
const db = require('../db');
const redisClient = require('./redisClient');
const { hasKnowledgeRouteKeyword } = require('./knowledgeKeywords');
function normalizeTextForSpeech(text) {
return (text || '')
.replace(/^#{1,6}\s*/gm, '')
.replace(/\*\*([^*]*)\*\*/g, '$1')
.replace(/__([^_]*)__/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/_([^_]+)_/g, '$1')
.replace(/~~([^~]*)~~/g, '$1')
.replace(/`{1,3}[^`]*`{1,3}/g, '')
.replace(/^[-*]{3,}\s*$/gm, '')
.replace(/^>\s*/gm, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/^[\s]*[-*+]\s+/gm, ' ')
.replace(/^[\s]*\d+[.)]\s+/gm, ' ')
.replace(/---\s*来源[:]/g, '来源:')
.replace(/\r/g, ' ')
.replace(/\n{2,}/g, '。')
.replace(/\n/g, ' ')
.replace(/。{2,}/g, '。')
.replace(/([])\1+/g, '$1')
.replace(/([。!?;,])\s*([。!?;,])/g, '$2')
.replace(/\s+/g, ' ')
.trim();
}
function splitTextForSpeech(text, maxLen = 180) {
const content = normalizeTextForSpeech(text);
if (!content) return [];
if (content.length <= maxLen) return [content];
const chunks = [];
let remaining = content;
while (remaining.length > maxLen) {
const currentMaxLen = chunks.length === 0 ? Math.min(90, maxLen) : maxLen;
let splitIndex = Math.max(
remaining.lastIndexOf('。', currentMaxLen),
remaining.lastIndexOf('', currentMaxLen),
remaining.lastIndexOf('', currentMaxLen),
remaining.lastIndexOf('', currentMaxLen),
remaining.lastIndexOf('', currentMaxLen),
remaining.lastIndexOf(',', currentMaxLen)
);
if (splitIndex < Math.floor(currentMaxLen / 2)) {
splitIndex = currentMaxLen;
} else {
splitIndex += 1;
}
chunks.push(remaining.slice(0, splitIndex).trim());
remaining = remaining.slice(splitIndex).trim();
}
if (remaining) chunks.push(remaining);
return chunks.filter(Boolean);
}
function estimateSpeechDurationMs(text) {
const plainText = normalizeTextForSpeech(text).replace(/\s+/g, '');
const length = plainText.length;
return Math.max(4000, Math.min(60000, length * 180));
}
function normalizeKnowledgeAlias(text) {
return String(text || '')
.replace(/一成[,、。!?\s]+系统/g, '一成系统')
.replace(/X{2}系统/gi, '一成系统')
.replace(/[\u4e00-\u9fff]{1,3}(?:成|城|程|诚|乘|声|生)[,、\s]*系统/g, '一成系统')
.replace(/(?:一城|逸城|一程|易成|一诚|亦成|艺成|溢成|义成|毅成|怡成|以成|已成|亿成|忆成|益成|益生|易诚|义诚|忆诚|以诚|一声|亿生|易乘)系统/g, '一成系统')
.replace(/大窝|大握|大我|大卧/g, '大沃')
.replace(/盛咖学院|圣咖学愿|盛咖学院|圣咖学院|盛卡学愿/g, '盛咖学愿')
.replace(/AI众享|Ai众享|爱众享|艾众享|哎众享/gi, 'Ai众享')
.replace(/暖炉原理/g, '火炉原理');
}
function hasKnowledgeKeyword(text) {
const normalized = normalizeKnowledgeAlias(text).replace(/\s+/g, '');
return hasKnowledgeRouteKeyword(normalized);
}
function isKnowledgeFollowUp(text) {
const normalized = String(text || '').trim().replace(/[,。!??~\s]+$/g, '').replace(/^(那你|那再|那(?!个|种|款|些)|你再|再来|再|麻烦你|帮我)[,、\s]*/g, '');
if (!normalized) return false;
if (/^(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人|规格是什么|什么规格|包装是什么|什么包装|剂型是什么|什么剂型|形态是什么|什么形态|一天几次|每天几次|每日几次|一天吃几次|每天吃几次|一天服用几次|每日服用几次)$/.test(normalized)) {
return true;
}
// ========== 质疑/纠正/怀疑/复查类话术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);
}
// KB-First: 纯闲聊/告别白名单匹配则跳过KB直接交给S2S
function isPureChitchat(text) {
const t = (text || '').trim();
if (!t) return true;
return /^(喂|你好|您好|嗨|哈喽|hello|hi|在吗|在不在|早上好|中午好|下午好|晚上好|早安|晚安|谢谢|谢谢你|谢谢啦|多谢|感谢|再见|拜拜|拜|好的|嗯|哦|行|对|是的|没有了|没事了|不用了|可以了|好的谢谢|没问题|知道了|明白了|了解了|好嘞|好吧|行吧|ok|okay)[,。!??~\s]*[啊呀吧呢哦嗯嘛哈的了]*[!。??~]*$/i.test(t);
}
function shouldForceKnowledgeRoute(userText, context = []) {
const text = (userText || '').trim();
if (!text) return false;
if (hasKnowledgeKeyword(text)) return true;
if (!isKnowledgeFollowUp(text)) return false;
const recentContextText = (Array.isArray(context) ? context : [])
.slice(-6)
.map((item) => String(item?.content || '').trim())
.join('\n');
return hasKnowledgeKeyword(recentContextText);
}
function withHandoffSummary(session, context) {
const summary = String(session?.handoffSummary || '').trim();
if (!summary || session?.handoffSummaryUsed) {
return context;
}
return [
{ role: 'assistant', content: `会话交接摘要:${summary}` },
...(Array.isArray(context) ? context : []),
];
}
function getRuleBasedDirectRouteDecision(userText) {
const text = (userText || '').trim();
if (!text) return { route: 'chat', args: {} };
if (/(几点|几号|日期|星期|周几|现在时间|当前时间)/.test(text)) return { route: 'get_current_time', args: {} };
if (/(天气|气温|下雨|晴天|阴天|温度)/.test(text)) {
return { route: 'query_weather', args: { city: text.replace(/.*?(北京|上海|广州|深圳|杭州|成都|重庆|武汉|西安|南京|苏州|天津|长沙|郑州|青岛|宁波|无锡)/, '$1') || '北京' } };
}
if (/(订单|物流|快递|单号)/.test(text)) return { route: 'query_order', args: { order_id: text } };
if (/^[\d\s+\-*/().=%]+$/.test(text) || /(等于多少|帮我算|计算一下|算一下)/.test(text)) {
return { route: 'calculate', args: { expression: text.replace(/(帮我算|计算一下|算一下|等于多少)/g, '').trim() || text } };
}
if (/(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(产品|公司.*产品|产品.*有哪些|有什么产品|产品介绍|产品列表|公司介绍|我们公司|你们公司|你们的产品|我们的产品|都有什么|有些什么)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(培训|新人入门|新人.*起步|团队培训|会议组织|起步三关|成长上总裁|精品会议)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(招商|代理|加盟|招募|事业机会|创业|代理商|招商稿|合作.*加盟|事业.*合作)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(一成系统|Ai众享|AI众享|数字化工作室|盛咖学愿|邀约话术|文化解析|AI赋能|ai赋能|团队发展|一成AI|一成Ai|AI落地|ai落地|转观念)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(地址|电话|联系方式|总部|分公司|公司.*实力|公司.*背景|PM公司|德国PM)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(成分|功效|怎么吃|怎么服用|吃法|用法|服用方法|副作用|好转反应|排毒反应|整应反应|搭配|原料|配方)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(能吃吗|可以吃吗|能喝吗|可以喝吗|能用吗|可以用吗|一起吃|同时吃|混着吃|搭配吃|跟药一起)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(发痒|头晕|便秘|腹泻|拉肚子|恶心|呕吐|长痘|出疹子|红肿|上火|胃痛|抽筋|浮肿|失眠|出汗)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(高血压|糖尿病|胆固醇|心脏病|肾病|肝病|痛风|贫血|甲亢|甲减|胃炎|肠炎|哮喘|湿疹|过敏|癌症|肿瘤|尿酸|结石)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(孕妇|哺乳期|怀孕|孕期|儿童|小孩|老人|老年人|手术后|化疗|放疗|术后|月子)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(多少钱|价格|贵不贵|性价比|划算|值得买|保质期|储存|哪里买|怎么买|多久见效|适合谁|适用人群)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(营养素|营养品|保健品|保健食品|营养补充|细胞营养|NTC|暖炉原理|火炉原理|阿育吠陀)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(科普|误区|认证|检测|检测报告|安全认证|GMP|Halal|临床试验)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(免疫力|抗疲劳|排毒|减肥|瘦身|护肤|护发|脱发|掉发|抗氧化|抗衰老|美容|美白|关节|骨密度|胶原蛋白)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(信用评级|行业排名|获奖|慈善|社会责任|不上市|汽车奖励|退休金|旅行|福利|发展历程|全球布局|各国地址|各国电话|多少个国家)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/(奖金制度|事业机会|创业|副业|兼职|投资多少|门槛|起步费|怎么赚钱|能赚钱吗|值得做吗)/.test(text)) {
return { route: 'search_knowledge', args: { query: text } };
}
if (/^(喂|你好|您好|嗨|哈喽|hello|hi|在吗|在不在|早上好|中午好|下午好|晚上好|早安|晚安|谢谢|感谢|再见|拜拜|嗯|哦|好的|对|是的|没有了|没事了|可以了|行|OK|ok)[,!。??~\s]*[啊呀吧呢哦嗯嘛哈的了]*[!。??~]*$/.test(text)) {
return { route: 'chat', args: {} };
}
if (/^(喂[,\s]*)?(你好|您好)[,!。??\s]*(在吗|请问)?[!。??]*$/.test(text)) {
return { route: 'chat', args: {} };
}
return { route: 'chat', args: {} };
}
function extractToolResultText(toolName, toolResult) {
if (!toolResult) return '';
if (toolName === 'search_knowledge') {
if (toolResult.errorType === 'timeout') {
return '知识库查询超时了,请稍后重试,或换一种更具体的问法再试。';
}
if (toolResult.errorType === 'not_configured') {
return '知识库当前未配置完成,请先检查知识库配置。';
}
if (toolResult.errorType === 'endpoint_not_configured') {
return '知识库已配置但方舟LLM端点未就绪暂时无法检索请稍后再试。';
}
if (toolResult.results && Array.isArray(toolResult.results)) {
return toolResult.results.map((item) => item.content || JSON.stringify(item)).join('\n');
}
if (typeof toolResult === 'string') return toolResult;
if (toolResult.error) return toolResult.error;
}
if (toolName === 'query_weather' && !toolResult.error) return `${toolResult.city}今天${toolResult.weather},气温${toolResult.temp},湿度${toolResult.humidity}${toolResult.wind}${toolResult.tips || ''}`.trim();
if (toolName === 'query_order' && !toolResult.error) return `订单${toolResult.order_id}当前状态是${toolResult.status},预计送达时间${toolResult.estimated_delivery},快递单号${toolResult.tracking_number}`;
if (toolName === 'get_current_time' && !toolResult.error) return `现在是${toolResult.datetime}${toolResult.weekday}`;
if (toolName === 'calculate' && !toolResult.error) return `${toolResult.expression} 的计算结果是 ${toolResult.formatted}`;
if (toolResult.error) return toolResult.error;
return typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
}
async function resolveReply(sessionId, session, text) {
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.cache_hit ? 'cache' : 'direct'} mode=${fastResult.retrieval_mode || 'answer'}`);
if (ragItems.length > 0) {
session.handoffSummaryUsed = true;
// raw 模式ragItems 已包含上下文 + 多个 KB 片段,直接透传
const isRawMode = fastResult.retrieval_mode === 'raw';
const finalRagItems = isRawMode
? ragItems
: [{ title: '知识库结果', content: normalizeTextForSpeech(replyText).replace(/^(根据知识库信息[,:\s]*|根据.*?[,]\s*)/i, '') || replyText }];
return {
delivery: 'external_rag',
speechText: '',
ragItems: finalRagItems,
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,
},
};
}
}
}
// 上下文加载:优先 Redis~5ms降级 MySQL~100ms
const _dbStart = Date.now();
let recentMessages = null;
if (process.env.ENABLE_REDIS_CONTEXT !== 'false') {
const redisHistory = await redisClient.getRecentHistory(sessionId, 5).catch(() => null);
if (redisHistory && redisHistory.length > 0) {
recentMessages = redisHistory;
const _dbMs = Date.now() - _dbStart;
if (_dbMs > 5) console.log(`[resolveReply] Redis getRecentHistory took ${_dbMs}ms session=${sessionId} items=${redisHistory.length}`);
}
}
if (!recentMessages) {
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;
const baseContext = scopedMessages
.filter((item) => item && (item.role === 'user' || item.role === 'assistant'))
.map((item) => ({ role: item.role, content: item.content }));
const context = withHandoffSummary(session, baseContext);
let routeDecision = getRuleBasedDirectRouteDecision(originalText);
// KB-First: 所有非闲聊查询强制先走知识库KB不命中再交给S2S自由回答
if (routeDecision.route === 'chat' && !isPureChitchat(originalText)) {
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
console.log(`[resolveReply] KB-First: forcing KB route for non-chitchat session=${sessionId}`);
}
let replyText = '';
let source = 'voice_bot';
let toolName = null;
let responseMeta = {
route: routeDecision.route,
original_text: originalText,
};
if (routeDecision.route === 'chat') {
session.handoffSummaryUsed = true;
return {
delivery: 'upstream_chat',
speechText: '',
ragItems: [],
source,
toolName,
routeDecision,
responseMeta,
};
} else {
toolName = routeDecision.route;
source = 'voice_tool';
const toolArgs = toolName === 'search_knowledge'
? { ...(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: metaToolArgs || {},
source: toolResult?.source || null,
original_query: toolResult?.original_query || routeDecision.args?.query || originalText,
rewritten_query: toolResult?.rewritten_query || null,
selected_dataset_ids: toolResult?.selected_dataset_ids || null,
selected_kb_routes: toolResult?.selected_kb_routes || null,
hit: typeof toolResult?.hit === 'boolean' ? toolResult.hit : null,
reason: toolResult?.reason || null,
error_type: toolResult?.errorType || null,
latency_ms: toolResult?.latency_ms || null,
};
const ragItems = toolName === 'search_knowledge'
? (toolResult?.hit && Array.isArray(toolResult?.results)
? toolResult.results
.filter((item) => item && item.content)
.map((item) => ({
title: item.title || '知识库结果',
content: item.content,
}))
: [])
: (!toolResult?.error && replyText
? [{ title: `${toolName}结果`, content: replyText }]
: []);
if (ragItems.length > 0) {
session.handoffSummaryUsed = true;
const isRawMode = toolResult?.retrieval_mode === 'raw';
let finalRagItems = ragItems;
if (toolName === 'search_knowledge' && !isRawMode) {
// 旧模式LLM 加工过的文本,清理后合并为单条
const speechText = normalizeTextForSpeech(replyText);
if (speechText) {
const cleanedText = speechText.replace(/^(根据知识库信息[,:\s]*|根据.*?[,]\s*)/i, '');
finalRagItems = [{ title: '知识库结果', content: cleanedText || speechText }];
}
}
// raw 模式ragItems 已包含上下文 + 多个 KB 片段,直接透传给 S2S
return {
delivery: 'external_rag',
speechText: '',
ragItems: finalRagItems,
source,
toolName,
routeDecision,
responseMeta,
};
}
if (toolName === 'search_knowledge' && !toolResult?.hit) {
session.handoffSummaryUsed = true;
// 敏感问题(传销/正规性知识库未命中时不交给S2S自由发挥直接返回安全回复
if (/(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)/.test(originalText)) {
const safeReply = '德国PM是一家1993年成立于德国的合法直销公司获得邓白氏AAA+认证业务覆盖100多个国家和地区。它不是传销是正规的直销企业哦。如果你想了解更多可以问我关于PM公司或产品的详细介绍。';
return {
delivery: 'external_rag',
speechText: '',
ragItems: [{ title: '品牌保护', content: safeReply }],
source: 'voice_tool',
toolName: 'search_knowledge',
routeDecision,
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: '',
ragItems: [],
source: 'voice_bot',
toolName: null,
routeDecision,
responseMeta,
};
}
}
const speechText = normalizeTextForSpeech(replyText);
session.handoffSummaryUsed = true;
if (!speechText) {
return { delivery: 'local_tts', speechText: '', ragItems: [], source, toolName, routeDecision, responseMeta };
}
return { delivery: 'local_tts', speechText, ragItems: [], source, toolName, routeDecision, responseMeta };
}
module.exports = {
getRuleBasedDirectRouteDecision,
normalizeKnowledgeAlias,
normalizeTextForSpeech,
splitTextForSpeech,
estimateSpeechDurationMs,
shouldForceKnowledgeRoute,
isPureChitchat,
resolveReply,
};