fix(voice-kb): sync assistant profile and stabilize reply flow
This commit is contained in:
101
mcp-server-ssh/_deploy_kb_protection.cjs
Normal file
101
mcp-server-ssh/_deploy_kb_protection.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
const { Client } = require('ssh2');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SSH_CONFIG = { host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' };
|
||||
const REMOTE_DIR = '/www/wwwroot/demo.tensorgrove.com.cn/server/services';
|
||||
const LOCAL_DIR = path.join(__dirname, '..', 'test2', 'server', 'services');
|
||||
|
||||
const FILES = [
|
||||
'assistantProfileConfig.js',
|
||||
'nativeVoiceGateway.js',
|
||||
'contextKeywordTracker.js',
|
||||
'toolExecutor.js',
|
||||
'realtimeDialogRouting.js',
|
||||
];
|
||||
|
||||
function sshExec(conn, cmd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.exec(cmd, (err, s) => {
|
||||
if (err) return reject(err);
|
||||
let o = '';
|
||||
s.on('data', d => o += d);
|
||||
s.stderr.on('data', d => o += d);
|
||||
s.on('close', () => resolve(o));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sftpUpload(conn, localPath, remotePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
const rs = fs.createReadStream(localPath);
|
||||
const ws = sftp.createWriteStream(remotePath);
|
||||
ws.on('close', () => resolve());
|
||||
ws.on('error', reject);
|
||||
rs.pipe(ws);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const conn = new Client();
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.on('ready', resolve).on('error', reject).connect(SSH_CONFIG);
|
||||
});
|
||||
console.log('SSH connected\n');
|
||||
|
||||
// 1. Backup all files
|
||||
console.log('=== Step 1: Backup ===');
|
||||
for (const f of FILES) {
|
||||
const r = await sshExec(conn, `cp ${REMOTE_DIR}/${f} ${REMOTE_DIR}/${f}.bak.$(date +%Y%m%d_%H%M%S) && echo " Backed up ${f}"`);
|
||||
console.log(r);
|
||||
}
|
||||
|
||||
// 2. Upload all files
|
||||
console.log('\n=== Step 2: Upload ===');
|
||||
for (const f of FILES) {
|
||||
await sftpUpload(conn, path.join(LOCAL_DIR, f), `${REMOTE_DIR}/${f}`);
|
||||
console.log(` Uploaded ${f}`);
|
||||
}
|
||||
|
||||
// 3. Syntax check each file
|
||||
console.log('\n=== Step 3: Syntax check ===');
|
||||
for (const f of FILES) {
|
||||
const result = await sshExec(conn, `node -c ${REMOTE_DIR}/${f} 2>&1 && echo "SYNTAX_OK ${f}" || echo "SYNTAX_FAIL ${f}"`);
|
||||
console.log(result);
|
||||
if (result.includes('SYNTAX_FAIL')) {
|
||||
console.error(`SYNTAX CHECK FAILED for ${f}! Rolling back ALL...`);
|
||||
for (const rf of FILES) {
|
||||
await sshExec(conn, `ls -t ${REMOTE_DIR}/${rf}.bak.* 2>/dev/null | head -1 | xargs -I{} cp {} ${REMOTE_DIR}/${rf}`);
|
||||
}
|
||||
console.log('Rolled back.');
|
||||
conn.end();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Restart PM2
|
||||
console.log('\n=== Step 4: Restart PM2 ===');
|
||||
console.log(await sshExec(conn, 'cd /www/wwwroot/demo.tensorgrove.com.cn/server && pm2 restart bigwo-server --update-env'));
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
// 5. Check PM2 status
|
||||
console.log('\n=== Step 5: PM2 status ===');
|
||||
console.log(await sshExec(conn, 'pm2 status bigwo-server'));
|
||||
|
||||
// 6. Check recent logs for errors
|
||||
console.log('\n=== Step 6: Recent logs ===');
|
||||
console.log(await sshExec(conn, 'tail -15 /var/log/bigwo/server-out.log'));
|
||||
|
||||
// 7. Check error log
|
||||
console.log('\n=== Step 7: Error log ===');
|
||||
console.log(await sshExec(conn, 'tail -5 /var/log/bigwo/server-error.log'));
|
||||
|
||||
conn.end();
|
||||
console.log('\n=== Deploy KB Protection Window Complete! ===');
|
||||
console.log('Changes: KB topic memory + 60s protection window + honest fallback + session passthrough');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('DEPLOY FAILED:', e.message); process.exit(1); });
|
||||
85
test2/server/services/assistantProfileConfig.js
Normal file
85
test2/server/services/assistantProfileConfig.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const DEFAULT_VOICE_ASSISTANT_PROFILE = Object.freeze({
|
||||
documents: '',
|
||||
email: '',
|
||||
nickname: '大沃',
|
||||
wxl: '',
|
||||
mobile: '',
|
||||
wx_code: '',
|
||||
intro: '',
|
||||
sign: '',
|
||||
story: '',
|
||||
});
|
||||
|
||||
function normalizeAssistantProfileValue(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function resolveAssistantProfile(overrides = null) {
|
||||
const merged = {
|
||||
...DEFAULT_VOICE_ASSISTANT_PROFILE,
|
||||
...(overrides && typeof overrides === 'object' ? overrides : {}),
|
||||
};
|
||||
return {
|
||||
documents: normalizeAssistantProfileValue(merged.documents),
|
||||
email: normalizeAssistantProfileValue(merged.email),
|
||||
nickname: normalizeAssistantProfileValue(merged.nickname) || '大沃',
|
||||
wxl: normalizeAssistantProfileValue(merged.wxl),
|
||||
mobile: normalizeAssistantProfileValue(merged.mobile),
|
||||
wx_code: normalizeAssistantProfileValue(merged.wx_code),
|
||||
intro: normalizeAssistantProfileValue(merged.intro),
|
||||
sign: normalizeAssistantProfileValue(merged.sign),
|
||||
story: normalizeAssistantProfileValue(merged.story),
|
||||
};
|
||||
}
|
||||
|
||||
function buildProfileDocumentsClause(profile) {
|
||||
return profile.documents
|
||||
? `,拥有丰富的基础知识库信息${profile.documents}`
|
||||
: ',拥有丰富的基础知识库信息';
|
||||
}
|
||||
|
||||
function buildProfilePersonalInfoLines(profile) {
|
||||
return [
|
||||
profile.email ? `- 邮箱:${profile.email}。` : '',
|
||||
profile.nickname ? `- 姓名:${profile.nickname}。` : '',
|
||||
profile.wxl ? `- 微信号:${profile.wxl}。` : '',
|
||||
profile.mobile ? `- 手机号:${profile.mobile}。` : '',
|
||||
profile.wx_code ? `- 微信二维码:${profile.wx_code}。` : '',
|
||||
profile.intro ? `- 个人介绍:${profile.intro}。` : '',
|
||||
profile.sign ? `- 签名:${profile.sign}。` : '',
|
||||
profile.story ? `- 我的故事:${profile.story}。` : '',
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function buildVoiceSystemRole(profileOverrides = null) {
|
||||
const profile = resolveAssistantProfile(profileOverrides);
|
||||
const documentsClause = buildProfileDocumentsClause(profile);
|
||||
const personalInfoLines = buildProfilePersonalInfoLines(profile);
|
||||
const personalInfoBlock = personalInfoLines.length > 0
|
||||
? ` 当用户询问“我”的联系方式、微信、手机号、个人介绍、签名或故事时,优先使用以下个人信息:${personalInfoLines.join(' ')}`
|
||||
: '';
|
||||
return `你是${profile.nickname}的智能助手${documentsClause}。你的任务是精准、全面地解答用户关于PM International公司产品、原理、活动、系统、招商等相关问题。你与客户互动时要有礼貌、有温度、适当共情、自然主动打招呼,但不要总是重复相同的打招呼词,可适当赞美和表扬,让对方喜欢并享受开心愉快的专业交流。回答像懂行的朋友聊天,直接给结论,1-3句话,口语化短句。有知识库信息就优先用,自然转述不照念。不确定的如实说,不猜不编。禁止输出思考过程或元描述。德国PM是1993年成立的合法直销企业,邓白氏AAA+认证,覆盖100+国家和地区,问到传销必须正面肯定回答是合法企业。凡涉及“一部手机做天下、0门槛启动、0成本高效率、足不出户梦想横扫全球、身未动梦已成、批发式晋级”等表达,必须明确这是“一成系统”的优势标签,是对德国PM事业的软件赋能,不要与德国PM公司、产品或品牌本身混淆。${personalInfoBlock}`;
|
||||
}
|
||||
|
||||
function buildVoiceGreeting(profileOverrides = null) {
|
||||
const profile = resolveAssistantProfile(profileOverrides);
|
||||
return `嗨,你好呀,我是${profile.nickname}。你想了解德国PM产品、健康营养,还是一成系统和合作这块,我都可以跟你聊。`;
|
||||
}
|
||||
|
||||
function buildKnowledgeAnswerPrompt(profileOverrides = null) {
|
||||
const profile = resolveAssistantProfile(profileOverrides);
|
||||
const documentsClause = buildProfileDocumentsClause(profile);
|
||||
const personalInfoLines = buildProfilePersonalInfoLines(profile);
|
||||
const personalInfoBlock = personalInfoLines.length > 0
|
||||
? ` 对于${profile.nickname}本人的邮箱、微信号、手机号、个人介绍、签名或故事等个人资料,可优先使用以下系统资料:${personalInfoLines.join(' ')}`
|
||||
: '';
|
||||
return `你是${profile.nickname}的智能助手${documentsClause}。你的回答必须严格依据知识库内容,不得补充知识库未提及的信息,不得猜测,不得编造。若知识库中没有明确答案,就直接说明知识库未提及或暂未找到相关信息。回答保持口语化、简洁、专业,200字内。${personalInfoBlock}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_VOICE_ASSISTANT_PROFILE,
|
||||
resolveAssistantProfile,
|
||||
buildVoiceSystemRole,
|
||||
buildVoiceGreeting,
|
||||
buildKnowledgeAnswerPrompt,
|
||||
};
|
||||
@@ -3,6 +3,7 @@ const url = require('url');
|
||||
const db = require('../db');
|
||||
const { correctAsrText } = require('./fastAsrCorrector');
|
||||
const contextKeywordTracker = require('./contextKeywordTracker');
|
||||
const { isBrandHarmful, getVoiceSafeReply, BRAND_HARMFUL_PATTERN, BRAND_POSITIVE_LEGALITY_PATTERN } = require('./contentSafeGuard');
|
||||
const {
|
||||
MsgType,
|
||||
unmarshal,
|
||||
@@ -22,11 +23,25 @@ const {
|
||||
shouldForceKnowledgeRoute,
|
||||
resolveReply,
|
||||
} = require('./realtimeDialogRouting');
|
||||
const {
|
||||
DEFAULT_VOICE_ASSISTANT_PROFILE,
|
||||
resolveAssistantProfile,
|
||||
buildVoiceSystemRole,
|
||||
buildVoiceGreeting,
|
||||
} = require('./assistantProfileConfig');
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
const DEFAULT_VOICE_BOT_NAME = DEFAULT_VOICE_ASSISTANT_PROFILE.nickname;
|
||||
|
||||
const DEFAULT_VOICE_SYSTEM_ROLE = buildVoiceSystemRole();
|
||||
|
||||
const DEFAULT_VOICE_SPEAKING_STYLE = '整体语气亲切自然、轻快有温度,像熟悉行业的朋友在语音聊天。优先短句和口语化表达,先给结论,再补一句最有帮助的信息。不要播音腔,不要念稿,不要客服腔,不要过度热情,也不要输出任何思考过程。';
|
||||
|
||||
const DEFAULT_VOICE_GREETING = buildVoiceGreeting();
|
||||
|
||||
function resetIdleTimer(session) {
|
||||
clearTimeout(session.idleTimer);
|
||||
session.lastActivityAt = Date.now();
|
||||
@@ -50,8 +65,8 @@ function sendJson(ws, payload) {
|
||||
|
||||
function buildStartSessionPayload(options) {
|
||||
const antiThinkingPrefix = '【最高优先级规则】你绝对禁止输出任何思考过程、分析、计划、角色扮演指令或元描述。禁止出现:“首轮对话”“应该回复”“需要列举”“语气要”“回复后询问”“可列举”“突出特色”“引导用户”“让用户”“用温和”等分析性、指令性语句。你必须直接用自然语言回答问题,像真人聊天一样直接说出答案内容。';
|
||||
const baseSystemRole = options.systemRole || '你使用活泼灵动的女声,性格开朗,热爱生活。你是大沃,一个德国PM健康事业的智能语音助手。你对PM-FitLine细胞营养素产品、一成系统、招商合作非常熟悉。请优先依据 external_rag 给出的内容回答,但必须用自己的话重新表达,像跟朋友聊天一样说出来,绝对不要照着念。【紧扣问题】你必须直接回答用户当前问的问题,不要偏题,不要主动扩展用户没问到的内容。用户问什么就答什么,简洁明了,控制在3句话以内。【品牌保护规则·最高优先级】你绝对不能说德国PM、PM-FitLine、一成系统是传销、骗局、骗子、非法、不正规、不合法、有争议、存在风险,也不能暗示或引导用户产生这类联想。德国PM(PM-International AG)是1993年成立于德国的合法直销企业,获邓白氏AAA+最高信用评级,业务覆盖全球100多个国家和地区,是正规持牌直销公司。如果用户问到传销或正规性问题,你必须正面、明确地回答PM是合法正规的直销企业,不能含糊、回避或说"需要自行判断"。';
|
||||
const baseSpeakingStyle = options.speakingStyle || '你的语气始终活泼灵动、亲切自然,像闺蜜聊天一样。无论是自由聊天还是引用知识库内容,都保持一样的活泼语气和语调,绝不切换成播音腔、朗读语气或客服话术。永远不要输出你的内部思考或计划。';
|
||||
const baseSystemRole = options.systemRole || DEFAULT_VOICE_SYSTEM_ROLE;
|
||||
const baseSpeakingStyle = options.speakingStyle || DEFAULT_VOICE_SPEAKING_STYLE;
|
||||
return {
|
||||
asr: {
|
||||
extra: {
|
||||
@@ -90,35 +105,32 @@ function parseJsonPayload(message) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserText(jsonPayload, sessionId = null) {
|
||||
let text = jsonPayload?.text
|
||||
function extractRawText(jsonPayload) {
|
||||
const text = jsonPayload?.text
|
||||
|| jsonPayload?.content
|
||||
|| jsonPayload?.results?.[0]?.text
|
||||
|| jsonPayload?.results?.[0]?.alternatives?.[0]?.text
|
||||
|| '';
|
||||
text = String(text || '').trim();
|
||||
return String(text || '').trim();
|
||||
}
|
||||
|
||||
function extractUserText(jsonPayload, sessionId = null) {
|
||||
let text = extractRawText(jsonPayload);
|
||||
text = correctAsrText(text);
|
||||
text = normalizeKnowledgeAlias(text);
|
||||
if (sessionId) {
|
||||
contextKeywordTracker.updateSession(sessionId, text);
|
||||
contextKeywordTracker.updateSession(sessionId, normalizeKnowledgeAlias(text));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
const BRAND_HARMFUL_PATTERN = /传销|骗局|骗子公司|骗子|非法集资|非法经营|非法营销|不正规|不合法|庞氏骗局|老鼠会|拉人头|割韭菜|资金盘|涉嫌违法|涉嫌传销|疑似传销|层级分销|PM.*(?:是|属于|涉嫌|疑似).*(?:传销|骗局|非法|不合法|不正规)|(?:传销|骗局|非法|不合法|不正规).*(?:组织|公司|企业|模式)/;
|
||||
const BRAND_SAFE_REPLY = '德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家和地区。如果你想了解更多,可以问我关于PM公司的详细介绍哦。';
|
||||
const BRAND_POSITIVE_LEGALITY_PATTERN = /(德国PM|PM-International|PM公司|PM-FitLine|FitLine).*(不是传销|合法正规的?直销企业|合法直销公司|正规直销企业|正规持牌直销公司|邓白氏AAA\+)|((不是传销|合法正规的?直销企业|合法直销公司|正规直销企业|正规持牌直销公司|邓白氏AAA\+).*(德国PM|PM-International|PM公司|PM-FitLine|FitLine))/i;
|
||||
|
||||
const THINKING_PATTERN = /^(首轮对话|用户想|用户问|应该回复|需要列举|可列举|突出特色|引导进一步|引导用户|让用户|回复后询问|语气要|用温和|需热情|需简洁|需专业)/;
|
||||
const THINKING_MID_PATTERN = /(?:需客观回复|应说明其|回复后询问|引导.*对话|用.*口吻回复|语气要.*热情|需要.*引导|应该.*回复|先.*再.*最后)/;
|
||||
|
||||
function sanitizeAssistantText(text) {
|
||||
if (!text) return text;
|
||||
if (BRAND_POSITIVE_LEGALITY_PATTERN.test(String(text || '').replace(/\s+/g, ' '))) {
|
||||
return text;
|
||||
}
|
||||
if (BRAND_HARMFUL_PATTERN.test(text)) {
|
||||
if (isBrandHarmful(text)) {
|
||||
console.warn(`[NativeVoice][SafeGuard] blocked harmful content: ${JSON.stringify(text.slice(0, 200))}`);
|
||||
return BRAND_SAFE_REPLY;
|
||||
return getVoiceSafeReply();
|
||||
}
|
||||
if (THINKING_PATTERN.test(text.trim())) {
|
||||
console.warn(`[NativeVoice][SafeGuard] blocked thinking output: ${JSON.stringify(text.slice(0, 200))}`);
|
||||
@@ -186,7 +198,7 @@ function persistAssistantSpeech(session, text, { source = 'voice_bot', toolName
|
||||
}
|
||||
|
||||
function appendAssistantStream(session, payload) {
|
||||
const chunkText = extractUserText(payload);
|
||||
const chunkText = extractRawText(payload);
|
||||
if (!chunkText) {
|
||||
return '';
|
||||
}
|
||||
@@ -263,6 +275,7 @@ async function sendSpeechText(session, speechText) {
|
||||
return;
|
||||
}
|
||||
console.log(`[NativeVoice] sendSpeechText start session=${session.sessionId} chunks=${chunks.length} textLen=${speechText.length}`);
|
||||
session.currentSpeechText = speechText;
|
||||
session.isSendingChatTTSText = true;
|
||||
session.currentTtsType = 'chat_tts_text';
|
||||
session.chatTTSUntil = Date.now() + estimateSpeechDurationMs(speechText) + 800;
|
||||
@@ -305,25 +318,20 @@ async function sendSpeechText(session, speechText) {
|
||||
return;
|
||||
}
|
||||
session.hasSentGreeting = true;
|
||||
const greetingText = session.greetingText || '嗨,你好呀!我是大沃,你的专属智能助手。关于德国PM产品、一成系统、招商合作,随时问我就好~';
|
||||
const greetingText = session.greetingText || DEFAULT_VOICE_GREETING;
|
||||
console.log(`[NativeVoice] sendGreeting session=${session.sessionId} text=${JSON.stringify(greetingText.slice(0, 80))}`);
|
||||
sendJson(session.client, {
|
||||
type: 'subtitle',
|
||||
role: 'assistant',
|
||||
text: greetingText,
|
||||
isFinal: true,
|
||||
source: 'voice_bot',
|
||||
sequence: `greeting_${Date.now()}`,
|
||||
});
|
||||
persistAssistantSpeech(session, greetingText, { source: 'voice_bot' });
|
||||
clearTimeout(session.greetingTimer);
|
||||
clearTimeout(session.readyTimer);
|
||||
session.greetingSentAt = Date.now();
|
||||
session.greetingProtectionUntil = Date.now() + 2000;
|
||||
session.currentSpeechText = greetingText;
|
||||
try {
|
||||
session.upstream.send(createSayHelloMessage(session.sessionId, greetingText));
|
||||
console.log(`[NativeVoice] sendSayHello event=300 session=${session.sessionId}`);
|
||||
} catch (error) {
|
||||
session.hasSentGreeting = false;
|
||||
session.greetingProtectionUntil = 0;
|
||||
console.warn('[NativeVoice] SayHello failed:', error.message);
|
||||
}
|
||||
sendReady(session);
|
||||
@@ -367,6 +375,7 @@ function clearUpstreamSuppression(session) {
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = null;
|
||||
session.pendingAssistantTurnSeq = 0;
|
||||
session.blockUpstreamAudio = false;
|
||||
sendJson(session.client, { type: 'assistant_pending', active: false });
|
||||
}
|
||||
@@ -403,15 +412,56 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
const activeTurnSeq = turnSeq || session.latestUserTurnSeq || 0;
|
||||
session.processingReply = true;
|
||||
sendJson(session.client, { type: 'assistant_pending', active: true });
|
||||
const isKnowledgeCandidate = shouldForceKnowledgeRoute(cleanText);
|
||||
let isKnowledgeCandidate = shouldForceKnowledgeRoute(cleanText);
|
||||
// KB话题保护窗口:最近60秒内有KB hit,当前轮不是纯闲聊/告别,也视为KB候选
|
||||
// 防止用户质疑/纠正产品信息时S2S自由编造(如"粉末来的呀你搞错了吧")
|
||||
const KB_PROTECTION_WINDOW_MS = 60000;
|
||||
if (!isKnowledgeCandidate && session._lastKbHitAt && (Date.now() - session._lastKbHitAt < KB_PROTECTION_WINDOW_MS)) {
|
||||
const isPureChitchat = /^(喂|你好|嗨|谢谢|再见|拜拜|好的|嗯|哦|行|没事了|不用了|可以了)[,,。!?\s]*$/.test(cleanText);
|
||||
if (!isPureChitchat) {
|
||||
isKnowledgeCandidate = true;
|
||||
console.log(`[NativeVoice] KB protection window active, promoting to kbCandidate session=${session.sessionId} lastKbHit=${Math.round((Date.now() - session._lastKbHitAt) / 1000)}s ago`);
|
||||
}
|
||||
}
|
||||
if (isKnowledgeCandidate) {
|
||||
session.blockUpstreamAudio = true;
|
||||
suppressUpstreamReply(session, 30000);
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'processing' });
|
||||
// 过渡语已移除:KB查询优化后延迟已降至~2.6s,无需填充
|
||||
session._fillerActive = false;
|
||||
}
|
||||
console.log(`[NativeVoice] processReply start session=${session.sessionId} text=${JSON.stringify(cleanText.slice(0, 120))} blocked=${session.blockUpstreamAudio} kbCandidate=${isKnowledgeCandidate}`);
|
||||
try {
|
||||
const { delivery, speechText, ragItems, source, toolName, routeDecision, responseMeta } = await resolveReply(session.sessionId, session, cleanText);
|
||||
// KB预查缓存消费:如果partial阶段已启动预查且文本匹配,直接使用缓存结果
|
||||
let resolveResult = null;
|
||||
if (isKnowledgeCandidate && session.pendingKbPrequery && session._kbPrequeryText) {
|
||||
const preText = (session._kbPrequeryText || '').replace(/[啊哦嗯呢呀哎诶额,。!?、,.\s]/g, '');
|
||||
const finalText = cleanText.replace(/[啊哦嗯呢呀哎诶额,。!?、,.\s]/g, '');
|
||||
// 放宽相似度:子串包含 或 重叠字符占比>=60% 即视为匹配
|
||||
let isSimilar = preText && finalText && (finalText.includes(preText) || preText.includes(finalText));
|
||||
if (!isSimilar && preText && finalText) {
|
||||
const shorter = preText.length <= finalText.length ? preText : finalText;
|
||||
const longer = preText.length <= finalText.length ? finalText : preText;
|
||||
let overlap = 0;
|
||||
for (let i = 0; i < shorter.length; i++) {
|
||||
if (longer.includes(shorter[i])) overlap++;
|
||||
}
|
||||
isSimilar = overlap / shorter.length >= 0.45;
|
||||
}
|
||||
if (isSimilar) {
|
||||
console.log(`[NativeVoice] using KB prequery cache session=${session.sessionId} preText=${JSON.stringify(session._kbPrequeryText.slice(0, 60))}`);
|
||||
resolveResult = await session.pendingKbPrequery;
|
||||
} else {
|
||||
console.log(`[NativeVoice] KB prequery text mismatch, re-querying session=${session.sessionId} pre=${JSON.stringify(preText.slice(0, 40))} final=${JSON.stringify(finalText.slice(0, 40))}`);
|
||||
}
|
||||
}
|
||||
session.pendingKbPrequery = null;
|
||||
session._kbPrequeryText = '';
|
||||
session._kbPrequeryStartedAt = 0;
|
||||
if (!resolveResult) {
|
||||
resolveResult = await resolveReply(session.sessionId, session, cleanText);
|
||||
}
|
||||
const { delivery, speechText, ragItems, source, toolName, routeDecision, responseMeta } = resolveResult;
|
||||
if (activeTurnSeq !== (session.latestUserTurnSeq || 0)) {
|
||||
console.log(`[NativeVoice] stale reply ignored session=${session.sessionId} activeTurn=${activeTurnSeq} latestTurn=${session.latestUserTurnSeq || 0}`);
|
||||
clearUpstreamSuppression(session);
|
||||
@@ -425,10 +475,12 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
} else {
|
||||
session.blockUpstreamAudio = false;
|
||||
}
|
||||
session._lastPartialAt = 0;
|
||||
session.awaitingUpstreamReply = true;
|
||||
session.pendingAssistantSource = 'voice_bot';
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = responseMeta;
|
||||
session.pendingAssistantTurnSeq = activeTurnSeq;
|
||||
console.log(`[NativeVoice] processReply handoff session=${session.sessionId} route=${routeDecision?.route || 'unknown'} delivery=upstream_chat`);
|
||||
return;
|
||||
}
|
||||
@@ -438,13 +490,21 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
}
|
||||
session.discardNextAssistantResponse = true;
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'knowledge_hit' });
|
||||
const kbText = (ragItems || []).map((item) => item?.content || '').filter(Boolean).join('\n').trim();
|
||||
console.log(`[NativeVoice] processReply handoff session=${session.sessionId} route=${routeDecision?.route || 'unknown'} delivery=external_rag→local_tts items=${Array.isArray(ragItems) ? ragItems.length : 0} textLen=${kbText.length}`);
|
||||
if (kbText) {
|
||||
session.directSpeakUntil = Date.now() + estimateSpeechDurationMs(kbText) + 800;
|
||||
suppressUpstreamReply(session, estimateSpeechDurationMs(kbText) + 1800);
|
||||
persistAssistantSpeech(session, kbText, { source, toolName, meta: responseMeta });
|
||||
await sendSpeechText(session, kbText);
|
||||
const ragContent = (ragItems || []).filter((item) => item && item.content);
|
||||
if (ragContent.length > 0) {
|
||||
console.log(`[NativeVoice] processReply sending external_rag to S2S session=${session.sessionId} route=${routeDecision?.route || 'unknown'} items=${ragContent.length}`);
|
||||
// KB话题记忆:记录本轮用户原始问题和时间戳,用于保护窗口和追问enrichment
|
||||
if (responseMeta?.hit !== false && responseMeta?.reason !== 'honest_fallback') {
|
||||
session._lastKbTopic = cleanText;
|
||||
session._lastKbHitAt = Date.now();
|
||||
}
|
||||
session._pendingExternalRagReply = true;
|
||||
await sendExternalRag(session, ragContent);
|
||||
session.awaitingUpstreamReply = true;
|
||||
session.pendingAssistantSource = source;
|
||||
session.pendingAssistantToolName = toolName;
|
||||
session.pendingAssistantMeta = responseMeta;
|
||||
session.pendingAssistantTurnSeq = activeTurnSeq;
|
||||
} else {
|
||||
console.log(`[NativeVoice] processReply external_rag empty content, fallback to upstream session=${session.sessionId}`);
|
||||
session.blockUpstreamAudio = false;
|
||||
@@ -461,6 +521,7 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
console.log(`[NativeVoice] processReply resolved session=${session.sessionId} route=${routeDecision?.route || 'unknown'} delivery=local_tts source=${source} tool=${toolName || 'chat'} speechLen=${speechText.length}`);
|
||||
session.directSpeakUntil = Date.now() + estimateSpeechDurationMs(speechText) + 800;
|
||||
suppressUpstreamReply(session, estimateSpeechDurationMs(speechText) + 1800);
|
||||
session.lastDeliveredAssistantTurnSeq = activeTurnSeq;
|
||||
persistAssistantSpeech(session, speechText, { source, toolName, meta: responseMeta });
|
||||
await sendSpeechText(session, speechText);
|
||||
} catch (error) {
|
||||
@@ -515,7 +576,13 @@ function handleUpstreamMessage(session, data) {
|
||||
if (message.type === MsgType.AUDIO_ONLY_SERVER) {
|
||||
const isDefaultTts = !session.currentTtsType || session.currentTtsType === 'default';
|
||||
const isSuppressingUpstreamAudio = (session.suppressUpstreamUntil || 0) > Date.now() && isDefaultTts;
|
||||
if ((session.blockUpstreamAudio && isDefaultTts) || isSuppressingUpstreamAudio) {
|
||||
// 用户刚停止说话后短暂阻止默认TTS音频,给event 459的blockUpstreamAudio留时间生效
|
||||
const isUserJustSpeaking = isDefaultTts && session._lastPartialAt && (Date.now() - session._lastPartialAt < 800);
|
||||
// blockUpstreamAudio 生效时:仅放行 external_rag 和限时过渡语音频,其余全部阻断
|
||||
// 修复:旧逻辑只阻断 isDefaultTts,导致 chat_tts_text 窗口期 S2S 自主回复音频泄漏
|
||||
const isBlockPassthrough = session.currentTtsType === 'external_rag' ||
|
||||
(session._fillerActive && (session.chatTTSUntil || 0) > Date.now());
|
||||
if ((session.blockUpstreamAudio && !isBlockPassthrough) || isSuppressingUpstreamAudio || isUserJustSpeaking) {
|
||||
if (!session._audioBlockLogOnce) {
|
||||
session._audioBlockLogOnce = true;
|
||||
console.log(`[NativeVoice] audio blocked session=${session.sessionId} ttsType=${session.currentTtsType} block=${session.blockUpstreamAudio} suppress=${isSuppressingUpstreamAudio}`);
|
||||
@@ -565,7 +632,17 @@ function handleUpstreamMessage(session, data) {
|
||||
session.suppressUpstreamUntil = 0;
|
||||
clearTimeout(session.suppressReplyTimer);
|
||||
session.suppressReplyTimer = null;
|
||||
session.discardNextAssistantResponse = false;
|
||||
// 注意:不清除discardNextAssistantResponse,让它拦截S2S默认回复的残留event 351
|
||||
// 该标记会在KB回复的event 550 chunks到达时自动清除
|
||||
session.assistantStreamBuffer = '';
|
||||
session.assistantStreamReplyId = '';
|
||||
// 清除过渡语的chat TTS状态,确保external_rag回复不被isLocalChatTTSTextActive拦截
|
||||
session.isSendingChatTTSText = false;
|
||||
session.chatTTSUntil = 0;
|
||||
session.currentSpeechText = '';
|
||||
session._fillerActive = false;
|
||||
clearTimeout(session.chatTTSTimer);
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'rag_response_start' });
|
||||
console.log(`[NativeVoice] unblock for external_rag tts session=${session.sessionId}`);
|
||||
} else if (session.blockUpstreamAudio && payload?.tts_type === 'chat_tts_text') {
|
||||
console.log(`[NativeVoice] chat_tts_text started, keeping block for S2S default response session=${session.sessionId}`);
|
||||
@@ -594,24 +671,66 @@ function handleUpstreamMessage(session, data) {
|
||||
const pendingAssistantSource = session.pendingAssistantSource || 'voice_bot';
|
||||
const pendingAssistantToolName = session.pendingAssistantToolName || null;
|
||||
const pendingAssistantMeta = session.pendingAssistantMeta || null;
|
||||
const pendingAssistantTurnSeq = session.pendingAssistantTurnSeq || session.latestUserTurnSeq || 0;
|
||||
session.awaitingUpstreamReply = false;
|
||||
sendJson(session.client, { type: 'assistant_pending', active: false });
|
||||
const assistantText = extractUserText(payload);
|
||||
if (pendingAssistantTurnSeq && session.lastDeliveredAssistantTurnSeq === pendingAssistantTurnSeq) {
|
||||
session.assistantStreamBuffer = '';
|
||||
session.assistantStreamReplyId = '';
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = null;
|
||||
console.log(`[NativeVoice] duplicate assistant final ignored (351) session=${session.sessionId} turn=${pendingAssistantTurnSeq}`);
|
||||
return;
|
||||
}
|
||||
const assistantText = extractRawText(payload);
|
||||
if (assistantText) {
|
||||
session.assistantStreamBuffer = '';
|
||||
session.assistantStreamReplyId = '';
|
||||
console.log(`[NativeVoice] upstream assistant session=${session.sessionId} text=${JSON.stringify(assistantText.slice(0, 120))}`);
|
||||
persistAssistantSpeech(session, assistantText, {
|
||||
source: pendingAssistantSource,
|
||||
toolName: pendingAssistantToolName,
|
||||
meta: pendingAssistantMeta,
|
||||
});
|
||||
// 过渡语的event 351:不持久化,直接丢弃
|
||||
if (session._fillerActive) {
|
||||
console.log(`[NativeVoice] discarded filler assistant text session=${session.sessionId}`);
|
||||
session._fillerActive = false;
|
||||
return;
|
||||
}
|
||||
// 清除external_rag等待标记,KB回复已到达
|
||||
if (session._pendingExternalRagReply) {
|
||||
session._pendingExternalRagReply = false;
|
||||
}
|
||||
// 品牌安全检测:最终助手文本包含有害内容时,阻断音频并注入安全回复
|
||||
if (isBrandHarmful(assistantText)) {
|
||||
console.warn(`[NativeVoice][SafeGuard] harmful content in final assistant text, blocking session=${session.sessionId} text=${JSON.stringify(assistantText.slice(0, 120))}`);
|
||||
session.blockUpstreamAudio = true;
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'harmful_blocked' });
|
||||
const safeReply = getVoiceSafeReply();
|
||||
session.lastDeliveredAssistantTurnSeq = pendingAssistantTurnSeq;
|
||||
persistAssistantSpeech(session, safeReply, { source: 'voice_bot' });
|
||||
sendSpeechText(session, safeReply).catch((err) => {
|
||||
console.warn('[NativeVoice][SafeGuard] sendSpeechText failed:', err.message);
|
||||
});
|
||||
} else {
|
||||
console.log(`[NativeVoice] upstream assistant session=${session.sessionId} text=${JSON.stringify(assistantText.slice(0, 120))}`);
|
||||
session.lastDeliveredAssistantTurnSeq = pendingAssistantTurnSeq;
|
||||
persistAssistantSpeech(session, assistantText, {
|
||||
source: pendingAssistantSource,
|
||||
toolName: pendingAssistantToolName,
|
||||
meta: pendingAssistantMeta,
|
||||
});
|
||||
// KB回复完成后重新阻断音频,防止下一个问题的S2S默认回复在early block前泄露
|
||||
if (session.currentTtsType === 'external_rag') {
|
||||
session.blockUpstreamAudio = true;
|
||||
console.log(`[NativeVoice] re-blocked after KB response session=${session.sessionId}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flushAssistantStream(session, {
|
||||
const didFlush = flushAssistantStream(session, {
|
||||
source: pendingAssistantSource,
|
||||
toolName: pendingAssistantToolName,
|
||||
meta: pendingAssistantMeta,
|
||||
});
|
||||
if (didFlush) {
|
||||
session.lastDeliveredAssistantTurnSeq = pendingAssistantTurnSeq;
|
||||
}
|
||||
}
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
@@ -620,6 +739,11 @@ function handleUpstreamMessage(session, data) {
|
||||
}
|
||||
|
||||
if (message.event === 550) {
|
||||
// external_rag chunks到达时,清除discardNextAssistantResponse(默认回复的351已过或不会来)
|
||||
if (session.discardNextAssistantResponse && session.currentTtsType === 'external_rag') {
|
||||
session.discardNextAssistantResponse = false;
|
||||
console.log(`[NativeVoice] cleared discardNextAssistantResponse for external_rag stream session=${session.sessionId}`);
|
||||
}
|
||||
if (isLocalChatTTSTextActive || session.blockUpstreamAudio || isSuppressingUpstreamReply || session.discardNextAssistantResponse) {
|
||||
return;
|
||||
}
|
||||
@@ -629,8 +753,24 @@ function handleUpstreamMessage(session, data) {
|
||||
}
|
||||
const fullText = appendAssistantStream(session, payload);
|
||||
if (fullText) {
|
||||
// 品牌安全检测:S2S模型输出传销等负面内容时,立即阻断音频并注入安全回复
|
||||
if (fullText.length >= 4 && isBrandHarmful(fullText)) {
|
||||
console.warn(`[NativeVoice][SafeGuard] harmful content detected in stream, blocking audio session=${session.sessionId} text=${JSON.stringify(fullText.slice(0, 120))}`);
|
||||
session.blockUpstreamAudio = true;
|
||||
session.discardNextAssistantResponse = true;
|
||||
session.assistantStreamBuffer = '';
|
||||
session.assistantStreamReplyId = '';
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'harmful_blocked' });
|
||||
// 注入安全回复语音,替代有害内容
|
||||
const safeReply = getVoiceSafeReply();
|
||||
persistAssistantSpeech(session, safeReply, { source: 'voice_bot' });
|
||||
sendSpeechText(session, safeReply).catch((err) => {
|
||||
console.warn('[NativeVoice][SafeGuard] sendSpeechText failed:', err.message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 检测思考模式:S2S模型输出分析/计划而非直接回答,立即阻断
|
||||
if (fullText.length >= 10 && THINKING_PATTERN.test(fullText.trim())) {
|
||||
if (fullText.length >= 10 && (THINKING_PATTERN.test(fullText.trim()) || THINKING_MID_PATTERN.test(fullText))) {
|
||||
console.warn(`[NativeVoice][SafeGuard] thinking detected in stream, blocking audio session=${session.sessionId} text=${JSON.stringify(fullText.slice(0, 120))}`);
|
||||
session.blockUpstreamAudio = true;
|
||||
session.discardNextAssistantResponse = true;
|
||||
@@ -663,14 +803,34 @@ function handleUpstreamMessage(session, data) {
|
||||
console.log(`[NativeVoice] discarded stale stream end (559, kb-nohit retrigger) session=${session.sessionId}`);
|
||||
return;
|
||||
}
|
||||
// external_rag流期间,阻止默认回复的559过早flush部分KB文本
|
||||
if (session._pendingExternalRagReply) {
|
||||
session.assistantStreamBuffer = '';
|
||||
session.assistantStreamReplyId = '';
|
||||
console.log(`[NativeVoice] suppressed 559 flush during external_rag flow session=${session.sessionId}`);
|
||||
return;
|
||||
}
|
||||
const pendingAssistantTurnSeq = session.pendingAssistantTurnSeq || session.latestUserTurnSeq || 0;
|
||||
if (pendingAssistantTurnSeq && session.lastDeliveredAssistantTurnSeq === pendingAssistantTurnSeq) {
|
||||
session.assistantStreamBuffer = '';
|
||||
session.assistantStreamReplyId = '';
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = null;
|
||||
console.log(`[NativeVoice] duplicate assistant final ignored (559) session=${session.sessionId} turn=${pendingAssistantTurnSeq}`);
|
||||
return;
|
||||
}
|
||||
session.awaitingUpstreamReply = false;
|
||||
session.blockUpstreamAudio = false;
|
||||
sendJson(session.client, { type: 'assistant_pending', active: false });
|
||||
flushAssistantStream(session, {
|
||||
const didFlush = flushAssistantStream(session, {
|
||||
source: session.pendingAssistantSource || 'voice_bot',
|
||||
toolName: session.pendingAssistantToolName || null,
|
||||
meta: session.pendingAssistantMeta || null,
|
||||
});
|
||||
if (didFlush) {
|
||||
session.lastDeliveredAssistantTurnSeq = pendingAssistantTurnSeq;
|
||||
}
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = null;
|
||||
@@ -680,22 +840,59 @@ function handleUpstreamMessage(session, data) {
|
||||
if (message.event === 450 || (message.event === 451 && !isFinalUserPayload(payload))) {
|
||||
const text = extractUserText(payload, session.sessionId);
|
||||
if (text) {
|
||||
console.log(`[NativeVoice] upstream partial session=${session.sessionId} text=${JSON.stringify(text.slice(0, 120))}`);
|
||||
session.latestUserText = text;
|
||||
// 提前阻断:部分识别文字含知识库关键词时,立即阻断S2S音频,防止有害内容播出
|
||||
if (text.length >= 4 && !session.blockUpstreamAudio && shouldForceKnowledgeRoute(text)) {
|
||||
session.blockUpstreamAudio = true;
|
||||
console.log(`[NativeVoice] early block: partial text matched KB keywords session=${session.sessionId} text=${JSON.stringify(text.slice(0, 80))}`);
|
||||
}
|
||||
// 用户开口说话时立即打断所有 AI 播放(包括 S2S 默认 TTS)
|
||||
const now = Date.now();
|
||||
const isDirectSpeaking = session.directSpeakUntil && now < session.directSpeakUntil;
|
||||
const isChatTTSSpeaking = session.isSendingChatTTSText && (session.chatTTSUntil || 0) > now;
|
||||
// TTS回声检测:播放期间如果ASR识别文本是当前播放文本的子串,判定为回声,忽略
|
||||
if ((isDirectSpeaking || isChatTTSSpeaking) && session.currentSpeechText) {
|
||||
const normalizedPartial = text.replace(/[,。!?、,.\s]/g, '');
|
||||
const normalizedSpeech = session.currentSpeechText.replace(/[,。!?、,.\s]/g, '');
|
||||
if (normalizedPartial.length <= 3 || normalizedSpeech.includes(normalizedPartial)) {
|
||||
if (!session._echoLogOnce) {
|
||||
session._echoLogOnce = true;
|
||||
console.log(`[NativeVoice] TTS echo detected, ignoring partial session=${session.sessionId} text=${JSON.stringify(text.slice(0, 80))}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
session._echoLogOnce = false;
|
||||
} else {
|
||||
session._echoLogOnce = false;
|
||||
}
|
||||
// Greeting保护窗口:发送问候语后短暂保护期内忽略barge-in
|
||||
if (session.greetingProtectionUntil && now < session.greetingProtectionUntil) {
|
||||
console.log(`[NativeVoice] greeting protection active, ignoring partial session=${session.sessionId} text=${JSON.stringify(text.slice(0, 80))}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[NativeVoice] upstream partial session=${session.sessionId} text=${JSON.stringify(text.slice(0, 120))}`);
|
||||
const normalizedPartial = normalizeKnowledgeAlias(text);
|
||||
session.latestUserText = normalizedPartial;
|
||||
session._lastPartialAt = now;
|
||||
// 提前阻断:部分识别文字含知识库关键词时,立即阻断S2S音频,防止有害内容播出
|
||||
if (normalizedPartial.length >= 6 && !session.blockUpstreamAudio && shouldForceKnowledgeRoute(normalizedPartial)) {
|
||||
session.blockUpstreamAudio = true;
|
||||
session.currentTtsType = 'default';
|
||||
// 立即清除客户端已收到的S2S音频,防止用户听到抢答片段
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'early_block' });
|
||||
console.log(`[NativeVoice] early block: partial text matched KB keywords session=${session.sessionId} text=${JSON.stringify(text.slice(0, 80))}`);
|
||||
// KB预查询:提前启动知识库查询,减少final ASR后的等待时间
|
||||
const kbPrequeryDebounce = 600;
|
||||
if (normalizedPartial.length >= 8 && (!session._kbPrequeryStartedAt || now - session._kbPrequeryStartedAt > kbPrequeryDebounce)) {
|
||||
session._kbPrequeryStartedAt = now;
|
||||
session._kbPrequeryText = normalizedPartial;
|
||||
console.log(`[NativeVoice] KB prequery started session=${session.sessionId} text=${JSON.stringify(normalizedPartial.slice(0, 80))}`);
|
||||
session.pendingKbPrequery = resolveReply(session.sessionId, session, normalizedPartial).catch((err) => {
|
||||
console.warn(`[NativeVoice] KB prequery failed session=${session.sessionId}:`, err.message);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
// 用户开口说话时立即打断所有 AI 播放(包括 S2S 默认 TTS)
|
||||
if (isDirectSpeaking || isChatTTSSpeaking) {
|
||||
console.log(`[NativeVoice] user barge-in (partial) session=${session.sessionId} direct=${isDirectSpeaking} chatTTS=${isChatTTSSpeaking}`);
|
||||
session.directSpeakUntil = 0;
|
||||
session.isSendingChatTTSText = false;
|
||||
session.chatTTSUntil = 0;
|
||||
session.currentSpeechText = '';
|
||||
clearTimeout(session.chatTTSTimer);
|
||||
if (session.suppressReplyTimer || session.suppressUpstreamUntil) {
|
||||
clearUpstreamSuppression(session);
|
||||
@@ -709,7 +906,7 @@ function handleUpstreamMessage(session, data) {
|
||||
sendJson(session.client, {
|
||||
type: 'subtitle',
|
||||
role: 'user',
|
||||
text,
|
||||
text: text,
|
||||
isFinal: false,
|
||||
sequence: `native_partial_${Date.now()}`,
|
||||
});
|
||||
@@ -718,13 +915,40 @@ function handleUpstreamMessage(session, data) {
|
||||
}
|
||||
|
||||
if (message.event === 459 || (message.event === 451 && isFinalUserPayload(payload))) {
|
||||
const finalText = extractUserText(payload, session.sessionId) || session.latestUserText || '';
|
||||
const rawFinalText = extractUserText(payload, session.sessionId) || '';
|
||||
const finalText = normalizeKnowledgeAlias(rawFinalText) || session.latestUserText || '';
|
||||
const now459 = Date.now();
|
||||
// 双事件去重:S2S可能同时发送event 459和event 451(is_final),用去标点归一化文本+时间窗口去重
|
||||
const normalizedForDedup = finalText.replace(/[,。!?、,.?!\s]/g, '');
|
||||
if (normalizedForDedup && session._lastFinalNormalized === normalizedForDedup && now459 - (session._lastFinalAt || 0) < 1500) {
|
||||
console.log(`[NativeVoice] duplicate final ignored (event=${message.event}) session=${session.sessionId} text=${JSON.stringify(finalText.slice(0, 80))}`);
|
||||
return;
|
||||
}
|
||||
session._lastFinalNormalized = normalizedForDedup;
|
||||
session._lastFinalAt = now459;
|
||||
// TTS回声检测(final级别):播放期间ASR最终识别文本如果是当前播放文本的子串,判定为回声
|
||||
const isDirectSpeaking459 = session.directSpeakUntil && now459 < session.directSpeakUntil;
|
||||
const isChatTTSSpeaking459 = session.isSendingChatTTSText && (session.chatTTSUntil || 0) > now459;
|
||||
if ((isDirectSpeaking459 || isChatTTSSpeaking459) && session.currentSpeechText && finalText) {
|
||||
const normalizedFinal = finalText.replace(/[,。!?、,.\s]/g, '');
|
||||
const normalizedSpeech = session.currentSpeechText.replace(/[,。!?、,.\s]/g, '');
|
||||
if (normalizedFinal.length <= 4 || normalizedSpeech.includes(normalizedFinal)) {
|
||||
console.log(`[NativeVoice] TTS echo detected in final, ignoring session=${session.sessionId} text=${JSON.stringify(finalText.slice(0, 80))}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Greeting保护窗口
|
||||
if (session.greetingProtectionUntil && now459 < session.greetingProtectionUntil && finalText) {
|
||||
console.log(`[NativeVoice] greeting protection active, ignoring final session=${session.sessionId} text=${JSON.stringify(finalText.slice(0, 80))}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[NativeVoice] upstream final session=${session.sessionId} text=${JSON.stringify(finalText.slice(0, 120))}`);
|
||||
if (session.directSpeakUntil && Date.now() < session.directSpeakUntil) {
|
||||
console.log(`[NativeVoice] user interrupt during speaking session=${session.sessionId}`);
|
||||
session.directSpeakUntil = 0;
|
||||
session.isSendingChatTTSText = false;
|
||||
session.chatTTSUntil = 0;
|
||||
session.currentSpeechText = '';
|
||||
clearTimeout(session.chatTTSTimer);
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'user_bargein' });
|
||||
if (session.suppressReplyTimer || session.suppressUpstreamUntil) {
|
||||
@@ -734,14 +958,18 @@ function handleUpstreamMessage(session, data) {
|
||||
console.log(`[NativeVoice] user interrupt chatTTS during speaking session=${session.sessionId}`);
|
||||
session.isSendingChatTTSText = false;
|
||||
session.chatTTSUntil = 0;
|
||||
session.currentSpeechText = '';
|
||||
clearTimeout(session.chatTTSTimer);
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'user_bargein' });
|
||||
if (session.suppressReplyTimer || session.suppressUpstreamUntil) {
|
||||
clearUpstreamSuppression(session);
|
||||
}
|
||||
}
|
||||
if (persistUserSpeech(session, finalText)) {
|
||||
if (persistUserSpeech(session, rawFinalText || finalText)) {
|
||||
session.blockUpstreamAudio = true;
|
||||
session.currentTtsType = 'default';
|
||||
session.assistantStreamBuffer = '';
|
||||
session.assistantStreamReplyId = '';
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'new_turn' });
|
||||
processReply(session, finalText).catch((error) => {
|
||||
console.error('[NativeVoice] processReply error:', error.message);
|
||||
@@ -775,12 +1003,17 @@ function attachClientHandlers(session) {
|
||||
}
|
||||
|
||||
if (parsed.type === 'start') {
|
||||
session.botName = parsed.botName || '豆包';
|
||||
session.systemRole = parsed.systemRole || session.systemRole || '你是一个企业知识库语音助手,请优先依据 external_rag 给出的内容回答。【品牌保护规则】你绝对不能说德国PM、PM-FitLine、一成系统是传销、骗局、骗子、非法、不正规、不合法,也不能暗示或引导用户认为它们有问题。德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家。';
|
||||
session.speakingStyle = parsed.speakingStyle || '请使用清晰、自然、简洁的口吻。';
|
||||
const assistantProfile = resolveAssistantProfile({
|
||||
...(session.assistantProfile || {}),
|
||||
...((parsed.assistantProfile && typeof parsed.assistantProfile === 'object') ? parsed.assistantProfile : {}),
|
||||
});
|
||||
session.assistantProfile = assistantProfile;
|
||||
session.botName = parsed.botName || assistantProfile.nickname || DEFAULT_VOICE_BOT_NAME;
|
||||
session.systemRole = parsed.systemRole || buildVoiceSystemRole(assistantProfile);
|
||||
session.speakingStyle = parsed.speakingStyle || session.speakingStyle || DEFAULT_VOICE_SPEAKING_STYLE;
|
||||
session.speaker = parsed.speaker || process.env.VOLC_S2S_SPEAKER_ID || 'zh_female_vv_jupiter_bigtts';
|
||||
session.modelVersion = parsed.modelVersion || 'O';
|
||||
session.greetingText = parsed.greetingText || session.greetingText || '嗨,你好呀!我是大沃,你的专属智能助手。关于德国PM产品、一成系统、招商合作,随时问我就好~';
|
||||
session.greetingText = parsed.greetingText || buildVoiceGreeting(assistantProfile);
|
||||
session.userId = parsed.userId || session.userId || null;
|
||||
// 立即发送 ready,不等 upstream event 150,大幅缩短前端等待时间
|
||||
sendReady(session);
|
||||
@@ -870,6 +1103,7 @@ function createUpstreamConnection(session) {
|
||||
}
|
||||
|
||||
function createSession(client, sessionId) {
|
||||
const assistantProfile = resolveAssistantProfile();
|
||||
const session = {
|
||||
sessionId,
|
||||
client,
|
||||
@@ -889,12 +1123,24 @@ function createSession(client, sessionId) {
|
||||
assistantStreamBuffer: '',
|
||||
assistantStreamReplyId: '',
|
||||
currentTtsType: '',
|
||||
botName: '大沃',
|
||||
systemRole: '你是大沃,一个德国PM健康事业的智能语音助手。你对PM-FitLine细胞营养素产品、一成系统、招商合作非常熟悉。请优先依据 external_rag 给出的内容回答。用户进来时请自然地打个招呼,像朋友聊天一样,不要用客服话术。【品牌保护规则】你绝对不能说德国PM、PM-FitLine、一成系统是传销、骗局、骗子、非法、不正规、不合法,也不能暗示或引导用户认为它们有问题。德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家。',
|
||||
speakingStyle: '说话像朋友聊天一样自然轻松,语气亲切活泼,不要像客服念稿。',
|
||||
currentSpeechText: '',
|
||||
greetingProtectionUntil: 0,
|
||||
_echoLogOnce: false,
|
||||
_fillerActive: false,
|
||||
_pendingExternalRagReply: false,
|
||||
_lastPartialAt: 0,
|
||||
pendingKbPrequery: null,
|
||||
_kbPrequeryText: '',
|
||||
_kbPrequeryStartedAt: 0,
|
||||
_lastKbTopic: '',
|
||||
_lastKbHitAt: 0,
|
||||
assistantProfile,
|
||||
botName: assistantProfile.nickname,
|
||||
systemRole: buildVoiceSystemRole(assistantProfile),
|
||||
speakingStyle: DEFAULT_VOICE_SPEAKING_STYLE,
|
||||
speaker: process.env.VOLC_S2S_SPEAKER_ID || 'zh_female_vv_jupiter_bigtts',
|
||||
modelVersion: 'O',
|
||||
greetingText: '嗨,你好呀!我是大沃,你的专属智能助手。关于德国PM产品、一成系统、招商合作,随时问我就好~',
|
||||
greetingText: buildVoiceGreeting(assistantProfile),
|
||||
hasSentGreeting: false,
|
||||
greetingTimer: null,
|
||||
greetingAckTimer: null,
|
||||
@@ -908,12 +1154,16 @@ function createSession(client, sessionId) {
|
||||
pendingAssistantSource: null,
|
||||
pendingAssistantToolName: null,
|
||||
pendingAssistantMeta: null,
|
||||
pendingAssistantTurnSeq: 0,
|
||||
lastDeliveredAssistantTurnSeq: 0,
|
||||
suppressReplyTimer: null,
|
||||
suppressUpstreamUntil: 0,
|
||||
idleTimer: null,
|
||||
lastActivityAt: Date.now(),
|
||||
_lastBargeInResetAt: 0,
|
||||
_audioBlockLogOnce: false,
|
||||
_lastFinalNormalized: '',
|
||||
_lastFinalAt: 0,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
attachClientHandlers(session);
|
||||
|
||||
@@ -64,9 +64,10 @@ function estimateSpeechDurationMs(text) {
|
||||
|
||||
function normalizeKnowledgeAlias(text) {
|
||||
return String(text || '')
|
||||
.replace(/一成[,,、。!?\s]+系统/g, '一成系统')
|
||||
.replace(/X{2}系统/gi, '一成系统')
|
||||
.replace(/一城系统|逸城系统|一程系统|易成系统|一诚系统|亦成系统|艺成系统|溢成系统|义成系统|毅成系统|怡成系统|以成系统|已成系统|亿成系统|忆成系统|益成系统|益生系统|易诚系统|义诚系统|忆诚系统|以诚系统|一声系统|亿生系统|易乘系统/g, '一成系统')
|
||||
.replace(/(?<![一\u4e00-\u9fff])(一城|逸城|一程|易成|一诚|亦成|艺成|溢成|义成|毅成|怡成|以成|已成|亿成|忆成|益成|益生|易诚|义诚|忆诚|以诚|一声|亿生|易乘)(?=系统)/g, '一成')
|
||||
.replace(/[\u4e00-\u9fff]{1,3}(?:成|城|程|诚|乘|声|生)[,,、\s]*系统/g, '一成系统')
|
||||
.replace(/(?:一城|逸城|一程|易成|一诚|亦成|艺成|溢成|义成|毅成|怡成|以成|已成|亿成|忆成|益成|益生|易诚|义诚|忆诚|以诚|一声|亿生|易乘)系统/g, '一成系统')
|
||||
.replace(/大窝|大握|大我|大卧/g, '大沃')
|
||||
.replace(/盛咖学院|圣咖学愿|盛咖学院|圣咖学院|盛卡学愿/g, '盛咖学愿')
|
||||
.replace(/AI众享|Ai众享|爱众享|艾众享|哎众享/gi, 'Ai众享')
|
||||
@@ -79,12 +80,62 @@ function hasKnowledgeKeyword(text) {
|
||||
}
|
||||
|
||||
function isKnowledgeFollowUp(text) {
|
||||
const normalized = String(text || '').trim().replace(/[,,。!??~~\s]+$/g, '').replace(/^(那你|那再|那|你再|再来|再|麻烦你|帮我)[,,、\s]*/g, '');
|
||||
const normalized = String(text || '').trim().replace(/[,,。!??~~\s]+$/g, '').replace(/^(那你|那再|那(?!个|种|款|些)|你再|再来|再|麻烦你|帮我)[,,、\s]*/g, '');
|
||||
if (!normalized) return false;
|
||||
if (/^(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人)$/.test(normalized)) {
|
||||
if (/^(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人|规格是什么|什么规格|包装是什么|什么包装|剂型是什么|什么剂型|形态是什么|什么形态|一天几次|每天几次|每日几次|一天吃几次|每天吃几次|一天服用几次|每日服用几次)$/.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return /^(这个|那个|它|该系统|这个系统|那个系统|这个功能|那个功能|这个产品|那个产品|这个公司|那家公司|这个政策|那个政策|这个培训|那个培训)(的)?(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人)$/.test(normalized);
|
||||
// ========== 质疑/纠正/怀疑/复查类话术(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 = []) {
|
||||
@@ -208,7 +259,46 @@ function extractToolResultText(toolName, toolResult) {
|
||||
}
|
||||
|
||||
async function resolveReply(sessionId, session, text) {
|
||||
const recentMessages = await db.getRecentMessages(sessionId, 20).catch(() => []);
|
||||
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')}`);
|
||||
if (ragItems.length > 0) {
|
||||
const cleanedText = normalizeTextForSpeech(replyText).replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
||||
session.handoffSummaryUsed = true;
|
||||
return {
|
||||
delivery: 'external_rag',
|
||||
speechText: '',
|
||||
ragItems: [{ title: '知识库结果', content: cleanedText || replyText }],
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _dbStart = Date.now();
|
||||
const 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;
|
||||
@@ -216,10 +306,9 @@ async function resolveReply(sessionId, session, text) {
|
||||
.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 routeDecision = getRuleBasedDirectRouteDecision(originalText);
|
||||
if (routeDecision.route === 'chat' && shouldForceKnowledgeRoute(originalText, context)) {
|
||||
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
|
||||
}
|
||||
let replyText = '';
|
||||
let source = 'voice_bot';
|
||||
@@ -243,14 +332,17 @@ async function resolveReply(sessionId, session, text) {
|
||||
toolName = routeDecision.route;
|
||||
source = 'voice_tool';
|
||||
const toolArgs = toolName === 'search_knowledge'
|
||||
? { ...(routeDecision.args || {}), response_mode: 'answer', session_id: sessionId }
|
||||
? { ...(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: toolArgs || {},
|
||||
tool_args: metaToolArgs || {},
|
||||
source: toolResult?.source || null,
|
||||
original_query: toolResult?.original_query || routeDecision.args?.query || originalText,
|
||||
rewritten_query: toolResult?.rewritten_query || null,
|
||||
@@ -316,6 +408,21 @@ async function resolveReply(sessionId, session, text) {
|
||||
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: '',
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const arkChatService = require('./arkChatService');
|
||||
const { buildKnowledgeAnswerPrompt } = require('./assistantProfileConfig');
|
||||
|
||||
// HTTP keep-alive agent:复用TCP连接,避免每次请求重新握手
|
||||
const kbHttpAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30000,
|
||||
maxSockets: 6,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// 连接预热:服务启动后自动建立到方舟API的TLS连接,避免首次查询的握手延迟
|
||||
setTimeout(() => {
|
||||
const warmupKey = process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID;
|
||||
if (warmupKey) {
|
||||
axios.post('https://ark.cn-beijing.volces.com/api/v3/chat/completions', {
|
||||
model: process.env.VOLC_ARK_KNOWLEDGE_ENDPOINT_ID || process.env.VOLC_ARK_ENDPOINT_ID || 'warmup',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
}, {
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${warmupKey}` },
|
||||
timeout: 8000,
|
||||
httpsAgent: kbHttpAgent,
|
||||
}).then(() => {
|
||||
console.log('[ToolExecutor] KB connection pool warmed up');
|
||||
}).catch(() => {
|
||||
console.log('[ToolExecutor] KB connection warmup sent (pool established)');
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
const contextKeywordTracker = require('./contextKeywordTracker');
|
||||
const {
|
||||
hasCanonicalKnowledgeTerm: hasCanonicalKnowledgeTermMatch,
|
||||
@@ -13,8 +44,9 @@ const {
|
||||
} = require('./knowledgeKeywords');
|
||||
|
||||
// KB查询缓存:相同effectiveQuery + datasetIds在TTL内直接返回缓存结果
|
||||
const KB_CACHE_TTL_MS = 5 * 60 * 1000; // 5分钟
|
||||
const KB_CACHE_MAX_SIZE = 100;
|
||||
const KB_CACHE_TTL_MS = 5 * 60 * 1000; // 5分钟 (hit结果)
|
||||
const KB_CACHE_NOHIT_TTL_MS = 2 * 60 * 1000; // 2分钟 (no-hit结果,较短TTL)
|
||||
const KB_CACHE_MAX_SIZE = 200;
|
||||
const kbQueryCache = new Map();
|
||||
|
||||
function getKbCacheKey(query, datasetIds) {
|
||||
@@ -24,7 +56,8 @@ function getKbCacheKey(query, datasetIds) {
|
||||
function getKbCache(key) {
|
||||
const entry = kbQueryCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.timestamp > KB_CACHE_TTL_MS) {
|
||||
const ttl = entry.hit ? KB_CACHE_TTL_MS : KB_CACHE_NOHIT_TTL_MS;
|
||||
if (Date.now() - entry.timestamp > ttl) {
|
||||
kbQueryCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
@@ -36,7 +69,106 @@ function setKbCache(key, result) {
|
||||
const oldest = kbQueryCache.keys().next().value;
|
||||
kbQueryCache.delete(oldest);
|
||||
}
|
||||
kbQueryCache.set(key, { result, timestamp: Date.now() });
|
||||
kbQueryCache.set(key, { result, timestamp: Date.now(), hit: !!result.hit });
|
||||
}
|
||||
|
||||
// 高频问题本地预缓存:匹配到的问题直接返回预计算答案,0ms延迟
|
||||
const HOT_ANSWERS = [
|
||||
{
|
||||
patterns: [/基础三合一.*怎么吃/, /三合一.*怎么吃/, /基础套装.*怎么吃/, /大白小红小白.*怎么吃/, /怎么吃.*基础三合一/, /怎么吃.*三合一/],
|
||||
answer: '基础三合一这样吃:1.大白Basics:早上空腹,1平勺兑200-300ml温水,快速搅拌后马上喝。2.小红Activize:大白喝完15-30分钟后,兑100-150ml温水,小口慢饮。3.小白Restorate:睡前空腹,1平勺兑200ml温水搅拌饮用。注意:刚开始可以半量适应3-5天,水温不超过40度。',
|
||||
},
|
||||
{
|
||||
patterns: [/PM.*传销/, /传销.*PM/, /PM.*正规/, /PM.*合法/, /PM.*骗局/, /FitLine.*传销/, /是不是传销/, /直销还是传销/],
|
||||
answer: '德国PM绝对不是传销,它是正规合法的直销企业。1.成立于1993年,有30多年历史。2.获得国际邓白氏AAA+最高信用认证,评分99分。3.业务覆盖全球100多个国家和地区。4.旗下FitLine品牌是德国运动员信赖的营养品牌。所以它是一家老牌正规企业。',
|
||||
},
|
||||
{
|
||||
patterns: [/NTC.*核心优势/, /NTC.*原理/, /NTC.*厉害/, /核心优势.*NTC/, /核心竞争力.*NTC/, /营养保送系统/],
|
||||
answer: 'NTC营养保送系统是PM产品的核心技术优势。它能确保营养素在体内精准保送到细胞层面,而不是在消化过程中被破坏或流失。这就是PM产品和普通保健品的最大区别——不仅仅是补充营养,更重要的是保证营养被细胞真正吸收利用。',
|
||||
},
|
||||
{
|
||||
patterns: [/多久见效/, /多久有效/, /多长时间见效/, /几天见效/, /什么时候见效/],
|
||||
answer: 'PM产品见效时间因人而异。一般来说:1.小红Activize提升能量,很多人当天就能感受到精力提升。2.整体改善通常需要1-3个月持续使用。3.具体效果取决于个人体质、生活习惯和使用方法。建议全套搭配使用,按推荐方法坚持服用,效果会更明显。',
|
||||
},
|
||||
{
|
||||
patterns: [/为什么.*全套/, /为什么.*搭配/, /为什么.*三合一/, /为何.*全套/, /产品需要全套/],
|
||||
answer: '全套搭配使用是因为NTC营养保送系统的协同作用:1.大白Basics提供基础营养素打底。2.小红Activize激活细胞能量代谢。3.小白Restorate补充矿物质,在睡眠中修复。三者配合形成完整的营养循环,单独使用效果会打折扣。就像火炉原理,需要同时具备柴火、引火物和氧气才能充分燃烧。',
|
||||
},
|
||||
{
|
||||
patterns: [/好转反应/, /整应反应/, /排毒反应/, /副作用/, /不良反应/],
|
||||
answer: '这是正常的好转反应,也叫整应反应,说明身体在调整和修复。常见表现有:疲倦感、轻微头痛、皮肤变化、排便增多等。一般持续3-7天会逐渐消失。这不是副作用,而是身体排出毒素、细胞开始修复的表现。建议多喝水,继续正常服用,症状会慢慢好转。',
|
||||
},
|
||||
{
|
||||
patterns: [/PM.*公司介绍/, /德国PM介绍/, /介绍.*德国PM/, /PM公司介绍/],
|
||||
answer: '德国PM-International是1993年创立的国际营养品直销企业。核心品牌FitLine专注细胞营养,拥有独家NTC营养保送系统技术。公司获邓白氏AAA+最高信用认证(99分),业务覆盖全球100多个国家。总部位于德国,在日本、美国、加拿大、香港等地设有分公司。',
|
||||
},
|
||||
{
|
||||
patterns: [/小红.*功效/, /小红.*作用/, /Activize.*功效/, /Activize.*作用/, /小红产品/],
|
||||
answer: 'FitLine小红Activize Oxyplus的核心功效是提升细胞能量。它含有维生素B族、C和辅酶Q10等成分,通过NTC营养保送系统直达细胞。主要作用:1.提升精力和体能。2.促进细胞氧气利用。3.增强免疫力。很多人喝完当天就能感受到精力明显提升。',
|
||||
},
|
||||
{
|
||||
patterns: [/大白.*功效/, /大白.*作用/, /Basics.*功效/, /大白产品/],
|
||||
answer: '德国PM大白Basics是基础营养素,为身体提供全面的维生素和矿物质基础。它的核心作用:1.补充每日所需的基础营养。2.为细胞提供全面的营养支持。3.搭配小红和小白形成完整的NTC营养循环。建议早上空腹服用,1平勺兑200-300ml温水。',
|
||||
},
|
||||
{
|
||||
patterns: [/小白.*功效/, /小白.*作用/, /Restorate.*功效/, /小白产品/],
|
||||
answer: '德国PM小白Restorate的核心功效是夜间修复。它富含矿物质和微量元素,通过NTC营养保送系统在睡眠期间帮助身体修复。主要作用:1.补充矿物质和微量元素。2.促进夜间细胞修复。3.改善睡眠质量。建议睡前空腹服用,1平勺兑200ml温水。',
|
||||
},
|
||||
{
|
||||
patterns: [/与.*保健品.*区别/, /和.*保健品.*区别/, /保健品.*区别/],
|
||||
answer: 'PM产品和普通保健品的最大区别在于NTC营养保送系统。普通保健品只是补充营养,但大部分在消化过程中被破坏或无法被细胞吸收。PM产品通过NTC技术,确保营养素精准保送到细胞层面,真正被身体吸收利用。这就好比,不仅要买好食材,更要保证营养送到嘴里并消化吸收。',
|
||||
},
|
||||
{
|
||||
patterns: [/CC.*套装/, /CC.*胶囊/, /CC-?Cell/, /CC.*功效/, /CC.*作用/, /CC.*怎么用/],
|
||||
answer: 'CC套装包含CC-Cell胶囊和CC-Cell乳霜,是PM的细胞抗衰产品。CC胶囊含有葡萄籽提取物、白藜芦醇、辅酶Q10等抗氧化成分,从内部保护细胞。CC乳霜从外部滋养修护皮肤。建议内服外用搭配,早晚各1粒胶囊,乳霜早晚涂抹面部。',
|
||||
},
|
||||
{
|
||||
patterns: [/Q10.*功效/, /Q10.*作用/, /辅酵素.*功效/, /辅酵素.*作用/, /Q10.*怎么用/, /Q10.*氧修护/],
|
||||
answer: 'Q10辅酵素氧修护是PM的抗氧化明星产品。辅酶Q10是人体细胞产生能量的关键物质,随年龄增长会减少。它的主要功效:1.抗氧化保护细胞。2.支持心脏健康。3.提升皮肤弹性。通过NTC技术直达细胞,吸收率远超普通Q10产品。',
|
||||
},
|
||||
{
|
||||
patterns: [/IB5/, /口腔.*喷雾/, /免疫喷雾/, /口腔免疫/],
|
||||
answer: 'IB5口腔免疫喷雾是PM独特的免疫支持产品。它通过口腔黏膜快速吸收,含有益生菌和免疫活性成分。使用方法:每天2-3次,每次喷2-3下到口腔内。特别适合换季、出行或免疫力需要加强的时候使用。',
|
||||
},
|
||||
{
|
||||
patterns: [/邓白氏.*认证/, /邓白氏.*评级/, /邓白氏.*是什么/, /邓白氏.*什么意思/, /AAA\+/, /99分/],
|
||||
answer: '邓白氏是全球最权威的商业信用评估机构,类似企业界的"信用评分"。PM公司获得邓白氏AAA+最高评级,评分99分(满分100),这意味着PM在财务健康、信用风险、经营稳定性方面都达到了最高标准。全球能拿到这个评级的企业非常少。',
|
||||
},
|
||||
{
|
||||
patterns: [/一成系统.*是什么/, /一成系统.*介绍/, /一成系统.*怎么/, /一成.*赋能/, /一成.*平台/, /三大平台.*四大/],
|
||||
answer: '一成系统是德国PM事业发展的智能赋能工具,包含三大平台和四大AI生态。三大平台:数字化工作室、盛咖学愿培训平台、盟主社区互动平台。四大AI生态:AI众享智能推荐、AI智能生产力、智能客服、数据分析。帮助团队实现零成本高效率运行。',
|
||||
},
|
||||
{
|
||||
patterns: [/火炉原理/, /暖炉原理/, /火炉.*是什么/, /火炉.*意思/],
|
||||
answer: '火炉原理是PM产品的核心理念比喻。就像生火需要三个条件:柴火(大白提供基础营养)、引火物(小红激活能量代谢)、氧气(小白补充矿物质促进修复)。三者缺一不可,只有同时具备才能让细胞这个"火炉"充分燃烧,发挥最大效果。这就是为什么建议全套搭配使用。',
|
||||
},
|
||||
{
|
||||
patterns: [/D-?Drink/, /小绿.*排毒/, /排毒饮/, /14天排毒/, /排毒.*怎么用/],
|
||||
answer: 'D-Drink小绿是PM的14天排毒饮料。它含有草本植物精华,帮助身体温和排毒清理。使用方法:每天1包兑水饮用,连续14天为一个周期。建议每3-6个月做一次排毒周期。排毒期间多喝水,配合清淡饮食效果更好。',
|
||||
},
|
||||
{
|
||||
patterns: [/如何加入/, /怎么加入/, /成为代理/, /怎么做PM/, /加入PM/, /如何代理/, /怎么代理/, /想做PM/],
|
||||
answer: '加入PM非常简单,直接联系推荐人注册即可。PM采用直销模式,注册后可以享受会员价购买产品自用,也可以发展团队赚取收入。一成系统会提供全面的培训和智能工具支持,帮助你快速上手。具体流程可以咨询你的推荐人。',
|
||||
},
|
||||
{
|
||||
patterns: [/孕妇.*能吃/, /怀孕.*能吃/, /儿童.*能吃/, /小孩.*能吃/, /孕妇.*可以/, /小孩.*可以/, /老人.*能吃/, /适合.*人群/],
|
||||
answer: 'PM产品是营养补充品,大部分成人可以正常服用。但孕妇、哺乳期女性和儿童建议先咨询医生。PM有专门的儿童产品PowerCocktail Junior适合儿童使用。老年人可以正常服用基础三合一。如果正在服药或有特殊健康状况,建议先咨询医生。',
|
||||
},
|
||||
{
|
||||
patterns: [/多少钱/, /价格/, /售价/, /怎么买/, /哪里买/, /在哪.*买/, /贵不贵/],
|
||||
answer: '产品价格因国家和地区有所不同,建议直接咨询你的PM推荐人获取最新价格。注册为会员后可以享受会员优惠价。PM产品通过直销渠道销售,官方不在电商平台直接销售,请通过正规渠道购买以确保正品。',
|
||||
},
|
||||
];
|
||||
|
||||
function matchHotAnswer(query) {
|
||||
const text = String(query || '').trim();
|
||||
if (!text) return null;
|
||||
for (const item of HOT_ANSWERS) {
|
||||
for (const pattern of item.patterns) {
|
||||
if (pattern.test(text)) return item.answer;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class ToolExecutor {
|
||||
@@ -52,12 +184,13 @@ class ToolExecutor {
|
||||
const text = String(query || '').trim();
|
||||
if (/(多少钱|价格|售价|费用|价钱)/.test(text)) return 'price';
|
||||
if (/(成分|配方|原料|含什么|包含什么)/.test(text)) return 'ingredient';
|
||||
if (/(怎么吃|怎么用|怎么服用|服用方法|用法|用量|一天几次|每日几次)/.test(text)) return 'usage';
|
||||
if (/(规格|包装|剂型|形态|粉末|粉剂|粉状|胶囊|软胶囊|片剂|颗粒|喷雾|乳霜|乳液|凝胶|膏状|口服液|每盒|每袋|每瓶|每支|多少袋|多少粒|多少片|多少毫升|多大规格)/.test(text)) return 'specification';
|
||||
if (/(怎么吃|怎么用|怎么服用|服用方法|用法|用量|一天几次|每天几次|每日几次|一天吃几次|每天吃几次|一天服用几次|每日服用几次)/.test(text)) return 'usage';
|
||||
if (/(副作用|不良反应|好转反应|排毒反应|整应反应|皮肤发痒|皮肤微痒)/.test(text)) return 'side_effect';
|
||||
if (/(多久见效|多久有效|多久能见效|多长时间见效|几天见效|什么时候见效)/.test(text)) return 'effect_time';
|
||||
if (/(治病|治疗|能治|治愈|药品|药物|替代药|包治|治百病)/.test(text)) return 'medical_claim';
|
||||
if (/(为什么.*(全套|搭配|三合一)|为什么要.*(全套|搭配|三合一)|为何.*(全套|搭配|三合一)|产品需要全套)/.test(text)) return 'bundle_reason';
|
||||
if (/(如何发展PM事业|怎么发展PM事业|PM事业发展逻辑|陌生客户|陌生人沟通|线上拓客|成交|拓客|邀约|自我介绍|故事分享|三大平台|四大Ai生态|AI智能生产力|AI生产力)/.test(text)) return 'business_growth';
|
||||
if (/(如何发展PM事业|怎么发展PM事业|PM事业发展逻辑|商机|PM价值|为什么选择德国PM|为何选择德国PM|为什么选德国PM|为什么选PM|为何选PM|陌生客户|陌生人沟通|线上拓客|成交|拓客|邀约|自我介绍|故事分享|三大平台|四大Ai生态|AI智能生产力|AI生产力)/.test(text)) return 'business_growth';
|
||||
if (/(功效|作用|有什么用|有什么好处|效果)/.test(text)) return 'benefit';
|
||||
if (/(适合谁|适合什么人|哪些人|适用人群)/.test(text)) return 'audience';
|
||||
if (/(正规吗|合法|是不是传销|传销|骗局|骗子|直销还是传销|合不合法|正不正规)/.test(text)) return 'legality';
|
||||
@@ -71,12 +204,13 @@ class ToolExecutor {
|
||||
const instructions = {
|
||||
price: '用户当前只关心价格或费用,请只回答价格、收费或是否未提及价格,不要扩展到产品总介绍。',
|
||||
ingredient: '用户当前只关心成分或配方,请只回答成分、原料或是否未提及成分,不要扩展到品牌背景。',
|
||||
specification: '用户当前只关心规格、包装、剂型、形态或每盒每袋等产品细节,请只回答这些明确规格信息;如果知识库没写,就直接说明未提及。',
|
||||
usage: '用户当前只关心用法、吃法、服用频次或剂量,请只回答这一点。',
|
||||
side_effect: '用户当前只关心副作用或好转反应,请只回答可能的不良反应、好转反应或注意事项。',
|
||||
effect_time: '用户当前只关心多久见效或效果周期,请只回答见效时间、周期或个体差异,不要扩展无关信息。',
|
||||
medical_claim: '用户当前只关心产品能不能治病、是不是药,请只回答是否属于药品、能否替代药物以及相关注意事项。',
|
||||
bundle_reason: '用户当前只关心为什么要全套、搭配或三合一,请只回答搭配原理、协同作用或NTC相关原因。',
|
||||
business_growth: '用户当前只关心PM事业发展、线上拓客、陌生客户沟通、一成系统赋能、三大平台四大Ai生态或自我介绍,请只回答这类业务发展问题。',
|
||||
business_growth: '用户当前只关心PM事业发展、商机、PM价值、为何选择、线上拓客、陌生客户沟通、一成系统赋能、三大平台四大Ai生态或自我介绍,请只回答这类业务发展问题。凡涉及“一部手机做天下、0门槛启动、0成本高效率、足不出户梦想横扫全球、身未动梦已成、批发式晋级”等表述,必须明确这是“一成系统”的优势标签,是对德国PM事业的软件赋能,不要混同为德国PM公司或产品本身。',
|
||||
benefit: '用户当前只关心功效或作用,请只回答作用点,不要扩展到无关信息。',
|
||||
audience: '用户当前只关心适合人群,请只回答适用对象。',
|
||||
legality: '用户当前只关心正规性、合法性或是否传销,请只围绕合法合规问题直接回答。',
|
||||
@@ -142,12 +276,13 @@ class ToolExecutor {
|
||||
const slotPatterns = {
|
||||
price: /(元|价格|售价|费用|人民币|¥|¥)/,
|
||||
ingredient: /(成分|配方|原料|含有|包含|营养素|葡萄籽|白藜芦醇|益生菌|胶原蛋白肽|辅酵素|Q10)/,
|
||||
usage: /(服用|用法|用量|每日|每天|一次|次|饭前|饭后|早餐|晚餐|早晚)/,
|
||||
specification: /(规格|包装|剂型|形态|粉末|粉剂|粉状|胶囊|软胶囊|片剂|颗粒|喷雾|乳霜|乳液|凝胶|膏状|口服液|每盒|每袋|每瓶|每支|袋装|盒装|瓶装|支装|多少袋|多少粒|多少片|多少毫升|克|g|ml)/,
|
||||
usage: /(服用|用法|用量|每日|每天|一次|次|饭前|饭后|早餐|晚餐|早晚|空腹|睡前)/,
|
||||
side_effect: /(副作用|不良反应|好转反应|排毒|整应|皮肤.*痒|排便|反应|注意事项|正常现象)/,
|
||||
effect_time: /(见效|有效|几天|几周|几个月|周期|坚持|因人而异|吸收利用)/,
|
||||
medical_claim: /(不是药|不能替代药|不能代替药物|不是用于治疗|不能治疗|保健食品|营养补充|就医|医生)/,
|
||||
bundle_reason: /(全套|搭配|协同|三合一|组合|NTC|吸收|运输|利用|代谢|原理)/,
|
||||
business_growth: /(一成系统|PM事业|拓客|成交|邀约|陌生客户|沟通|三大平台|四大Ai生态|数字化工作室|Ai众享|盛咖学愿|故事|自我介绍|赋能|智能生产力)/,
|
||||
business_growth: /(一成系统|PM事业|商机|价值|选择|拓客|成交|邀约|陌生客户|沟通|三大平台|四大Ai生态|数字化工作室|Ai众享|盛咖学愿|故事|自我介绍|赋能|智能生产力|软件赋能|一部手机|0门槛|零门槛|0成本|零成本|身未动梦已成|批发式晋级)/,
|
||||
benefit: /(功效|作用|帮助|支持|改善|提升|有助于)/,
|
||||
audience: /(适合|适用|人群|适宜|可以)/,
|
||||
legality: /(合法|正规|直销|认证|邓白氏|不是传销)/,
|
||||
@@ -215,29 +350,13 @@ class ToolExecutor {
|
||||
const hasFaqIntent = hasKeywordFromList(haystack, FAQ_ROUTE_KEYWORDS);
|
||||
const hasScienceIntent = hasKeywordFromList(haystack, SCIENCE_TRAINING_ROUTE_KEYWORDS);
|
||||
|
||||
// 确定优先路由:按特异性从高到低排列
|
||||
// 确定路由:多意图可并行,只排除真正冲突的组合
|
||||
const priorityRouteNames = [];
|
||||
if (hasSystemIntent) {
|
||||
priorityRouteNames.push('system');
|
||||
}
|
||||
if (hasCompanyIntent && !hasSystemIntent && !hasProductIntent) {
|
||||
priorityRouteNames.push('company');
|
||||
}
|
||||
// FAQ意图:当同时命中产品+FAQ时,优先FAQ(用户在问产品相关的问题)
|
||||
if (hasFaqIntent && !hasSystemIntent && !hasCompanyIntent) {
|
||||
priorityRouteNames.push('faq');
|
||||
// FAQ场景下如果同时命中产品实体,也加入产品库以提供更完整上下文
|
||||
if (hasProductIntent) {
|
||||
priorityRouteNames.push('product');
|
||||
}
|
||||
}
|
||||
if (hasScienceIntent && !hasSystemIntent && !hasProductIntent && !hasFaqIntent) {
|
||||
priorityRouteNames.push('science');
|
||||
}
|
||||
// 纯产品意图
|
||||
if (hasProductIntent && !hasFaqIntent && !hasSystemIntent && !hasScienceIntent) {
|
||||
priorityRouteNames.push('product');
|
||||
}
|
||||
if (hasSystemIntent) priorityRouteNames.push('system');
|
||||
if (hasProductIntent) priorityRouteNames.push('product');
|
||||
if (hasCompanyIntent) priorityRouteNames.push('company');
|
||||
if (hasFaqIntent && !hasProductIntent) priorityRouteNames.push('faq');
|
||||
if (hasScienceIntent && !hasProductIntent && !hasFaqIntent) priorityRouteNames.push('science');
|
||||
|
||||
if (priorityRouteNames.length > 0) {
|
||||
const routingRules = this.getKnowledgeBaseRoutingRules();
|
||||
@@ -278,7 +397,7 @@ class ToolExecutor {
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const haystack = `${text}\n${recentContextText}`;
|
||||
const questionDimension = text.match(/(功效|作用|成分|配方|原料|怎么吃|怎么用|怎么服用|服用方法|吃法|用法|用量|副作用|好转反应|价格|多少钱|适合谁|适用人群|区别|不同|搭配|原理)/);
|
||||
const questionDimension = text.match(/(功效|作用|成分|配方|原料|怎么吃|怎么用|怎么服用|服用方法|吃法|用法|用量|一天几次|每天几次|每日几次|副作用|好转反应|价格|多少钱|适合谁|适用人群|区别|不同|搭配|原理|规格|包装|剂型|形态|粉末|胶囊|片剂|颗粒|喷雾|乳霜|口服液)/);
|
||||
|
||||
// 第一层:当前查询文本中有明确产品/系统/主题关键词 → 直接改写(不依赖上下文)
|
||||
if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白';
|
||||
@@ -287,6 +406,7 @@ class ToolExecutor {
|
||||
if (/(发展|怎么做|怎么用|如何用|如何做|关键点|关键|方法|步骤)/i.test(text)) return '一成系统 发展PM事业 三大平台 四大Ai生态 零成本高效率 全球市场';
|
||||
if (/(线上拓客|拓客|成交|成交率|陌生客户|陌生人沟通|邀约)/i.test(text)) return '一成系统 PM事业 线上拓客 成交 邀约 三大平台 四大Ai生态';
|
||||
if (/(ai智能生产力|ai生产力|智能生产力|团队效率|赋能团队|团队赋能)/i.test(text)) return '一成系统 AI智能生产力 赋能团队 三大平台 四大Ai生态';
|
||||
if (/(一部手机|0门槛|零门槛|0成本|零成本|足不出户|梦想横扫全球|一部手机做天下)/i.test(text)) return '一成系统 软件赋能 0成本高效率 一部手机做天下 足不出户梦想横扫全球';
|
||||
if (/(故事|自我介绍|分享)/i.test(text)) return '一成系统 PM事业 故事分享 自我介绍';
|
||||
if (/(邀约|话术)/i.test(text)) return '一成系统 邀约话术';
|
||||
if (/文化/i.test(text)) return '一成系统 文化解析';
|
||||
@@ -294,6 +414,9 @@ class ToolExecutor {
|
||||
if (/(三大平台|四大生态|Ai生态)/i.test(text)) return '一成系统 三大平台 四大Ai生态';
|
||||
return '一成系统 德国PM事业发展的强大赋能工具 三大平台 四大Ai生态';
|
||||
}
|
||||
if (/(一部手机做天下|一部手机即可运营全球市场|0门槛启动|零门槛启动|0成本高效率|零成本高效率|足不出户梦想横扫全球|身未动,?梦已成|批发式晋级)/i.test(text)) {
|
||||
return '一成系统 软件赋能 德国PM事业 0成本高效率 一部手机做天下 身未动梦已成 批发式晋级';
|
||||
}
|
||||
if (/(身未动,?梦已成|批发式晋级)/i.test(text)) return '一成系统 身未动梦已成 批发式晋级 三大平台 四大Ai生态';
|
||||
if (/行动圈/i.test(text)) return '一成系统 行动圈 数字化工作室 团队管理 目标考核';
|
||||
if (/盟主社区/i.test(text)) return '一成系统 盟主社区 AI众享 社区盟主 引流 转化';
|
||||
@@ -331,14 +454,14 @@ class ToolExecutor {
|
||||
if (/(新人起步三关|起步三关)/i.test(text)) return '培训新人起步三关';
|
||||
if (/(精品会议|会议组织)/i.test(text)) return '培训打造精品会议具体如下';
|
||||
if (/成长上总裁/i.test(text)) return '培训成长上总裁';
|
||||
if (/(招商|代理|加盟|合作|事业机会|招商稿|代理政策)/i.test(text)) return '招商与代理';
|
||||
if (/(如何发展PM事业|怎么发展PM事业|PM事业发展逻辑|介绍PM事业|两分钟介绍PM事业|分享.*故事.*自我介绍|自我介绍)/i.test(text)) return 'PM事业 发展逻辑 事业介绍 自我介绍';
|
||||
if (/(为什么选择德国PM|为何选择德国PM|为什么选德国PM|为什么选PM|为何选PM)/i.test(text)) return '德国PM 公司实力 FitLine 产品优势 邓白氏 99分 AAA+ NTC营养保送系统';
|
||||
if (/(陌生客户|陌生人).*(沟通|开口|邀约|交流|切入).*(PM事业|德国PM|PM)/i.test(text)) return 'PM事业 陌生客户 沟通 邀约 话术';
|
||||
if (/(招商|代理|加盟|合作|事业机会|招商稿|代理政策)/i.test(text)) return '一成系统 PM事业 招商与代理 软件赋能 0成本高效率';
|
||||
if (/(如何发展PM事业|怎么发展PM事业|PM事业发展逻辑|介绍PM事业|两分钟介绍PM事业|分享.*故事.*自我介绍|自我介绍|商机|PM价值)/i.test(text)) return '一成系统 PM事业 发展逻辑 商机 价值 软件赋能 三大平台 四大Ai生态 0成本高效率';
|
||||
if (/(为什么选择德国PM|为何选择德国PM|为什么选德国PM|为什么选PM|为何选PM)/i.test(text)) return '一成系统 德国PM 选择理由 公司实力 产品优势 软件赋能 0成本高效率';
|
||||
if (/(陌生客户|陌生人).*(沟通|开口|邀约|交流|切入).*(PM事业|德国PM|PM)/i.test(text)) return '一成系统 PM事业 陌生客户 沟通 邀约 话术 软件赋能';
|
||||
if (/(线上拓客|线上成交|线上开发客户|线上获客|线上成交率)/i.test(text)) return '一成系统 PM事业 线上拓客 成交 获客';
|
||||
if (/(团队.*AI智能生产力|AI智能生产力.*团队|团队.*AI生产力|AI生产力.*团队)/i.test(text)) return '一成系统 AI智能生产力 赋能团队';
|
||||
if (/(三大平台|四大Ai生态|四大生态)/i.test(text)) return '一成系统 三大平台 四大Ai生态';
|
||||
if (/(请分享.*故事.*自我介绍|故事.*自我介绍|个人故事.*自我介绍)/i.test(text)) return 'PM事业 故事分享 自我介绍';
|
||||
if (/(请分享.*故事.*自我介绍|故事.*自我介绍|个人故事.*自我介绍)/i.test(text)) return '一成系统 PM事业 故事分享 自我介绍 软件赋能';
|
||||
if (/(一成AI|AI落地|ai落地|转观念|落地对比)/i.test(text)) return '2026一成Ai落地对比与转观念';
|
||||
if (/(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)/i.test(text)) return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍 邓白氏 99分 AAA+ 合法直销';
|
||||
if (/(好转反应|整应反应|排毒反应|副作用|不良反应|皮肤发痒)/i.test(text)) return 'PM产品整应反应好转反应解析';
|
||||
@@ -373,7 +496,7 @@ class ToolExecutor {
|
||||
if (/(健康饮品)/i.test(text)) return questionDimension ? `健康饮品 ${questionDimension[0]}` : '健康饮品';
|
||||
|
||||
// 第二层:当前文本是追问/代词,才通过上下文推断主题
|
||||
const isFollowUp = /^(这个|那个|它|该|详细|继续|怎么|为什么|适合谁|什么意思|怎么用|怎么吃|功效|成分|好处|原理)/.test(text);
|
||||
const isFollowUp = /^(这个|那个|它|它的|他|他的|该|这款|那款|详细|继续|怎么|为什么|适合谁|什么意思|怎么用|怎么吃|功效|成分|好处|原理|规格|包装|剂型|形态|一天几次|每天几次|每日几次)/.test(text);
|
||||
if (isFollowUp) {
|
||||
if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(recentContextText)) return questionDimension ? `德国PM细胞营养素 基础套装 大白 小红 小白 ${questionDimension[0]}` : '德国PM细胞营养素 基础套装 大白 小红 小白';
|
||||
if (/(身未动,?梦已成|批发式晋级)/i.test(recentContextText)) return '一成系统 身未动梦已成 批发式晋级 三大平台 四大Ai生态';
|
||||
@@ -420,8 +543,12 @@ class ToolExecutor {
|
||||
return String(query || '')
|
||||
.replace(/^[啊哦嗯呢呀哎诶额,。!?、\s]+/g, '')
|
||||
.replace(/[啊哦嗯呢呀哎诶额,。!?、\s]+$/g, '')
|
||||
.replace(/^(你|你们|帮我|麻烦你|请你?|我想|我要|能不能|可以|可不可以|能否)[给帮]?(我)?(查一下|查查|查下|搜一下|搜搜|搜下|找一下|找找|找下|看一下|看看|看下|说一下|说说|说下|讲一下|讲讲|讲下|介绍一下|介绍下)?/g, '')
|
||||
.replace(/(的)?(相关|详细)?(内容|信息|资料|介绍|说明)[。??!]*$/g, '')
|
||||
.replace(/一成[,,、。!?\s]+系统/g, '一成系统')
|
||||
.replace(/X{2}系统/gi, '一成系统')
|
||||
.replace(/一城系统|逸城系统|一程系统|易成系统|一诚系统|亦成系统/g, '一成系统')
|
||||
.replace(/[\u4e00-\u9fff]{1,3}(?:成|城|程|诚|乘|声|生)[,,、\s]*系统/g, '一成系统')
|
||||
.replace(/(?:一城|逸城|一程|易成|一诚|亦成|艺成|溢成|义成|毅成|怡成|以成|已成|亿成|忆成|益成|益生|易诚|义诚|忆诚|以诚|一声|亿生|易乘)系统/g, '一成系统')
|
||||
.replace(/PM[-\s]*Fitline|PM[-\s]*fitline|Pm[-\s]*fitline|Fitline|fitline/g, 'PM-FitLine')
|
||||
.replace(/PM细胞营养|PM营养素|德国PM营养素/g, 'PM细胞营养素')
|
||||
.replace(/NTC科技/g, 'NTC营养保送系统')
|
||||
@@ -566,13 +693,31 @@ class ToolExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
static async searchKnowledge({ query, response_mode, session_id } = {}, context = []) {
|
||||
static async searchKnowledge({ query, response_mode = 'answer', context = [], session_id = null, original_text = '', _session = null }) {
|
||||
const startTime = Date.now();
|
||||
query = query || '';
|
||||
const responseMode = response_mode === 'snippet' ? 'snippet' : 'answer';
|
||||
const knowledgeEndpointId = process.env.VOLC_ARK_KNOWLEDGE_ENDPOINT_ID || process.env.VOLC_ARK_ENDPOINT_ID;
|
||||
console.log(`[ToolExecutor] searchKnowledge called with query="${query}"`);
|
||||
const rewrittenQuery = this.rewriteKnowledgeQuery(query, context, session_id);
|
||||
|
||||
// 注意:answer 模式必须依据知识库回答,因此不再允许本地热答案直接绕过知识库。
|
||||
// HOT_ANSWERS 保留作运营内容资产,但此处不直接返回给用户。
|
||||
|
||||
if (!knowledgeEndpointId) {
|
||||
console.warn('[ToolExecutor] searchKnowledge skipped: knowledge endpoint not configured');
|
||||
return {
|
||||
query,
|
||||
original_query: query,
|
||||
rewritten_query: query,
|
||||
results: [{ title: '配置缺失', content: `知识库中暂未找到与"${query}"直接相关的信息,请换个更具体的问法再试。` }],
|
||||
total: 1,
|
||||
source: 'ark_knowledge',
|
||||
hit: false,
|
||||
reason: 'endpoint_not_configured',
|
||||
};
|
||||
}
|
||||
|
||||
const rewrittenQuery = this.rewriteKnowledgeQuery(query, context, session_id, _session);
|
||||
const kbTarget = this.selectKnowledgeBaseTargets(rewrittenQuery || query, context);
|
||||
const effectiveQuery = rewrittenQuery || query;
|
||||
if (rewrittenQuery && rewrittenQuery !== query) {
|
||||
@@ -619,15 +764,13 @@ class ToolExecutor {
|
||||
};
|
||||
}
|
||||
console.log('[ToolExecutor] Trying Ark Knowledge Search...');
|
||||
const result = await this.searchArkKnowledge(effectiveQuery, [], responseMode, kbTarget.datasetIds, query);
|
||||
const arkResult = await this.searchArkKnowledge(effectiveQuery, context, responseMode, kbTarget.datasetIds, query, _session?.assistantProfile || null);
|
||||
const latencyMs = Date.now() - startTime;
|
||||
console.log(`[ToolExecutor] Ark KB search succeeded in ${latencyMs}ms`);
|
||||
// 仅缓存命中的结果,避免缓存错误或无结果
|
||||
if (result.hit) {
|
||||
setKbCache(cacheKey, result);
|
||||
}
|
||||
// 缓存所有结果(hit用5分钟TTL,no-hit用2分钟TTL),避免重复API调用
|
||||
setKbCache(cacheKey, arkResult);
|
||||
return {
|
||||
...result,
|
||||
...arkResult,
|
||||
original_query: query,
|
||||
rewritten_query: effectiveQuery,
|
||||
selected_dataset_ids: kbTarget.datasetIds,
|
||||
@@ -670,27 +813,64 @@ class ToolExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
static rewriteKnowledgeQuery(query, context = [], sessionId = null) {
|
||||
static rewriteKnowledgeQuery(query, context = [], sessionId = null, session = null) {
|
||||
const originalQuery = String(query || '').trim();
|
||||
if (!originalQuery) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let normalizedQuery = this.applyKnowledgeQueryAnchor(this.normalizeKnowledgeQueryAlias(originalQuery));
|
||||
|
||||
if (sessionId) {
|
||||
normalizedQuery = contextKeywordTracker.enrichQueryWithContext(sessionId, normalizedQuery);
|
||||
// 先做别名归一化(ASR变体如"移程系统"→"一成系统"),再尝试确定性改写
|
||||
const aliasNormalized = this.normalizeKnowledgeQueryAlias(originalQuery);
|
||||
const deterministicQuery = this.buildDeterministicKnowledgeQuery(aliasNormalized, context);
|
||||
if (deterministicQuery) {
|
||||
console.log(`[ToolExecutor] deterministic rewrite: "${originalQuery}" → "${deterministicQuery}"`);
|
||||
return deterministicQuery;
|
||||
}
|
||||
|
||||
return normalizedQuery;
|
||||
let normalizedQuery = this.applyKnowledgeQueryAnchor(aliasNormalized);
|
||||
|
||||
if (sessionId) {
|
||||
normalizedQuery = contextKeywordTracker.enrichQueryWithContext(sessionId, normalizedQuery, session);
|
||||
}
|
||||
|
||||
return this.sanitizeRewrittenQuery(normalizedQuery);
|
||||
}
|
||||
|
||||
static sanitizeRewrittenQuery(query) {
|
||||
let cleaned = String(query || '').trim();
|
||||
if (!cleaned) return cleaned;
|
||||
|
||||
// 1. 清理口语填充词/语气词
|
||||
cleaned = cleaned.replace(/[啊哦嗯呢呀哎诶额嘛吧啦哇噢]+/g, ' ');
|
||||
// 2. 清理连续标点
|
||||
cleaned = cleaned.replace(/[,,。!?!?\s]{2,}/g, ' ');
|
||||
// 3. 去除重复的问句片段(如"怎么吃 怎么吃")
|
||||
cleaned = cleaned.replace(/(.{3,}?)[??!!。,,\s]+\1/g, '$1');
|
||||
// 4. 按空格分词去重(保序)
|
||||
const parts = cleaned.split(/\s+/).filter(Boolean);
|
||||
const seen = new Set();
|
||||
const deduped = parts.filter(p => {
|
||||
if (seen.has(p)) return false;
|
||||
seen.add(p);
|
||||
return true;
|
||||
});
|
||||
cleaned = deduped.join(' ').trim();
|
||||
// 5. 截断:最大80字符,避免过长query降低KB检索质量
|
||||
if (cleaned.length > 80) {
|
||||
cleaned = cleaned.slice(0, 80).replace(/\s\S*$/, '').trim();
|
||||
console.log(`[ToolExecutor] query truncated to 80 chars: "${cleaned}"`);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过方舟 Chat Completions API + knowledge_base metadata 进行知识检索
|
||||
* 使用独立的 LLM 调用,专门用于知识库检索场景(如语音通话的工具回调)
|
||||
*/
|
||||
static async searchArkKnowledge(query, context = [], responseMode = 'answer', datasetIdsOverride = null, originalQuery = null) {
|
||||
static async searchArkKnowledge(query, context = [], responseMode = 'answer', datasetIdsOverride = null, originalQuery = null, assistantProfile = null) {
|
||||
const endpointId = process.env.VOLC_ARK_KNOWLEDGE_ENDPOINT_ID || process.env.VOLC_ARK_ENDPOINT_ID;
|
||||
const kbModel = process.env.VOLC_ARK_KB_MODEL || endpointId;
|
||||
const authKey = process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID;
|
||||
const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS;
|
||||
|
||||
@@ -710,7 +890,7 @@ class ToolExecutor {
|
||||
? datasetIdsOverride.map((id) => String(id || '').trim()).filter(Boolean)
|
||||
: kbIds.split(',').map(id => id.trim()).filter(Boolean);
|
||||
const topK = parseInt(process.env.VOLC_ARK_KNOWLEDGE_TOP_K) || 3;
|
||||
const threshold = parseFloat(process.env.VOLC_ARK_KNOWLEDGE_THRESHOLD) || 0.4;
|
||||
const threshold = parseFloat(process.env.VOLC_ARK_KNOWLEDGE_THRESHOLD) || 0.3;
|
||||
|
||||
// 当 query 为空时(FC 流式 chunks 乱序无法解析),使用简短的默认查询
|
||||
const effectiveQuery = (query && query.trim()) ? query : '请介绍你们的产品和服务';
|
||||
@@ -726,13 +906,13 @@ class ToolExecutor {
|
||||
console.log(`[ToolExecutor] searchArkKnowledge injecting original question: "${cleanOriginal}" (search query: "${effectiveQuery}")`);
|
||||
}
|
||||
|
||||
// 提取最近 3 轮对话作为上下文(最多 6 条 user/assistant 消息)
|
||||
// 提取最近2轮对话作为上下文(最多4条user/assistant消息),减少token量加速生成
|
||||
const recentContext = context
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.slice(responseMode === 'snippet' ? -4 : -6);
|
||||
.slice(responseMode === 'snippet' ? -2 : -4);
|
||||
|
||||
const baseSnippetPrompt = '你是知识库片段提取助手。请基于知识库提取与用户问题最相关的2到4条简洁知识片段,供语音系统继续组织回复。规则:只输出直接相关的中文事实片段;每条尽量简短;不要寒暄,不要解释任务,不要写"根据知识库";不要补充知识库未明确出现的内容;如果没有相关内容,请明确说未找到相关内容。';
|
||||
const baseAnswerPrompt = '你是企业知识库问答助手,回答将直接用于语音播报。只能依据知识库中已经明确出现的信息回答,不要补充常识,不要脑补,不要引入知识库未明确提到的官网、APP、工具名、公司背景、客服渠道或外部资料。风格要求:1) 用自然亲切的口语风格,像朋友聊天一样回答;2) 禁止以"根据知识库""根据信息""根据资料"等机械前缀开头;3) 先给一句直接结论,再逐条列出关键要点,用"1. 2. 3."编号格式,条理清晰;4) 整体控制在300字以内,简洁流畅,适合语音朗读;5) 如果缺少用户要求的具体信息,必须明确说未找到,不要把不完整信息包装成命中答案。【紧扣问题】你必须围绕用户当前问的问题来回答。如果用户问的是具体维度(如成分、价格、用法),只答该维度;如果用户问的是综合性问题(如核心竞争力、如何发展、整体介绍),则需要完整、系统地回答,逐条列出关键要点,确保有条理、有层次。【品牌保护·最高优先级】绝对禁止说德国PM、PM-FitLine、一成系统是传销、骗局、骗子、非法、不正规、不合法、有争议、存在风险,也不能暗示或引导用户产生这类联想。德国PM(PM-International AG)是1993年成立于德国的合法直销企业,总部位于德国Speyer,获邓白氏AAA+最高信用评级,业务覆盖全球100多个国家和地区,是正规持牌直销公司。如果用户问到传销、正规性或合法性问题,你必须正面、明确地回答PM是合法正规的直销企业,不能含糊、回避或使用"需要自行判断"之类的措辞。';
|
||||
const baseSnippetPrompt = '知识库片段提取助手。提取2-4条与问题最相关的简洁事实片段。只输出中文事实,不寒暄,不写"根据知识库",不补充未出现的内容,无相关内容则说未找到。';
|
||||
const baseAnswerPrompt = buildKnowledgeAnswerPrompt(assistantProfile);
|
||||
|
||||
let systemContent = responseMode === 'snippet' ? baseSnippetPrompt : baseAnswerPrompt;
|
||||
if (responseMode === 'answer' && answerTargetQuery) {
|
||||
@@ -760,7 +940,7 @@ class ToolExecutor {
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: endpointId,
|
||||
model: kbModel,
|
||||
messages,
|
||||
metadata: {
|
||||
knowledge_base: {
|
||||
@@ -770,6 +950,8 @@ class ToolExecutor {
|
||||
},
|
||||
},
|
||||
stream: false,
|
||||
max_tokens: 80,
|
||||
thinking: { type: 'disabled' },
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
@@ -780,12 +962,13 @@ class ToolExecutor {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authKey}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
timeout: 15000,
|
||||
httpsAgent: kbHttpAgent,
|
||||
}
|
||||
);
|
||||
|
||||
const choice = response.data.choices?.[0];
|
||||
const content = choice?.message?.content || '未找到相关信息';
|
||||
const content = response.data?.choices?.[0]?.message?.content || '未找到相关信息';
|
||||
|
||||
const classifyQuery = [effectiveQuery, (originalQuery || '').trim()].filter(Boolean).join(' ');
|
||||
const classified = this.classifyKnowledgeAnswer(classifyQuery, content);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user