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

347 lines
18 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 { 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(/X{2}系统/gi, '一成系统')
.replace(/一城系统|逸城系统|一程系统|易成系统|一诚系统|亦成系统|艺成系统|溢成系统|义成系统|毅成系统|怡成系统|以成系统|已成系统|亿成系统|忆成系统|益成系统|益生系统|易诚系统|义诚系统|忆诚系统|以诚系统|一声系统|亿生系统|易乘系统/g, '一成系统')
.replace(/(?<![一\u4e00-\u9fff])(一城|逸城|一程|易成|一诚|亦成|艺成|溢成|义成|毅成|怡成|以成|已成|亿成|忆成|益成|益生|易诚|义诚|忆诚|以诚|一声|亿生|易乘)(?=系统)/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;
}
return /^(这个|那个|它|该系统|这个系统|那个系统|这个功能|那个功能|这个产品|那个产品|这个公司|那家公司|这个政策|那个政策|这个培训|那个培训)(的)?(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人)$/.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 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', session_id: sessionId }
: 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,
};