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:
User
2026-04-03 10:19:16 +08:00
parent 5b824cd16a
commit fe25229de7
6 changed files with 797 additions and 69 deletions

View File

@@ -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,