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