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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user