feat: conversation long-term memory + fix source ENUM bug

- 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
This commit is contained in:
User
2026-04-03 10:19:16 +08:00
parent 5b824cd16a
commit fe25229de7
6 changed files with 797 additions and 69 deletions

View File

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

View File

@@ -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` 替代 | 新函数统一管理所有来源的摘要注入 |

View File

@@ -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;
// 写入 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,
};

View File

@@ -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,18 +766,6 @@ 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))}`);
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, {
@@ -749,7 +778,6 @@ function handleUpstreamMessage(session, data) {
session.blockUpstreamAudio = true;
console.log(`[NativeVoice] re-blocked after KB response session=${session.sessionId}`);
}
}
} else {
const didFlush = flushAssistantStream(session, {
source: pendingAssistantSource,
@@ -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);

View File

@@ -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,

View File

@@ -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,