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

481 lines
27 KiB
JavaScript
Raw Normal View History

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);
}
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.hot_answer ? 'hot_answer' : (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);
if (routeDecision.route === 'chat' && shouldForceKnowledgeRoute(originalText, context)) {
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
}
// KB保护窗口60秒内有KB命中当前非纯闲聊强制走KB搜索
// 防止追问(如"它需要漱口吗"绕过KB走S2S自由编造
const KB_PROTECTION_WINDOW_MS = 60000;
if (routeDecision.route === 'chat' && session?._lastKbHitAt && (Date.now() - session._lastKbHitAt < KB_PROTECTION_WINDOW_MS)) {
const isPureChitchat = /^(喂|你好|嗨|hi|hello|谢谢|谢谢你|谢谢啦|多谢|感谢|再见|拜拜|拜|好的|嗯|哦|行|没事了|不用了|可以了|好的谢谢|没问题|知道了|明白了|了解了|好嘞|好吧|行吧|ok|okay)[,。!?~\s]*$/i.test(originalText);
if (!isPureChitchat) {
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
console.log(`[resolveReply] KB protection window active, forcing KB route session=${sessionId} lastKbHit=${Math.round((Date.now() - session._lastKbHitAt) / 1000)}s ago`);
}
}
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,
resolveReply,
};