diff --git a/test2/server/db/index.js b/test2/server/db/index.js index 36d0719..aa21ab3 100644 --- a/test2/server/db/index.js +++ b/test2/server/db/index.js @@ -26,8 +26,8 @@ async function migrateSchema() { if (!(await columnMatchesType('messages', 'role', "'system'"))) { await pool.execute("ALTER TABLE `messages` MODIFY COLUMN `role` ENUM('user', 'assistant', 'tool', 'system') NOT NULL"); } - if (!(await columnMatchesType('messages', 'source', "'chat_bot'"))) { - await pool.execute("ALTER TABLE `messages` MODIFY COLUMN `source` ENUM('voice_asr', 'voice_bot', 'voice_tool', 'chat_user', 'chat_bot') NOT NULL"); + if (!(await columnMatchesType('messages', 'source', "'search_knowledge'"))) { + await pool.execute("ALTER TABLE `messages` MODIFY COLUMN `source` ENUM('voice_asr', 'voice_bot', 'voice_tool', 'chat_user', 'chat_bot', 'search_knowledge') NOT NULL"); } await ensureColumnExists('messages', 'tool_name', '`tool_name` VARCHAR(64) NULL AFTER `source`'); await ensureColumnExists('messages', 'meta_json', '`meta_json` JSON NULL AFTER `tool_name`'); @@ -81,7 +81,7 @@ async function initialize() { session_id VARCHAR(128) NOT NULL, role ENUM('user', 'assistant', 'tool', 'system') NOT NULL, content TEXT NOT NULL, - source ENUM('voice_asr', 'voice_bot', 'voice_tool', 'chat_user', 'chat_bot') NOT NULL, + source ENUM('voice_asr', 'voice_bot', 'voice_tool', 'chat_user', 'chat_bot', 'search_knowledge') NOT NULL, tool_name VARCHAR(64), meta_json JSON, created_at BIGINT, @@ -90,6 +90,21 @@ async function initialize() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); + await pool.execute(` + CREATE TABLE IF NOT EXISTS conversation_summaries ( + id INT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(128) NOT NULL, + user_id VARCHAR(128), + summary TEXT NOT NULL, + turn_count INT DEFAULT 0, + topics JSON, + created_at BIGINT, + updated_at BIGINT, + UNIQUE INDEX idx_session (session_id), + INDEX idx_user_time (user_id, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + await migrateSchema(); console.log(`[DB] MySQL connected: ${dbName}, tables ready`); @@ -202,9 +217,31 @@ async function getSessionList(userId, limit = 50) { */ async function deleteSession(sessionId) { await pool.execute('DELETE FROM messages WHERE session_id = ?', [sessionId]); + await pool.execute('DELETE FROM conversation_summaries WHERE session_id = ?', [sessionId]); await pool.execute('DELETE FROM sessions WHERE id = ?', [sessionId]); } +// ==================== Conversation Summaries ==================== + +async function upsertConversationSummary(sessionId, userId, summary, { turnCount = 0, topics = null } = {}) { + const now = Date.now(); + const topicsJson = topics ? JSON.stringify(topics) : null; + await pool.execute( + `INSERT INTO conversation_summaries (session_id, user_id, summary, turn_count, topics, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE summary=VALUES(summary), turn_count=VALUES(turn_count), topics=VALUES(topics), updated_at=VALUES(updated_at)`, + [sessionId, userId || null, summary, turnCount, topicsJson, now, now] + ); +} + +async function getSessionSummary(sessionId) { + const [rows] = await pool.execute( + 'SELECT summary, turn_count, topics, updated_at FROM conversation_summaries WHERE session_id = ? LIMIT 1', + [sessionId] + ); + return rows[0] || null; +} + module.exports = { initialize, getPool, @@ -217,4 +254,6 @@ module.exports = { getHistoryForLLM, getSessionList, deleteSession, + upsertConversationSummary, + getSessionSummary, }; diff --git a/test2/server/docs/conversation-long-term-memory.md b/test2/server/docs/conversation-long-term-memory.md new file mode 100644 index 0000000..836f513 --- /dev/null +++ b/test2/server/docs/conversation-long-term-memory.md @@ -0,0 +1,425 @@ +# 对话长期记忆方案:会话内摘要 + 跨会话衔接 + +## 一、背景与问题 + +### 1.1 现状 + +当前对话记忆体系有 4 层,均为短期记忆: + +| 层级 | 机制 | 存储 | 有效窗口 | 代码位置 | +|------|------|------|----------|----------| +| L1 | Redis 对话历史 | 最近5轮(10条) | 30分钟TTL | `redisClient.js` HISTORY_MAX_LEN=10 | +| L2 | MySQL 全量记录 | 无限 | 永久 | `db.addMessage()` | +| L3 | contextKeywordTracker | 最近8个关键词 | 30分钟TTL | `contextKeywordTracker.js` | +| L4 | KB话题记忆 | 最后1个话题 | 60秒TTL | `session._lastKbTopic` | +| L5 | handoffSummary | 确定性摘要(最后8条) | 会话生命周期 | `loadHandoffSummaryForVoice()` | + +### 1.2 双重核心问题 + +**问题 A:会话内遗忘(第6轮起丢失)** + +S2S 和 KB 检索实际使用的上下文只有最近5轮原文(`getRecentHistory(sessionId, 5)`),第6轮开始的信息完全丢失。 + +**问题 B:会话重启时上下文断裂** + +| 断裂场景 | 触发条件 | 当前结果 | +|----------|----------|----------| +| ① 同 sessionId 快速重连 | 网络抖动/页面刷新(30min内) | Redis 历史还在,但 session 内存状态全丢(`_lastKbTopic`、`contextKeywordTracker`、`_turnCount`) | +| ② 同 sessionId 超时重连 | 用户隔一段时间再来(30min后) | Redis TTL 过期,只剩 MySQL + `loadHandoffSummaryForVoice`(确定性摘要,只取最后8条,质量差) | +| ③ PM2 重启/崩溃恢复 | 部署或进程崩溃 | 所有 session 内存清零,Redis 还在 | +| ④ voice → chat 模式切换 | 用户从语音切文字 | `chat.js` 有 `loadHandoffMessages` 做交接,但也是确定性摘要 | + +> **注意**:新 sessionId = 全新对话,不需要跨 session 记忆继承。 + +**关键差距**:MySQL 存了全量原文却从未被用于生成高质量摘要;`buildDeterministicHandoffSummary` 只做模式提取("当前问题 + 上一轮关注 + 已给信息"),压缩比低、语义丢失大。 + +### 1.3 受影响场景 + +| 场景 | 当前体验 | 占比估算 | +|------|----------|----------| +| 用户聊了8轮后追问"刚才那个产品" | AI 丢失上下文,回答偏题 | ~20-30% 深度对话 | +| 长对话中多产品对比 | 早期提到的产品信息丢失 | ~15% | +| 网络抖动后重连继续聊 | 内存状态丢失,KB话题追踪断裂 | ~10% | +| 隔30分钟再打开继续咨询 | Redis过期,只剩粗糙的确定性摘要 | ~10% | +| voice→chat 切换后追问语音聊的内容 | 只有确定性摘要,细节丢失 | ~5% | + +--- + +## 二、设计目标 + +解决两个并列的核心问题,缺一不可: + +| 目标 | 描述 | 衡量标准 | +|------|------|----------| +| **G1:会话内长记忆** | 单次对话超过5轮后仍能准确关联早期话题 | 10轮后追问命中率 ≥ 80% | +| **G2:会话重启上下文衔接** | 同sessionId重连/PM2重启后无缝延续对话 | 重连后首轮上下文关联率 ≥ 90% | + +--- + +## 三、记忆分层架构(三层设计) + +``` +┌─────────────────────────────────────────────────────┐ +│ L1 热记忆(Redis) │ +│ · 最近3轮原文(精确上下文) │ +│ · 当前会话摘要(LLM生成,每3轮更新) │ +│ · TTL: 2小时 │ +├─────────────────────────────────────────────────────┤ +│ L2 温记忆(MySQL conversation_summaries 表) │ +│ · 每个 session 的最终摘要(会话结束时写入) │ +│ · 永久存储,同 sessionId 重连时可恢复 │ +├─────────────────────────────────────────────────────┤ +│ L3 冷记忆(MySQL messages 表,现有) │ +│ · 全量原文记录 │ +│ · 仅在 L1+L2 都缺失时作为降级数据源 │ +│ · 通过 buildDeterministicHandoffSummary 提取 │ +└─────────────────────────────────────────────────────┘ +``` + +**加载优先级**:L1(Redis摘要) → L2(MySQL摘要) → L3(MySQL原文确定性提取) + +--- + +## 四、方案详细设计 + +### 4.1 支柱一:会话内摘要(解决 G1) + +#### 触发机制 + +``` +用户说话 → persistUserSpeech → session._turnCount++ +AI回复 → persistAssistantSpeech ──→ _turnCount % 3 === 0 ? + ├─ Yes → 异步 summarize() [不阻塞] + │ ↓ + │ LLM(旧摘要 + 最近3轮原文) + │ ↓ + │ Redis.setSummary(sessionId) + │ MySQL.upsertSummary(sessionId) // 双写 + └─ No → 继续 +``` + +#### 滚雪球数据流 + +``` +Round 1-3: 原文正常存Redis +Round 3 完成后: + → LLM(Round 1-3 原文) → 生成摘要 S1 → 存 Redis + MySQL + → 后续上下文 = S1 + 最近3轮原文 + +Round 6 完成后: + → LLM(S1 + Round 4-6 原文) → 生成摘要 S2 → 存 Redis + MySQL + → 后续上下文 = S2 + 最近3轮原文 + +...以此类推(每次摘要都包含之前所有轮次的压缩信息) +``` + +#### 上下文注入 + +```javascript +// resolveReply 中 +const summary = await loadBestSummary(sessionId); +const recentHistory = await redisClient.getRecentHistory(sessionId, 3); + +const context = []; +if (summary) { + context.push({ role: 'system', content: `[历史对话摘要] ${summary}` }); +} +context.push(...recentHistory.map(item => ({ role: item.role, content: item.content }))); +``` + +### 4.2 支柱二:会话重启衔接(解决 G2) + +#### 新增 MySQL 表:`conversation_summaries` + +```sql +CREATE TABLE conversation_summaries ( + id INT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(128) NOT NULL, + user_id VARCHAR(128), + summary TEXT NOT NULL, -- LLM 生成的摘要 + turn_count INT DEFAULT 0, -- 该 session 的总轮次 + topics JSON, -- 提取的话题标签 ["活力健","基础三合一","一成系统"] + created_at BIGINT, + updated_at BIGINT, + UNIQUE INDEX idx_session (session_id), + INDEX idx_user_time (user_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### 断裂场景修复方案 + +| 场景 | 修复策略 | 加载顺序 | +|------|----------|----------| +| ① 同 sessionId 快速重连(30min内) | Redis 摘要仍在,直接使用 | L1 Redis | +| ② 同 sessionId 超时重连(30min后) | Redis 过期,从 MySQL `conversation_summaries` 加载该 sessionId 的摘要 | L2 MySQL摘要 | +| ③ PM2 重启 | Redis 还在(Redis 独立进程),从 Redis 加载;Redis 也丢了则走 L2 | L1 → L2 | +| ④ voice→chat 模式切换 | `chat.js` 的 `loadHandoffMessages` 改为优先从 `conversation_summaries` 加载 LLM 摘要 | L2 MySQL摘要 | + +#### 核心函数:`loadBestSummary(sessionId)` + +```javascript +async function loadBestSummary(sessionId) { + // 1. 尝试 Redis(最快,~1ms) + const redisSummary = await redisClient.getSummary(sessionId); + if (redisSummary) return redisSummary; + + // 2. 尝试 MySQL 当前 session 的摘要(~5ms) + const sessionSummary = await db.getSessionSummary(sessionId); + if (sessionSummary) { + // 回填 Redis 加速后续读取 + await redisClient.setSummary(sessionId, sessionSummary.summary); + return sessionSummary.summary; + } + + // 3. 降级:MySQL 原文 → 确定性摘要(现有逻辑) + return null; // 交给现有的 loadHandoffSummaryForVoice 兜底 +} +``` + +#### 会话结束时持久化 + +在 `session.client.on('close')` 中触发: + +```javascript +// nativeVoiceGateway.js client close handler +session.client.on('close', () => { + // ...existing cleanup... + + // 持久化最终摘要到 MySQL(异步,不阻塞关闭流程) + persistFinalSummary(session).catch(err => { + console.warn('[NativeVoice] persistFinalSummary failed:', err.message); + }); +}); + +async function persistFinalSummary(session) { + if (!session._turnCount || session._turnCount < 2) return; // 太短的对话不存 + + // 优先用已有的 LLM 摘要 + let summary = await redisClient.getSummary(session.sessionId); + + // 如果还没生成过摘要(对话不足3轮),立刻生成一次 + if (!summary && session._turnCount >= 2) { + const history = await redisClient.getRecentHistory(session.sessionId, 5); + if (history && history.length >= 2) { + summary = await summarizer.summarizeConversation(null, history); + } + } + + if (summary) { + await db.upsertConversationSummary(session.sessionId, session.userId, summary, { + turnCount: session._turnCount || 0, + topics: extractTopicTags(summary), // 从摘要中提取话题标签 + }); + } +} +``` + +### 4.3 话题标签提取(增强跨会话关联) + +从摘要中用正则提取产品名/系统名/话题关键词,存入 `topics` JSON 字段: + +```javascript +function extractTopicTags(summary) { + const tags = new Set(); + // 复用现有的 knowledgeKeywords 和 knowledgeQueryResolver + const { TRACKER_KEYWORD_GROUPS, buildKeywordRegex } = require('./knowledgeKeywords'); + for (const group of TRACKER_KEYWORD_GROUPS) { + const regex = buildKeywordRegex(group, 'gi'); + const matches = summary.match(regex); + if (matches) matches.forEach(m => tags.add(m)); + } + return [...tags].slice(0, 10); +} +``` + +用途:未来可按 `topics` 做更精准的跨会话记忆匹配(如用户上次聊的是"活力健",这次又提"活力健",优先加载那次摘要)。 + +--- + +## 五、摘要 Prompt 设计 + +### 会话内摘要 Prompt(每3轮触发) + +``` +你是对话摘要助手。将以下对话历史浓缩为简短摘要,必须保留: +1. 用户询问过的所有产品名称和具体问题 +2. AI给出的关键数字(剂量、价格、数量等) +3. 用户表达的偏好或关注点 +4. 未解决的问题或用户的疑虑 + +规则:只输出摘要正文,不加前缀或标题。150字以内。用"用户"和"助手"指代双方。 +``` + +### 跨会话注入时的格式 + +``` +用户上次({时间差描述})的对话摘要:{summary} +``` + +例如:`用户上次(2小时前)的对话摘要:用户咨询了活力健和基础三合一的区别,助手介绍了两者成分差异。用户对活力健的服用方法比较关心,每天2次每次1条。用户还问了适不适合高血压人群。` + +--- + +## 六、关键设计决策 + +| 决策点 | 建议值 | 理由 | +|--------|--------|------| +| **摘要触发** | `persistAssistantSpeech` 后异步 | 不阻塞对话链路,用户无感知 | +| **摘要模型** | 复用 Seed-2.0-lite (`ep-20260320175538-lcg7g`) | 已部署,延迟低(~1-2s),成本低 | +| **摘要 max_tokens** | 120 | 控制摘要长度,避免注入过长上下文 | +| **摘要 thinking** | `{ type: 'enabled' }` | 异步执行不阻塞,开启推理提升摘要质量 | +| **Redis key** | `voice:summary:{sessionId}` | 与对话历史同级 | +| **Redis TTL** | 7200秒(2小时) | 长于对话历史的30分钟 | +| **MySQL 双写** | 每次摘要同时写 Redis + MySQL | Redis 快读 + MySQL 持久化 | +| **原文保留** | 摘要后仍保留最近3轮原文 | 最近对话需精确上下文 | +| **最小对话门槛** | `_turnCount >= 2` 才持久化 | 太短的对话(打招呼就走)不值得存 | +| **失败降级** | LLM摘要失败 → 确定性摘要 → 5轮原文 | 三级降级保证不影响主链路 | + +--- + +## 七、涉及文件修改 + +| 文件 | 修改内容 | 复杂度 | +|------|----------|--------| +| **新增** `services/conversationSummarizer.js` | LLM 摘要生成、异步触发、`loadBestSummary`、`persistFinalSummary`、话题标签提取 | 中 | +| `db/index.js` | 新增 `conversation_summaries` 建表 + `upsertConversationSummary` / `getSessionSummary` | 中 | +| `services/redisClient.js` | 新增 `setSummary()` / `getSummary()` | 低 | +| `services/nativeVoiceGateway.js` | session 增加 `_turnCount`;`persistUserSpeech` 计轮;`persistAssistantSpeech` 后触发摘要;`client.close` 时 `persistFinalSummary` | 低 | +| `services/realtimeDialogRouting.js` | `resolveReply` 调用 `loadBestSummary` 注入上下文;`getRecentHistory` 轮数 5→3 | 低 | +| `services/kbRetriever.js` | `searchAndRerank` 的 conversationHistory 也注入摘要 | 低 | +| `routes/chat.js` | `loadHandoffMessages` 优先从 `conversation_summaries` 加载 LLM 摘要 | 低 | + +### 新增文件结构:`conversationSummarizer.js` + +``` +exports: + - triggerSummarizeIfNeeded(session, sessionId) // 检查轮次 → 异步触发 + - summarizeConversation(existingSummary, messages) // 调 LLM 生成摘要 + - loadBestSummary(sessionId) // 三级降级加载最优摘要 + - persistFinalSummary(session) // 会话结束时持久化 + - extractTopicTags(text) // 从文本提取话题标签 + +constants: + - SUMMARIZE_EVERY_N_TURNS = 3 + - SUMMARY_MAX_TOKENS = 120 + - SUMMARY_MODEL = process.env.VOLC_ARK_KB_MODEL + - MIN_TURNS_TO_PERSIST = 2 +``` + +--- + +## 八、成本与性能影响 + +### 成本 + +| 指标 | 值 | +|------|-----| +| 会话内摘要频率 | 每3轮1次 ≈ 平均每会话1-3次 | +| 会话结束摘要 | 每会话最多+1次(如果轮次不足3) | +| 单次摘要 token | 输入~400 + 输出~120 ≈ 520 tokens | +| Seed-2.0-lite 价格 | ~0.001元/千tokens(估算) | +| 单会话增加成本 | ~0.001-0.004元 | + +### 性能 + +| 指标 | 影响 | +|------|------| +| 对话响应延迟 | **0ms 增加**(纯异步 fire-and-forget) | +| 会话启动延迟 | +5-10ms(`loadBestSummary` 读 Redis/MySQL) | +| Redis 读取 | +1次 getSummary/次对话轮(~1ms) | +| Redis 存储 | +1 key/session(~300 bytes) | +| MySQL 存储 | +1 row/session(~500 bytes) | +| MySQL 查询 | 新 session 启动时 1次(按 userId 查最近摘要,~5ms,有索引) | +| LLM 调用(后台) | ~1-2s/次,不阻塞用户 | + +--- + +## 九、风险评估 + +| 风险 | 概率 | 影响 | 缓解措施 | +|------|------|------|----------| +| 摘要丢失关键产品名 | 中 | 高 | Prompt 强调保留产品名+数字;话题标签做辅助索引 | +| 摘要引入幻觉 | 低 | 高 | max_tokens=120;摘要只做压缩不做推理;`[历史对话摘要]` 前缀标识 | +| LLM 调用失败 | 低 | 中 | 三级降级:Redis摘要 → MySQL摘要 → 确定性摘要 → 5轮原文 | +| MySQL 写入失败 | 低 | 低 | 摘要双写 Redis+MySQL,任一成功即可;下次触发时重试 | +| 与 contextKeywordTracker 冲突 | 低 | 低 | 互补:摘要提供语义上下文,tracker 提供精确关键词路由 | +| 用户隐私(摘要留存) | - | - | 摘要遵循现有 messages 的同等存储策略;deleteSession 时同步删除摘要 | + +--- + +## 十、实施计划 + +### P0 - 核心双支柱(1.5天) + +1. `db/index.js`:新增 `conversation_summaries` 表 + CRUD 方法 +2. `redisClient.js`:新增 `setSummary` / `getSummary` +3. **新建** `conversationSummarizer.js`:LLM 摘要 + `loadBestSummary` + `persistFinalSummary` +4. `nativeVoiceGateway.js`:轮次计数 + 摘要触发 + close 时持久化 +5. `realtimeDialogRouting.js`:`resolveReply` 注入摘要上下文 + +### P1 - 增强(0.5天) + +6. `kbRetriever.js`:KB 检索的 conversationHistory 也注入摘要 +7. `routes/chat.js`:voice→chat 切换时优先加载 LLM 摘要 +8. 话题标签提取 + `extractTopicTags` + +### P2 - 高级(未来) + +9. 摘要质量监控(定期抽检摘要 vs 原文的信息保留率) +10. 用户画像标签聚合(高频话题、偏好产品、健康关注点)——如未来需要跨 session 记忆再启用 + +--- + +## 十一、验证方案 + +### 会话内记忆测试 + +``` +1. 模拟6轮对话 → 验证第3轮后生成摘要 +2. 验证摘要正确保留产品名和关键数字 +3. 第7轮追问第1轮的产品 → 验证上下文关联正确 +4. LLM 摘要失败时 → 验证降级到5轮原文模式 +``` + +### 跨会话衔接测试 + +``` +5. 完成6轮对话 → 断开 → 同 sessionId 重连 → 验证首轮即有摘要上下文 +6. 完成6轮对话 → 等 Redis 过期 → 重连 → 验证从 MySQL 加载摘要 +8. PM2 重启 → 重连 → 验证摘要恢复 +9. voice→chat 切换 → 验证 chat 模式拿到语音会话摘要 +``` + +### 降级测试 + +``` +10. Redis 不可用 → 验证从 MySQL 加载 + 主链路不受影响 +11. MySQL 摘要表为空 → 验证降级到确定性摘要 +12. LLM 服务不可用 → 验证降级到5轮原文模式 +``` + +--- + +## 十二、配置项 + +| 环境变量 | 默认值 | 说明 | +|----------|--------|------| +| `ENABLE_CONVERSATION_SUMMARY` | `true` | 总开关 | +| `SUMMARY_EVERY_N_TURNS` | `3` | 每N轮触发一次摘要 | +| `SUMMARY_MAX_TOKENS` | `120` | 摘要最大 token 数 | +| `SUMMARY_REDIS_TTL_SECONDS` | `7200` | 摘要 Redis TTL(秒) | +| `SUMMARY_MODEL` | 同 `VOLC_ARK_KB_MODEL` | 摘要使用的模型 endpoint | +| `SUMMARY_MIN_TURNS_TO_PERSIST` | `2` | 最小轮次门槛,低于此值不持久化 | + +--- + +## 十三、与现有机制的关系 + +| 现有机制 | 变更 | 共存关系 | +|----------|------|----------| +| `redisClient.pushMessage` / `getRecentHistory` | `getRecentHistory` 轮数从5降到3 | 摘要覆盖早期轮次,原文只需最近3轮 | +| `contextKeywordTracker` | 不变 | 互补:tracker 做精确关键词路由,摘要做语义上下文 | +| `session._lastKbTopic` | 不变 | 互补:60s 窗口保护仍需要,摘要不能替代实时话题追踪 | +| `loadHandoffSummaryForVoice` | 改为降级备选 | LLM 摘要不可用时才调用确定性摘要 | +| `buildDeterministicHandoffSummary` | 保留作为 L3 降级 | 三级降级的最后一道防线 | +| `withHandoffSummary` | 由 `loadBestSummary` 替代 | 新函数统一管理所有来源的摘要注入 | diff --git a/test2/server/services/conversationSummarizer.js b/test2/server/services/conversationSummarizer.js new file mode 100644 index 0000000..3224da2 --- /dev/null +++ b/test2/server/services/conversationSummarizer.js @@ -0,0 +1,200 @@ +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, +}; diff --git a/test2/server/services/nativeVoiceGateway.js b/test2/server/services/nativeVoiceGateway.js index 56fc4fb..89d2953 100644 --- a/test2/server/services/nativeVoiceGateway.js +++ b/test2/server/services/nativeVoiceGateway.js @@ -27,21 +27,27 @@ const { const ToolExecutor = require('./toolExecutor'); const { DEFAULT_VOICE_ASSISTANT_PROFILE, + DEFAULT_CONSULTANT_CONTACT, resolveAssistantProfile, + getAssistantDisplayName, buildVoiceSystemRole, buildVoiceGreeting, } = require('./assistantProfileConfig'); const { getAssistantProfile } = require('./assistantProfileService'); const redisClient = require('./redisClient'); +const { checkProductLinkTrigger } = require('./productLinkTrigger'); +const { triggerSummarizeIfNeeded, persistFinalSummary } = require('./conversationSummarizer'); const sessions = new Map(); +const CONSULTANT_REFERRAL_PATTERN = /咨询(?:专业|你的)?顾问|健康管理顾问|联系顾问|一对一指导|咨询专业|咨询医生|咨询营养师|咨询专业人士|建议.*咨询|问问医生|问问.*营养师/; + const IDLE_TIMEOUT_MS = 5 * 60 * 1000; const AUDIO_KEEPALIVE_INTERVAL_MS = 20 * 1000; // 3200 bytes ≈ 66ms of silence at 24kHz s16le mono (larger frame to ensure S2S acceptance) const SILENT_AUDIO_FRAME = Buffer.alloc(3200, 0); -const DEFAULT_VOICE_BOT_NAME = DEFAULT_VOICE_ASSISTANT_PROFILE.nickname; +const DEFAULT_VOICE_BOT_NAME = getAssistantDisplayName(DEFAULT_VOICE_ASSISTANT_PROFILE); const DEFAULT_VOICE_SYSTEM_ROLE = buildVoiceSystemRole(); @@ -187,6 +193,7 @@ function persistUserSpeech(session, text) { session.lastPersistedUserAt = now; session.latestUserText = cleanText; session.latestUserTurnSeq = (session.latestUserTurnSeq || 0) + 1; + session._turnCount = (session._turnCount || 0) + 1; resetIdleTimer(session); db.addMessage(session.sessionId, 'user', cleanText, 'voice_asr').catch((e) => console.warn('[NativeVoice][DB] add user failed:', e.message)); redisClient.pushMessage(session.sessionId, { role: 'user', content: cleanText, source: 'voice_asr' }).catch(() => {}); @@ -223,6 +230,24 @@ function persistAssistantSpeech(session, text, { source = 'voice_bot', toolName toolName, sequence: `native_assistant_${now}`, }); + // 异步触发摘要检查(每N轮) + if (persistToDb) { + triggerSummarizeIfNeeded(session, session.sessionId); + } + if (CONSULTANT_REFERRAL_PATTERN.test(cleanText)) { + const contact = session.consultantContact || DEFAULT_CONSULTANT_CONTACT; + if (contact.mobile || contact.wx_qr_code || contact.wechat_id) { + console.log(`[NativeVoice] consultant referral detected session=${session.sessionId} text=${JSON.stringify(cleanText.slice(0, 80))}`); + sendJson(session.client, { + type: 'consultant_contact', + name: contact.name || '大沃专业健康管理顾问', + mobile: contact.mobile || '', + wx_qr_code: contact.wx_qr_code || '', + wechat_id: contact.wechat_id || '', + message: '如需个性化健康建议,可联系大沃专业健康管理顾问', + }); + } + } return true; } @@ -250,6 +275,7 @@ function flushAssistantStream(session, { source = 'voice_bot', toolName = null, return persistAssistantSpeech(session, fullText, { source, toolName, meta }); } + async function loadHandoffSummaryForVoice(session) { try { const history = await db.getHistoryForLLM(session.sessionId, 10); @@ -404,6 +430,7 @@ function clearUpstreamSuppression(session) { session.pendingAssistantSource = null; session.pendingAssistantToolName = null; session.pendingAssistantMeta = null; + session._pendingEvidencePack = null; session.pendingAssistantTurnSeq = 0; session.blockUpstreamAudio = false; sendJson(session.client, { type: 'assistant_pending', active: false }); @@ -487,7 +514,18 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq | if (!resolveResult) { resolveResult = await resolveReply(session.sessionId, session, cleanText); } - const { delivery, speechText, ragItems, source, toolName, routeDecision, responseMeta } = resolveResult; + const { delivery, speechText, ragItems, source, toolName, routeDecision, responseMeta, evidencePack } = resolveResult; + // 产品链接触发检测:用户请求查看产品详情时推送对应链接 + const productLinkResult = checkProductLinkTrigger(cleanText); + if (productLinkResult.triggered && productLinkResult.product) { + console.log(`[NativeVoice] product link triggered session=${session.sessionId} product=${productLinkResult.product.name}`); + sendJson(session.client, { + type: 'product_link', + product: productLinkResult.product.name, + link: productLinkResult.product.link, + description: productLinkResult.product.description, + }); + } if (activeTurnSeq !== (session.latestUserTurnSeq || 0)) { console.log(`[NativeVoice] stale reply ignored session=${session.sessionId} activeTurn=${activeTurnSeq} latestTurn=${session.latestUserTurnSeq || 0}`); clearUpstreamSuppression(session); @@ -501,6 +539,7 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq | } session.blockUpstreamAudio = false; session._lastPartialAt = 0; + session._pendingEvidencePack = null; session.awaitingUpstreamReply = true; session.pendingAssistantSource = 'voice_bot'; session.pendingAssistantToolName = null; @@ -515,7 +554,7 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq | } session.discardNextAssistantResponse = true; sendJson(session.client, { type: 'tts_reset', reason: 'knowledge_hit' }); - const ragContent = (ragItems || []).filter((item) => item && item.content); + const ragContent = (ragItems || []).filter((item) => item && item.content && item.kind !== 'context'); if (ragContent.length > 0) { console.log(`[NativeVoice] processReply sending external_rag to S2S session=${session.sessionId} route=${routeDecision?.route || 'unknown'} items=${ragContent.length}`); // KB话题记忆:记录本轮用户原始问题和时间戳,用于保护窗口和追问enrichment @@ -523,6 +562,7 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq | session._lastKbTopic = cleanText; session._lastKbHitAt = Date.now(); } + session._pendingEvidencePack = evidencePack || null; // 不提前发KB原文作字幕:等S2S event 351返回实际语音文本后再更新字幕 // 这样字幕和语音保持一致(S2S会基于RAG内容生成自然口语化的回答) session._pendingExternalRagReply = true; @@ -708,6 +748,7 @@ function handleUpstreamMessage(session, data) { session.pendingAssistantSource = null; session.pendingAssistantToolName = null; session.pendingAssistantMeta = null; + session._pendingEvidencePack = null; console.log(`[NativeVoice] duplicate assistant final ignored (351) session=${session.sessionId} turn=${pendingAssistantTurnSeq}`); return; } @@ -725,30 +766,17 @@ function handleUpstreamMessage(session, data) { if (session._pendingExternalRagReply) { session._pendingExternalRagReply = false; } - // 品牌安全检测:最终助手文本包含有害内容时,阻断音频并注入安全回复 - if (isBrandHarmful(assistantText)) { - console.warn(`[NativeVoice][SafeGuard] harmful content in final assistant text, blocking session=${session.sessionId} text=${JSON.stringify(assistantText.slice(0, 120))}`); + console.log(`[NativeVoice] upstream assistant session=${session.sessionId} text=${JSON.stringify(assistantText.slice(0, 120))}`); + session.lastDeliveredAssistantTurnSeq = pendingAssistantTurnSeq; + persistAssistantSpeech(session, assistantText, { + source: pendingAssistantSource, + toolName: pendingAssistantToolName, + meta: pendingAssistantMeta, + }); + // KB回复完成后重新阻断音频,防止下一个问题的S2S默认回复在early block前泄露 + if (session.currentTtsType === 'external_rag') { session.blockUpstreamAudio = true; - sendJson(session.client, { type: 'tts_reset', reason: 'harmful_blocked' }); - const safeReply = getVoiceSafeReply(); - session.lastDeliveredAssistantTurnSeq = pendingAssistantTurnSeq; - persistAssistantSpeech(session, safeReply, { source: 'voice_bot' }); - sendSpeechText(session, safeReply).catch((err) => { - console.warn('[NativeVoice][SafeGuard] sendSpeechText failed:', err.message); - }); - } else { - console.log(`[NativeVoice] upstream assistant session=${session.sessionId} text=${JSON.stringify(assistantText.slice(0, 120))}`); - session.lastDeliveredAssistantTurnSeq = pendingAssistantTurnSeq; - persistAssistantSpeech(session, assistantText, { - source: pendingAssistantSource, - toolName: pendingAssistantToolName, - meta: pendingAssistantMeta, - }); - // KB回复完成后重新阻断音频,防止下一个问题的S2S默认回复在early block前泄露 - if (session.currentTtsType === 'external_rag') { - session.blockUpstreamAudio = true; - console.log(`[NativeVoice] re-blocked after KB response session=${session.sessionId}`); - } + console.log(`[NativeVoice] re-blocked after KB response session=${session.sessionId}`); } } else { const didFlush = flushAssistantStream(session, { @@ -763,6 +791,7 @@ function handleUpstreamMessage(session, data) { session.pendingAssistantSource = null; session.pendingAssistantToolName = null; session.pendingAssistantMeta = null; + session._pendingEvidencePack = null; return; } @@ -845,6 +874,7 @@ function handleUpstreamMessage(session, data) { session.pendingAssistantSource = null; session.pendingAssistantToolName = null; session.pendingAssistantMeta = null; + session._pendingEvidencePack = null; console.log(`[NativeVoice] duplicate assistant final ignored (559) session=${session.sessionId} turn=${pendingAssistantTurnSeq}`); return; } @@ -862,6 +892,7 @@ function handleUpstreamMessage(session, data) { session.pendingAssistantSource = null; session.pendingAssistantToolName = null; session.pendingAssistantMeta = null; + session._pendingEvidencePack = null; return; } @@ -1042,7 +1073,7 @@ function attachClientHandlers(session) { ...((parsed.assistantProfile && typeof parsed.assistantProfile === 'object') ? parsed.assistantProfile : {}), }); session.assistantProfile = assistantProfile; - session.botName = parsed.botName || assistantProfile.nickname || DEFAULT_VOICE_BOT_NAME; + session.botName = parsed.botName || getAssistantDisplayName(assistantProfile) || DEFAULT_VOICE_BOT_NAME; session.systemRole = buildVoiceSystemRole(assistantProfile); session.speakingStyle = parsed.speakingStyle || session.speakingStyle || DEFAULT_VOICE_SPEAKING_STYLE; session.speaker = parsed.speaker || process.env.VOLC_S2S_SPEAKER_ID || 'zh_female_vv_jupiter_bigtts'; @@ -1089,6 +1120,10 @@ function attachClientHandlers(session) { if (session.upstream && session.upstream.readyState === WebSocket.OPEN) { session.upstream.close(); } + // 会话结束时持久化摘要到 MySQL + persistFinalSummary(session).catch((err) => { + console.warn('[NativeVoice] persistFinalSummary failed:', err.message); + }); sessions.delete(session.sessionId); }); } @@ -1164,6 +1199,7 @@ function createSession(client, sessionId) { _echoLogOnce: false, _fillerActive: false, _pendingExternalRagReply: false, + _pendingEvidencePack: null, _lastPartialAt: 0, pendingKbPrequery: null, _kbPrequeryText: '', @@ -1171,7 +1207,7 @@ function createSession(client, sessionId) { _lastKbTopic: '', _lastKbHitAt: 0, assistantProfile, - botName: assistantProfile.nickname, + botName: getAssistantDisplayName(assistantProfile), systemRole: buildVoiceSystemRole(assistantProfile), speakingStyle: DEFAULT_VOICE_SPEAKING_STYLE, speaker: process.env.VOLC_S2S_SPEAKER_ID || 'zh_female_vv_jupiter_bigtts', @@ -1201,6 +1237,7 @@ function createSession(client, sessionId) { _lastFinalNormalized: '', _lastFinalAt: 0, _audioKeepaliveTimer: null, + _turnCount: 0, }; sessions.set(sessionId, session); attachClientHandlers(session); diff --git a/test2/server/services/realtimeDialogRouting.js b/test2/server/services/realtimeDialogRouting.js index cbc4dac..155f20c 100644 --- a/test2/server/services/realtimeDialogRouting.js +++ b/test2/server/services/realtimeDialogRouting.js @@ -1,7 +1,9 @@ const ToolExecutor = require('./toolExecutor'); const db = require('../db'); const redisClient = require('./redisClient'); +const knowledgeQueryResolver = require('./knowledgeQueryResolver'); const { hasKnowledgeRouteKeyword } = require('./knowledgeKeywords'); +const { loadBestSummary } = require('./conversationSummarizer'); function normalizeTextForSpeech(text) { return (text || '') @@ -64,7 +66,8 @@ function estimateSpeechDurationMs(text) { } function normalizeKnowledgeAlias(text) { - return String(text || '') + const semanticNormalized = knowledgeQueryResolver.normalizeKnowledgeText(text); + return String(semanticNormalized || '') .replace(/一成[,,、。!?\s]+系统/g, '一成系统') .replace(/X{2}系统/gi, '一成系统') .replace(/[\u4e00-\u9fff]{1,3}(?:成|城|程|诚|乘|声|生)[,,、\s]*系统/g, '一成系统') @@ -77,7 +80,7 @@ function normalizeKnowledgeAlias(text) { function hasKnowledgeKeyword(text) { const normalized = normalizeKnowledgeAlias(text).replace(/\s+/g, ''); - return hasKnowledgeRouteKeyword(normalized); + return hasKnowledgeRouteKeyword(normalized) || knowledgeQueryResolver.hasExplicitKnowledgeEntity(normalized, { skipAsrCorrection: true }); } function isKnowledgeFollowUp(text) { @@ -253,7 +256,7 @@ function extractToolResultText(toolName, toolResult) { return '知识库已配置但方舟LLM端点未就绪,暂时无法检索,请稍后再试。'; } if (toolResult.results && Array.isArray(toolResult.results)) { - return toolResult.results.map((item) => item.content || JSON.stringify(item)).join('\n'); + return toolResult.results.filter((item) => item.kind !== 'instruction' && item.kind !== 'context').map((item) => item.content || JSON.stringify(item)).join('\n'); } if (typeof toolResult === 'string') return toolResult; if (toolResult.error) return toolResult.error; @@ -272,24 +275,23 @@ async function resolveReply(sessionId, session, text) { // 快速路径:知识库候选先尝试无context的热答案/缓存命中,跳过DB查询(省50-200ms) if (shouldForceKnowledgeRoute(originalText)) { - const fastResult = await ToolExecutor.execute('search_knowledge', { query: originalText, response_mode: 'answer', session_id: sessionId, _session: session }, []); + const fastResult = await ToolExecutor.execute('search_knowledge', { query: originalText, session_id: sessionId, _session: session }, []); if (fastResult && fastResult.hit) { const replyText = extractToolResultText('search_knowledge', fastResult); - const ragItems = fastResult.hit && Array.isArray(fastResult.results) - ? fastResult.results.filter(i => i && i.content).map(i => ({ title: i.title || '知识库结果', content: i.content })) + const evidenceItems = Array.isArray(fastResult?.evidence_pack?.items) && fastResult.evidence_pack.items.length > 0 + ? fastResult.evidence_pack.items + : (Array.isArray(fastResult.results) ? fastResult.results : []); + const ragItems = fastResult.hit && Array.isArray(evidenceItems) + ? evidenceItems.filter(i => i && i.content && i.kind !== 'context').map(i => ({ ...i })) : []; - console.log(`[resolveReply] fast-path hit in ${Date.now() - _resolveStart}ms session=${sessionId} source=${fastResult.cache_hit ? 'cache' : 'direct'} mode=${fastResult.retrieval_mode || 'answer'}`); + console.log(`[resolveReply] fast-path hit in ${Date.now() - _resolveStart}ms session=${sessionId} source=${fastResult.cache_hit ? 'cache' : 'direct'} mode=raw`); if (ragItems.length > 0) { session.handoffSummaryUsed = true; - // raw 模式:ragItems 已包含上下文 + 多个 KB 片段,直接透传 - const isRawMode = fastResult.retrieval_mode === 'raw'; - const finalRagItems = isRawMode - ? ragItems - : [{ title: '知识库结果', content: normalizeTextForSpeech(replyText).replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '') || replyText }]; return { delivery: 'external_rag', speechText: '', - ragItems: finalRagItems, + ragItems, + evidencePack: fastResult.evidence_pack || null, source: 'voice_tool', toolName: 'search_knowledge', routeDecision: { route: 'search_knowledge', args: { query: originalText } }, @@ -311,7 +313,7 @@ async function resolveReply(sessionId, session, text) { const _dbStart = Date.now(); let recentMessages = null; if (process.env.ENABLE_REDIS_CONTEXT !== 'false') { - const redisHistory = await redisClient.getRecentHistory(sessionId, 5).catch(() => null); + const redisHistory = await redisClient.getRecentHistory(sessionId, 3).catch(() => null); if (redisHistory && redisHistory.length > 0) { recentMessages = redisHistory; const _dbMs = Date.now() - _dbStart; @@ -319,7 +321,7 @@ async function resolveReply(sessionId, session, text) { } } if (!recentMessages) { - recentMessages = await db.getRecentMessages(sessionId, 10).catch(() => []); + recentMessages = await db.getRecentMessages(sessionId, 6).catch(() => []); const _dbMs = Date.now() - _dbStart; if (_dbMs > 50) console.log(`[resolveReply] DB getRecentMessages took ${_dbMs}ms session=${sessionId}`); } @@ -329,7 +331,12 @@ async function resolveReply(sessionId, session, text) { const baseContext = scopedMessages .filter((item) => item && (item.role === 'user' || item.role === 'assistant')) .map((item) => ({ role: item.role, content: item.content })); - const context = withHandoffSummary(session, baseContext); + // 注入对话摘要(三级降级:Redis → MySQL → null) + const summary = await loadBestSummary(sessionId).catch(() => null); + const contextWithSummary = summary + ? [{ role: 'system', content: `[历史对话摘要] ${summary}` }, ...baseContext] + : baseContext; + const context = withHandoffSummary(session, contextWithSummary); let routeDecision = getRuleBasedDirectRouteDecision(originalText); // KB-First: 所有非闲聊查询强制先走知识库,KB不命中再交给S2S自由回答 if (routeDecision.route === 'chat' && !isPureChitchat(originalText)) { @@ -358,7 +365,7 @@ async function resolveReply(sessionId, session, text) { toolName = routeDecision.route; source = 'voice_tool'; const toolArgs = toolName === 'search_knowledge' - ? { ...(routeDecision.args || {}), response_mode: 'answer', session_id: sessionId, _session: session } + ? { ...(routeDecision.args || {}), session_id: sessionId, _session: session } : routeDecision.args; const metaToolArgs = toolArgs && typeof toolArgs === 'object' ? Object.fromEntries(Object.entries(toolArgs).filter(([key]) => key !== '_session')) @@ -380,14 +387,15 @@ async function resolveReply(sessionId, session, text) { latency_ms: toolResult?.latency_ms || null, }; + const evidenceItems = Array.isArray(toolResult?.evidence_pack?.items) && toolResult.evidence_pack.items.length > 0 + ? toolResult.evidence_pack.items + : (Array.isArray(toolResult?.results) ? toolResult.results : []); + const ragItems = toolName === 'search_knowledge' - ? (toolResult?.hit && Array.isArray(toolResult?.results) - ? toolResult.results - .filter((item) => item && item.content) - .map((item) => ({ - title: item.title || '知识库结果', - content: item.content, - })) + ? (toolResult?.hit && Array.isArray(evidenceItems) + ? evidenceItems + .filter((item) => item && item.content && item.kind !== 'context') + .map((item) => ({ ...item })) : []) : (!toolResult?.error && replyText ? [{ title: `${toolName}结果`, content: replyText }] @@ -395,23 +403,11 @@ async function resolveReply(sessionId, session, text) { if (ragItems.length > 0) { session.handoffSummaryUsed = true; - const isRawMode = toolResult?.retrieval_mode === 'raw'; - let finalRagItems = ragItems; - - if (toolName === 'search_knowledge' && !isRawMode) { - // 旧模式:LLM 加工过的文本,清理后合并为单条 - const speechText = normalizeTextForSpeech(replyText); - if (speechText) { - const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, ''); - finalRagItems = [{ title: '知识库结果', content: cleanedText || speechText }]; - } - } - // raw 模式:ragItems 已包含上下文 + 多个 KB 片段,直接透传给 S2S - return { delivery: 'external_rag', speechText: '', - ragItems: finalRagItems, + ragItems, + evidencePack: toolResult?.evidence_pack || null, source, toolName, routeDecision, @@ -428,6 +424,7 @@ async function resolveReply(sessionId, session, text) { delivery: 'external_rag', speechText: '', ragItems: [{ title: '品牌保护', content: safeReply }], + evidencePack: toolResult?.evidence_pack || null, source: 'voice_tool', toolName: 'search_knowledge', routeDecision, @@ -443,6 +440,7 @@ async function resolveReply(sessionId, session, text) { delivery: 'external_rag', speechText: '', ragItems: [{ title: '知识库未命中', content: honestReply }], + evidencePack: toolResult?.evidence_pack || null, source: 'voice_tool', toolName: 'search_knowledge', routeDecision, diff --git a/test2/server/services/redisClient.js b/test2/server/services/redisClient.js index 7b611c6..54ee548 100644 --- a/test2/server/services/redisClient.js +++ b/test2/server/services/redisClient.js @@ -10,6 +10,7 @@ const HISTORY_MAX_LEN = 10; // 5轮 × 2条/轮 const HISTORY_TTL_S = 1800; // 30分钟 const KB_CACHE_HIT_TTL_S = 300; // 5分钟 const KB_CACHE_NOHIT_TTL_S = 120; // 2分钟 +const SUMMARY_TTL_S = parseInt(process.env.SUMMARY_REDIS_TTL_SECONDS) || 7200; // 2小时 // ============ 连接管理 ============ let client = null; @@ -128,6 +129,32 @@ async function clearSession(sessionId) { } } +// ============ 对话摘要 ============ +const summaryKey = (sessionId) => `voice:summary:${sessionId}`; + +async function setSummary(sessionId, summary) { + if (!isAvailable() || !summary) return false; + try { + const key = summaryKey(sessionId); + await client.set(key, summary, 'EX', SUMMARY_TTL_S); + return true; + } catch (err) { + console.warn('[Redis] setSummary failed:', err.message); + return false; + } +} + +async function getSummary(sessionId) { + if (!isAvailable()) return null; + try { + const key = summaryKey(sessionId); + return await client.get(key); + } catch (err) { + console.warn('[Redis] getSummary failed:', err.message); + return null; + } +} + // ============ KB 缓存 ============ const kbCacheKey = (cacheKey) => `kb_cache:${cacheKey}`; @@ -178,6 +205,8 @@ module.exports = { pushMessage, getRecentHistory, clearSession, + setSummary, + getSummary, setKbCache, getKbCache, disconnect,