feat: conversation long-term memory + fix source ENUM bug
- New: conversationSummarizer.js (LLM summary every 3 turns, loadBestSummary, persistFinalSummary) - db/index.js: conversation_summaries table, upsertConversationSummary, getSessionSummary - redisClient.js: setSummary/getSummary (TTL 2h) - nativeVoiceGateway.js: _turnCount tracking, trigger summarize, persist on close - realtimeDialogRouting.js: inject summary context, reduce history 5->3 rounds - Fix: messages source ENUM missing 'search_knowledge' causing chat DB writes to fail
This commit is contained in:
200
test2/server/services/conversationSummarizer.js
Normal file
200
test2/server/services/conversationSummarizer.js
Normal file
@@ -0,0 +1,200 @@
|
||||
const axios = require('axios');
|
||||
const redisClient = require('./redisClient');
|
||||
const db = require('../db');
|
||||
|
||||
// ============ 配置 ============
|
||||
const ENABLED = (process.env.ENABLE_CONVERSATION_SUMMARY || 'true') !== 'false';
|
||||
const SUMMARIZE_EVERY_N_TURNS = parseInt(process.env.SUMMARY_EVERY_N_TURNS) || 3;
|
||||
const SUMMARY_MAX_TOKENS = parseInt(process.env.SUMMARY_MAX_TOKENS) || 120;
|
||||
const MIN_TURNS_TO_PERSIST = parseInt(process.env.SUMMARY_MIN_TURNS_TO_PERSIST) || 2;
|
||||
const SUMMARY_MODEL = process.env.VOLC_ARK_KB_MODEL || process.env.VOLC_ARK_ENDPOINT_ID || '';
|
||||
const ARK_API_KEY = process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID || '';
|
||||
const ARK_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3/chat/completions';
|
||||
|
||||
const SUMMARY_PROMPT = `你是对话摘要助手。将以下对话历史浓缩为简短摘要,必须保留:
|
||||
1. 用户询问过的所有产品名称和具体问题
|
||||
2. AI给出的关键数字(剂量、价格、数量等)
|
||||
3. 用户表达的偏好或关注点
|
||||
4. 未解决的问题或用户的疑虑
|
||||
|
||||
规则:只输出摘要正文,不加前缀或标题。150字以内。用"用户"和"助手"指代双方。`;
|
||||
|
||||
// ============ LLM 摘要生成 ============
|
||||
|
||||
async function summarizeConversation(existingSummary, recentMessages) {
|
||||
if (!ARK_API_KEY || !SUMMARY_MODEL) {
|
||||
console.warn('[Summarizer] missing ARK_API_KEY or SUMMARY_MODEL, skip');
|
||||
return null;
|
||||
}
|
||||
|
||||
const transcript = recentMessages
|
||||
.map((m) => `${m.role === 'user' ? '用户' : '助手'}:${(m.content || '').trim()}`)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (!transcript) return null;
|
||||
|
||||
const userContent = existingSummary
|
||||
? `已有摘要:${existingSummary}\n\n新增对话:\n${transcript}`
|
||||
: `对话记录:\n${transcript}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(ARK_BASE_URL, {
|
||||
model: SUMMARY_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: SUMMARY_PROMPT },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
max_tokens: SUMMARY_MAX_TOKENS,
|
||||
stream: false,
|
||||
thinking: { type: 'enabled' },
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ARK_API_KEY}`,
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const content = response.data?.choices?.[0]?.message?.content;
|
||||
return content ? content.trim() : null;
|
||||
} catch (err) {
|
||||
console.warn('[Summarizer] LLM call failed:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 触发检查 ============
|
||||
|
||||
function triggerSummarizeIfNeeded(session, sessionId) {
|
||||
if (!ENABLED) return;
|
||||
const turnCount = session._turnCount || 0;
|
||||
if (turnCount < SUMMARIZE_EVERY_N_TURNS) return;
|
||||
if (turnCount % SUMMARIZE_EVERY_N_TURNS !== 0) return;
|
||||
|
||||
// 异步执行,不阻塞对话
|
||||
_doSummarize(session, sessionId).catch((err) => {
|
||||
console.warn('[Summarizer] async summarize failed:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
async function _doSummarize(session, sessionId) {
|
||||
// 获取现有摘要
|
||||
const existingSummary = await redisClient.getSummary(sessionId);
|
||||
|
||||
// 获取最近3轮原文
|
||||
let recent = await redisClient.getRecentHistory(sessionId, SUMMARIZE_EVERY_N_TURNS);
|
||||
if (!recent || recent.length < 2) {
|
||||
// Redis 缺失时从 DB 降级
|
||||
try {
|
||||
recent = await db.getHistoryForLLM(sessionId, SUMMARIZE_EVERY_N_TURNS * 2);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (!recent || recent.length < 2) return;
|
||||
|
||||
const summary = await summarizeConversation(existingSummary, recent);
|
||||
if (!summary) return;
|
||||
|
||||
// 双写 Redis + MySQL
|
||||
await redisClient.setSummary(sessionId, summary);
|
||||
db.upsertConversationSummary(sessionId, session.userId || null, summary, {
|
||||
turnCount: session._turnCount || 0,
|
||||
topics: extractTopicTags(summary),
|
||||
}).catch((err) => {
|
||||
console.warn('[Summarizer] MySQL upsert failed:', err.message);
|
||||
});
|
||||
|
||||
console.log(`[Summarizer] session=${sessionId} turn=${session._turnCount} summary=${summary.length}chars`);
|
||||
}
|
||||
|
||||
// ============ 三级降级加载 ============
|
||||
|
||||
async function loadBestSummary(sessionId) {
|
||||
// L1: Redis(~1ms)
|
||||
try {
|
||||
const redisSummary = await redisClient.getSummary(sessionId);
|
||||
if (redisSummary) return redisSummary;
|
||||
} catch { /* continue to L2 */ }
|
||||
|
||||
// L2: MySQL conversation_summaries(~5ms)
|
||||
try {
|
||||
const row = await db.getSessionSummary(sessionId);
|
||||
if (row && row.summary) {
|
||||
// 回填 Redis
|
||||
redisClient.setSummary(sessionId, row.summary).catch(() => {});
|
||||
return row.summary;
|
||||
}
|
||||
} catch { /* continue to L3 */ }
|
||||
|
||||
// L3: 降级到现有确定性摘要(由调用方处理)
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============ 会话结束时持久化 ============
|
||||
|
||||
async function persistFinalSummary(session) {
|
||||
if (!ENABLED) return;
|
||||
if (!session._turnCount || session._turnCount < MIN_TURNS_TO_PERSIST) return;
|
||||
|
||||
const sessionId = session.sessionId;
|
||||
|
||||
// 优先用已有的 LLM 摘要
|
||||
let summary = null;
|
||||
try {
|
||||
summary = await redisClient.getSummary(sessionId);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// 如果还没生成过摘要(对话不足3轮但>=2轮),立刻生成一次
|
||||
if (!summary) {
|
||||
let history = await redisClient.getRecentHistory(sessionId, 5);
|
||||
if (!history || history.length < 2) {
|
||||
try {
|
||||
history = await db.getHistoryForLLM(sessionId, 10);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (history && history.length >= 2) {
|
||||
summary = await summarizeConversation(null, history);
|
||||
}
|
||||
}
|
||||
|
||||
if (!summary) return;
|
||||
|
||||
// 写入 MySQL(Redis 无需写,会话已结束)
|
||||
await db.upsertConversationSummary(sessionId, session.userId || null, summary, {
|
||||
turnCount: session._turnCount || 0,
|
||||
topics: extractTopicTags(summary),
|
||||
});
|
||||
|
||||
console.log(`[Summarizer] persisted final summary for session=${sessionId} turns=${session._turnCount}`);
|
||||
}
|
||||
|
||||
// ============ 话题标签提取 ============
|
||||
|
||||
const PRODUCT_KEYWORDS = [
|
||||
'活力健', '基础三合一', '肽美', '小红', '大白', '小白',
|
||||
'FitLine', 'PM', '一成系统', '大沃',
|
||||
'心脏宝', '关节灵', '益力康', '免疫宝', '纤体乐',
|
||||
'奥适宝', 'Optimal Set', 'Basics', 'Activize', 'Beauty',
|
||||
'Restorate', 'PowerCocktail', 'ProShape',
|
||||
];
|
||||
|
||||
function extractTopicTags(text) {
|
||||
if (!text) return null;
|
||||
const tags = new Set();
|
||||
for (const kw of PRODUCT_KEYWORDS) {
|
||||
if (text.includes(kw)) {
|
||||
tags.add(kw);
|
||||
}
|
||||
}
|
||||
const arr = [...tags].slice(0, 10);
|
||||
return arr.length > 0 ? arr : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
triggerSummarizeIfNeeded,
|
||||
summarizeConversation,
|
||||
loadBestSummary,
|
||||
persistFinalSummary,
|
||||
extractTopicTags,
|
||||
SUMMARIZE_EVERY_N_TURNS,
|
||||
};
|
||||
@@ -27,21 +27,27 @@ const {
|
||||
const ToolExecutor = require('./toolExecutor');
|
||||
const {
|
||||
DEFAULT_VOICE_ASSISTANT_PROFILE,
|
||||
DEFAULT_CONSULTANT_CONTACT,
|
||||
resolveAssistantProfile,
|
||||
getAssistantDisplayName,
|
||||
buildVoiceSystemRole,
|
||||
buildVoiceGreeting,
|
||||
} = require('./assistantProfileConfig');
|
||||
const { getAssistantProfile } = require('./assistantProfileService');
|
||||
const redisClient = require('./redisClient');
|
||||
const { checkProductLinkTrigger } = require('./productLinkTrigger');
|
||||
const { triggerSummarizeIfNeeded, persistFinalSummary } = require('./conversationSummarizer');
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
const CONSULTANT_REFERRAL_PATTERN = /咨询(?:专业|你的)?顾问|健康管理顾问|联系顾问|一对一指导|咨询专业|咨询医生|咨询营养师|咨询专业人士|建议.*咨询|问问医生|问问.*营养师/;
|
||||
|
||||
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;
|
||||
const DEFAULT_VOICE_BOT_NAME = getAssistantDisplayName(DEFAULT_VOICE_ASSISTANT_PROFILE);
|
||||
|
||||
const DEFAULT_VOICE_SYSTEM_ROLE = buildVoiceSystemRole();
|
||||
|
||||
@@ -187,6 +193,7 @@ function persistUserSpeech(session, text) {
|
||||
session.lastPersistedUserAt = now;
|
||||
session.latestUserText = cleanText;
|
||||
session.latestUserTurnSeq = (session.latestUserTurnSeq || 0) + 1;
|
||||
session._turnCount = (session._turnCount || 0) + 1;
|
||||
resetIdleTimer(session);
|
||||
db.addMessage(session.sessionId, 'user', cleanText, 'voice_asr').catch((e) => console.warn('[NativeVoice][DB] add user failed:', e.message));
|
||||
redisClient.pushMessage(session.sessionId, { role: 'user', content: cleanText, source: 'voice_asr' }).catch(() => {});
|
||||
@@ -223,6 +230,24 @@ function persistAssistantSpeech(session, text, { source = 'voice_bot', toolName
|
||||
toolName,
|
||||
sequence: `native_assistant_${now}`,
|
||||
});
|
||||
// 异步触发摘要检查(每N轮)
|
||||
if (persistToDb) {
|
||||
triggerSummarizeIfNeeded(session, session.sessionId);
|
||||
}
|
||||
if (CONSULTANT_REFERRAL_PATTERN.test(cleanText)) {
|
||||
const contact = session.consultantContact || DEFAULT_CONSULTANT_CONTACT;
|
||||
if (contact.mobile || contact.wx_qr_code || contact.wechat_id) {
|
||||
console.log(`[NativeVoice] consultant referral detected session=${session.sessionId} text=${JSON.stringify(cleanText.slice(0, 80))}`);
|
||||
sendJson(session.client, {
|
||||
type: 'consultant_contact',
|
||||
name: contact.name || '大沃专业健康管理顾问',
|
||||
mobile: contact.mobile || '',
|
||||
wx_qr_code: contact.wx_qr_code || '',
|
||||
wechat_id: contact.wechat_id || '',
|
||||
message: '如需个性化健康建议,可联系大沃专业健康管理顾问',
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -250,6 +275,7 @@ function flushAssistantStream(session, { source = 'voice_bot', toolName = null,
|
||||
return persistAssistantSpeech(session, fullText, { source, toolName, meta });
|
||||
}
|
||||
|
||||
|
||||
async function loadHandoffSummaryForVoice(session) {
|
||||
try {
|
||||
const history = await db.getHistoryForLLM(session.sessionId, 10);
|
||||
@@ -404,6 +430,7 @@ function clearUpstreamSuppression(session) {
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = null;
|
||||
session._pendingEvidencePack = null;
|
||||
session.pendingAssistantTurnSeq = 0;
|
||||
session.blockUpstreamAudio = false;
|
||||
sendJson(session.client, { type: 'assistant_pending', active: false });
|
||||
@@ -487,7 +514,18 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
if (!resolveResult) {
|
||||
resolveResult = await resolveReply(session.sessionId, session, cleanText);
|
||||
}
|
||||
const { delivery, speechText, ragItems, source, toolName, routeDecision, responseMeta } = resolveResult;
|
||||
const { delivery, speechText, ragItems, source, toolName, routeDecision, responseMeta, evidencePack } = resolveResult;
|
||||
// 产品链接触发检测:用户请求查看产品详情时推送对应链接
|
||||
const productLinkResult = checkProductLinkTrigger(cleanText);
|
||||
if (productLinkResult.triggered && productLinkResult.product) {
|
||||
console.log(`[NativeVoice] product link triggered session=${session.sessionId} product=${productLinkResult.product.name}`);
|
||||
sendJson(session.client, {
|
||||
type: 'product_link',
|
||||
product: productLinkResult.product.name,
|
||||
link: productLinkResult.product.link,
|
||||
description: productLinkResult.product.description,
|
||||
});
|
||||
}
|
||||
if (activeTurnSeq !== (session.latestUserTurnSeq || 0)) {
|
||||
console.log(`[NativeVoice] stale reply ignored session=${session.sessionId} activeTurn=${activeTurnSeq} latestTurn=${session.latestUserTurnSeq || 0}`);
|
||||
clearUpstreamSuppression(session);
|
||||
@@ -501,6 +539,7 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
}
|
||||
session.blockUpstreamAudio = false;
|
||||
session._lastPartialAt = 0;
|
||||
session._pendingEvidencePack = null;
|
||||
session.awaitingUpstreamReply = true;
|
||||
session.pendingAssistantSource = 'voice_bot';
|
||||
session.pendingAssistantToolName = null;
|
||||
@@ -515,7 +554,7 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
}
|
||||
session.discardNextAssistantResponse = true;
|
||||
sendJson(session.client, { type: 'tts_reset', reason: 'knowledge_hit' });
|
||||
const ragContent = (ragItems || []).filter((item) => item && item.content);
|
||||
const ragContent = (ragItems || []).filter((item) => item && item.content && item.kind !== 'context');
|
||||
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
|
||||
@@ -523,6 +562,7 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
session._lastKbTopic = cleanText;
|
||||
session._lastKbHitAt = Date.now();
|
||||
}
|
||||
session._pendingEvidencePack = evidencePack || null;
|
||||
// 不提前发KB原文作字幕:等S2S event 351返回实际语音文本后再更新字幕
|
||||
// 这样字幕和语音保持一致(S2S会基于RAG内容生成自然口语化的回答)
|
||||
session._pendingExternalRagReply = true;
|
||||
@@ -708,6 +748,7 @@ function handleUpstreamMessage(session, data) {
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = null;
|
||||
session._pendingEvidencePack = null;
|
||||
console.log(`[NativeVoice] duplicate assistant final ignored (351) session=${session.sessionId} turn=${pendingAssistantTurnSeq}`);
|
||||
return;
|
||||
}
|
||||
@@ -725,30 +766,17 @@ function handleUpstreamMessage(session, data) {
|
||||
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))}`);
|
||||
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;
|
||||
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}`);
|
||||
}
|
||||
console.log(`[NativeVoice] re-blocked after KB response session=${session.sessionId}`);
|
||||
}
|
||||
} else {
|
||||
const didFlush = flushAssistantStream(session, {
|
||||
@@ -763,6 +791,7 @@ function handleUpstreamMessage(session, data) {
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = null;
|
||||
session._pendingEvidencePack = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -845,6 +874,7 @@ function handleUpstreamMessage(session, data) {
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = null;
|
||||
session._pendingEvidencePack = null;
|
||||
console.log(`[NativeVoice] duplicate assistant final ignored (559) session=${session.sessionId} turn=${pendingAssistantTurnSeq}`);
|
||||
return;
|
||||
}
|
||||
@@ -862,6 +892,7 @@ function handleUpstreamMessage(session, data) {
|
||||
session.pendingAssistantSource = null;
|
||||
session.pendingAssistantToolName = null;
|
||||
session.pendingAssistantMeta = null;
|
||||
session._pendingEvidencePack = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1042,7 +1073,7 @@ function attachClientHandlers(session) {
|
||||
...((parsed.assistantProfile && typeof parsed.assistantProfile === 'object') ? parsed.assistantProfile : {}),
|
||||
});
|
||||
session.assistantProfile = assistantProfile;
|
||||
session.botName = parsed.botName || assistantProfile.nickname || DEFAULT_VOICE_BOT_NAME;
|
||||
session.botName = parsed.botName || getAssistantDisplayName(assistantProfile) || DEFAULT_VOICE_BOT_NAME;
|
||||
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';
|
||||
@@ -1089,6 +1120,10 @@ function attachClientHandlers(session) {
|
||||
if (session.upstream && session.upstream.readyState === WebSocket.OPEN) {
|
||||
session.upstream.close();
|
||||
}
|
||||
// 会话结束时持久化摘要到 MySQL
|
||||
persistFinalSummary(session).catch((err) => {
|
||||
console.warn('[NativeVoice] persistFinalSummary failed:', err.message);
|
||||
});
|
||||
sessions.delete(session.sessionId);
|
||||
});
|
||||
}
|
||||
@@ -1164,6 +1199,7 @@ function createSession(client, sessionId) {
|
||||
_echoLogOnce: false,
|
||||
_fillerActive: false,
|
||||
_pendingExternalRagReply: false,
|
||||
_pendingEvidencePack: null,
|
||||
_lastPartialAt: 0,
|
||||
pendingKbPrequery: null,
|
||||
_kbPrequeryText: '',
|
||||
@@ -1171,7 +1207,7 @@ function createSession(client, sessionId) {
|
||||
_lastKbTopic: '',
|
||||
_lastKbHitAt: 0,
|
||||
assistantProfile,
|
||||
botName: assistantProfile.nickname,
|
||||
botName: getAssistantDisplayName(assistantProfile),
|
||||
systemRole: buildVoiceSystemRole(assistantProfile),
|
||||
speakingStyle: DEFAULT_VOICE_SPEAKING_STYLE,
|
||||
speaker: process.env.VOLC_S2S_SPEAKER_ID || 'zh_female_vv_jupiter_bigtts',
|
||||
@@ -1201,6 +1237,7 @@ function createSession(client, sessionId) {
|
||||
_lastFinalNormalized: '',
|
||||
_lastFinalAt: 0,
|
||||
_audioKeepaliveTimer: null,
|
||||
_turnCount: 0,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
attachClientHandlers(session);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const ToolExecutor = require('./toolExecutor');
|
||||
const db = require('../db');
|
||||
const redisClient = require('./redisClient');
|
||||
const knowledgeQueryResolver = require('./knowledgeQueryResolver');
|
||||
const { hasKnowledgeRouteKeyword } = require('./knowledgeKeywords');
|
||||
const { loadBestSummary } = require('./conversationSummarizer');
|
||||
|
||||
function normalizeTextForSpeech(text) {
|
||||
return (text || '')
|
||||
@@ -64,7 +66,8 @@ function estimateSpeechDurationMs(text) {
|
||||
}
|
||||
|
||||
function normalizeKnowledgeAlias(text) {
|
||||
return String(text || '')
|
||||
const semanticNormalized = knowledgeQueryResolver.normalizeKnowledgeText(text);
|
||||
return String(semanticNormalized || '')
|
||||
.replace(/一成[,,、。!?\s]+系统/g, '一成系统')
|
||||
.replace(/X{2}系统/gi, '一成系统')
|
||||
.replace(/[\u4e00-\u9fff]{1,3}(?:成|城|程|诚|乘|声|生)[,,、\s]*系统/g, '一成系统')
|
||||
@@ -77,7 +80,7 @@ function normalizeKnowledgeAlias(text) {
|
||||
|
||||
function hasKnowledgeKeyword(text) {
|
||||
const normalized = normalizeKnowledgeAlias(text).replace(/\s+/g, '');
|
||||
return hasKnowledgeRouteKeyword(normalized);
|
||||
return hasKnowledgeRouteKeyword(normalized) || knowledgeQueryResolver.hasExplicitKnowledgeEntity(normalized, { skipAsrCorrection: true });
|
||||
}
|
||||
|
||||
function isKnowledgeFollowUp(text) {
|
||||
@@ -253,7 +256,7 @@ function extractToolResultText(toolName, toolResult) {
|
||||
return '知识库已配置但方舟LLM端点未就绪,暂时无法检索,请稍后再试。';
|
||||
}
|
||||
if (toolResult.results && Array.isArray(toolResult.results)) {
|
||||
return toolResult.results.map((item) => item.content || JSON.stringify(item)).join('\n');
|
||||
return toolResult.results.filter((item) => item.kind !== 'instruction' && item.kind !== 'context').map((item) => item.content || JSON.stringify(item)).join('\n');
|
||||
}
|
||||
if (typeof toolResult === 'string') return toolResult;
|
||||
if (toolResult.error) return toolResult.error;
|
||||
@@ -272,24 +275,23 @@ async function resolveReply(sessionId, session, text) {
|
||||
|
||||
// 快速路径:知识库候选先尝试无context的热答案/缓存命中,跳过DB查询(省50-200ms)
|
||||
if (shouldForceKnowledgeRoute(originalText)) {
|
||||
const fastResult = await ToolExecutor.execute('search_knowledge', { query: originalText, response_mode: 'answer', session_id: sessionId, _session: session }, []);
|
||||
const fastResult = await ToolExecutor.execute('search_knowledge', { query: originalText, 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 }))
|
||||
const evidenceItems = Array.isArray(fastResult?.evidence_pack?.items) && fastResult.evidence_pack.items.length > 0
|
||||
? fastResult.evidence_pack.items
|
||||
: (Array.isArray(fastResult.results) ? fastResult.results : []);
|
||||
const ragItems = fastResult.hit && Array.isArray(evidenceItems)
|
||||
? evidenceItems.filter(i => i && i.content && i.kind !== 'context').map(i => ({ ...i }))
|
||||
: [];
|
||||
console.log(`[resolveReply] fast-path hit in ${Date.now() - _resolveStart}ms session=${sessionId} source=${fastResult.cache_hit ? 'cache' : 'direct'} mode=${fastResult.retrieval_mode || 'answer'}`);
|
||||
console.log(`[resolveReply] fast-path hit in ${Date.now() - _resolveStart}ms session=${sessionId} source=${fastResult.cache_hit ? 'cache' : 'direct'} mode=raw`);
|
||||
if (ragItems.length > 0) {
|
||||
session.handoffSummaryUsed = true;
|
||||
// raw 模式:ragItems 已包含上下文 + 多个 KB 片段,直接透传
|
||||
const isRawMode = fastResult.retrieval_mode === 'raw';
|
||||
const finalRagItems = isRawMode
|
||||
? ragItems
|
||||
: [{ title: '知识库结果', content: normalizeTextForSpeech(replyText).replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '') || replyText }];
|
||||
return {
|
||||
delivery: 'external_rag',
|
||||
speechText: '',
|
||||
ragItems: finalRagItems,
|
||||
ragItems,
|
||||
evidencePack: fastResult.evidence_pack || null,
|
||||
source: 'voice_tool',
|
||||
toolName: 'search_knowledge',
|
||||
routeDecision: { route: 'search_knowledge', args: { query: originalText } },
|
||||
@@ -311,7 +313,7 @@ async function resolveReply(sessionId, session, text) {
|
||||
const _dbStart = Date.now();
|
||||
let recentMessages = null;
|
||||
if (process.env.ENABLE_REDIS_CONTEXT !== 'false') {
|
||||
const redisHistory = await redisClient.getRecentHistory(sessionId, 5).catch(() => null);
|
||||
const redisHistory = await redisClient.getRecentHistory(sessionId, 3).catch(() => null);
|
||||
if (redisHistory && redisHistory.length > 0) {
|
||||
recentMessages = redisHistory;
|
||||
const _dbMs = Date.now() - _dbStart;
|
||||
@@ -319,7 +321,7 @@ async function resolveReply(sessionId, session, text) {
|
||||
}
|
||||
}
|
||||
if (!recentMessages) {
|
||||
recentMessages = await db.getRecentMessages(sessionId, 10).catch(() => []);
|
||||
recentMessages = await db.getRecentMessages(sessionId, 6).catch(() => []);
|
||||
const _dbMs = Date.now() - _dbStart;
|
||||
if (_dbMs > 50) console.log(`[resolveReply] DB getRecentMessages took ${_dbMs}ms session=${sessionId}`);
|
||||
}
|
||||
@@ -329,7 +331,12 @@ async function resolveReply(sessionId, session, text) {
|
||||
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);
|
||||
// 注入对话摘要(三级降级:Redis → MySQL → null)
|
||||
const summary = await loadBestSummary(sessionId).catch(() => null);
|
||||
const contextWithSummary = summary
|
||||
? [{ role: 'system', content: `[历史对话摘要] ${summary}` }, ...baseContext]
|
||||
: baseContext;
|
||||
const context = withHandoffSummary(session, contextWithSummary);
|
||||
let routeDecision = getRuleBasedDirectRouteDecision(originalText);
|
||||
// KB-First: 所有非闲聊查询强制先走知识库,KB不命中再交给S2S自由回答
|
||||
if (routeDecision.route === 'chat' && !isPureChitchat(originalText)) {
|
||||
@@ -358,7 +365,7 @@ 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, _session: session }
|
||||
? { ...(routeDecision.args || {}), session_id: sessionId, _session: session }
|
||||
: routeDecision.args;
|
||||
const metaToolArgs = toolArgs && typeof toolArgs === 'object'
|
||||
? Object.fromEntries(Object.entries(toolArgs).filter(([key]) => key !== '_session'))
|
||||
@@ -380,14 +387,15 @@ async function resolveReply(sessionId, session, text) {
|
||||
latency_ms: toolResult?.latency_ms || null,
|
||||
};
|
||||
|
||||
const evidenceItems = Array.isArray(toolResult?.evidence_pack?.items) && toolResult.evidence_pack.items.length > 0
|
||||
? toolResult.evidence_pack.items
|
||||
: (Array.isArray(toolResult?.results) ? toolResult.results : []);
|
||||
|
||||
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?.hit && Array.isArray(evidenceItems)
|
||||
? evidenceItems
|
||||
.filter((item) => item && item.content && item.kind !== 'context')
|
||||
.map((item) => ({ ...item }))
|
||||
: [])
|
||||
: (!toolResult?.error && replyText
|
||||
? [{ title: `${toolName}结果`, content: replyText }]
|
||||
@@ -395,23 +403,11 @@ async function resolveReply(sessionId, session, text) {
|
||||
|
||||
if (ragItems.length > 0) {
|
||||
session.handoffSummaryUsed = true;
|
||||
const isRawMode = toolResult?.retrieval_mode === 'raw';
|
||||
let finalRagItems = ragItems;
|
||||
|
||||
if (toolName === 'search_knowledge' && !isRawMode) {
|
||||
// 旧模式:LLM 加工过的文本,清理后合并为单条
|
||||
const speechText = normalizeTextForSpeech(replyText);
|
||||
if (speechText) {
|
||||
const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
||||
finalRagItems = [{ title: '知识库结果', content: cleanedText || speechText }];
|
||||
}
|
||||
}
|
||||
// raw 模式:ragItems 已包含上下文 + 多个 KB 片段,直接透传给 S2S
|
||||
|
||||
return {
|
||||
delivery: 'external_rag',
|
||||
speechText: '',
|
||||
ragItems: finalRagItems,
|
||||
ragItems,
|
||||
evidencePack: toolResult?.evidence_pack || null,
|
||||
source,
|
||||
toolName,
|
||||
routeDecision,
|
||||
@@ -428,6 +424,7 @@ async function resolveReply(sessionId, session, text) {
|
||||
delivery: 'external_rag',
|
||||
speechText: '',
|
||||
ragItems: [{ title: '品牌保护', content: safeReply }],
|
||||
evidencePack: toolResult?.evidence_pack || null,
|
||||
source: 'voice_tool',
|
||||
toolName: 'search_knowledge',
|
||||
routeDecision,
|
||||
@@ -443,6 +440,7 @@ async function resolveReply(sessionId, session, text) {
|
||||
delivery: 'external_rag',
|
||||
speechText: '',
|
||||
ragItems: [{ title: '知识库未命中', content: honestReply }],
|
||||
evidencePack: toolResult?.evidence_pack || null,
|
||||
source: 'voice_tool',
|
||||
toolName: 'search_knowledge',
|
||||
routeDecision,
|
||||
|
||||
@@ -10,6 +10,7 @@ const HISTORY_MAX_LEN = 10; // 5轮 × 2条/轮
|
||||
const HISTORY_TTL_S = 1800; // 30分钟
|
||||
const KB_CACHE_HIT_TTL_S = 300; // 5分钟
|
||||
const KB_CACHE_NOHIT_TTL_S = 120; // 2分钟
|
||||
const SUMMARY_TTL_S = parseInt(process.env.SUMMARY_REDIS_TTL_SECONDS) || 7200; // 2小时
|
||||
|
||||
// ============ 连接管理 ============
|
||||
let client = null;
|
||||
@@ -128,6 +129,32 @@ async function clearSession(sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 对话摘要 ============
|
||||
const summaryKey = (sessionId) => `voice:summary:${sessionId}`;
|
||||
|
||||
async function setSummary(sessionId, summary) {
|
||||
if (!isAvailable() || !summary) return false;
|
||||
try {
|
||||
const key = summaryKey(sessionId);
|
||||
await client.set(key, summary, 'EX', SUMMARY_TTL_S);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[Redis] setSummary failed:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSummary(sessionId) {
|
||||
if (!isAvailable()) return null;
|
||||
try {
|
||||
const key = summaryKey(sessionId);
|
||||
return await client.get(key);
|
||||
} catch (err) {
|
||||
console.warn('[Redis] getSummary failed:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ KB 缓存 ============
|
||||
const kbCacheKey = (cacheKey) => `kb_cache:${cacheKey}`;
|
||||
|
||||
@@ -178,6 +205,8 @@ module.exports = {
|
||||
pushMessage,
|
||||
getRecentHistory,
|
||||
clearSession,
|
||||
setSummary,
|
||||
getSummary,
|
||||
setKbCache,
|
||||
getKbCache,
|
||||
disconnect,
|
||||
|
||||
Reference in New Issue
Block a user