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,
|
|||
|
|
};
|