Files
bigwo/test2/server/services/conversationSummarizer.js
User fe25229de7 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
2026-04-03 10:19:16 +08:00

201 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// 写入 MySQLRedis 无需写,会话已结束)
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,
};