feat(server): KB prompt优化、字幕修复、S2S重连、助手配置API
- assistantProfileConfig: KB answer prompt改为分层策略(严格产品信息+灵活常识补充) - nativeVoiceGateway: S2S upstream自动重连(最多50次)、event 351字幕debounce(800ms取最长文本) - toolExecutor: 确定性query改写增强、KB查询传递session上下文 - contextKeywordTracker: 支持KB话题记忆优先enrichment - contentSafeGuard: 新增品牌安全内容过滤服务 - assistantProfileService: 新增助手配置CRUD服务 - routes/assistantProfile: 新增助手配置API路由 - knowledgeKeywords: 扩展KB关键词词典 - fastAsrCorrector: ASR纠错规则更新 - tests/: KB prompt测试、保护窗口测试、Viking性能测试 - docs/: 助手配置API文档、系统提示词目录
This commit is contained in:
65
test2/server/routes/assistantProfile.js
Normal file
65
test2/server/routes/assistantProfile.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAssistantProfile, clearAssistantProfileCache } = require('../services/assistantProfileService');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const userId = String(req.query.userId || '').trim() || null;
|
||||
const forceRefresh = String(req.query.forceRefresh || '').trim() === 'true';
|
||||
const result = await getAssistantProfile({ userId, forceRefresh });
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: {
|
||||
userId,
|
||||
profile: result.profile,
|
||||
source: result.source,
|
||||
cached: result.cached,
|
||||
fetchedAt: result.fetchedAt,
|
||||
configured: result.configured,
|
||||
error: result.error,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AssistantProfile] query failed:', error.message);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: error.message,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/refresh', async (req, res) => {
|
||||
try {
|
||||
const userId = String(req.body?.userId || '').trim() || null;
|
||||
clearAssistantProfileCache(userId);
|
||||
const result = await getAssistantProfile({ userId, forceRefresh: true });
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: {
|
||||
userId,
|
||||
profile: result.profile,
|
||||
source: result.source,
|
||||
cached: result.cached,
|
||||
fetchedAt: result.fetchedAt,
|
||||
configured: result.configured,
|
||||
error: result.error,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AssistantProfile] refresh failed:', error.message);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: error.message,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,14 +3,14 @@ const router = express.Router();
|
||||
const cozeChatService = require('../services/cozeChatService');
|
||||
const arkChatService = require('../services/arkChatService');
|
||||
const ToolExecutor = require('../services/toolExecutor');
|
||||
const contextKeywordTracker = require('../services/contextKeywordTracker');
|
||||
const { shouldForceKnowledgeRoute } = require('../services/realtimeDialogRouting');
|
||||
const { isBrandHarmful, getTextSafeReply } = require('../services/contentSafeGuard');
|
||||
const db = require('../db');
|
||||
|
||||
// 存储文字对话的会话状态(sessionId -> session)
|
||||
const chatSessions = new Map();
|
||||
|
||||
const BRAND_HARMFUL_PATTERN = /传销|骗局|骗子公司|非法集资|非法经营|不正规|不合法|庞氏骗局|老鼠会|拉人头的|割韭菜/;
|
||||
const BRAND_SAFE_REPLY = '德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家和地区。如果你想了解更多,可以问我关于PM公司的详细介绍哦。';
|
||||
|
||||
function normalizeAssistantText(text) {
|
||||
let result = String(text || '')
|
||||
@@ -22,9 +22,9 @@ function normalizeAssistantText(text) {
|
||||
.replace(/([。!?;,])\s*([。!?;,])/g, '$2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (BRAND_HARMFUL_PATTERN.test(result)) {
|
||||
if (isBrandHarmful(result)) {
|
||||
console.warn(`[Chat][SafeGuard] blocked harmful content: ${JSON.stringify(result.slice(0, 200))}`);
|
||||
return BRAND_SAFE_REPLY;
|
||||
return getTextSafeReply();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -52,16 +52,46 @@ async function loadHandoffMessages(sessionId, voiceSubtitles = []) {
|
||||
return voiceMessages;
|
||||
}
|
||||
|
||||
async function buildChatSessionState(sessionId, voiceSubtitles = []) {
|
||||
const voiceMessages = await loadHandoffMessages(sessionId, voiceSubtitles);
|
||||
let handoffSummary = '';
|
||||
try {
|
||||
handoffSummary = await arkChatService.summarizeContextForHandoff(voiceMessages, 3);
|
||||
} catch (error) {
|
||||
console.warn('[Chat] summarizeContextForHandoff failed:', error.message);
|
||||
function buildDeterministicHandoffSummary(messages = []) {
|
||||
const normalizedMessages = (Array.isArray(messages) ? messages : [])
|
||||
.filter((item) => item && (item.role === 'user' || item.role === 'assistant') && String(item.content || '').trim())
|
||||
.slice(-8);
|
||||
if (!normalizedMessages.length) {
|
||||
return '';
|
||||
}
|
||||
const userMessages = normalizedMessages.filter((item) => item.role === 'user');
|
||||
const currentQuestion = String(userMessages[userMessages.length - 1]?.content || '').trim();
|
||||
const previousQuestion = String(userMessages[userMessages.length - 2]?.content || '').trim();
|
||||
const assistantFacts = normalizedMessages
|
||||
.filter((item) => item.role === 'assistant')
|
||||
.slice(-2)
|
||||
.map((item) => String(item.content || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => item.slice(0, 60))
|
||||
.join(';');
|
||||
const parts = [];
|
||||
if (currentQuestion) {
|
||||
parts.push(`当前问题:${currentQuestion}`);
|
||||
}
|
||||
if (previousQuestion && previousQuestion !== currentQuestion) {
|
||||
parts.push(`上一轮关注:${previousQuestion}`);
|
||||
}
|
||||
if (assistantFacts) {
|
||||
parts.push(`已给信息:${assistantFacts}`);
|
||||
}
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
async function buildChatSessionState(sessionId, voiceSubtitles = [], userId = null) {
|
||||
const voiceMessages = await loadHandoffMessages(sessionId, voiceSubtitles);
|
||||
voiceMessages
|
||||
.filter((item) => item.role === 'user')
|
||||
.slice(-6)
|
||||
.forEach((item) => contextKeywordTracker.updateSession(sessionId, item.content));
|
||||
const handoffSummary = buildDeterministicHandoffSummary(voiceMessages);
|
||||
return {
|
||||
userId: `user_${sessionId.slice(0, 12)}`,
|
||||
userId: userId || `user_${sessionId.slice(0, 12)}`,
|
||||
profileUserId: userId || null,
|
||||
conversationId: null,
|
||||
voiceMessages,
|
||||
handoffSummary,
|
||||
@@ -127,7 +157,7 @@ async function tryKnowledgeReply(sessionId, session, message) {
|
||||
if (!shouldForceKnowledgeRoute(text, context)) {
|
||||
return null;
|
||||
}
|
||||
const result = await ToolExecutor.execute('search_knowledge', { query: text }, context);
|
||||
const result = await ToolExecutor.execute('search_knowledge', { query: text, session_id: sessionId, _session: { userId: session?.userId || null, profileUserId: session?.profileUserId || null } }, context);
|
||||
if (!result?.hit) {
|
||||
return null;
|
||||
}
|
||||
@@ -160,7 +190,7 @@ async function tryKnowledgeReply(sessionId, session, message) {
|
||||
* 创建文字对话会话,可选传入语音通话的历史字幕
|
||||
*/
|
||||
router.post('/start', async (req, res) => {
|
||||
const { sessionId, voiceSubtitles = [] } = req.body;
|
||||
const { sessionId, voiceSubtitles = [], userId = null } = req.body;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
@@ -170,10 +200,10 @@ router.post('/start', async (req, res) => {
|
||||
return res.status(500).json({ success: false, error: 'Coze 智能体未配置,请设置 COZE_API_TOKEN 和 COZE_BOT_ID' });
|
||||
}
|
||||
|
||||
const sessionState = await buildChatSessionState(sessionId, voiceSubtitles);
|
||||
const sessionState = await buildChatSessionState(sessionId, voiceSubtitles, userId);
|
||||
|
||||
// 更新数据库会话模式为 chat
|
||||
try { await db.createSession(sessionId, `user_${sessionId.slice(0, 12)}`, 'chat'); } catch (e) {}
|
||||
try { await db.createSession(sessionId, userId || `user_${sessionId.slice(0, 12)}`, 'chat'); } catch (e) {}
|
||||
|
||||
chatSessions.set(sessionId, sessionState);
|
||||
|
||||
@@ -195,7 +225,7 @@ router.post('/start', async (req, res) => {
|
||||
*/
|
||||
router.post('/send', async (req, res) => {
|
||||
try {
|
||||
const { sessionId, message } = req.body;
|
||||
const { sessionId, message, userId = null } = req.body;
|
||||
|
||||
if (!sessionId || !message) {
|
||||
return res.status(400).json({ success: false, error: 'sessionId and message are required' });
|
||||
@@ -205,10 +235,15 @@ router.post('/send', async (req, res) => {
|
||||
|
||||
// 自动创建会话(如果不存在)
|
||||
if (!session) {
|
||||
session = await buildChatSessionState(sessionId, []);
|
||||
session = await buildChatSessionState(sessionId, [], userId);
|
||||
chatSessions.set(sessionId, session);
|
||||
}
|
||||
if (userId) {
|
||||
session.userId = userId;
|
||||
session.profileUserId = userId;
|
||||
}
|
||||
session.lastActiveAt = Date.now();
|
||||
contextKeywordTracker.updateSession(sessionId, message);
|
||||
|
||||
console.log(`[Chat] User(${sessionId}): ${message}`);
|
||||
|
||||
@@ -296,7 +331,7 @@ router.get('/history/:sessionId', (req, res) => {
|
||||
* 流式发送文字消息(SSE),逐字输出 Coze 智能体回复
|
||||
*/
|
||||
router.post('/send-stream', async (req, res) => {
|
||||
const { sessionId, message } = req.body;
|
||||
const { sessionId, message, userId = null } = req.body;
|
||||
|
||||
if (!sessionId || !message) {
|
||||
return res.status(400).json({ success: false, error: 'sessionId and message are required' });
|
||||
@@ -304,10 +339,15 @@ router.post('/send-stream', async (req, res) => {
|
||||
|
||||
let session = chatSessions.get(sessionId);
|
||||
if (!session) {
|
||||
session = await buildChatSessionState(sessionId, []);
|
||||
session = await buildChatSessionState(sessionId, [], userId);
|
||||
chatSessions.set(sessionId, session);
|
||||
}
|
||||
if (userId) {
|
||||
session.userId = userId;
|
||||
session.profileUserId = userId;
|
||||
}
|
||||
session.lastActiveAt = Date.now();
|
||||
contextKeywordTracker.updateSession(sessionId, message);
|
||||
|
||||
console.log(`[Chat][SSE] User(${sessionId}): ${message}`);
|
||||
|
||||
@@ -339,6 +379,10 @@ router.post('/send-stream', async (req, res) => {
|
||||
// 首次对话时注入语音历史作为上下文
|
||||
const extraMessages = !session.conversationId ? buildInitialContextMessages(session) : [];
|
||||
|
||||
// 流式缓冲检测:累积 chunk 内容,实时检测有害关键词
|
||||
let streamBuffer = '';
|
||||
let harmfulDetected = false;
|
||||
|
||||
const result = await cozeChatService.chatStream(
|
||||
session.userId,
|
||||
message,
|
||||
@@ -346,24 +390,36 @@ router.post('/send-stream', async (req, res) => {
|
||||
extraMessages,
|
||||
{
|
||||
onChunk: (text) => {
|
||||
if (harmfulDetected) return;
|
||||
streamBuffer += text;
|
||||
// 实时检测流式内容是否包含有害关键词
|
||||
if (isBrandHarmful(streamBuffer)) {
|
||||
harmfulDetected = true;
|
||||
console.warn(`[Chat][SSE][SafeGuard] harmful content detected in stream, intercepting session=${sessionId} buffer=${JSON.stringify(streamBuffer.slice(0, 200))}`);
|
||||
// 发送重置信号,告诉前端丢弃已收到的 chunk
|
||||
res.write(`data: ${JSON.stringify({ type: 'stream_reset', reason: 'content_safety' })}\n\n`);
|
||||
return;
|
||||
}
|
||||
res.write(`data: ${JSON.stringify({ type: 'chunk', content: text })}\n\n`);
|
||||
},
|
||||
onDone: () => {},
|
||||
}
|
||||
);
|
||||
const normalizedContent = normalizeAssistantText(result.content);
|
||||
|
||||
// 如果流式过程中检测到有害内容,使用安全回复替换
|
||||
const finalContent = harmfulDetected ? getTextSafeReply() : normalizeAssistantText(result.content);
|
||||
|
||||
// 保存 Coze 返回的 conversationId
|
||||
session.conversationId = result.conversationId;
|
||||
session.handoffSummaryUsed = true;
|
||||
console.log(`[Chat][SSE] Assistant(${sessionId}): ${normalizedContent?.substring(0, 100)}`);
|
||||
console.log(`[Chat][SSE] Assistant(${sessionId}): ${finalContent?.substring(0, 100)}${harmfulDetected ? ' [SAFE_REPLACED]' : ''}`);
|
||||
|
||||
// 写入数据库:AI 回复
|
||||
if (normalizedContent) {
|
||||
db.addMessage(sessionId, 'assistant', normalizedContent, 'chat_bot').catch(e => console.warn('[DB] addMessage failed:', e.message));
|
||||
if (finalContent) {
|
||||
db.addMessage(sessionId, 'assistant', finalContent, 'chat_bot').catch(e => console.warn('[DB] addMessage failed:', e.message));
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'done', content: normalizedContent })}\n\n`);
|
||||
res.write(`data: ${JSON.stringify({ type: 'done', content: finalContent })}\n\n`);
|
||||
res.end();
|
||||
} catch (error) {
|
||||
console.error('[Chat][SSE] Stream failed:', error.message);
|
||||
|
||||
@@ -2,6 +2,8 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const ToolExecutor = require('../services/toolExecutor');
|
||||
const contextKeywordTracker = require('../services/contextKeywordTracker');
|
||||
const { getRuleBasedDirectRouteDecision, shouldForceKnowledgeRoute } = require('../services/realtimeDialogRouting');
|
||||
const DEFAULT_TOOLS = require('../config/tools');
|
||||
const db = require('../db');
|
||||
|
||||
@@ -66,6 +68,9 @@ router.post('/direct/message', async (req, res) => {
|
||||
if (!sessionId || !text || !source) {
|
||||
return res.status(400).json({ success: false, error: 'sessionId, text and source are required' });
|
||||
}
|
||||
if (role === 'user') {
|
||||
contextKeywordTracker.updateSession(sessionId, text);
|
||||
}
|
||||
await db.addMessage(sessionId, role === 'user' ? 'user' : 'assistant', text, source, toolName || null);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -94,9 +99,19 @@ router.post('/direct/query', async (req, res) => {
|
||||
const context = await db.getHistoryForLLM(sessionId, 20).catch(() => []);
|
||||
const cleanQuery = (query || '').trim();
|
||||
if (appendUserMessage && cleanQuery) {
|
||||
contextKeywordTracker.updateSession(sessionId, cleanQuery);
|
||||
await db.addMessage(sessionId, 'user', cleanQuery, 'voice_asr').catch(() => null);
|
||||
}
|
||||
const result = await ToolExecutor.execute('search_knowledge', { query: cleanQuery }, context);
|
||||
if (!appendUserMessage && cleanQuery) {
|
||||
contextKeywordTracker.updateSession(sessionId, cleanQuery);
|
||||
}
|
||||
const routeDecision = getRuleBasedDirectRouteDecision(cleanQuery);
|
||||
const forceKb = shouldForceKnowledgeRoute(cleanQuery, context);
|
||||
const shouldSearchKb = routeDecision.route === 'search_knowledge' || forceKb;
|
||||
const directSession = directSessions.get(sessionId);
|
||||
const result = shouldSearchKb
|
||||
? await ToolExecutor.execute('search_knowledge', { query: cleanQuery, session_id: sessionId, _session: { userId: directSession?.userId || null } }, context)
|
||||
: { hit: false, reason: 'route_skip', source: 'route_skip', error: '该问题不在知识库范围内,请咨询其他问题。' };
|
||||
let contentText = JSON.stringify(result);
|
||||
if (result && result.results && Array.isArray(result.results)) {
|
||||
contentText = result.results.map((item) => item.content || JSON.stringify(item)).join('\n');
|
||||
|
||||
Reference in New Issue
Block a user