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, };