- 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
201 lines
6.4 KiB
JavaScript
201 lines
6.4 KiB
JavaScript
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,
|
||
};
|