const ToolExecutor = require('./toolExecutor'); const arkChatService = require('./arkChatService'); const db = require('../db'); 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)); } async function polishForSpeech(rawText, userQuestion) { const POLISH_TIMEOUT_MS = 3000; try { const messages = [ { role: 'system', content: '你是一个语音播报润色助手。请将下面的知识库回答改写为自然、亲切的口语风格,像朋友聊天一样。要求:1) 保留所有关键信息和数据,不得编造;2) 去掉"根据知识库信息"等机械前缀;3) 适合语音朗读,简洁流畅;4) 控制在120字以内;5) 只输出改写后的文本,不要加引号或解释。', }, { role: 'user', content: `用户问题:${userQuestion}\n\n原始回答:${rawText}`, }, ]; const result = await Promise.race([ arkChatService.chat(messages, [], { useKnowledgeBase: false }), new Promise((_, reject) => setTimeout(() => reject(new Error('polish timeout')), POLISH_TIMEOUT_MS)), ]); const polished = (result?.content || '').trim(); if (polished && polished.length >= 10) { console.log(`[RealtimeRouting] polishForSpeech ok len=${polished.length} original=${rawText.length}`); return polished; } } catch (err) { console.warn('[RealtimeRouting] polishForSpeech failed:', err.message); } return null; } function buildDirectRouteMessages(session, context, userText) { const messages = []; const systemPrompt = [ '你是语音前置路由器,只负责判断当前用户问题应该走哪条链路。', '你必须只输出一个 JSON 对象,不要输出解释、代码块或额外文本。', '允许的 route 只有:chat、search_knowledge、query_weather、query_order、get_current_time、calculate。', '规则如下:', '1. 企业产品、功能、政策、售后、专业说明、品牌官方信息 -> search_knowledge。', '2. 天气 -> query_weather。', '3. 订单状态 -> query_order。', '4. 当前时间、日期、星期 -> get_current_time。', '5. 明确的数学计算 -> calculate。', '6. 闲聊、问候、开放式泛化交流 -> chat。', '输出格式示例:{"route":"chat","args":{},"reply":""}', '如果 route=search_knowledge,args 中必须包含 query。', '如果 route=query_weather,args 中必须包含 city。', '如果 route=query_order,args 中必须包含 order_id。', '如果 route=calculate,args 中必须包含 expression。', `当前助手设定:${session.systemRole || '你是一个友善的智能助手。'} ${session.speakingStyle || '请使用温和、清晰的口吻。'}`, ].join('\n'); messages.push({ role: 'system', content: systemPrompt }); (context || []).slice(-6).forEach((item) => { if (item && item.role && item.content) { messages.push({ role: item.role, content: item.content }); } }); messages.push({ role: 'user', content: userText }); return messages; } function buildDirectChatMessages(session, context, userText) { const messages = []; const systemPrompt = [ session.systemRole || '你是一个友善的智能助手。', session.speakingStyle || '请使用温和、清晰的口吻。', '这是语音对话场景,请直接给出自然、完整、适合朗读的中文回复。', '如果不是基于知识库或工具结果,就不要冒充官方结论。', ].join('\n'); messages.push({ role: 'system', content: systemPrompt }); (context || []).slice(-10).forEach((item) => { if (item && item.role && item.content) { messages.push({ role: item.role, content: item.content }); } }); messages.push({ role: 'user', content: userText }); return messages; } function normalizeKnowledgeAlias(text) { return String(text || '') .replace(/X{2}系统/gi, '一成系统') .replace(/一城系统|逸城系统|一程系统|易成系统|一诚系统|亦成系统|艺成系统|溢成系统|义成系统|毅成系统|怡成系统|以成系统|已成系统|亿成系统|忆成系统|益成系统/g, '一成系统') .replace(/(? 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 parseDirectRouteDecision(content, userText) { const raw = (content || '').trim(); const jsonText = raw.replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim(); const start = jsonText.indexOf('{'); const end = jsonText.lastIndexOf('}'); const candidate = start >= 0 && end > start ? jsonText.slice(start, end + 1) : jsonText; try { const parsed = JSON.parse(candidate); const route = parsed.route; const args = parsed.args && typeof parsed.args === 'object' ? parsed.args : {}; if (route === 'chat') return { route: 'chat', args: {} }; if (route === 'search_knowledge') return { route: 'search_knowledge', args: { query: args.query || userText } }; if (route === 'query_weather' && args.city) return { route: 'query_weather', args: { city: args.city } }; if (route === 'query_order' && args.order_id) return { route: 'query_order', args: { order_id: args.order_id } }; if (route === 'get_current_time') return { route: 'get_current_time', args: {} }; if (route === 'calculate' && args.expression) return { route: 'calculate', args: { expression: args.expression } }; } catch (error) { console.warn('[NativeVoice] route JSON parse failed:', error.message, 'raw=', raw); } return { route: 'search_knowledge', args: { query: userText } }; } 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 (/^(喂|你好|您好|嗨|哈喽|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 recentMessages = await db.getRecentMessages(sessionId, 20).catch(() => []); 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); 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 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' } : routeDecision.args; const toolResult = await ToolExecutor.execute(routeDecision.route, toolArgs, context); replyText = extractToolResultText(toolName, toolResult); responseMeta = { ...responseMeta, tool_name: toolName, tool_args: toolArgs || {}, 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) { let speechText = normalizeTextForSpeech(replyText); session.handoffSummaryUsed = true; if (toolName === 'search_knowledge' && speechText) { const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, ''); return { delivery: 'external_rag', speechText: '', ragItems: [{ title: '知识库结果', content: cleanedText || speechText }], source, toolName, routeDecision, responseMeta, }; } return { delivery: 'external_rag', speechText: '', ragItems, 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' }, }; } 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, };