- 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文档、系统提示词目录
542 lines
19 KiB
JavaScript
542 lines
19 KiB
JavaScript
/**
|
||
* KB保护窗口 + 质疑关键词 + query去噪 + 话题记忆 功能性测试
|
||
* 针对"粉末"幻觉问题暴露的所有修复点进行回归测试
|
||
*
|
||
* 运行方式: node --test tests/test_kb_protection.js
|
||
*/
|
||
const { describe, it } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
// ===== 被测模块加载 =====
|
||
const { shouldForceKnowledgeRoute } = require('../services/realtimeDialogRouting');
|
||
const { hasKnowledgeRouteKeyword } = require('../services/knowledgeKeywords');
|
||
const contextKeywordTracker = require('../services/contextKeywordTracker');
|
||
|
||
// toolExecutor 需要特殊处理(有外部依赖),直接require静态方法
|
||
let ToolExecutor;
|
||
try {
|
||
ToolExecutor = require('../services/toolExecutor');
|
||
} catch (e) {
|
||
// toolExecutor可能依赖env,提供fallback
|
||
ToolExecutor = null;
|
||
}
|
||
|
||
// ================================================================
|
||
// 1. isKnowledgeFollowUp / shouldForceKnowledgeRoute 质疑场景测试
|
||
// ================================================================
|
||
describe('shouldForceKnowledgeRoute — 质疑/纠正类话术', () => {
|
||
// 为所有需要context的测试准备KB上下文
|
||
const kbContext = [
|
||
{ role: 'user', content: '骨关节产品有哪些' },
|
||
{ role: 'assistant', content: '德国PM的健骨至尊氨糖软骨素胶囊...' },
|
||
];
|
||
|
||
// ---- 1. 直接否定 ----
|
||
describe('直接否定', () => {
|
||
const cases = [
|
||
'不是的',
|
||
'不是啊',
|
||
'不是不是',
|
||
'才不是',
|
||
'没有啊',
|
||
'没有吧',
|
||
'哪有',
|
||
'不是这么回事',
|
||
'不是这么说的',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true,
|
||
`"${text}" should be recognized as KB follow-up with KB context`);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 2. 指出错误 ----
|
||
describe('指出错误', () => {
|
||
const cases = [
|
||
'你搞错了吧',
|
||
'说错了',
|
||
'弄错了',
|
||
'记错了',
|
||
'搞混了吧',
|
||
'你说反了',
|
||
'记岔了',
|
||
'张冠李戴',
|
||
'答非所问',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true,
|
||
`"${text}" should be recognized as KB follow-up`);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 3. 说AI不对 ----
|
||
describe('说AI不对', () => {
|
||
const cases = [
|
||
'不对不对',
|
||
'你说的不对',
|
||
'不准确',
|
||
'说得不准',
|
||
'回答有误',
|
||
'不太对吧',
|
||
'说的有问题',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 4. 与自己认知矛盾 ----
|
||
describe('与认知矛盾', () => {
|
||
const cases = [
|
||
'跟我了解的不一样',
|
||
'我记得不是这样',
|
||
'我听说不是这样的',
|
||
'跟之前说的不一样',
|
||
'前后矛盾',
|
||
'你刚才不是说胶囊吗',
|
||
'别人告诉我是粉末',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 5. 怀疑/不信 ----
|
||
describe('怀疑不信', () => {
|
||
const cases = [
|
||
'我不信',
|
||
'骗人的吧',
|
||
'忽悠人呢',
|
||
'吹牛吧',
|
||
'太夸张了',
|
||
'离谱',
|
||
'扯淡',
|
||
'有依据吗',
|
||
'有证据吗',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 6. 要求复查 ----
|
||
describe('要求复查', () => {
|
||
const cases = [
|
||
'你再查查',
|
||
'再确认一下',
|
||
'重新查一下',
|
||
'核实一下',
|
||
'帮我再查一下',
|
||
'你查一下',
|
||
'搞清楚再说',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 7. 委婉质疑 ----
|
||
describe('委婉质疑', () => {
|
||
const cases = [
|
||
'好像不是这样吧',
|
||
'我觉得不太对',
|
||
'恐怕不是吧',
|
||
'感觉不对',
|
||
'我怎么记得不一样',
|
||
'印象中不是这样',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 8. 质问来源 ----
|
||
describe('质问来源', () => {
|
||
const cases = [
|
||
'谁说的',
|
||
'谁告诉你的',
|
||
'你从哪知道的',
|
||
'有什么根据',
|
||
'你确定吗',
|
||
'确定吗',
|
||
'真的吗',
|
||
'真的假的',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 9. 不可能/反问 ----
|
||
describe('不可能/反问', () => {
|
||
const cases = [
|
||
'怎么可能',
|
||
'不可能',
|
||
'不会吧',
|
||
'不是吧',
|
||
'开玩笑吧',
|
||
'别逗了',
|
||
'胡说',
|
||
'瞎说',
|
||
'别瞎说',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 10. 产品形态纠正 ----
|
||
describe('产品形态纠正', () => {
|
||
const cases = [
|
||
'粉末来的呀',
|
||
'是胶囊不是粉末',
|
||
'这个是冲剂',
|
||
'直接吞的',
|
||
'冲着喝的',
|
||
'是片剂',
|
||
'口服液来着',
|
||
'泡着喝',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 11. 纠正句式 ----
|
||
describe('纠正句式', () => {
|
||
const cases = [
|
||
'到底是粉末还是胶囊',
|
||
'究竟是什么形状',
|
||
'应该是冲剂吧',
|
||
'明明是粉末',
|
||
'其实是胶囊',
|
||
'怎么变成粉末了',
|
||
'不应该是这样',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应识别为KB追问`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text, kbContext), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---- 复现"粉末"原始场景 ----
|
||
describe('复现原始"粉末"幻觉场景', () => {
|
||
it('"粉末来的呀,你是搞错了吧?" → 必须走KB路由', () => {
|
||
assert.equal(shouldForceKnowledgeRoute('粉末来的呀,你是搞错了吧?', kbContext), true);
|
||
});
|
||
|
||
it('"粉末来的呀,你是搞错了吧?" → 无context也应命中(关键词直接匹配)', () => {
|
||
assert.equal(shouldForceKnowledgeRoute('粉末来的呀,你是搞错了吧?'), true);
|
||
});
|
||
|
||
it('"不对,是粉末不是胶囊" → 走KB路由', () => {
|
||
assert.equal(shouldForceKnowledgeRoute('不对,是粉末不是胶囊', kbContext), true);
|
||
});
|
||
});
|
||
|
||
// ---- 负面用例:纯闲聊不应走KB ----
|
||
describe('负面用例:纯闲聊不应触发KB', () => {
|
||
const cases = [
|
||
'你好',
|
||
'谢谢',
|
||
'再见',
|
||
'今天天气好',
|
||
'哈哈哈',
|
||
'嗯嗯',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 不应走KB路由`, () => {
|
||
assert.equal(shouldForceKnowledgeRoute(text), false,
|
||
`"${text}" should NOT route to KB`);
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
// ================================================================
|
||
// 2. hasKnowledgeRouteKeyword — 新增质疑关键词命中测试
|
||
// ================================================================
|
||
describe('hasKnowledgeRouteKeyword — 新增质疑/产品形态关键词', () => {
|
||
describe('产品剂型直接命中', () => {
|
||
const keywords = ['粉末', '胶囊', '片剂', '冲剂', '口服液', '软胶囊', '颗粒', '膏状'];
|
||
for (const kw of keywords) {
|
||
it(`"${kw}" → 应直接命中KB关键词`, () => {
|
||
assert.equal(hasKnowledgeRouteKeyword(kw), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('质疑词直接命中', () => {
|
||
const keywords = ['搞错了', '说错了', '不对', '确定吗', '真的吗', '不可能', '胡说', '离谱', '核实一下'];
|
||
for (const kw of keywords) {
|
||
it(`"${kw}" → 应直接命中KB关键词`, () => {
|
||
assert.equal(hasKnowledgeRouteKeyword(kw), true);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('复合句子中的关键词命中', () => {
|
||
const cases = [
|
||
'粉末来的呀你搞错了',
|
||
'这个产品不对,应该是胶囊',
|
||
'你确定吗这个是冲剂',
|
||
'谁说的这是片剂',
|
||
];
|
||
for (const text of cases) {
|
||
it(`"${text}" → 应命中KB关键词`, () => {
|
||
assert.equal(hasKnowledgeRouteKeyword(text), true);
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
// ================================================================
|
||
// 3. sanitizeRewrittenQuery — 去噪截断测试
|
||
// ================================================================
|
||
describe('sanitizeRewrittenQuery — query后处理', () => {
|
||
// 只有ToolExecutor加载成功才测试
|
||
const skip = !ToolExecutor || !ToolExecutor.sanitizeRewrittenQuery;
|
||
|
||
it('清理口语填充词', { skip }, () => {
|
||
const result = ToolExecutor.sanitizeRewrittenQuery('骨关节啊怎么吃呢');
|
||
assert.ok(!result.includes('啊'), `Should remove filler: got "${result}"`);
|
||
assert.ok(!result.includes('呢'), `Should remove filler: got "${result}"`);
|
||
assert.ok(result.includes('骨关节'), 'Should keep core content');
|
||
assert.ok(result.includes('怎么吃'), 'Should keep core content');
|
||
});
|
||
|
||
it('去除重复片段', { skip }, () => {
|
||
const result = ToolExecutor.sanitizeRewrittenQuery('骨关节 骨关节 怎么吃');
|
||
const count = (result.match(/骨关节/g) || []).length;
|
||
assert.equal(count, 1, `Should dedupe: got "${result}"`);
|
||
});
|
||
|
||
it('截断超长query到80字符', { skip }, () => {
|
||
const longQuery = '德国PM细胞营养素基础套装大白小红小白一成系统NTC营养保送系统的功效作用成分配方怎么吃怎么用服用方法适合谁适用人群区别搭配原理价格多少钱哪里买';
|
||
const result = ToolExecutor.sanitizeRewrittenQuery(longQuery);
|
||
assert.ok(result.length <= 80, `Should truncate to <=80: got len=${result.length}`);
|
||
});
|
||
|
||
it('空输入返回空', { skip }, () => {
|
||
assert.equal(ToolExecutor.sanitizeRewrittenQuery(''), '');
|
||
assert.equal(ToolExecutor.sanitizeRewrittenQuery(null), '');
|
||
});
|
||
|
||
it('正常query不应被破坏', { skip }, () => {
|
||
const result = ToolExecutor.sanitizeRewrittenQuery('德国PM骨关节产品怎么吃');
|
||
assert.ok(result.includes('德国PM'), 'Should preserve content');
|
||
assert.ok(result.includes('骨关节'), 'Should preserve content');
|
||
assert.ok(result.includes('怎么吃'), 'Should preserve content');
|
||
});
|
||
|
||
it('清理连续标点', { skip }, () => {
|
||
const result = ToolExecutor.sanitizeRewrittenQuery('骨关节??!!怎么吃');
|
||
assert.ok(!/[?!]{2,}/.test(result), `Should clean punctuation: got "${result}"`);
|
||
});
|
||
});
|
||
|
||
// ================================================================
|
||
// 4. enrichQueryWithContext — KB话题记忆优先级测试
|
||
// ================================================================
|
||
describe('enrichQueryWithContext — KB话题记忆优先', () => {
|
||
const sessionId = 'test_session_' + Date.now();
|
||
|
||
it('有KB话题记忆时,追问应关联KB话题而非历史keyword', () => {
|
||
// 模拟session有KB话题记忆
|
||
const mockSession = {
|
||
_lastKbTopic: '骨关节产品有哪些',
|
||
_lastKbHitAt: Date.now(),
|
||
};
|
||
|
||
// 往keyword tracker中注入旧关键词(模拟上一个话题是"一成系统")
|
||
contextKeywordTracker.updateSession(sessionId, '一成系统详细介绍');
|
||
|
||
const result = contextKeywordTracker.enrichQueryWithContext(sessionId, '怎么吃', mockSession);
|
||
|
||
assert.ok(result.includes('骨关节'), `Should use KB topic memory: got "${result}"`);
|
||
assert.ok(!result.includes('一成系统'), `Should NOT use old keyword: got "${result}"`);
|
||
});
|
||
|
||
it('KB话题记忆过期后,回退到keyword tracker', () => {
|
||
const mockSession = {
|
||
_lastKbTopic: '骨关节产品有哪些',
|
||
_lastKbHitAt: Date.now() - 120000, // 2分钟前,已过期
|
||
};
|
||
|
||
contextKeywordTracker.updateSession(sessionId, '一成系统详细介绍');
|
||
const result = contextKeywordTracker.enrichQueryWithContext(sessionId, '怎么吃', mockSession);
|
||
|
||
assert.ok(!result.includes('骨关节'), `Should NOT use expired KB topic: got "${result}"`);
|
||
});
|
||
|
||
it('无session时使用keyword tracker', () => {
|
||
contextKeywordTracker.updateSession(sessionId, '基础套装功效');
|
||
const result = contextKeywordTracker.enrichQueryWithContext(sessionId, '怎么吃', null);
|
||
|
||
assert.ok(result.includes('怎么吃'), `Should include query: got "${result}"`);
|
||
});
|
||
|
||
it('非追问类query不做enrichment', () => {
|
||
const mockSession = {
|
||
_lastKbTopic: '骨关节产品有哪些',
|
||
_lastKbHitAt: Date.now(),
|
||
};
|
||
|
||
const result = contextKeywordTracker.enrichQueryWithContext(sessionId, '德国PM公司介绍', mockSession);
|
||
assert.equal(result, '德国PM公司介绍', 'Non-follow-up should not be enriched');
|
||
});
|
||
});
|
||
|
||
// ================================================================
|
||
// 5. KB保护窗口 — 集成场景模拟测试
|
||
// ================================================================
|
||
describe('KB保护窗口 — 场景模拟', () => {
|
||
// 模拟nativeVoiceGateway中的保护窗口逻辑
|
||
function simulateProtectionWindow(cleanText, session) {
|
||
let isKnowledgeCandidate = shouldForceKnowledgeRoute(cleanText);
|
||
const KB_PROTECTION_WINDOW_MS = 60000;
|
||
if (!isKnowledgeCandidate && session._lastKbHitAt && (Date.now() - session._lastKbHitAt < KB_PROTECTION_WINDOW_MS)) {
|
||
const isPureChitchat = /^(喂|你好|嗨|谢谢|再见|拜拜|好的|嗯|哦|行|没事了|不用了|可以了)[,,。!?\s]*$/.test(cleanText);
|
||
if (!isPureChitchat) {
|
||
isKnowledgeCandidate = true;
|
||
}
|
||
}
|
||
return isKnowledgeCandidate;
|
||
}
|
||
|
||
it('复现场景:KB hit后用户说"粉末来的呀你搞错了吧" → 应走KB', () => {
|
||
const session = { _lastKbHitAt: Date.now() - 5000, _lastKbTopic: '骨关节' };
|
||
assert.equal(simulateProtectionWindow('粉末来的呀,你是搞错了吧?', session), true);
|
||
});
|
||
|
||
it('保护窗口内:"你说的不对" → 应走KB', () => {
|
||
const session = { _lastKbHitAt: Date.now() - 10000 };
|
||
assert.equal(simulateProtectionWindow('你说的不对', session), true);
|
||
});
|
||
|
||
it('保护窗口内:"哦这样啊" → 非闲聊,应走KB', () => {
|
||
const session = { _lastKbHitAt: Date.now() - 10000 };
|
||
assert.equal(simulateProtectionWindow('哦这样啊', session), true);
|
||
});
|
||
|
||
it('保护窗口内:纯闲聊"谢谢" → 不应走KB', () => {
|
||
const session = { _lastKbHitAt: Date.now() - 10000 };
|
||
assert.equal(simulateProtectionWindow('谢谢', session), false);
|
||
});
|
||
|
||
it('保护窗口内:纯闲聊"好的" → 不应走KB', () => {
|
||
const session = { _lastKbHitAt: Date.now() - 10000 };
|
||
assert.equal(simulateProtectionWindow('好的', session), false);
|
||
});
|
||
|
||
it('保护窗口内:纯闲聊"再见" → 不应走KB', () => {
|
||
const session = { _lastKbHitAt: Date.now() - 10000 };
|
||
assert.equal(simulateProtectionWindow('再见', session), false);
|
||
});
|
||
|
||
it('保护窗口过期后:"你搞错了" → 仍走KB(因为关键词直接命中)', () => {
|
||
const session = { _lastKbHitAt: Date.now() - 120000 };
|
||
assert.equal(simulateProtectionWindow('你搞错了', session), true);
|
||
});
|
||
|
||
it('保护窗口过期后:"天气怎么样" → 不走KB', () => {
|
||
const session = { _lastKbHitAt: Date.now() - 120000 };
|
||
assert.equal(simulateProtectionWindow('今天心情好', session), false);
|
||
});
|
||
|
||
it('无KB历史时:"随便聊聊" → 不走KB', () => {
|
||
const session = { _lastKbHitAt: 0 };
|
||
assert.equal(simulateProtectionWindow('随便聊聊', session), false);
|
||
});
|
||
});
|
||
|
||
// ================================================================
|
||
// 6. 端到端场景回归测试(模拟完整对话链路)
|
||
// ================================================================
|
||
describe('端到端场景回归 — 模拟"粉末"幻觉完整链路', () => {
|
||
it('场景1: 问骨关节→追问怎么吃→质疑粉末,三轮都应走KB', () => {
|
||
// 第1轮:问骨关节
|
||
const r1 = shouldForceKnowledgeRoute('骨关节产品有哪些');
|
||
assert.equal(r1, true, '第1轮"骨关节产品有哪些"应走KB');
|
||
|
||
// 第2轮:追问怎么吃(有上下文)
|
||
const ctx = [
|
||
{ role: 'user', content: '骨关节产品有哪些' },
|
||
{ role: 'assistant', content: '德国PM的健骨至尊氨糖软骨素胶囊...' },
|
||
];
|
||
const r2 = shouldForceKnowledgeRoute('怎么吃', ctx);
|
||
assert.equal(r2, true, '第2轮"怎么吃"有KB上下文应走KB');
|
||
|
||
// 第3轮:质疑(关键词直接命中)
|
||
const r3 = shouldForceKnowledgeRoute('粉末来的呀,你是搞错了吧?');
|
||
assert.equal(r3, true, '第3轮"粉末来的呀搞错了吧"应走KB');
|
||
});
|
||
|
||
it('场景2: 用户用各种方式质疑,全部应走KB', () => {
|
||
const challengeTexts = [
|
||
'粉末来的呀,你是搞错了吧?',
|
||
'不对不对,是粉末',
|
||
'你说的不对,这个是冲剂',
|
||
'我记得不是胶囊啊',
|
||
'你再查查,应该是粉末',
|
||
'好像不是这样吧',
|
||
'谁告诉你是胶囊的',
|
||
'怎么可能是胶囊',
|
||
'明明是粉末状的',
|
||
'冲着喝的不是吞的',
|
||
];
|
||
const ctx = [
|
||
{ role: 'user', content: '骨关节产品' },
|
||
{ role: 'assistant', content: '氨糖软骨素胶囊' },
|
||
];
|
||
|
||
for (const text of challengeTexts) {
|
||
const result = shouldForceKnowledgeRoute(text, ctx);
|
||
assert.equal(result, true, `质疑"${text}"应走KB路由`);
|
||
}
|
||
});
|
||
|
||
it('场景3: 正常追问也应走KB', () => {
|
||
const followUps = [
|
||
'详细说说',
|
||
'怎么吃',
|
||
'多少钱',
|
||
'适合谁',
|
||
'功效是什么',
|
||
'成分是什么',
|
||
];
|
||
const ctx = [
|
||
{ role: 'user', content: '一成系统' },
|
||
{ role: 'assistant', content: '一成系统是德国PM的细胞营养素体系...' },
|
||
];
|
||
|
||
for (const text of followUps) {
|
||
const result = shouldForceKnowledgeRoute(text, ctx);
|
||
assert.equal(result, true, `追问"${text}"有KB上下文应走KB`);
|
||
}
|
||
});
|
||
});
|
||
|
||
console.log('\n=== 测试文件加载完成,开始执行 ===\n');
|