Files
bigwo/test2/server/services/conversationSummarizer.js

201 lines
6.4 KiB
JavaScript
Raw Normal View History

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