refactor(server): optimize KB retrieval and voice context

This commit is contained in:
User
2026-03-31 09:46:40 +08:00
parent 56940676f6
commit 5b824cd16a
15 changed files with 3135 additions and 143 deletions

View File

@@ -21,6 +21,7 @@ const {
splitTextForSpeech,
estimateSpeechDurationMs,
shouldForceKnowledgeRoute,
isPureChitchat,
resolveReply,
} = require('./realtimeDialogRouting');
const ToolExecutor = require('./toolExecutor');
@@ -36,6 +37,9 @@ const redisClient = require('./redisClient');
const sessions = new Map();
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const AUDIO_KEEPALIVE_INTERVAL_MS = 20 * 1000;
// 3200 bytes ≈ 66ms of silence at 24kHz s16le mono (larger frame to ensure S2S acceptance)
const SILENT_AUDIO_FRAME = Buffer.alloc(3200, 0);
const DEFAULT_VOICE_BOT_NAME = DEFAULT_VOICE_ASSISTANT_PROFILE.nickname;
@@ -60,6 +64,25 @@ function resetIdleTimer(session) {
}, IDLE_TIMEOUT_MS);
}
function startAudioKeepalive(session) {
clearInterval(session._audioKeepaliveTimer);
session._audioKeepaliveTimer = setInterval(() => {
if (session.upstream && session.upstream.readyState === WebSocket.OPEN && session.upstreamReady) {
session.upstream.send(createAudioMessage(session.sessionId, SILENT_AUDIO_FRAME));
console.log(`[NativeVoice] audio keepalive sent session=${session.sessionId}`);
} else {
console.log(`[NativeVoice] audio keepalive skipped session=${session.sessionId} ready=${session.upstreamReady} wsState=${session.upstream ? session.upstream.readyState : 'null'}`);
}
}, AUDIO_KEEPALIVE_INTERVAL_MS);
}
function resetAudioKeepalive(session) {
if (session._audioKeepaliveTimer) {
clearInterval(session._audioKeepaliveTimer);
startAudioKeepalive(session);
}
}
function sendJson(ws, payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
@@ -73,7 +96,8 @@ function buildStartSessionPayload(options) {
return {
asr: {
extra: {
context: '一成,一成系统,大沃,PM,PM-FitLine,FitLine,细胞营养素,Ai众享,AI众享,盛咖学愿,数字化工作室,Activize,Basics,Restorate,NTC,基础三合一,招商,阿育吠陀,小红产品,小红,小白,大白,肽美,艾特维,德丽,德维,宝丽,美固健,Activize Oxyplus,Basic Power,CitrusCare,NutriSunny,Q10,Omega,葡萄籽,白藜芦醇,益生菌,胶原蛋白肽,Germany,FitLine细胞营养,FitLine营养素,德国PM营养素,德国PM FitLine,德国PM细胞营养,德国PM产品,德国PM健康,德国PM事业,德国PM招商,一成,一成团队,一成商学院,数字化,数字化运营,数字化经营,数字化营销,数字化创业,数字化工作室,数字化事业,招商加盟,合作加盟,事业合作',
context: '一成,一成系统,大沃,PM,PM-FitLine,FitLine,细胞营养素,Ai众享,AI众享,盛咖学愿,数字化工作室,Activize,Basics,Restorate,NTC,基础三合一,基础套装,招商,阿育吠陀,小红产品,小红,小白,大白,肽美,艾特维,德丽,德维,宝丽,美固健,Activize Oxyplus,Basic Power,CitrusCare,NutriSunny,Q10,Omega,葡萄籽,白藜芦醇,益生菌,胶原蛋白肽,Germany,FitLine细胞营养,FitLine营养素,德国PM营养素,德国PM FitLine,德国PM细胞营养,德国PM产品,德国PM健康,德国PM事业,德国PM招商,一成,一成团队,一成商学院,数字化,数字化运营,数字化经营,数字化营销,数字化创业,数字化工作室,数字化事业,招商加盟,合作加盟,事业合作,活力健,倍力健,氨基酸,乐活,排毒饮,小绿,纤萃,草本茶,发宝,乳酪煲,关节套装,细胞抗氧素,辅酵素,氧修护,CC套装,CC-Cell,Generation 50+,ProShape,D-Drink,IB5,MEN+,儿童倍适,小红精华液,PowerCocktail,PowerCocktail Junior,TopShape,Fitness-Drink,Herbal Tea,Hair+,Med Dental+,Young Care,Zellschutz,Apple Antioxy,Antioxy,BCAA,Women+,小黑,发健,口腔免疫喷雾,乳清蛋白,男士护肤,去角质,面膜,叶黄素,维适多,护理牙膏,火炉原理,暖炉原理,运动饮料,健康饮品,好转反应,整健反应,骨骼健,顾心,舒采健,衡醇饮,小粉C,异黄酮,倍适,眼霜,洁面,爽肤水',
boosting_table_id: 'ab4fde15-79b5-47e9-82b6-5125cca39f63',
nbest: 1,
},
},
@@ -417,17 +441,8 @@ 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 });
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 = /^(喂|你好|嗨|hi|hello|谢谢|谢谢你|谢谢啦|多谢|感谢|再见|拜拜|拜|好的|嗯|哦|行|没事了|不用了|可以了|好的谢谢|没问题|知道了|明白了|了解了|好嘞|好吧|行吧|ok|okay)[,。!?~\s]*$/i.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`);
}
}
// KB-First: 所有非闲聊查询都视为KB候选阻断S2S音频等待KB结果
let isKnowledgeCandidate = !isPureChitchat(cleanText);
if (isKnowledgeCandidate) {
session.blockUpstreamAudio = true;
suppressUpstreamReply(session, 30000);
@@ -623,6 +638,7 @@ function handleUpstreamMessage(session, data) {
session.upstreamReady = true;
console.log(`[NativeVoice] upstream ready session=${session.sessionId}`);
resetIdleTimer(session);
startAudioKeepalive(session);
sendGreeting(session);
return;
}
@@ -879,13 +895,12 @@ function handleUpstreamMessage(session, data) {
const normalizedPartial = normalizeKnowledgeAlias(text);
session.latestUserText = normalizedPartial;
session._lastPartialAt = now;
// 提前阻断:部分识别文字含知识库关键词时,立即阻断S2S音频防止有害内容播出
if (normalizedPartial.length >= 6 && !session.blockUpstreamAudio && shouldForceKnowledgeRoute(normalizedPartial)) {
// KB-First: 非闲聊文本一律提前阻断S2S音频防止有害内容播出
if (normalizedPartial.length >= 6 && !session.blockUpstreamAudio && !isPureChitchat(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))}`);
console.log(`[NativeVoice] early block: non-chitchat partial 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)) {
@@ -1005,6 +1020,7 @@ function attachClientHandlers(session) {
if (isBinary) {
if (session.upstream && session.upstream.readyState === WebSocket.OPEN && session.upstreamReady) {
session.upstream.send(createAudioMessage(session.sessionId, raw));
resetAudioKeepalive(session);
}
return;
}
@@ -1027,11 +1043,11 @@ function attachClientHandlers(session) {
});
session.assistantProfile = assistantProfile;
session.botName = parsed.botName || assistantProfile.nickname || DEFAULT_VOICE_BOT_NAME;
session.systemRole = parsed.systemRole || buildVoiceSystemRole(assistantProfile);
session.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 || buildVoiceGreeting(assistantProfile);
session.greetingText = buildVoiceGreeting(assistantProfile);
// 立即发送 ready不等 upstream event 150大幅缩短前端等待时间
sendReady(session);
session.upstream = createUpstreamConnection(session);
@@ -1069,6 +1085,7 @@ function attachClientHandlers(session) {
clearTimeout(session.readyTimer);
clearTimeout(session.suppressReplyTimer);
clearTimeout(session.idleTimer);
clearInterval(session._audioKeepaliveTimer);
if (session.upstream && session.upstream.readyState === WebSocket.OPEN) {
session.upstream.close();
}
@@ -1108,6 +1125,8 @@ function createUpstreamConnection(session) {
upstream.on('close', (code) => {
console.log(`[NativeVoice] upstream closed session=${session.sessionId} code=${code}`);
session.upstreamReady = false;
clearInterval(session._audioKeepaliveTimer);
session._audioKeepaliveTimer = null;
sendJson(session.client, { type: 'upstream_closed', code });
setTimeout(() => {
if (session.client && session.client.readyState === WebSocket.OPEN) {
@@ -1181,6 +1200,7 @@ function createSession(client, sessionId) {
_audioBlockLogOnce: false,
_lastFinalNormalized: '',
_lastFinalAt: 0,
_audioKeepaliveTimer: null,
};
sessions.set(sessionId, session);
attachClientHandlers(session);