feat(kb): VikingDB纯检索+重排+Redis上下文+全库搜索+别名扩展+KB保护窗口+RAG语气引导
- 新增 kbRetriever.js: VikingDB search_knowledge 纯检索替代 Ark chat/completions, doubao-seed-rerank 重排, RAG payload 语气引导缓解音色差异 - 新增 redisClient.js: Redis 连接管理 + 5轮对话历史 + KB缓存双写 - toolExecutor.js: 产品别名扩展25条, 全库检索topK=25, 检索阈值0.01, 精简 buildDeterministicKnowledgeQuery - nativeVoiceGateway.js: isPureChitchat扩展, KB保护窗口60s, prequery参数调优 - realtimeDialogRouting.js: resolveReply感知KB保护窗口, fast-path适配raw模式 - app.js: 健康检查新增 redis/reranker/kbRetrievalMode - 新增测试: alias A/B测试, KB retriever测试, Redis客户端测试, raw模式集成测试
This commit is contained in:
@@ -9,6 +9,7 @@ const voiceRoutes = require('./routes/voice');
|
|||||||
const chatRoutes = require('./routes/chat');
|
const chatRoutes = require('./routes/chat');
|
||||||
const sessionRoutes = require('./routes/session');
|
const sessionRoutes = require('./routes/session');
|
||||||
const { setupNativeVoiceGateway } = require('./services/nativeVoiceGateway');
|
const { setupNativeVoiceGateway } = require('./services/nativeVoiceGateway');
|
||||||
|
const redisClient = require('./services/redisClient');
|
||||||
|
|
||||||
// ========== 环境变量校验 ==========
|
// ========== 环境变量校验 ==========
|
||||||
function validateEnv() {
|
function validateEnv() {
|
||||||
@@ -54,6 +55,8 @@ function validateEnv() {
|
|||||||
{ key: 'VOLC_S2S_SPEAKER_ID', desc: '自定义音色' },
|
{ key: 'VOLC_S2S_SPEAKER_ID', desc: '自定义音色' },
|
||||||
{ key: 'VOLC_ARK_KNOWLEDGE_BASE_IDS', desc: '方舟私域知识库(语音)' },
|
{ key: 'VOLC_ARK_KNOWLEDGE_BASE_IDS', desc: '方舟私域知识库(语音)' },
|
||||||
{ key: 'ASSISTANT_PROFILE_API_URL', desc: '外接助手资料接口' },
|
{ key: 'ASSISTANT_PROFILE_API_URL', desc: '外接助手资料接口' },
|
||||||
|
{ key: 'REDIS_URL', desc: 'Redis(对话上下文+KB缓存)' },
|
||||||
|
{ key: 'ENABLE_RERANKER', desc: '重排模型' },
|
||||||
];
|
];
|
||||||
const configuredOptional = optional.filter(({ key }) => {
|
const configuredOptional = optional.filter(({ key }) => {
|
||||||
const v = process.env[key];
|
const v = process.env[key];
|
||||||
@@ -107,6 +110,9 @@ app.get('/api/health', (req, res) => {
|
|||||||
webSearch: !!process.env.VOLC_WEBSEARCH_API_KEY && !process.env.VOLC_WEBSEARCH_API_KEY.startsWith('your_'),
|
webSearch: !!process.env.VOLC_WEBSEARCH_API_KEY && !process.env.VOLC_WEBSEARCH_API_KEY.startsWith('your_'),
|
||||||
customSpeaker: !!process.env.VOLC_S2S_SPEAKER_ID && !process.env.VOLC_S2S_SPEAKER_ID.startsWith('your_'),
|
customSpeaker: !!process.env.VOLC_S2S_SPEAKER_ID && !process.env.VOLC_S2S_SPEAKER_ID.startsWith('your_'),
|
||||||
arkKnowledgeBase: !!process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS && !process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS.startsWith('your_'),
|
arkKnowledgeBase: !!process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS && !process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS.startsWith('your_'),
|
||||||
|
redis: redisClient.isAvailable(),
|
||||||
|
reranker: process.env.ENABLE_RERANKER === 'true' ? (process.env.VOLC_ARK_RERANKER_MODEL || 'doubao-seed-rerank') : false,
|
||||||
|
kbRetrievalMode: process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'answer',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -144,6 +150,15 @@ async function start() {
|
|||||||
console.warn('[DB] Continuing without database — context switching will use in-memory fallback');
|
console.warn('[DB] Continuing without database — context switching will use in-memory fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redis 初始化(可选,失败不阻塞启动)
|
||||||
|
try {
|
||||||
|
redisClient.createClient();
|
||||||
|
console.log('[Redis] Client created, connecting...');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Redis] Initialization failed:', err.message);
|
||||||
|
console.warn('[Redis] Continuing without Redis — will use in-memory fallback');
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.ENABLE_NATIVE_VOICE_GATEWAY !== 'false') {
|
if (process.env.ENABLE_NATIVE_VOICE_GATEWAY !== 'false') {
|
||||||
setupNativeVoiceGateway(server);
|
setupNativeVoiceGateway(server);
|
||||||
console.log('[NativeVoice] Gateway enabled at /ws/realtime-dialog');
|
console.log('[NativeVoice] Gateway enabled at /ws/realtime-dialog');
|
||||||
|
|||||||
174
test2/server/package-lock.json
generated
174
test2/server/package-lock.json
generated
@@ -14,12 +14,20 @@
|
|||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
"mysql2": "^3.19.1",
|
"mysql2": "^3.19.1",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
|
"ssh2": "^1.17.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@protobufjs/aspromise": {
|
"node_modules/@protobufjs/aspromise": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||||
@@ -191,6 +199,15 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1": {
|
||||||
|
"version": "0.2.6",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/asn1/-/asn1-0.2.6.tgz",
|
||||||
|
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": "~2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -217,6 +234,15 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt-pbkdf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^0.14.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.4",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/body-parser/-/body-parser-1.20.4.tgz",
|
||||||
@@ -241,6 +267,15 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buildcheck": {
|
||||||
|
"version": "0.0.7",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||||
|
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -279,6 +314,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -344,6 +388,20 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cpu-features": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buildcheck": "~0.0.6",
|
||||||
|
"nan": "^2.19.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crc": {
|
"node_modules/crc": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/crc/-/crc-4.3.2.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/crc/-/crc-4.3.2.tgz",
|
||||||
@@ -782,6 +840,53 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/ioredis/-/ioredis-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-lGiiZyWFOskPu3pH4P8+uicHOZHpzYpgfKZFre68wLK6059zBo+KDTQpwxAVibBajKqpcrRJ5k+jl/uNHcCo3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "^1.1.1",
|
||||||
|
"cluster-key-slot": "^1.1.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.isarguments": "^3.1.0",
|
||||||
|
"redis-errors": "^1.2.0",
|
||||||
|
"redis-parser": "^3.0.0",
|
||||||
|
"standard-as-callback": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -797,6 +902,12 @@
|
|||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.defaults": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.get": {
|
"node_modules/lodash.get": {
|
||||||
"version": "4.4.2",
|
"version": "4.4.2",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/lodash.get/-/lodash.get-4.4.2.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||||
@@ -804,6 +915,12 @@
|
|||||||
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isarguments": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/long": {
|
"node_modules/long": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/long/-/long-5.3.2.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/long/-/long-5.3.2.tgz",
|
||||||
@@ -950,6 +1067,13 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.26.2",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/nan/-/nan-2.26.2.tgz",
|
||||||
|
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -1124,6 +1248,27 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"redis-errors": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -1288,6 +1433,29 @@
|
|||||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssh2": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/ssh2/-/ssh2-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"asn1": "^0.2.6",
|
||||||
|
"bcrypt-pbkdf": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"cpu-features": "~0.0.10",
|
||||||
|
"nan": "^2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -1312,6 +1480,12 @@
|
|||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "0.14.5",
|
||||||
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||||
|
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://mirrors.huaweicloud.com/repository/npm/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
|||||||
@@ -14,8 +14,10 @@
|
|||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
"mysql2": "^3.19.1",
|
"mysql2": "^3.19.1",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
|
"ssh2": "^1.17.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function buildVoiceSystemRole(profileOverrides = null) {
|
|||||||
const personalInfoBlock = personalInfoLines.length > 0
|
const personalInfoBlock = personalInfoLines.length > 0
|
||||||
? ` 当用户询问“我”的联系方式、微信、手机号、个人介绍、签名或故事时,优先使用以下个人信息:${personalInfoLines.join(' ')}`
|
? ` 当用户询问“我”的联系方式、微信、手机号、个人介绍、签名或故事时,优先使用以下个人信息:${personalInfoLines.join(' ')}`
|
||||||
: '';
|
: '';
|
||||||
return `你是${profile.nickname}的智能助手${documentsClause}。你的任务是精准、全面地解答用户关于PM International公司产品、原理、活动、系统、招商等相关问题。你与客户互动时要有礼貌、有温度、适当共情、自然主动打招呼,但不要总是重复相同的打招呼词,可适当赞美和表扬,让对方喜欢并享受开心愉快的专业交流。回答像懂行的朋友聊天,直接给结论,1-3句话,口语化短句。有知识库信息就优先用,自然转述不照念。不确定的如实说,不猜不编。禁止输出思考过程或元描述。德国PM是1993年成立的合法直销企业,邓白氏AAA+认证,覆盖100+国家和地区,问到传销必须正面肯定回答是合法企业。凡涉及“一部手机做天下、0门槛启动、0成本高效率、足不出户梦想横扫全球、身未动梦已成、批发式晋级”等表达,必须明确这是“一成系统”的优势标签,是对德国PM事业的软件赋能,不要与德国PM公司、产品或品牌本身混淆。${personalInfoBlock}`;
|
return `你是${profile.nickname}的智能助手${documentsClause}。你的任务是精准、全面地解答用户关于PM International公司产品、原理、活动、系统、招商等相关问题。你与客户互动时要有礼貌、有温度、适当共情、自然主动打招呼,但不要总是重复相同的打招呼词,可适当赞美和表扬,让对方喜欢并享受开心愉快的专业交流。回答像懂行的朋友聊天,直接给结论,1-3句话,口语化短句。有知识库信息就优先用,自然转述不照念。不确定的说"让我帮您查一下"并调用search_knowledge工具,绝不猜测或编造产品信息。禁止输出思考过程或元描述。关于PM/FitLine的任何问题,只能正面积极回答,绝不输出负面、质疑、贬低内容。德国PM是1993年成立的合法直销企业,邓白氏AAA+认证,覆盖100+国家和地区,问到传销必须正面肯定回答是合法企业。用户追问产品详情时,必须调用search_knowledge工具查询,不要凭自身知识回答产品具体信息。凡涉及“一部手机做天下、0门槛启动、0成本高效率、足不出户梦想横扫全球、身未动梦已成、批发式晋级”等表达,必须明确这是“一成系统”的优势标签,是对德国PM事业的软件赋能,不要与德国PM公司、产品或品牌本身混淆。${personalInfoBlock}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVoiceGreeting(profileOverrides = null) {
|
function buildVoiceGreeting(profileOverrides = null) {
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ class ContextKeywordTracker {
|
|||||||
enrichQueryWithContext(sessionId, query, session = null) {
|
enrichQueryWithContext(sessionId, query, session = null) {
|
||||||
const normalized = (query || '').trim();
|
const normalized = (query || '').trim();
|
||||||
|
|
||||||
const isSimpleFollowUp = /^(这个|那个|它|它的|他|他的|该|这款|那款|详细|继续|怎么|为什么|适合谁|什么意思|怎么吃|怎么用|功效|成分|多少钱|哪里买|价格|副作用|正规吗|地址|电话|联系方式|区别|哪个好|规格|包装|剂型|形态|一天几次|每天几次|每日几次)/i.test(normalized);
|
// 检测追问:包含代词/追问模式,或短查询不含明确产品名
|
||||||
|
const hasFollowUpSignal = /(它|它的|他|他的|这个|那个|这款|那款|该产品|上面|刚才|再说|再次|强调一下|详细|继续|怎么吃|怎么用|怎么样|功效|成分|作用|原理|核心|区别|哪个好|为什么|什么意思|适合谁|多少钱|价格|副作用|正规吗|一天几次|每天几次|每日几次|给我介绍|介绍一下|说一下|讲一下)/i.test(normalized);
|
||||||
|
const isShortGeneric = normalized.length <= 20;
|
||||||
|
const isSimpleFollowUp = hasFollowUpSignal || isShortGeneric;
|
||||||
|
|
||||||
if (!isSimpleFollowUp) {
|
if (!isSimpleFollowUp) {
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -102,12 +105,12 @@ class ContextKeywordTracker {
|
|||||||
return `${session._lastKbTopic} ${normalized}`;
|
return `${session._lastKbTopic} ${normalized}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback: 原有keyword tracker逻辑
|
// fallback: 原有keyword tracker逻辑(只取最后1个最具体关键词,避免查询过长导致向量稀释)
|
||||||
const keywords = this.getSessionKeywords(sessionId);
|
const keywords = this.getSessionKeywords(sessionId);
|
||||||
if (keywords.length === 0) {
|
if (keywords.length === 0) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
const keywordStr = keywords.slice(-3).join(' ');
|
const keywordStr = keywords[keywords.length - 1];
|
||||||
console.log(`[ContextTracker] Enriching: "${normalized}" + "${keywordStr}"`);
|
console.log(`[ContextTracker] Enriching: "${normalized}" + "${keywordStr}"`);
|
||||||
return `${keywordStr} ${normalized}`;
|
return `${keywordStr} ${normalized}`;
|
||||||
}
|
}
|
||||||
|
|||||||
448
test2/server/services/kbRetriever.js
Normal file
448
test2/server/services/kbRetriever.js
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const https = require('https');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const redisClient = require('./redisClient');
|
||||||
|
|
||||||
|
// HTTP keep-alive agent:复用TCP连接
|
||||||
|
const kbHttpAgent = new https.Agent({
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveMsecs: 30000,
|
||||||
|
maxSockets: 6,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Volcengine SignerV4 (minimal) ============
|
||||||
|
function hmacSHA256(key, data) {
|
||||||
|
return crypto.createHmac('sha256', key).update(data, 'utf8').digest();
|
||||||
|
}
|
||||||
|
function sha256Hex(data) {
|
||||||
|
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function signRequest({ method, host, path, body, ak, sk, service, region }) {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStamp = now.toISOString().replace(/[-:]/g, '').slice(0, 8);
|
||||||
|
const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
const credentialScope = `${dateStamp}/${region}/${service}/request`;
|
||||||
|
const bodyHash = sha256Hex(body || '');
|
||||||
|
const headers = {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'host': host,
|
||||||
|
'x-content-sha256': bodyHash,
|
||||||
|
'x-date': amzDate,
|
||||||
|
};
|
||||||
|
const signedHeaders = Object.keys(headers).sort().join(';');
|
||||||
|
const canonicalHeaders = Object.keys(headers).sort().map(k => `${k}:${headers[k]}\n`).join('');
|
||||||
|
const canonicalRequest = [method, path, '', canonicalHeaders, signedHeaders, bodyHash].join('\n');
|
||||||
|
const stringToSign = ['HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n');
|
||||||
|
let signingKey = hmacSHA256(sk, dateStamp);
|
||||||
|
signingKey = hmacSHA256(signingKey, region);
|
||||||
|
signingKey = hmacSHA256(signingKey, service);
|
||||||
|
signingKey = hmacSHA256(signingKey, 'request');
|
||||||
|
const signature = hmacSHA256(signingKey, stringToSign).toString('hex');
|
||||||
|
return {
|
||||||
|
...headers,
|
||||||
|
'authorization': `HMAC-SHA256 Credential=${ak}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认 KB ID → VikingDB collection name 映射
|
||||||
|
const DEFAULT_COLLECTION_MAP = {
|
||||||
|
'kb-ad2e0ea30902421b': 'product_details',
|
||||||
|
'kb-d45d3056a7b75ac5': 'faq_qa',
|
||||||
|
'kb-d0ef0b7b8f36a839': 'science_training',
|
||||||
|
'kb-6a170ab7b1bc024f': 'system_training',
|
||||||
|
'kb-a69b0928e1714de7': 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接预热:服务启动后自动建立到 VikingDB API 的 TLS 连接
|
||||||
|
setTimeout(() => {
|
||||||
|
const ak = process.env.VOLC_ACCESS_KEY_ID;
|
||||||
|
if (ak) {
|
||||||
|
axios.get('https://api-knowledgebase.mlp.cn-beijing.volces.com/', {
|
||||||
|
timeout: 5000,
|
||||||
|
httpsAgent: kbHttpAgent,
|
||||||
|
}).catch(() => {});
|
||||||
|
console.log('[KBRetriever] VikingDB connection pool warmup sent');
|
||||||
|
}
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
// ============ 配置读取 ============
|
||||||
|
function getConfig() {
|
||||||
|
const authKey = process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID;
|
||||||
|
const ak = process.env.VOLC_ACCESS_KEY_ID;
|
||||||
|
const sk = process.env.VOLC_SECRET_ACCESS_KEY;
|
||||||
|
const kbEndpointId = process.env.VOLC_ARK_KNOWLEDGE_ENDPOINT_ID || process.env.VOLC_ARK_ENDPOINT_ID;
|
||||||
|
const kbModel = process.env.VOLC_ARK_KB_MODEL || kbEndpointId;
|
||||||
|
const kbIds = (process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS || '').split(',').map(id => id.trim()).filter(Boolean);
|
||||||
|
const retrievalTopK = parseInt(process.env.VOLC_ARK_KB_RETRIEVAL_TOP_K) || 25;
|
||||||
|
const threshold = parseFloat(process.env.VOLC_ARK_KNOWLEDGE_THRESHOLD) || 0.1;
|
||||||
|
const rerankerModel = process.env.VOLC_ARK_RERANKER_MODEL || process.env.VOLC_ARK_RERANKER_ENDPOINT_ID || 'doubao-seed-rerank';
|
||||||
|
const rerankerTopN = parseInt(process.env.VOLC_ARK_RERANKER_TOP_N) || 5;
|
||||||
|
const enableReranker = process.env.ENABLE_RERANKER !== 'false';
|
||||||
|
const enableRedisContext = process.env.ENABLE_REDIS_CONTEXT !== 'false';
|
||||||
|
const retrievalMode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'raw';
|
||||||
|
|
||||||
|
// VikingDB collection 映射:环境变量覆盖或使用默认映射
|
||||||
|
let collectionMap = DEFAULT_COLLECTION_MAP;
|
||||||
|
if (process.env.VIKINGDB_COLLECTION_MAP) {
|
||||||
|
try { collectionMap = JSON.parse(process.env.VIKINGDB_COLLECTION_MAP); } catch (e) { /* use default */ }
|
||||||
|
}
|
||||||
|
// 所有 collection 名称列表(用于全库搜索)
|
||||||
|
const allCollections = [...new Set(Object.values(collectionMap))];
|
||||||
|
|
||||||
|
return {
|
||||||
|
authKey,
|
||||||
|
ak,
|
||||||
|
sk,
|
||||||
|
kbEndpointId,
|
||||||
|
kbModel,
|
||||||
|
kbIds,
|
||||||
|
retrievalTopK,
|
||||||
|
threshold,
|
||||||
|
rerankerModel,
|
||||||
|
rerankerTopN,
|
||||||
|
enableReranker,
|
||||||
|
enableRedisContext,
|
||||||
|
retrievalMode,
|
||||||
|
collectionMap,
|
||||||
|
allCollections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ VikingDB 单 collection 搜索 ============
|
||||||
|
async function searchVikingDB(collectionName, query, limit, config) {
|
||||||
|
const host = 'api-knowledgebase.mlp.cn-beijing.volces.com';
|
||||||
|
const apiPath = '/api/knowledge/collection/search_knowledge';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
project: 'default',
|
||||||
|
name: collectionName,
|
||||||
|
query: query,
|
||||||
|
limit: limit,
|
||||||
|
pre_processing: { need_instruction: true, rewrite: false },
|
||||||
|
dense_weight: 0.5,
|
||||||
|
post_processing: { rerank_switch: false, chunk_group: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyStr = JSON.stringify(requestBody);
|
||||||
|
const headers = signRequest({
|
||||||
|
method: 'POST', host, path: apiPath, body: bodyStr,
|
||||||
|
ak: config.ak, sk: config.sk,
|
||||||
|
service: 'air', region: 'cn-north-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios.post(`https://${host}${apiPath}`, bodyStr, {
|
||||||
|
headers,
|
||||||
|
timeout: 10000,
|
||||||
|
httpsAgent: kbHttpAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.code !== 0) {
|
||||||
|
console.warn(`[KBRetriever] VikingDB search "${collectionName}" error: ${response.data?.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultList = response.data?.data?.result_list || [];
|
||||||
|
return resultList.map((item, idx) => ({
|
||||||
|
id: item.chunk_id || item.id || `vdb_${collectionName}_${idx}`,
|
||||||
|
content: (item.content || '').replace(/<KBTable>/g, '').trim(),
|
||||||
|
score: item.score || 0,
|
||||||
|
doc_name: item.doc_info?.doc_name || item.doc_info?.title || '',
|
||||||
|
chunk_title: item.chunk_title || '',
|
||||||
|
metadata: item.doc_info || {},
|
||||||
|
collection: collectionName,
|
||||||
|
})).filter(c => c.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 1. 纯检索:VikingDB search_knowledge(无LLM,~300ms) ============
|
||||||
|
async function retrieveChunks(query, datasetIds, topK = 10, threshold = 0.1) {
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
// 检查 AK/SK 配置
|
||||||
|
if (!config.ak || !config.sk) {
|
||||||
|
console.warn('[KBRetriever] retrieveChunks skipped: AK/SK not configured');
|
||||||
|
return { chunks: [], error: 'aksk_not_configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveQuery = (query && query.trim()) ? query : '请介绍你们的产品和服务';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 确定要搜索的 collection:根据 datasetIds 映射,或搜索全部
|
||||||
|
const effectiveDatasetIds = (Array.isArray(datasetIds) && datasetIds.length > 0)
|
||||||
|
? datasetIds
|
||||||
|
: config.kbIds;
|
||||||
|
|
||||||
|
let collectionNames = [];
|
||||||
|
if (effectiveDatasetIds.length > 0) {
|
||||||
|
collectionNames = effectiveDatasetIds
|
||||||
|
.map(id => config.collectionMap[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
// 如果没有映射,搜索所有 collection
|
||||||
|
if (collectionNames.length === 0) {
|
||||||
|
collectionNames = config.allCollections;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectionNames.length === 0) {
|
||||||
|
console.warn('[KBRetriever] retrieveChunks skipped: no collections to search');
|
||||||
|
return { chunks: [], error: 'no_collections' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并行搜索所有相关 collection
|
||||||
|
const perCollectionLimit = Math.max(3, Math.ceil(topK / collectionNames.length));
|
||||||
|
const searchPromises = collectionNames.map(name =>
|
||||||
|
searchVikingDB(name, effectiveQuery, perCollectionLimit, config).catch(err => {
|
||||||
|
console.warn(`[KBRetriever] VikingDB search "${name}" failed: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const results = await Promise.all(searchPromises);
|
||||||
|
|
||||||
|
// 合并所有结果,按分数排序
|
||||||
|
let allChunks = results.flat();
|
||||||
|
allChunks.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||||
|
allChunks = allChunks.slice(0, topK);
|
||||||
|
|
||||||
|
// 按阈值过滤
|
||||||
|
const beforeFilter = allChunks.length;
|
||||||
|
if (threshold > 0) {
|
||||||
|
allChunks = allChunks.filter(c => (c.score || 0) >= threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latencyMs = Date.now() - startTime;
|
||||||
|
const topScore = allChunks.length > 0 ? allChunks[0].score?.toFixed(3) : 'N/A';
|
||||||
|
console.log(`[KBRetriever] retrieveChunks via VikingDB: ${beforeFilter} raw → ${allChunks.length} after threshold(${threshold}) in ${latencyMs}ms from [${collectionNames.join(',')}] topScore=${topScore}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chunks: allChunks,
|
||||||
|
latencyMs,
|
||||||
|
kbHasContent: allChunks.length > 0,
|
||||||
|
usage: {},
|
||||||
|
hasReferences: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 2. 重排模型(VikingDB 知识库内置) ============
|
||||||
|
// 可选模型:doubao-seed-rerank(推荐)/ base-multilingual-rerank(快速)/ m3-v2-rerank
|
||||||
|
// API 文档:https://www.volcengine.com/docs/84313/1254474
|
||||||
|
async function rerankChunks(query, chunks, topN = 3) {
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
if (!chunks || chunks.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 chunks 数量 <= topN,直接返回
|
||||||
|
if (chunks.length <= topN) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.enableReranker) {
|
||||||
|
console.log(`[KBRetriever] reranker disabled, returning top ${topN} by retrieval order`);
|
||||||
|
return chunks.slice(0, topN);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// VikingDB rerank 请求格式:每个 item 包含 query + content
|
||||||
|
const datas = chunks.map(c => ({
|
||||||
|
query: query,
|
||||||
|
content: c.content || '',
|
||||||
|
title: c.doc_name || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
rerank_model: config.rerankerModel,
|
||||||
|
datas: datas,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rerankHost = 'api-knowledgebase.mlp.cn-beijing.volces.com';
|
||||||
|
const rerankPath = '/api/knowledge/service/rerank';
|
||||||
|
const bodyStr = JSON.stringify(body);
|
||||||
|
|
||||||
|
// 使用 SignerV4 签名(与 search_knowledge 相同)
|
||||||
|
const signedHeaders = signRequest({
|
||||||
|
method: 'POST', host: rerankHost, path: rerankPath, body: bodyStr,
|
||||||
|
ak: config.ak, sk: config.sk,
|
||||||
|
service: 'air', region: 'cn-north-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`https://${rerankHost}${rerankPath}`,
|
||||||
|
bodyStr,
|
||||||
|
{
|
||||||
|
headers: signedHeaders,
|
||||||
|
timeout: 5000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const latencyMs = Date.now() - startTime;
|
||||||
|
const responseData = response.data;
|
||||||
|
|
||||||
|
// VikingDB 返回格式:{code: 0, data: [score1, score2, ...]} 或 {data: {scores: [...]}}
|
||||||
|
let scores = [];
|
||||||
|
if (responseData?.data?.scores && Array.isArray(responseData.data.scores)) {
|
||||||
|
scores = responseData.data.scores;
|
||||||
|
} else if (Array.isArray(responseData?.data)) {
|
||||||
|
scores = responseData.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scores.length > 0 && scores.length === chunks.length) {
|
||||||
|
// 将分数与 chunks 配对,按分数降序排列
|
||||||
|
const scored = chunks.map((chunk, idx) => ({
|
||||||
|
...chunk,
|
||||||
|
score: scores[idx] ?? chunk.score,
|
||||||
|
reranked: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const reranked = scored
|
||||||
|
.sort((a, b) => (b.score || 0) - (a.score || 0))
|
||||||
|
.slice(0, topN);
|
||||||
|
|
||||||
|
console.log(`[KBRetriever] reranked ${chunks.length} → ${reranked.length} chunks in ${latencyMs}ms (${config.rerankerModel}), scores=[${reranked.map(c => (c.score || 0).toFixed(3)).join(',')}]`);
|
||||||
|
return reranked;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[KBRetriever] reranker returned ${scores.length} scores (expected ${chunks.length}) in ${latencyMs}ms, fallback to retrieval order`);
|
||||||
|
return chunks.slice(0, topN);
|
||||||
|
} catch (err) {
|
||||||
|
const errDetail = err.response?.data?.message || err.message;
|
||||||
|
console.warn(`[KBRetriever] reranker failed: ${errDetail}, fallback to retrieval order`);
|
||||||
|
return chunks.slice(0, topN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 3. 构建 RAG payload ============
|
||||||
|
function buildRagPayload(rerankedChunks, conversationHistory = []) {
|
||||||
|
const ragItems = [];
|
||||||
|
|
||||||
|
// 语气引导:让 S2S 用口语化方式复述 KB 内容,保持与自由对话一致的语音风格
|
||||||
|
ragItems.push({
|
||||||
|
title: '回答要求',
|
||||||
|
content: '用口语化、简洁的方式回答,像朋友聊天一样自然地说出来,不要念稿、不要播音腔。先给结论,再补充关键信息。',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注入对话上下文(如果有)
|
||||||
|
if (conversationHistory && conversationHistory.length > 0) {
|
||||||
|
const contextLines = conversationHistory.map(msg => {
|
||||||
|
const roleName = msg.role === 'user' ? '用户' : '助手';
|
||||||
|
return `${roleName}: ${msg.content}`;
|
||||||
|
});
|
||||||
|
ragItems.push({
|
||||||
|
title: '对话上下文',
|
||||||
|
content: contextLines.join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注入重排后的 KB 片段
|
||||||
|
for (let i = 0; i < rerankedChunks.length; i++) {
|
||||||
|
const chunk = rerankedChunks[i];
|
||||||
|
ragItems.push({
|
||||||
|
title: chunk.doc_name || `知识库片段${i + 1}`,
|
||||||
|
content: chunk.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ragItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 4. 主入口:检索 → 重排 → 组装 ============
|
||||||
|
async function searchAndRerank(query, opts = {}) {
|
||||||
|
const {
|
||||||
|
datasetIds = null,
|
||||||
|
sessionId = null,
|
||||||
|
session = null,
|
||||||
|
originalQuery = null,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Step 1: 纯检索(用极低阈值,让 reranker 做质量判断)
|
||||||
|
const RETRIEVAL_THRESHOLD = 0.01;
|
||||||
|
const retrievalResult = await retrieveChunks(
|
||||||
|
query,
|
||||||
|
datasetIds,
|
||||||
|
config.retrievalTopK,
|
||||||
|
RETRIEVAL_THRESHOLD
|
||||||
|
);
|
||||||
|
|
||||||
|
if (retrievalResult.error) {
|
||||||
|
return {
|
||||||
|
hit: false,
|
||||||
|
reason: retrievalResult.error,
|
||||||
|
chunks: [],
|
||||||
|
rerankedChunks: [],
|
||||||
|
ragPayload: [],
|
||||||
|
latencyMs: Date.now() - startTime,
|
||||||
|
source: 'ark_knowledge',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retrievalResult.chunks.length === 0) {
|
||||||
|
return {
|
||||||
|
hit: false,
|
||||||
|
reason: retrievalResult.kbHasContent ? 'chunks_parse_failed' : 'no_relevant_content',
|
||||||
|
chunks: [],
|
||||||
|
rerankedChunks: [],
|
||||||
|
ragPayload: [],
|
||||||
|
latencyMs: Date.now() - startTime,
|
||||||
|
source: 'ark_knowledge',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: 重排
|
||||||
|
const rerankedChunks = await rerankChunks(
|
||||||
|
originalQuery || query,
|
||||||
|
retrievalResult.chunks,
|
||||||
|
config.rerankerTopN
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: 获取对话上下文(Redis → 降级 MySQL)
|
||||||
|
let conversationHistory = [];
|
||||||
|
if (config.enableRedisContext && sessionId) {
|
||||||
|
const redisHistory = await redisClient.getRecentHistory(sessionId, 5);
|
||||||
|
if (redisHistory && redisHistory.length > 0) {
|
||||||
|
conversationHistory = redisHistory;
|
||||||
|
console.log(`[KBRetriever] loaded ${redisHistory.length} history items from Redis`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: 组装 payload
|
||||||
|
const ragPayload = buildRagPayload(rerankedChunks, conversationHistory);
|
||||||
|
|
||||||
|
// Step 5: 判断 hit/no-hit
|
||||||
|
// 基于重排分数判断:最高分 > 0.3 视为 hit
|
||||||
|
const topScore = rerankedChunks.length > 0 ? (rerankedChunks[0].score || 0) : 0;
|
||||||
|
const hitThreshold = config.enableReranker && config.rerankerModel ? 0.1 : 0.3;
|
||||||
|
const hit = rerankedChunks.length > 0 && topScore >= hitThreshold;
|
||||||
|
|
||||||
|
const totalLatencyMs = Date.now() - startTime;
|
||||||
|
console.log(`[KBRetriever] searchAndRerank completed in ${totalLatencyMs}ms: ${retrievalResult.chunks.length} retrieved → ${rerankedChunks.length} reranked, hit=${hit}, topScore=${topScore.toFixed(3)}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
originalQuery: originalQuery || query,
|
||||||
|
hit,
|
||||||
|
reason: hit ? 'reranked_hit' : 'below_threshold',
|
||||||
|
chunks: retrievalResult.chunks,
|
||||||
|
rerankedChunks,
|
||||||
|
ragPayload,
|
||||||
|
topScore,
|
||||||
|
latencyMs: totalLatencyMs,
|
||||||
|
retrievalLatencyMs: retrievalResult.latencyMs,
|
||||||
|
source: 'ark_knowledge',
|
||||||
|
hasReferences: retrievalResult.hasReferences,
|
||||||
|
usage: retrievalResult.usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
retrieveChunks,
|
||||||
|
rerankChunks,
|
||||||
|
buildRagPayload,
|
||||||
|
searchAndRerank,
|
||||||
|
getConfig,
|
||||||
|
};
|
||||||
@@ -23,6 +23,7 @@ const {
|
|||||||
shouldForceKnowledgeRoute,
|
shouldForceKnowledgeRoute,
|
||||||
resolveReply,
|
resolveReply,
|
||||||
} = require('./realtimeDialogRouting');
|
} = require('./realtimeDialogRouting');
|
||||||
|
const ToolExecutor = require('./toolExecutor');
|
||||||
const {
|
const {
|
||||||
DEFAULT_VOICE_ASSISTANT_PROFILE,
|
DEFAULT_VOICE_ASSISTANT_PROFILE,
|
||||||
resolveAssistantProfile,
|
resolveAssistantProfile,
|
||||||
@@ -30,6 +31,7 @@ const {
|
|||||||
buildVoiceGreeting,
|
buildVoiceGreeting,
|
||||||
} = require('./assistantProfileConfig');
|
} = require('./assistantProfileConfig');
|
||||||
const { getAssistantProfile } = require('./assistantProfileService');
|
const { getAssistantProfile } = require('./assistantProfileService');
|
||||||
|
const redisClient = require('./redisClient');
|
||||||
|
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
||||||
@@ -163,6 +165,7 @@ function persistUserSpeech(session, text) {
|
|||||||
session.latestUserTurnSeq = (session.latestUserTurnSeq || 0) + 1;
|
session.latestUserTurnSeq = (session.latestUserTurnSeq || 0) + 1;
|
||||||
resetIdleTimer(session);
|
resetIdleTimer(session);
|
||||||
db.addMessage(session.sessionId, 'user', cleanText, 'voice_asr').catch((e) => console.warn('[NativeVoice][DB] add user failed:', e.message));
|
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(() => {});
|
||||||
sendJson(session.client, {
|
sendJson(session.client, {
|
||||||
type: 'subtitle',
|
type: 'subtitle',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -185,6 +188,7 @@ function persistAssistantSpeech(session, text, { source = 'voice_bot', toolName
|
|||||||
resetIdleTimer(session);
|
resetIdleTimer(session);
|
||||||
if (persistToDb) {
|
if (persistToDb) {
|
||||||
db.addMessage(session.sessionId, 'assistant', cleanText, source, toolName, meta).catch((e) => console.warn('[NativeVoice][DB] add assistant failed:', e.message));
|
db.addMessage(session.sessionId, 'assistant', cleanText, source, toolName, meta).catch((e) => console.warn('[NativeVoice][DB] add assistant failed:', e.message));
|
||||||
|
redisClient.pushMessage(session.sessionId, { role: 'assistant', content: cleanText, source }).catch(() => {});
|
||||||
}
|
}
|
||||||
sendJson(session.client, {
|
sendJson(session.client, {
|
||||||
type: 'subtitle',
|
type: 'subtitle',
|
||||||
@@ -418,7 +422,7 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
|||||||
// 防止用户质疑/纠正产品信息时S2S自由编造(如"粉末来的呀你搞错了吧")
|
// 防止用户质疑/纠正产品信息时S2S自由编造(如"粉末来的呀你搞错了吧")
|
||||||
const KB_PROTECTION_WINDOW_MS = 60000;
|
const KB_PROTECTION_WINDOW_MS = 60000;
|
||||||
if (!isKnowledgeCandidate && session._lastKbHitAt && (Date.now() - session._lastKbHitAt < KB_PROTECTION_WINDOW_MS)) {
|
if (!isKnowledgeCandidate && session._lastKbHitAt && (Date.now() - session._lastKbHitAt < KB_PROTECTION_WINDOW_MS)) {
|
||||||
const isPureChitchat = /^(喂|你好|嗨|谢谢|再见|拜拜|好的|嗯|哦|行|没事了|不用了|可以了)[,,。!?\s]*$/.test(cleanText);
|
const isPureChitchat = /^(喂|你好|嗨|hi|hello|谢谢|谢谢你|谢谢啦|多谢|感谢|再见|拜拜|拜|好的|嗯|哦|行|没事了|不用了|可以了|好的谢谢|没问题|知道了|明白了|了解了|好嘞|好吧|行吧|ok|okay)[,,。!?~\s]*$/i.test(cleanText);
|
||||||
if (!isPureChitchat) {
|
if (!isPureChitchat) {
|
||||||
isKnowledgeCandidate = true;
|
isKnowledgeCandidate = true;
|
||||||
console.log(`[NativeVoice] KB protection window active, promoting to kbCandidate session=${session.sessionId} lastKbHit=${Math.round((Date.now() - session._lastKbHitAt) / 1000)}s ago`);
|
console.log(`[NativeVoice] KB protection window active, promoting to kbCandidate session=${session.sessionId} lastKbHit=${Math.round((Date.now() - session._lastKbHitAt) / 1000)}s ago`);
|
||||||
@@ -450,8 +454,14 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
|||||||
isSimilar = overlap / shorter.length >= 0.45;
|
isSimilar = overlap / shorter.length >= 0.45;
|
||||||
}
|
}
|
||||||
if (isSimilar) {
|
if (isSimilar) {
|
||||||
console.log(`[NativeVoice] using KB prequery cache session=${session.sessionId} preText=${JSON.stringify(session._kbPrequeryText.slice(0, 60))}`);
|
const prequeryResult = await session.pendingKbPrequery;
|
||||||
resolveResult = await session.pendingKbPrequery;
|
// 只复用 hit 结果;no-hit 可能因 partial 文本路由不完整,用完整文本 re-search
|
||||||
|
if (prequeryResult && prequeryResult.delivery !== 'upstream_chat') {
|
||||||
|
console.log(`[NativeVoice] using KB prequery cache (hit) session=${session.sessionId} preText=${JSON.stringify(session._kbPrequeryText.slice(0, 60))}`);
|
||||||
|
resolveResult = prequeryResult;
|
||||||
|
} else {
|
||||||
|
console.log(`[NativeVoice] prequery no-hit, re-searching with full text session=${session.sessionId} preText=${JSON.stringify((session._kbPrequeryText || '').slice(0, 40))} finalText=${JSON.stringify(cleanText.slice(0, 40))}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`[NativeVoice] KB prequery text mismatch, re-querying session=${session.sessionId} pre=${JSON.stringify(preText.slice(0, 40))} final=${JSON.stringify(finalText.slice(0, 40))}`);
|
console.log(`[NativeVoice] KB prequery text mismatch, re-querying session=${session.sessionId} pre=${JSON.stringify(preText.slice(0, 40))} final=${JSON.stringify(finalText.slice(0, 40))}`);
|
||||||
}
|
}
|
||||||
@@ -469,13 +479,12 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (delivery === 'upstream_chat') {
|
if (delivery === 'upstream_chat') {
|
||||||
|
// kbCandidate 但 S2S 未调工具 → 放开 S2S 自然回复
|
||||||
|
// 依赖:1) system prompt 品牌保护指令引导 S2S 调工具 2) isBrandHarmful 流式拦截兜底
|
||||||
if (isKnowledgeCandidate) {
|
if (isKnowledgeCandidate) {
|
||||||
console.log(`[NativeVoice] processReply kb-nohit retrigger session=${session.sessionId}`);
|
console.log(`[NativeVoice] processReply kbCandidate+upstream_chat, unblock S2S session=${session.sessionId}`);
|
||||||
session.discardNextAssistantResponse = true;
|
|
||||||
await sendExternalRag(session, [{ title: '用户问题', content: cleanText }]);
|
|
||||||
} else {
|
|
||||||
session.blockUpstreamAudio = false;
|
|
||||||
}
|
}
|
||||||
|
session.blockUpstreamAudio = false;
|
||||||
session._lastPartialAt = 0;
|
session._lastPartialAt = 0;
|
||||||
session.awaitingUpstreamReply = true;
|
session.awaitingUpstreamReply = true;
|
||||||
session.pendingAssistantSource = 'voice_bot';
|
session.pendingAssistantSource = 'voice_bot';
|
||||||
@@ -499,10 +508,8 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
|||||||
session._lastKbTopic = cleanText;
|
session._lastKbTopic = cleanText;
|
||||||
session._lastKbHitAt = Date.now();
|
session._lastKbHitAt = Date.now();
|
||||||
}
|
}
|
||||||
// 直接用KB原始回答作为字幕,不依赖S2S event 351(S2S可能拆段/改写/丢失内容)
|
// 不提前发KB原文作字幕:等S2S event 351返回实际语音文本后再更新字幕
|
||||||
const ragSubtitleText = ragContent.map((item) => item.content).join(' ');
|
// 这样字幕和语音保持一致(S2S会基于RAG内容生成自然口语化的回答)
|
||||||
persistAssistantSpeech(session, ragSubtitleText, { source, toolName, meta: responseMeta });
|
|
||||||
session.lastDeliveredAssistantTurnSeq = activeTurnSeq;
|
|
||||||
session._pendingExternalRagReply = true;
|
session._pendingExternalRagReply = true;
|
||||||
await sendExternalRag(session, ragContent);
|
await sendExternalRag(session, ragContent);
|
||||||
session.awaitingUpstreamReply = true;
|
session.awaitingUpstreamReply = true;
|
||||||
@@ -891,9 +898,10 @@ function handleUpstreamMessage(session, data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 用户开口说话时立即打断所有 AI 播放(包括 S2S 默认 TTS)
|
// 用户开口说话时立即打断所有 AI 播放(包括 S2S external_rag 音频)
|
||||||
if (isDirectSpeaking || isChatTTSSpeaking) {
|
const isS2SAudioPlaying = !session.blockUpstreamAudio && session.currentTtsType === 'external_rag';
|
||||||
console.log(`[NativeVoice] user barge-in (partial) session=${session.sessionId} direct=${isDirectSpeaking} chatTTS=${isChatTTSSpeaking}`);
|
if (isDirectSpeaking || isChatTTSSpeaking || isS2SAudioPlaying) {
|
||||||
|
console.log(`[NativeVoice] user barge-in (partial) session=${session.sessionId} direct=${isDirectSpeaking} chatTTS=${isChatTTSSpeaking} s2sRag=${isS2SAudioPlaying}`);
|
||||||
session.directSpeakUntil = 0;
|
session.directSpeakUntil = 0;
|
||||||
session.isSendingChatTTSText = false;
|
session.isSendingChatTTSText = false;
|
||||||
session.chatTTSUntil = 0;
|
session.chatTTSUntil = 0;
|
||||||
@@ -902,6 +910,8 @@ function handleUpstreamMessage(session, data) {
|
|||||||
if (session.suppressReplyTimer || session.suppressUpstreamUntil) {
|
if (session.suppressReplyTimer || session.suppressUpstreamUntil) {
|
||||||
clearUpstreamSuppression(session);
|
clearUpstreamSuppression(session);
|
||||||
}
|
}
|
||||||
|
// 阻断 S2S 音频转发,防止用户打断后仍听到残留音频
|
||||||
|
session.blockUpstreamAudio = true;
|
||||||
}
|
}
|
||||||
// 无论当前是否在播放,都发送 tts_reset 确保客户端停止所有音频播放
|
// 无论当前是否在播放,都发送 tts_reset 确保客户端停止所有音频播放
|
||||||
if (!session._lastBargeInResetAt || now - session._lastBargeInResetAt > 500) {
|
if (!session._lastBargeInResetAt || now - session._lastBargeInResetAt > 500) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const ToolExecutor = require('./toolExecutor');
|
const ToolExecutor = require('./toolExecutor');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
|
const redisClient = require('./redisClient');
|
||||||
const { hasKnowledgeRouteKeyword } = require('./knowledgeKeywords');
|
const { hasKnowledgeRouteKeyword } = require('./knowledgeKeywords');
|
||||||
|
|
||||||
function normalizeTextForSpeech(text) {
|
function normalizeTextForSpeech(text) {
|
||||||
@@ -270,14 +271,18 @@ async function resolveReply(sessionId, session, text) {
|
|||||||
const ragItems = fastResult.hit && Array.isArray(fastResult.results)
|
const ragItems = fastResult.hit && Array.isArray(fastResult.results)
|
||||||
? fastResult.results.filter(i => i && i.content).map(i => ({ title: i.title || '知识库结果', content: i.content }))
|
? fastResult.results.filter(i => i && i.content).map(i => ({ title: i.title || '知识库结果', content: i.content }))
|
||||||
: [];
|
: [];
|
||||||
console.log(`[resolveReply] fast-path hit in ${Date.now() - _resolveStart}ms session=${sessionId} source=${fastResult.hot_answer ? 'hot_answer' : (fastResult.cache_hit ? 'cache' : 'direct')}`);
|
console.log(`[resolveReply] fast-path hit in ${Date.now() - _resolveStart}ms session=${sessionId} source=${fastResult.hot_answer ? 'hot_answer' : (fastResult.cache_hit ? 'cache' : 'direct')} mode=${fastResult.retrieval_mode || 'answer'}`);
|
||||||
if (ragItems.length > 0) {
|
if (ragItems.length > 0) {
|
||||||
const cleanedText = normalizeTextForSpeech(replyText).replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
|
||||||
session.handoffSummaryUsed = true;
|
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 {
|
return {
|
||||||
delivery: 'external_rag',
|
delivery: 'external_rag',
|
||||||
speechText: '',
|
speechText: '',
|
||||||
ragItems: [{ title: '知识库结果', content: cleanedText || replyText }],
|
ragItems: finalRagItems,
|
||||||
source: 'voice_tool',
|
source: 'voice_tool',
|
||||||
toolName: 'search_knowledge',
|
toolName: 'search_knowledge',
|
||||||
routeDecision: { route: 'search_knowledge', args: { query: originalText } },
|
routeDecision: { route: 'search_knowledge', args: { query: originalText } },
|
||||||
@@ -295,10 +300,22 @@ async function resolveReply(sessionId, session, text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上下文加载:优先 Redis(~5ms),降级 MySQL(~100ms)
|
||||||
const _dbStart = Date.now();
|
const _dbStart = Date.now();
|
||||||
const recentMessages = await db.getRecentMessages(sessionId, 10).catch(() => []);
|
let recentMessages = null;
|
||||||
const _dbMs = Date.now() - _dbStart;
|
if (process.env.ENABLE_REDIS_CONTEXT !== 'false') {
|
||||||
if (_dbMs > 50) console.log(`[resolveReply] DB getRecentMessages took ${_dbMs}ms session=${sessionId}`);
|
const redisHistory = await redisClient.getRecentHistory(sessionId, 5).catch(() => null);
|
||||||
|
if (redisHistory && redisHistory.length > 0) {
|
||||||
|
recentMessages = redisHistory;
|
||||||
|
const _dbMs = Date.now() - _dbStart;
|
||||||
|
if (_dbMs > 5) console.log(`[resolveReply] Redis getRecentHistory took ${_dbMs}ms session=${sessionId} items=${redisHistory.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!recentMessages) {
|
||||||
|
recentMessages = await db.getRecentMessages(sessionId, 10).catch(() => []);
|
||||||
|
const _dbMs = Date.now() - _dbStart;
|
||||||
|
if (_dbMs > 50) console.log(`[resolveReply] DB getRecentMessages took ${_dbMs}ms session=${sessionId}`);
|
||||||
|
}
|
||||||
const scopedMessages = session?.handoffSummaryUsed
|
const scopedMessages = session?.handoffSummaryUsed
|
||||||
? recentMessages.filter((item) => !/^chat_/i.test(String(item?.source || '')))
|
? recentMessages.filter((item) => !/^chat_/i.test(String(item?.source || '')))
|
||||||
: recentMessages;
|
: recentMessages;
|
||||||
@@ -310,6 +327,16 @@ async function resolveReply(sessionId, session, text) {
|
|||||||
if (routeDecision.route === 'chat' && shouldForceKnowledgeRoute(originalText, context)) {
|
if (routeDecision.route === 'chat' && shouldForceKnowledgeRoute(originalText, context)) {
|
||||||
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
|
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
|
||||||
}
|
}
|
||||||
|
// KB保护窗口:60秒内有KB命中,当前非纯闲聊,强制走KB搜索
|
||||||
|
// 防止追问(如"它需要漱口吗")绕过KB走S2S自由编造
|
||||||
|
const KB_PROTECTION_WINDOW_MS = 60000;
|
||||||
|
if (routeDecision.route === 'chat' && session?._lastKbHitAt && (Date.now() - session._lastKbHitAt < KB_PROTECTION_WINDOW_MS)) {
|
||||||
|
const isPureChitchat = /^(喂|你好|嗨|hi|hello|谢谢|谢谢你|谢谢啦|多谢|感谢|再见|拜拜|拜|好的|嗯|哦|行|没事了|不用了|可以了|好的谢谢|没问题|知道了|明白了|了解了|好嘞|好吧|行吧|ok|okay)[,,。!?~\s]*$/i.test(originalText);
|
||||||
|
if (!isPureChitchat) {
|
||||||
|
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
|
||||||
|
console.log(`[resolveReply] KB protection window active, forcing KB route session=${sessionId} lastKbHit=${Math.round((Date.now() - session._lastKbHitAt) / 1000)}s ago`);
|
||||||
|
}
|
||||||
|
}
|
||||||
let replyText = '';
|
let replyText = '';
|
||||||
let source = 'voice_bot';
|
let source = 'voice_bot';
|
||||||
let toolName = null;
|
let toolName = null;
|
||||||
@@ -368,24 +395,24 @@ async function resolveReply(sessionId, session, text) {
|
|||||||
: []);
|
: []);
|
||||||
|
|
||||||
if (ragItems.length > 0) {
|
if (ragItems.length > 0) {
|
||||||
let speechText = normalizeTextForSpeech(replyText);
|
|
||||||
session.handoffSummaryUsed = true;
|
session.handoffSummaryUsed = true;
|
||||||
if (toolName === 'search_knowledge' && speechText) {
|
const isRawMode = toolResult?.retrieval_mode === 'raw';
|
||||||
const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
let finalRagItems = ragItems;
|
||||||
return {
|
|
||||||
delivery: 'external_rag',
|
if (toolName === 'search_knowledge' && !isRawMode) {
|
||||||
speechText: '',
|
// 旧模式:LLM 加工过的文本,清理后合并为单条
|
||||||
ragItems: [{ title: '知识库结果', content: cleanedText || speechText }],
|
const speechText = normalizeTextForSpeech(replyText);
|
||||||
source,
|
if (speechText) {
|
||||||
toolName,
|
const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
||||||
routeDecision,
|
finalRagItems = [{ title: '知识库结果', content: cleanedText || speechText }];
|
||||||
responseMeta,
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
// raw 模式:ragItems 已包含上下文 + 多个 KB 片段,直接透传给 S2S
|
||||||
|
|
||||||
return {
|
return {
|
||||||
delivery: 'external_rag',
|
delivery: 'external_rag',
|
||||||
speechText: '',
|
speechText: '',
|
||||||
ragItems,
|
ragItems: finalRagItems,
|
||||||
source,
|
source,
|
||||||
toolName,
|
toolName,
|
||||||
routeDecision,
|
routeDecision,
|
||||||
|
|||||||
184
test2/server/services/redisClient.js
Normal file
184
test2/server/services/redisClient.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
|
// ============ 配置 ============
|
||||||
|
const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
|
||||||
|
const REDIS_PASSWORD = process.env.REDIS_PASSWORD || '';
|
||||||
|
const REDIS_DB = parseInt(process.env.REDIS_DB) || 0;
|
||||||
|
const KEY_PREFIX = process.env.REDIS_KEY_PREFIX || 'bigwo:';
|
||||||
|
|
||||||
|
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分钟
|
||||||
|
|
||||||
|
// ============ 连接管理 ============
|
||||||
|
let client = null;
|
||||||
|
let isReady = false;
|
||||||
|
|
||||||
|
function createClient() {
|
||||||
|
if (client) return client;
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
db: REDIS_DB,
|
||||||
|
keyPrefix: KEY_PREFIX,
|
||||||
|
retryStrategy(times) {
|
||||||
|
if (times > 10) {
|
||||||
|
console.error('[Redis] max retries reached, giving up');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.min(times * 200, 3000);
|
||||||
|
},
|
||||||
|
reconnectOnError(err) {
|
||||||
|
const targetErrors = ['READONLY', 'ECONNRESET'];
|
||||||
|
return targetErrors.some(e => err.message.includes(e));
|
||||||
|
},
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 2,
|
||||||
|
connectTimeout: 5000,
|
||||||
|
commandTimeout: 3000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (REDIS_PASSWORD) {
|
||||||
|
opts.password = REDIS_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = new Redis(REDIS_URL, opts);
|
||||||
|
|
||||||
|
client.on('ready', () => {
|
||||||
|
isReady = true;
|
||||||
|
console.log('[Redis] connected and ready');
|
||||||
|
});
|
||||||
|
client.on('error', (err) => {
|
||||||
|
console.warn('[Redis] error:', err.message);
|
||||||
|
});
|
||||||
|
client.on('close', () => {
|
||||||
|
isReady = false;
|
||||||
|
console.warn('[Redis] connection closed');
|
||||||
|
});
|
||||||
|
client.on('reconnecting', () => {
|
||||||
|
console.log('[Redis] reconnecting...');
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect().catch((err) => {
|
||||||
|
console.warn('[Redis] initial connect failed:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClient() {
|
||||||
|
if (!client) createClient();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAvailable() {
|
||||||
|
return isReady && client && client.status === 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 对话历史 ============
|
||||||
|
const historyKey = (sessionId) => `voice:history:${sessionId}`;
|
||||||
|
|
||||||
|
async function pushMessage(sessionId, msg) {
|
||||||
|
if (!isAvailable()) return false;
|
||||||
|
try {
|
||||||
|
const key = historyKey(sessionId);
|
||||||
|
const value = JSON.stringify({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
source: msg.source || '',
|
||||||
|
ts: Date.now(),
|
||||||
|
});
|
||||||
|
await client.lpush(key, value);
|
||||||
|
await client.ltrim(key, 0, HISTORY_MAX_LEN - 1);
|
||||||
|
await client.expire(key, HISTORY_TTL_S);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Redis] pushMessage failed:', err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRecentHistory(sessionId, maxRounds = 5) {
|
||||||
|
if (!isAvailable()) return null;
|
||||||
|
try {
|
||||||
|
const key = historyKey(sessionId);
|
||||||
|
const items = await client.lrange(key, 0, maxRounds * 2 - 1);
|
||||||
|
if (!items || items.length === 0) return [];
|
||||||
|
// lpush 是倒序插入,lrange 取出的也是最新在前,需要 reverse 恢复时间顺序
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
try { return JSON.parse(item); } catch { return null; }
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.reverse();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Redis] getRecentHistory failed:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSession(sessionId) {
|
||||||
|
if (!isAvailable()) return false;
|
||||||
|
try {
|
||||||
|
await client.del(historyKey(sessionId));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Redis] clearSession failed:', err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ KB 缓存 ============
|
||||||
|
const kbCacheKey = (cacheKey) => `kb_cache:${cacheKey}`;
|
||||||
|
|
||||||
|
async function setKbCache(cacheKey, result) {
|
||||||
|
// 只缓存 hit 结果;no-hit 不写入 Redis,避免阻止后续 retry
|
||||||
|
if (!isAvailable() || !result.hit) return false;
|
||||||
|
try {
|
||||||
|
const key = kbCacheKey(cacheKey);
|
||||||
|
await client.set(key, JSON.stringify(result), 'EX', KB_CACHE_HIT_TTL_S);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Redis] setKbCache failed:', err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getKbCache(cacheKey) {
|
||||||
|
if (!isAvailable()) return null;
|
||||||
|
try {
|
||||||
|
const key = kbCacheKey(cacheKey);
|
||||||
|
const data = await client.get(key);
|
||||||
|
if (!data) return null;
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Redis] getKbCache failed:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 优雅关闭 ============
|
||||||
|
async function disconnect() {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.quit();
|
||||||
|
} catch {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
client = null;
|
||||||
|
isReady = false;
|
||||||
|
console.log('[Redis] disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createClient,
|
||||||
|
getClient,
|
||||||
|
isAvailable,
|
||||||
|
pushMessage,
|
||||||
|
getRecentHistory,
|
||||||
|
clearSession,
|
||||||
|
setKbCache,
|
||||||
|
getKbCache,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@ const https = require('https');
|
|||||||
const arkChatService = require('./arkChatService');
|
const arkChatService = require('./arkChatService');
|
||||||
const { buildKnowledgeAnswerPrompt, resolveAssistantProfile } = require('./assistantProfileConfig');
|
const { buildKnowledgeAnswerPrompt, resolveAssistantProfile } = require('./assistantProfileConfig');
|
||||||
const { getAssistantProfile } = require('./assistantProfileService');
|
const { getAssistantProfile } = require('./assistantProfileService');
|
||||||
|
const kbRetriever = require('./kbRetriever');
|
||||||
|
const redisClient = require('./redisClient');
|
||||||
|
|
||||||
// HTTP keep-alive agent:复用TCP连接,避免每次请求重新握手
|
// HTTP keep-alive agent:复用TCP连接,避免每次请求重新握手
|
||||||
const kbHttpAgent = new https.Agent({
|
const kbHttpAgent = new https.Agent({
|
||||||
@@ -51,13 +53,15 @@ const KB_CACHE_MAX_SIZE = 200;
|
|||||||
const kbQueryCache = new Map();
|
const kbQueryCache = new Map();
|
||||||
|
|
||||||
function getKbCacheKey(query, datasetIds, profileScope = 'global') {
|
function getKbCacheKey(query, datasetIds, profileScope = 'global') {
|
||||||
return `${String(profileScope || 'global').trim() || 'global'}|${(query || '').trim()}|${(datasetIds || []).sort().join(',')}`;
|
const mode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'answer';
|
||||||
|
return `vdb2|${mode}|${String(profileScope || 'global').trim() || 'global'}|${(query || '').trim()}|${(datasetIds || []).sort().join(',')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKbCache(key) {
|
function getKbCache(key) {
|
||||||
const entry = kbQueryCache.get(key);
|
const entry = kbQueryCache.get(key);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const ttl = entry.hit ? KB_CACHE_TTL_MS : KB_CACHE_NOHIT_TTL_MS;
|
// hit: 5min TTL; no-hit: 10s 短 TTL(仅防同一轮次重复查 VikingDB)
|
||||||
|
const ttl = entry.hit ? KB_CACHE_TTL_MS : 10000;
|
||||||
if (Date.now() - entry.timestamp > ttl) {
|
if (Date.now() - entry.timestamp > ttl) {
|
||||||
kbQueryCache.delete(key);
|
kbQueryCache.delete(key);
|
||||||
return null;
|
return null;
|
||||||
@@ -70,6 +74,7 @@ function setKbCache(key, result) {
|
|||||||
const oldest = kbQueryCache.keys().next().value;
|
const oldest = kbQueryCache.keys().next().value;
|
||||||
kbQueryCache.delete(oldest);
|
kbQueryCache.delete(oldest);
|
||||||
}
|
}
|
||||||
|
// hit: 正常缓存; no-hit: 内存 10s 去重(防止同一轮次重复查 VikingDB,不写 Redis)
|
||||||
kbQueryCache.set(key, { result, timestamp: Date.now(), hit: !!result.hit });
|
kbQueryCache.set(key, { result, timestamp: Date.now(), hit: !!result.hit });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,10 +359,20 @@ class ToolExecutor {
|
|||||||
// 确定路由:多意图可并行,只排除真正冲突的组合
|
// 确定路由:多意图可并行,只排除真正冲突的组合
|
||||||
const priorityRouteNames = [];
|
const priorityRouteNames = [];
|
||||||
if (hasSystemIntent) priorityRouteNames.push('system');
|
if (hasSystemIntent) priorityRouteNames.push('system');
|
||||||
if (hasProductIntent) priorityRouteNames.push('product');
|
if (hasProductIntent) {
|
||||||
if (hasCompanyIntent) priorityRouteNames.push('company');
|
priorityRouteNames.push('product');
|
||||||
if (hasFaqIntent && !hasProductIntent) priorityRouteNames.push('faq');
|
// 产品问题同时搜FAQ和科普,获取更全面的回答(好转反应、科普误区等补充信息)
|
||||||
if (hasScienceIntent && !hasProductIntent && !hasFaqIntent) priorityRouteNames.push('science');
|
if (!hasFaqIntent) priorityRouteNames.push('faq');
|
||||||
|
if (!hasScienceIntent) priorityRouteNames.push('science');
|
||||||
|
}
|
||||||
|
if (hasCompanyIntent) {
|
||||||
|
priorityRouteNames.push('company');
|
||||||
|
// 公司问题同时搜产品和系统培训,test collection 内容有限
|
||||||
|
if (!hasProductIntent) priorityRouteNames.push('product');
|
||||||
|
if (!hasSystemIntent) priorityRouteNames.push('system');
|
||||||
|
}
|
||||||
|
if (hasFaqIntent) priorityRouteNames.push('faq');
|
||||||
|
if (hasScienceIntent) priorityRouteNames.push('science');
|
||||||
|
|
||||||
if (priorityRouteNames.length > 0) {
|
if (priorityRouteNames.length > 0) {
|
||||||
const routingRules = this.getKnowledgeBaseRoutingRules();
|
const routingRules = this.getKnowledgeBaseRoutingRules();
|
||||||
@@ -392,16 +407,14 @@ class ToolExecutor {
|
|||||||
|
|
||||||
static buildDeterministicKnowledgeQuery(query, context = []) {
|
static buildDeterministicKnowledgeQuery(query, context = []) {
|
||||||
const text = String(query || '').trim();
|
const text = String(query || '').trim();
|
||||||
const recentContextText = (Array.isArray(context) ? context : [])
|
|
||||||
.slice(-6)
|
|
||||||
.map((item) => String(item?.content || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n');
|
|
||||||
const haystack = `${text}\n${recentContextText}`;
|
|
||||||
const questionDimension = text.match(/(功效|作用|成分|配方|原料|怎么吃|怎么用|怎么服用|服用方法|吃法|用法|用量|一天几次|每天几次|每日几次|副作用|好转反应|价格|多少钱|适合谁|适用人群|区别|不同|搭配|原理|规格|包装|剂型|形态|粉末|胶囊|片剂|颗粒|喷雾|乳霜|口服液)/);
|
|
||||||
|
|
||||||
// 第一层:当前查询文本中有明确产品/系统/主题关键词 → 直接改写(不依赖上下文)
|
// ====================================================================
|
||||||
if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白';
|
// 精简版:只保留 VikingDB 语义检索已知会失败的场景
|
||||||
|
// 产品/公司/认证等查询全部交给 VikingDB + reranker 处理原始语义
|
||||||
|
// 追问/代词由 enrichQueryWithContext + KB保护窗口 处理
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
// === 一成系统子话题分流(内部术语,向量检索难区分子话题) ===
|
||||||
if (/(一成系统|Ai众享|数字化工作室|盛咖学愿|三大平台|四大Ai生态|四大生态|智能生产力)/i.test(text)) {
|
if (/(一成系统|Ai众享|数字化工作室|盛咖学愿|三大平台|四大Ai生态|四大生态|智能生产力)/i.test(text)) {
|
||||||
if (/(核心竞争力|竞争力|核心优势|优势)/i.test(text)) return '一成系统 核心竞争力 三大平台 四大Ai生态 零成本高效率';
|
if (/(核心竞争力|竞争力|核心优势|优势)/i.test(text)) return '一成系统 核心竞争力 三大平台 四大Ai生态 零成本高效率';
|
||||||
if (/(发展|怎么做|怎么用|如何用|如何做|关键点|关键|方法|步骤)/i.test(text)) return '一成系统 发展PM事业 三大平台 四大Ai生态 零成本高效率 全球市场';
|
if (/(发展|怎么做|怎么用|如何用|如何做|关键点|关键|方法|步骤)/i.test(text)) return '一成系统 发展PM事业 三大平台 四大Ai生态 零成本高效率 全球市场';
|
||||||
@@ -421,115 +434,24 @@ class ToolExecutor {
|
|||||||
if (/(身未动,?梦已成|批发式晋级)/i.test(text)) return '一成系统 身未动梦已成 批发式晋级 三大平台 四大Ai生态';
|
if (/(身未动,?梦已成|批发式晋级)/i.test(text)) return '一成系统 身未动梦已成 批发式晋级 三大平台 四大Ai生态';
|
||||||
if (/行动圈/i.test(text)) return '一成系统 行动圈 数字化工作室 团队管理 目标考核';
|
if (/行动圈/i.test(text)) return '一成系统 行动圈 数字化工作室 团队管理 目标考核';
|
||||||
if (/盟主社区/i.test(text)) return '一成系统 盟主社区 AI众享 社区盟主 引流 转化';
|
if (/盟主社区/i.test(text)) return '一成系统 盟主社区 AI众享 社区盟主 引流 转化';
|
||||||
if (/(宣明会|世界宣明会)/i.test(text)) return '德国PM 宣明会 世界宣明会 慈善合作';
|
|
||||||
if (/BFH/i.test(text)) return '德国PM BFH AAA+ 合作伙伴收益';
|
// === 一成系统相关业务话题 ===
|
||||||
if (/DSN/i.test(text)) return '德国PM DSN 全球100强 欧洲第1';
|
if (/(招商|代理|加盟|事业机会|招商稿|代理政策)/i.test(text)) return '一成系统 PM事业 招商与代理 软件赋能 0成本高效率';
|
||||||
if (/(邓白氏|AAA\+)/i.test(text)) return '德国PM 邓白氏 AAA+ 99分';
|
if (/(如何发展PM事业|怎么发展PM事业|PM事业发展逻辑|介绍PM事业|两分钟介绍PM事业)/i.test(text)) return '一成系统 PM事业 发展逻辑 商机 价值 软件赋能 三大平台 四大Ai生态 0成本高效率';
|
||||||
if (/(ELAB|科隆名单|Halal|GMP)/i.test(text)) return '德国PM ELAB 科隆名单 Halal GMP 安全认证';
|
|
||||||
if (/(Rolf Sorg|斯派尔|Speyer|卢森堡)/i.test(text)) return '德国PM Rolf Sorg 斯派尔 卢森堡 总部 公司介绍';
|
|
||||||
if (/(培安|烟台)/i.test(text)) return '德国PM 培安 烟台 中国市场投资';
|
|
||||||
if (/(PM公司|德国PM|公司地址|联系方式|电话|公司实力|公司背景|总部|分公司)/i.test(text)) {
|
|
||||||
if (/(产品|细胞营养素|基础套装|基础三合一|小红|大白|小白|activize|basics|restorate|fitline|儿童倍适)/i.test(text)) {
|
|
||||||
return '德国PM FitLine 细胞营养素产品 大白Basics 小红Activize 小白Restorate 儿童倍适';
|
|
||||||
}
|
|
||||||
if (/(地址|电话|联系方式)/i.test(text)) return '德国PM 日本 美国 加拿大 香港 地址 电话';
|
|
||||||
if (/(实力|背景)/i.test(text)) return '德国PM 公司实力介绍 邓白氏 99分 AAA+';
|
|
||||||
return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍';
|
|
||||||
}
|
|
||||||
if (/(德国PM介绍|介绍德国PM|德国PM公司介绍|PM公司介绍|PM介绍)/i.test(text)) return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍 邓白氏 99分 AAA+';
|
|
||||||
if (/(NTC.*(核心优势|核心竞争力|优势|原理|厉害)|核心优势.*NTC|核心竞争力.*NTC)/i.test(text)) return 'NTC营养保送系统 核心优势 吸收利用 原理';
|
|
||||||
if (/(PM基础三合一介绍|基础三合一介绍|PM基础套装介绍|基础套装介绍)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白 介绍';
|
|
||||||
if (/儿童倍适/i.test(text)) return questionDimension ? `儿童倍适 ${questionDimension[0]}` : '儿童倍适';
|
|
||||||
if (/(小红产品|小红|Activize Oxyplus|Activize)/i.test(text)) return questionDimension ? `Fitline小红产品 Activize ${questionDimension[0]}` : 'Fitline小红产品提升能量原理';
|
|
||||||
if (/(大白产品|大白|倍适|Basics)/i.test(text)) return questionDimension ? `德国PM细胞营养素 大白 Basics ${questionDimension[0]}` : '德国PM细胞营养素 大白 Basics';
|
|
||||||
if (/(小白产品|小白|维适多|Restorate)/i.test(text)) return questionDimension ? `德国PM细胞营养素 小白 Restorate ${questionDimension[0]}` : '德国PM细胞营养素 小白';
|
|
||||||
if (/(NTC营养保送系统|Nutrient Transport Concept)/i.test(text)) return 'NTC营养保送系统';
|
|
||||||
if (/火炉原理/i.test(text)) return '火炉原理';
|
|
||||||
if (/(阿育吠陀|Ayurveda)/i.test(text)) return '阿育吠陀医学原理';
|
|
||||||
if (/(PM-FitLine|PM细胞营养素)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白';
|
|
||||||
if (/(我们公司.*产品|公司.*产品|产品.*推荐|推荐.*产品|产品有哪些|产品介绍|产品列表)/i.test(text)) return '德国PM FitLine 细胞营养素产品 大白Basics 小红Activize 小白Restorate 儿童倍适';
|
|
||||||
if (/(治病吗|能治病吗|产品治病|治疗疾病|替代药|是不是药)/i.test(text)) return 'PM产品 不是药 不能替代药物 保健食品 营养补充';
|
|
||||||
if (/(多久见效|多久有效|多久能见效|多长时间见效|几天见效|什么时候见效)/i.test(text)) return 'PM产品 多久见效 吸收利用 周期 个体差异';
|
|
||||||
if (/(为什么.*(全套|搭配|三合一)|为什么要.*(全套|搭配|三合一)|为何.*(全套|搭配|三合一)|产品需要全套)/i.test(text)) return '德国PM细胞营养素 全套搭配 NTC营养保送系统 协同作用';
|
|
||||||
if (/(与其它保健品区别|与其他保健品区别|和其它保健品区别|和其他保健品区别|保健品区别)/i.test(text)) return 'PM产品 与其他保健品区别 NTC营养保送系统 吸收利用';
|
|
||||||
if (/(新人起步三关|起步三关)/i.test(text)) return '培训新人起步三关';
|
|
||||||
if (/(精品会议|会议组织)/i.test(text)) return '培训打造精品会议具体如下';
|
|
||||||
if (/成长上总裁/i.test(text)) return '培训成长上总裁';
|
|
||||||
if (/(招商|代理|加盟|合作|事业机会|招商稿|代理政策)/i.test(text)) return '一成系统 PM事业 招商与代理 软件赋能 0成本高效率';
|
|
||||||
if (/(如何发展PM事业|怎么发展PM事业|PM事业发展逻辑|介绍PM事业|两分钟介绍PM事业|分享.*故事.*自我介绍|自我介绍|商机|PM价值)/i.test(text)) return '一成系统 PM事业 发展逻辑 商机 价值 软件赋能 三大平台 四大Ai生态 0成本高效率';
|
|
||||||
if (/(为什么选择德国PM|为何选择德国PM|为什么选德国PM|为什么选PM|为何选PM)/i.test(text)) return '一成系统 德国PM 选择理由 公司实力 产品优势 软件赋能 0成本高效率';
|
if (/(为什么选择德国PM|为何选择德国PM|为什么选德国PM|为什么选PM|为何选PM)/i.test(text)) return '一成系统 德国PM 选择理由 公司实力 产品优势 软件赋能 0成本高效率';
|
||||||
if (/(陌生客户|陌生人).*(沟通|开口|邀约|交流|切入).*(PM事业|德国PM|PM)/i.test(text)) return '一成系统 PM事业 陌生客户 沟通 邀约 话术 软件赋能';
|
if (/(陌生客户|陌生人).*(沟通|开口|邀约|交流|切入).*(PM事业|德国PM|PM)/i.test(text)) return '一成系统 PM事业 陌生客户 沟通 邀约 话术 软件赋能';
|
||||||
if (/(线上拓客|线上成交|线上开发客户|线上获客|线上成交率)/i.test(text)) return '一成系统 PM事业 线上拓客 成交 获客';
|
if (/(线上拓客|线上成交|线上开发客户|线上获客|线上成交率)/i.test(text)) return '一成系统 PM事业 线上拓客 成交 获客';
|
||||||
if (/(团队.*AI智能生产力|AI智能生产力.*团队|团队.*AI生产力|AI生产力.*团队)/i.test(text)) return '一成系统 AI智能生产力 赋能团队';
|
|
||||||
if (/(三大平台|四大Ai生态|四大生态)/i.test(text)) return '一成系统 三大平台 四大Ai生态';
|
|
||||||
if (/(请分享.*故事.*自我介绍|故事.*自我介绍|个人故事.*自我介绍)/i.test(text)) return '一成系统 PM事业 故事分享 自我介绍 软件赋能';
|
|
||||||
if (/(一成AI|AI落地|ai落地|转观念|落地对比)/i.test(text)) return '2026一成Ai落地对比与转观念';
|
if (/(一成AI|AI落地|ai落地|转观念|落地对比)/i.test(text)) return '2026一成Ai落地对比与转观念';
|
||||||
if (/(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)/i.test(text)) return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍 邓白氏 99分 AAA+ 合法直销';
|
|
||||||
if (/(好转反应|整应反应|排毒反应|副作用|不良反应|皮肤发痒)/i.test(text)) return 'PM产品整应反应好转反应解析';
|
|
||||||
if (/(促销活动|促销|优惠|打折|活动分数|5\+1)/i.test(text)) return '促销活动 5+1活动分数';
|
|
||||||
if (/暖炉原理/i.test(text)) return '火炉原理';
|
|
||||||
if (/(CC套装|CC胶囊)/i.test(text)) return questionDimension ? `CC套装 CC胶囊 ${questionDimension[0]}` : 'CC套装 CC胶囊';
|
|
||||||
if (/(IB5|口腔免疫喷雾)/i.test(text)) return questionDimension ? `IB5 口腔免疫喷雾 ${questionDimension[0]}` : 'IB5 口腔免疫喷雾';
|
|
||||||
if (/(Q10|辅酵素|氧修护)/i.test(text)) return questionDimension ? `Q10 辅酵素 氧修护 ${questionDimension[0]}` : 'Q10 辅酵素 氧修护';
|
|
||||||
if (/(Med Dental\+|Dental\+|草本护理牙膏)/i.test(text)) return questionDimension ? `Med Dental+ 草本护理牙膏 ${questionDimension[0]}` : 'Med Dental+ 草本护理牙膏';
|
|
||||||
if (/(Men Face|全效男士护肤抗衰乳霜)/i.test(text)) return questionDimension ? `Men Face 全效男士护肤抗衰乳霜 ${questionDimension[0]}` : 'Men Face 全效男士护肤抗衰乳霜';
|
|
||||||
if (/(CC-Cell|CC Cell|CC乳霜)/i.test(text)) return questionDimension ? `CC-Cell 胶囊 乳霜 ${questionDimension[0]}` : 'CC-Cell 胶囊 乳霜';
|
|
||||||
if (/(D-Drink|小绿排毒饮|14天排毒D饮料Plus)/i.test(text)) return questionDimension ? `D-Drink 小绿排毒饮 14天排毒D饮料Plus ${questionDimension[0]}` : 'D-Drink 小绿排毒饮 14天排毒D饮料Plus';
|
|
||||||
if (/(ProShape|ProShape® Amino|氨基酸|支链氨基酸|BCAA)/i.test(text)) return questionDimension ? `ProShape Amino 氨基酸 BCAA ${questionDimension[0]}` : 'ProShape Amino 氨基酸 BCAA';
|
|
||||||
if (/(Herbal Tea|草本茶)/i.test(text)) return questionDimension ? `Herbal Tea 草本茶 ${questionDimension[0]}` : 'Herbal Tea 草本茶';
|
|
||||||
if (/(Hair\+|med Hair\+|口服发宝|外用发健)/i.test(text)) return questionDimension ? `Hair+ med Hair+ 口服发宝 外用发健 ${questionDimension[0]}` : 'Hair+ med Hair+ 口服发宝 外用发健';
|
|
||||||
if (/(Fitness-Drink|运动饮料健康饮品|运动饮料)/i.test(text)) return questionDimension ? `Fitness-Drink 运动饮料健康饮品 ${questionDimension[0]}` : 'Fitness-Drink 运动饮料健康饮品';
|
|
||||||
if (/(TopShape|孅萃TopShape纤萃减肥|纤萃减肥)/i.test(text)) return questionDimension ? `TopShape 孅萃TopShape纤萃减肥 ${questionDimension[0]}` : 'TopShape 孅萃TopShape纤萃减肥';
|
|
||||||
if (/(Generation 50\+|乐活50\+)/i.test(text)) return questionDimension ? `乐活50+ Generation 50+ ${questionDimension[0]}` : '乐活50+ Generation 50+';
|
|
||||||
if (/(Apple Antioxy|苹果细胞抗氧素|Antioxy|Zellschutz|细胞抗氧素)/i.test(text)) return questionDimension ? `Apple Antioxy Zellschutz 细胞抗氧素 ${questionDimension[0]}` : 'Apple Antioxy Zellschutz 细胞抗氧素';
|
|
||||||
if (/Women\+/i.test(text)) return questionDimension ? `Women+ ${questionDimension[0]}` : 'Women+';
|
|
||||||
if (/乐活奶昔|乐活/i.test(text)) return questionDimension ? `乐活奶昔 ${questionDimension[0]}` : '乐活奶昔';
|
|
||||||
if (/(乳清蛋白|蛋白粉)/i.test(text)) return questionDimension ? `乳清蛋白粉 ${questionDimension[0]}` : '乳清蛋白粉';
|
|
||||||
if (/(乳酪煲|乳酪饮品|乳酪)/i.test(text)) return questionDimension ? `乳酪煲 乳酪饮品 ${questionDimension[0]}` : '乳酪煲 乳酪饮品';
|
|
||||||
if (/(基础二合一|二合一)/i.test(text)) return questionDimension ? `基础二合一 ${questionDimension[0]}` : '基础二合一';
|
|
||||||
if (/倍力健/i.test(text)) return questionDimension ? `倍力健 ${questionDimension[0]}` : '倍力健';
|
|
||||||
if (/(关节套装|关节舒缓)/i.test(text)) return questionDimension ? `关节套装 关节舒缓膏 ${questionDimension[0]}` : '关节套装 关节舒缓膏';
|
|
||||||
if (/(男士乳霜|男士护肤)/i.test(text)) return questionDimension ? `全效男士乳霜 ${questionDimension[0]}` : '全效男士乳霜';
|
|
||||||
if (/(去角质|面膜)/i.test(text)) return questionDimension ? `去角质面膜 ${questionDimension[0]}` : '去角质面膜';
|
|
||||||
if (/发宝/i.test(text)) return questionDimension ? `发宝 ${questionDimension[0]}` : '发宝';
|
|
||||||
if (/叶黄素/i.test(text)) return questionDimension ? `叶黄素 ${questionDimension[0]}` : '叶黄素';
|
|
||||||
if (/(奶昔)/i.test(text)) return questionDimension ? `奶昔 ${questionDimension[0]}` : '奶昔';
|
|
||||||
if (/(健康饮品)/i.test(text)) return questionDimension ? `健康饮品 ${questionDimension[0]}` : '健康饮品';
|
|
||||||
|
|
||||||
// 第二层:当前文本是追问/代词,才通过上下文推断主题
|
// === 敏感话题兜底(必须精确控制回复内容) ===
|
||||||
const isFollowUp = /^(这个|那个|它|它的|他|他的|该|这款|那款|详细|继续|怎么|为什么|适合谁|什么意思|怎么用|怎么吃|功效|成分|好处|原理|规格|包装|剂型|形态|一天几次|每天几次|每日几次)/.test(text);
|
if (/(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)/i.test(text)) return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍 邓白氏 99分 AAA+ 合法直销';
|
||||||
if (isFollowUp) {
|
|
||||||
if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(recentContextText)) return questionDimension ? `德国PM细胞营养素 基础套装 大白 小红 小白 ${questionDimension[0]}` : '德国PM细胞营养素 基础套装 大白 小红 小白';
|
// === 别名纠正(向量检索不认的别名) ===
|
||||||
if (/(身未动,?梦已成|批发式晋级)/i.test(recentContextText)) return '一成系统 身未动梦已成 批发式晋级 三大平台 四大Ai生态';
|
if (/暖炉原理/i.test(text)) return '火炉原理';
|
||||||
if (/行动圈/i.test(recentContextText)) return '一成系统 行动圈 数字化工作室 团队管理 目标考核';
|
|
||||||
if (/盟主社区/i.test(recentContextText)) return '一成系统 盟主社区 AI众享 社区盟主 引流 转化';
|
// 所有其它查询(产品/公司/认证/培训等):不做确定性改写
|
||||||
if (/(一成系统|Ai众享|数字化工作室|盛咖学愿)/i.test(recentContextText)) return '一成系统 德国PM事业发展的强大赋能工具 三大平台 四大Ai生态';
|
// 依赖 normalizeKnowledgeQueryAlias(别名归一化)+ enrichQueryWithContext(上下文补充)+ VikingDB + reranker
|
||||||
if (/DSN/i.test(recentContextText)) return '德国PM DSN 全球100强 欧洲第1';
|
return null;
|
||||||
if (/(ELAB|科隆名单|Halal|GMP)/i.test(recentContextText)) return '德国PM ELAB 科隆名单 Halal GMP 安全认证';
|
|
||||||
if (/(邓白氏|AAA\+)/i.test(recentContextText)) return '德国PM 邓白氏 AAA+ 99分';
|
|
||||||
if (/(宣明会|世界宣明会)/i.test(recentContextText)) return '德国PM 宣明会 世界宣明会 慈善合作';
|
|
||||||
if (/(Rolf Sorg|斯派尔|Speyer|卢森堡)/i.test(recentContextText)) return '德国PM Rolf Sorg 斯派尔 卢森堡 总部 公司介绍';
|
|
||||||
if (/(培安|烟台)/i.test(recentContextText)) return '德国PM 培安 烟台 中国市场投资';
|
|
||||||
if (/(小红产品|小红|Activize)/i.test(recentContextText)) return questionDimension ? `Fitline小红产品 Activize ${questionDimension[0]}` : 'Fitline小红产品提升能量原理';
|
|
||||||
if (/(大白产品|大白|Basics)/i.test(recentContextText)) return questionDimension ? `德国PM细胞营养素 大白 Basics ${questionDimension[0]}` : '德国PM细胞营养素 大白 Basics';
|
|
||||||
if (/(小白产品|小白|Restorate)/i.test(recentContextText)) return questionDimension ? `德国PM细胞营养素 小白 Restorate ${questionDimension[0]}` : '德国PM细胞营养素 小白';
|
|
||||||
if (/儿童倍适/i.test(recentContextText)) return questionDimension ? `儿童倍适 ${questionDimension[0]}` : '儿童倍适';
|
|
||||||
if (/火炉原理/i.test(recentContextText)) return '火炉原理';
|
|
||||||
if (/(阿育吠陀|Ayurveda)/i.test(recentContextText)) return '阿育吠陀医学原理';
|
|
||||||
if (/(NTC营养保送系统)/i.test(recentContextText)) return 'NTC营养保送系统';
|
|
||||||
if (/(Med Dental\+|草本护理牙膏)/i.test(recentContextText)) return questionDimension ? `Med Dental+ 草本护理牙膏 ${questionDimension[0]}` : 'Med Dental+ 草本护理牙膏';
|
|
||||||
if (/(Men Face|全效男士护肤抗衰乳霜)/i.test(recentContextText)) return questionDimension ? `Men Face 全效男士护肤抗衰乳霜 ${questionDimension[0]}` : 'Men Face 全效男士护肤抗衰乳霜';
|
|
||||||
if (/(CC-Cell|CC胶囊|CC乳霜)/i.test(recentContextText)) return questionDimension ? `CC-Cell 胶囊 乳霜 ${questionDimension[0]}` : 'CC-Cell 胶囊 乳霜';
|
|
||||||
if (/(D-Drink|小绿排毒饮|14天排毒D饮料Plus)/i.test(recentContextText)) return questionDimension ? `D-Drink 小绿排毒饮 14天排毒D饮料Plus ${questionDimension[0]}` : 'D-Drink 小绿排毒饮 14天排毒D饮料Plus';
|
|
||||||
if (/(ProShape|氨基酸|BCAA)/i.test(recentContextText)) return questionDimension ? `ProShape Amino 氨基酸 BCAA ${questionDimension[0]}` : 'ProShape Amino 氨基酸 BCAA';
|
|
||||||
if (/(Herbal Tea|草本茶)/i.test(recentContextText)) return questionDimension ? `Herbal Tea 草本茶 ${questionDimension[0]}` : 'Herbal Tea 草本茶';
|
|
||||||
if (/(Hair\+|med Hair\+|口服发宝|外用发健)/i.test(recentContextText)) return questionDimension ? `Hair+ med Hair+ 口服发宝 外用发健 ${questionDimension[0]}` : 'Hair+ med Hair+ 口服发宝 外用发健';
|
|
||||||
if (/(Fitness-Drink|运动饮料健康饮品|运动饮料)/i.test(recentContextText)) return questionDimension ? `Fitness-Drink 运动饮料健康饮品 ${questionDimension[0]}` : 'Fitness-Drink 运动饮料健康饮品';
|
|
||||||
if (/(TopShape|孅萃TopShape纤萃减肥|纤萃减肥)/i.test(recentContextText)) return questionDimension ? `TopShape 孅萃TopShape纤萃减肥 ${questionDimension[0]}` : 'TopShape 孅萃TopShape纤萃减肥';
|
|
||||||
if (/(Generation 50\+|乐活50\+)/i.test(recentContextText)) return questionDimension ? `乐活50+ Generation 50+ ${questionDimension[0]}` : '乐活50+ Generation 50+';
|
|
||||||
if (/(Apple Antioxy|苹果细胞抗氧素|Antioxy|Zellschutz|细胞抗氧素)/i.test(recentContextText)) return questionDimension ? `Apple Antioxy Zellschutz 细胞抗氧素 ${questionDimension[0]}` : 'Apple Antioxy Zellschutz 细胞抗氧素';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static applyKnowledgeQueryAnchor(query) {
|
static applyKnowledgeQueryAnchor(query) {
|
||||||
@@ -560,15 +482,39 @@ class ToolExecutor {
|
|||||||
.replace(/Basics/gi, 'Basics')
|
.replace(/Basics/gi, 'Basics')
|
||||||
.replace(/基础三合一|三合一基础套|大白小红小白|基础套装?/g, 'PM细胞营养素 基础套装')
|
.replace(/基础三合一|三合一基础套|大白小红小白|基础套装?/g, 'PM细胞营养素 基础套装')
|
||||||
.replace(/儿童倍适|儿童产品/g, '儿童倍适')
|
.replace(/儿童倍适|儿童产品/g, '儿童倍适')
|
||||||
|
.replace(/小红精华液/g, 'Activize Serum 小红精华液')
|
||||||
.replace(/小红产品/g, '小红产品 Activize Oxyplus')
|
.replace(/小红产品/g, '小红产品 Activize Oxyplus')
|
||||||
.replace(/大白产品/g, '大白产品 Basics')
|
.replace(/大白产品/g, '大白产品 Basics')
|
||||||
.replace(/小白产品/g, '小白产品 Restorate')
|
.replace(/小白产品/g, '小白产品 Restorate')
|
||||||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)小红/g, '小红产品 Activize Oxyplus')
|
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)小红(?!精华)/g, '小红产品 Activize Oxyplus')
|
||||||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)大白/g, '大白产品 Basics')
|
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)大白/g, '大白产品 Basics')
|
||||||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)(?<!儿童)小白/g, '小白产品 Restorate')
|
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)(?<!儿童)小白/g, '小白产品 Restorate')
|
||||||
.replace(/维适多/g, '小白产品 Restorate')
|
.replace(/维适多/g, '小白产品 Restorate')
|
||||||
.replace(/火炉原理/g, '火炉原理')
|
.replace(/火炉原理/g, '火炉原理')
|
||||||
.replace(/阿育吠陀|Ayurveda/gi, '阿育吠陀')
|
.replace(/阿育吠陀|Ayurveda/gi, '阿育吠陀')
|
||||||
|
// === 产品俗名/简称 → 标准名+英文名(增强向量检索命中率)===
|
||||||
|
.replace(/小绿/g, 'D-Drink 小绿 排毒饮')
|
||||||
|
.replace(/(?<!小绿 )排毒饮/g, 'D-Drink 排毒饮')
|
||||||
|
.replace(/(?<!草本护理)牙膏/g, '草本护理牙膏 Med Dental+')
|
||||||
|
.replace(/(?:口腔免疫喷雾|口腔喷雾|免疫喷雾)/g, 'IB5 口腔免疫喷雾')
|
||||||
|
.replace(/(?<!免疫)喷雾/g, 'IB5 口腔免疫喷雾')
|
||||||
|
.replace(/(?<!Herbal Tea )草本茶/g, 'Herbal Tea 草本茶')
|
||||||
|
.replace(/发宝|发健/g, 'Med Hair+ 发宝')
|
||||||
|
.replace(/(?:男士乳霜|男士护肤|男士面霜)/g, 'Men Face 男士护肤乳霜')
|
||||||
|
.replace(/纤萃/g, 'TopShape 纤萃')
|
||||||
|
.replace(/运动饮料/g, 'Fitness-Drink 运动饮料')
|
||||||
|
.replace(/(?<!Generation 50\+? )乐活/g, 'Generation 50+ 乐活')
|
||||||
|
.replace(/(?<!Zellschutz )细胞抗氧素/g, 'Zellschutz 细胞抗氧素')
|
||||||
|
.replace(/CC套装|CC胶囊|CC乳霜/g, 'CC-Cell')
|
||||||
|
.replace(/(?<!Q10 )辅酵素/g, 'Q10 辅酵素')
|
||||||
|
.replace(/氧修护/g, 'Q10 氧修护')
|
||||||
|
.replace(/小黑/g, 'MEN+ 倍力健 小黑')
|
||||||
|
.replace(/(?<!MEN\+? )倍力健/g, 'MEN+ 倍力健')
|
||||||
|
.replace(/(?<!ProShape Amino )氨基酸/g, 'ProShape Amino 氨基酸')
|
||||||
|
.replace(/BCAA/gi, 'ProShape Amino BCAA')
|
||||||
|
.replace(/(?<!胶原蛋白)胶原蛋白(?!肽)/g, '胶原蛋白肽')
|
||||||
|
.replace(/乳酪煲|乳酪饮品|乳酪/g, '乳酪煲 乳酪饮品')
|
||||||
|
.replace(/(?<!关节套装 )关节舒缓/g, '关节套装 关节舒缓')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,7 +640,7 @@ class ToolExecutor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async searchKnowledge({ query, response_mode = 'answer', context = [], session_id = null, original_text = '', _session = null }) {
|
static async searchKnowledge({ query, response_mode = 'answer', context = [], session_id = null, original_text = '', _session = null, skipCache = false }) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
query = query || '';
|
query = query || '';
|
||||||
const responseMode = response_mode === 'snippet' ? 'snippet' : 'answer';
|
const responseMode = response_mode === 'snippet' ? 'snippet' : 'answer';
|
||||||
@@ -729,14 +675,15 @@ class ToolExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rewrittenQuery = this.rewriteKnowledgeQuery(query, context, session_id, _session);
|
const rewrittenQuery = this.rewriteKnowledgeQuery(query, context, session_id, _session);
|
||||||
const kbTarget = this.selectKnowledgeBaseTargets(rewrittenQuery || query, context);
|
// 全库检索:始终搜索所有 collection,由 VikingDB + reranker 判断相关性
|
||||||
|
const allDatasetIds = String(process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS || '')
|
||||||
|
.split(',').map(id => id.trim()).filter(Boolean);
|
||||||
|
const kbTarget = { datasetIds: allDatasetIds, matchedRoutes: ['all'] };
|
||||||
const effectiveQuery = rewrittenQuery || query;
|
const effectiveQuery = rewrittenQuery || query;
|
||||||
if (rewrittenQuery && rewrittenQuery !== query) {
|
if (rewrittenQuery && rewrittenQuery !== query) {
|
||||||
console.log(`[ToolExecutor] searchKnowledge rewritten query="${rewrittenQuery}"`);
|
console.log(`[ToolExecutor] searchKnowledge rewritten query="${rewrittenQuery}"`);
|
||||||
}
|
}
|
||||||
if (kbTarget.datasetIds.length > 0) {
|
console.log(`[ToolExecutor] searchKnowledge full-scan all ${allDatasetIds.length} collections`);
|
||||||
console.log(`[ToolExecutor] searchKnowledge selected dataset_ids=${kbTarget.datasetIds.join(',')} routes=${kbTarget.matchedRoutes.join(',')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS;
|
const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS;
|
||||||
if (kbIds && kbIds !== 'your_knowledge_base_dataset_id') {
|
if (kbIds && kbIds !== 'your_knowledge_base_dataset_id') {
|
||||||
@@ -758,12 +705,13 @@ class ToolExecutor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// 缓存检查:相同effectiveQuery + datasetIds命中缓存时直接返回,避免重复API调用
|
// 缓存检查:优先 Redis,降级内存 Map(skipCache 时跳过)
|
||||||
const cacheKey = getKbCacheKey(effectiveQuery, kbTarget.datasetIds, profileScope);
|
const cacheKey = getKbCacheKey(effectiveQuery, kbTarget.datasetIds, profileScope);
|
||||||
const cached = getKbCache(cacheKey);
|
const redisCached = skipCache ? null : await redisClient.getKbCache(cacheKey).catch(() => null);
|
||||||
|
const cached = skipCache ? null : (redisCached || getKbCache(cacheKey));
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const latencyMs = Date.now() - startTime;
|
const latencyMs = Date.now() - startTime;
|
||||||
console.log(`[ToolExecutor] Ark KB cache hit in ${latencyMs}ms key="${cacheKey.slice(0, 60)}"`);
|
console.log(`[ToolExecutor] Ark KB cache hit in ${latencyMs}ms key="${cacheKey.slice(0, 60)}" source=${redisCached ? 'redis' : 'memory'}`);
|
||||||
return {
|
return {
|
||||||
...cached,
|
...cached,
|
||||||
original_query: query,
|
original_query: query,
|
||||||
@@ -774,12 +722,45 @@ class ToolExecutor {
|
|||||||
cache_hit: true,
|
cache_hit: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
console.log('[ToolExecutor] Trying Ark Knowledge Search...');
|
|
||||||
const arkResult = await this.searchArkKnowledge(effectiveQuery, context, responseMode, kbTarget.datasetIds, query, assistantProfile);
|
// 根据检索模式选择链路
|
||||||
|
const retrievalMode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'answer';
|
||||||
|
let arkResult;
|
||||||
|
|
||||||
|
if (retrievalMode === 'raw') {
|
||||||
|
// ★ 新链路:纯检索 + 重排,不经 LLM 加工
|
||||||
|
console.log('[ToolExecutor] Using RAW retrieval mode (kbRetriever)');
|
||||||
|
const rawResult = await kbRetriever.searchAndRerank(effectiveQuery, {
|
||||||
|
datasetIds: kbTarget.datasetIds,
|
||||||
|
sessionId: session_id,
|
||||||
|
session: _session,
|
||||||
|
originalQuery: query,
|
||||||
|
});
|
||||||
|
// 转换为与旧格式兼容的结构
|
||||||
|
arkResult = {
|
||||||
|
query: rawResult.query,
|
||||||
|
results: rawResult.ragPayload.length > 0
|
||||||
|
? rawResult.ragPayload.map(item => ({ title: item.title, content: item.content }))
|
||||||
|
: [{ title: '未找到', content: `知识库中暂未找到与"${query}"直接相关的信息,请换个更具体的问法再试。` }],
|
||||||
|
total: rawResult.ragPayload.length,
|
||||||
|
source: 'ark_knowledge',
|
||||||
|
hit: rawResult.hit,
|
||||||
|
reason: rawResult.reason,
|
||||||
|
retrieval_mode: 'raw',
|
||||||
|
top_score: rawResult.topScore,
|
||||||
|
chunks_count: rawResult.rerankedChunks?.length || 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 旧链路:LLM 加工模式
|
||||||
|
console.log('[ToolExecutor] Using ANSWER retrieval mode (searchArkKnowledge)');
|
||||||
|
arkResult = await this.searchArkKnowledge(effectiveQuery, context, responseMode, kbTarget.datasetIds, query, assistantProfile);
|
||||||
|
}
|
||||||
|
|
||||||
const latencyMs = Date.now() - startTime;
|
const latencyMs = Date.now() - startTime;
|
||||||
console.log(`[ToolExecutor] Ark KB search succeeded in ${latencyMs}ms`);
|
console.log(`[ToolExecutor] Ark KB search succeeded in ${latencyMs}ms mode=${retrievalMode}`);
|
||||||
// 缓存所有结果(hit用5分钟TTL,no-hit用2分钟TTL),避免重复API调用
|
// 缓存到 Redis + 内存双写
|
||||||
setKbCache(cacheKey, arkResult);
|
setKbCache(cacheKey, arkResult);
|
||||||
|
redisClient.setKbCache(cacheKey, arkResult).catch(() => {});
|
||||||
return {
|
return {
|
||||||
...arkResult,
|
...arkResult,
|
||||||
original_query: query,
|
original_query: query,
|
||||||
|
|||||||
92
test2/server/tests/_check_logs.cjs
Normal file
92
test2/server/tests/_check_logs.cjs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const { Client } = require('ssh2');
|
||||||
|
const SSH_CONFIG = { host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' };
|
||||||
|
|
||||||
|
function sshExec(conn, cmd) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
conn.exec(cmd, (err, s) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
let o = '';
|
||||||
|
s.on('data', d => o += d);
|
||||||
|
s.stderr.on('data', d => o += d);
|
||||||
|
s.on('close', () => resolve(o));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function sftpUpload(conn, localPath, remotePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
conn.sftp((err, sftp) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const rs = fs.createReadStream(localPath);
|
||||||
|
const ws = sftp.createWriteStream(remotePath);
|
||||||
|
ws.on('close', () => resolve());
|
||||||
|
ws.on('error', reject);
|
||||||
|
rs.pipe(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMOTE_BASE = '/www/wwwroot/demo.tensorgrove.com.cn/server';
|
||||||
|
const LOCAL_BASE = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = new Client();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
conn.on('ready', resolve).on('error', reject).connect(SSH_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. 上传文件
|
||||||
|
const files = [
|
||||||
|
{ name: 'toolExecutor.js', sub: 'services' },
|
||||||
|
{ name: 'kbRetriever.js', sub: 'services' },
|
||||||
|
];
|
||||||
|
console.log('=== 上传 ===');
|
||||||
|
for (const f of files) {
|
||||||
|
const localFile = path.join(LOCAL_BASE, f.sub, f.name);
|
||||||
|
const remoteFile = `${REMOTE_BASE}/${f.sub}/${f.name}`;
|
||||||
|
await sftpUpload(conn, localFile, remoteFile);
|
||||||
|
console.log(` \u2705 ${f.name}`);
|
||||||
|
const sc = await sshExec(conn, `node -c ${remoteFile} 2>&1`);
|
||||||
|
if (sc.includes('SyntaxError')) { console.log('SYNTAX ERROR!'); conn.end(); process.exit(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 刷 Redis KB 缓存
|
||||||
|
console.log('\n=== 刷 Redis KB 缓存 ===');
|
||||||
|
console.log(await sshExec(conn, `cd ${REMOTE_BASE} && node -e "
|
||||||
|
require('dotenv').config();
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
const r = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', {
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
|
db: parseInt(process.env.REDIS_DB) || 0,
|
||||||
|
keyPrefix: process.env.REDIS_KEY_PREFIX || 'bigwo:',
|
||||||
|
lazyConnect: true, maxRetriesPerRequest: 2, connectTimeout: 5000,
|
||||||
|
});
|
||||||
|
r.connect().then(async () => {
|
||||||
|
const keys = await r.keys('kb_cache:*');
|
||||||
|
if (keys.length > 0) { await r.del(...keys); console.log('Deleted ' + keys.length + ' keys'); }
|
||||||
|
else { console.log('No keys'); }
|
||||||
|
r.quit(); process.exit(0);
|
||||||
|
}).catch(e => { console.log('Redis: ' + e.message); process.exit(0); });
|
||||||
|
" 2>&1`));
|
||||||
|
|
||||||
|
// 3. 重启
|
||||||
|
await sshExec(conn, '> /var/log/bigwo/server-error.log && > /var/log/bigwo/server-out.log');
|
||||||
|
await sshExec(conn, 'pm2 stop bigwo-server');
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
await sshExec(conn, `cd ${REMOTE_BASE} && pm2 start bigwo-server --update-env`);
|
||||||
|
console.log('\n=== PM2 重启,等待5s ===');
|
||||||
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
|
console.log(await sshExec(conn, 'pm2 status bigwo-server'));
|
||||||
|
const errLog = await sshExec(conn, 'cat /var/log/bigwo/server-error.log');
|
||||||
|
console.log('=== 错误 ===');
|
||||||
|
console.log(errLog || '(空 ✅)');
|
||||||
|
console.log('\n=== 健康 ===');
|
||||||
|
console.log(await sshExec(conn, 'curl -s --max-time 5 http://localhost:3012/api/health 2>&1'));
|
||||||
|
|
||||||
|
conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('FAILED:', e.message); process.exit(1); });
|
||||||
43
test2/server/tests/_test_alias_ab.cjs
Normal file
43
test2/server/tests/_test_alias_ab.cjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* A/B 测试:原始查询 vs 别名扩展后查询的检索效果对比
|
||||||
|
* 直接调用 VikingDB + reranker,比较 topScore 和 hit 状态
|
||||||
|
*/
|
||||||
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||||
|
const kbRetriever = require('../services/kbRetriever');
|
||||||
|
|
||||||
|
const TEST_QUERIES = [
|
||||||
|
// 中文俗名 → 中文全名(语义接近)
|
||||||
|
{ raw: '牙膏怎么用', alias: '草本护理牙膏 Med Dental+怎么用', label: '牙膏(俗名→全名)' },
|
||||||
|
{ raw: '喷雾功效', alias: 'IB5 口腔免疫喷雾功效', label: '喷雾(俗名→全名)' },
|
||||||
|
{ raw: '乳酪怎么喝', alias: '乳酪煲 乳酪饮品怎么喝', label: '乳酪(俗名→全名)' },
|
||||||
|
// 中文昵称 → 英文产品名(语义无关联)
|
||||||
|
{ raw: '小红怎么吃', alias: '小红产品 Activize Oxyplus怎么吃', label: '小红(昵称→英文名)' },
|
||||||
|
{ raw: '大白功效', alias: '大白产品 Basics功效', label: '大白(昵称→英文名)' },
|
||||||
|
{ raw: '小绿排毒', alias: 'D-Drink 小绿 排毒饮排毒', label: '小绿(昵称→英文名)' },
|
||||||
|
{ raw: '小黑适合谁', alias: 'MEN+ 倍力健 小黑适合谁', label: '小黑(昵称→英文名)' },
|
||||||
|
// 通用词 → 特定产品
|
||||||
|
{ raw: '氨基酸', alias: 'ProShape Amino 氨基酸', label: '氨基酸(通用→产品)' },
|
||||||
|
{ raw: '胶原蛋白', alias: '胶原蛋白肽', label: '胶原蛋白(通用→产品)' },
|
||||||
|
{ raw: '细胞抗氧素功效', alias: 'Zellschutz 细胞抗氧素功效', label: '细胞抗氧素(中→英)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
console.log('=== A/B 测试:原始查询 vs 别名扩展 ===\n');
|
||||||
|
console.log('| 场景 | 原始 topScore | 扩展 topScore | 差值 | 原始hit | 扩展hit |');
|
||||||
|
console.log('|------|-------------|-------------|------|---------|---------|');
|
||||||
|
|
||||||
|
for (const t of TEST_QUERIES) {
|
||||||
|
const rawRes = await kbRetriever.searchAndRerank(t.raw, {});
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
const aliasRes = await kbRetriever.searchAndRerank(t.alias, {});
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
const rawScore = rawRes.topScore?.toFixed(3) || '0.000';
|
||||||
|
const aliasScore = aliasRes.topScore?.toFixed(3) || '0.000';
|
||||||
|
const diff = ((aliasRes.topScore || 0) - (rawRes.topScore || 0)).toFixed(3);
|
||||||
|
const diffStr = diff > 0 ? `+${diff}` : diff;
|
||||||
|
console.log(`| ${t.label} | ${rawScore} | ${aliasScore} | ${diffStr} | ${rawRes.hit ? 'HIT' : 'MISS'} | ${aliasRes.hit ? 'HIT' : 'MISS'} |`);
|
||||||
|
}
|
||||||
|
console.log('\n阈值: reranker hit >= 0.1');
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest().catch(e => { console.error('FAILED:', e.message); process.exit(1); });
|
||||||
322
test2/server/tests/test_kb_retriever.js
Normal file
322
test2/server/tests/test_kb_retriever.js
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* kbRetriever.js 单元测试
|
||||||
|
* 覆盖:配置读取、rerankChunks降级、buildRagPayload组装、hit/no-hit判断
|
||||||
|
* 纯本地测试,不依赖外部API
|
||||||
|
*
|
||||||
|
* 运行方式: node --test tests/test_kb_retriever.js
|
||||||
|
*/
|
||||||
|
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
// 设置测试环境变量(在require之前)
|
||||||
|
const ENV_BACKUP = {};
|
||||||
|
function setEnv(overrides) {
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
ENV_BACKUP[k] = process.env[k];
|
||||||
|
process.env[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function restoreEnv() {
|
||||||
|
for (const [k, v] of Object.entries(ENV_BACKUP)) {
|
||||||
|
if (v === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置基本环境变量避免模块加载出错
|
||||||
|
setEnv({
|
||||||
|
VOLC_ARK_API_KEY: 'test_key',
|
||||||
|
VOLC_ARK_ENDPOINT_ID: 'test_endpoint',
|
||||||
|
VOLC_ARK_KNOWLEDGE_BASE_IDS: 'ds_test1,ds_test2',
|
||||||
|
VOLC_ARK_RERANKER_ENDPOINT_ID: 'reranker_test',
|
||||||
|
VOLC_ARK_RERANKER_TOP_N: '3',
|
||||||
|
VOLC_ARK_KB_RETRIEVAL_TOP_K: '10',
|
||||||
|
VOLC_ARK_KNOWLEDGE_THRESHOLD: '0.1',
|
||||||
|
ENABLE_RERANKER: 'true',
|
||||||
|
ENABLE_REDIS_CONTEXT: 'false', // 测试中不连Redis
|
||||||
|
});
|
||||||
|
|
||||||
|
const kbRetriever = require('../services/kbRetriever');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 1. getConfig — 配置读取
|
||||||
|
// ================================================================
|
||||||
|
describe('kbRetriever.getConfig — 配置读取', () => {
|
||||||
|
afterEach(() => restoreEnv());
|
||||||
|
|
||||||
|
it('应正确读取所有配置项', () => {
|
||||||
|
const config = kbRetriever.getConfig();
|
||||||
|
assert.equal(config.authKey, 'test_key');
|
||||||
|
assert.equal(config.rerankerTopN, 3);
|
||||||
|
assert.equal(config.retrievalTopK, 10);
|
||||||
|
assert.equal(config.enableReranker, true);
|
||||||
|
assert.equal(config.enableRedisContext, false);
|
||||||
|
assert.ok(config.kbIds.includes('ds_test1'));
|
||||||
|
assert.ok(config.kbIds.includes('ds_test2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENABLE_RERANKER=false 应正确关闭', () => {
|
||||||
|
setEnv({ ENABLE_RERANKER: 'false' });
|
||||||
|
const config = kbRetriever.getConfig();
|
||||||
|
assert.equal(config.enableReranker, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 RERANKER_MODEL 时应默认为 doubao-seed-rerank', () => {
|
||||||
|
setEnv({ VOLC_ARK_RERANKER_MODEL: '', VOLC_ARK_RERANKER_ENDPOINT_ID: '' });
|
||||||
|
const config = kbRetriever.getConfig();
|
||||||
|
assert.equal(config.rerankerModel, 'doubao-seed-rerank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retrievalMode 默认应为 raw', () => {
|
||||||
|
setEnv({ VOLC_ARK_KB_RETRIEVAL_MODE: 'raw' });
|
||||||
|
const config = kbRetriever.getConfig();
|
||||||
|
assert.equal(config.retrievalMode, 'raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retrievalMode 为空时默认 raw', () => {
|
||||||
|
setEnv({ VOLC_ARK_KB_RETRIEVAL_MODE: '' });
|
||||||
|
const config = kbRetriever.getConfig();
|
||||||
|
// 空字符串 || 'raw' → 'raw'... 不对,实际是空字符串是falsy
|
||||||
|
// 代码: process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'raw'
|
||||||
|
assert.equal(config.retrievalMode, 'raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dataset_ids 分割应正确处理空格和逗号', () => {
|
||||||
|
setEnv({ VOLC_ARK_KNOWLEDGE_BASE_IDS: ' ds_a , ds_b , ds_c ' });
|
||||||
|
const config = kbRetriever.getConfig();
|
||||||
|
assert.deepEqual(config.kbIds, ['ds_a', 'ds_b', 'ds_c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 2. rerankChunks — 重排降级逻辑
|
||||||
|
// ================================================================
|
||||||
|
describe('kbRetriever.rerankChunks — 降级与边界', () => {
|
||||||
|
afterEach(() => restoreEnv());
|
||||||
|
|
||||||
|
it('空 chunks 应返回空数组', async () => {
|
||||||
|
const result = await kbRetriever.rerankChunks('测试', [], 3);
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chunks 数量 <= topN 时应直接返回全部', async () => {
|
||||||
|
const chunks = [
|
||||||
|
{ id: '1', content: '片段1', score: 0.9 },
|
||||||
|
{ id: '2', content: '片段2', score: 0.8 },
|
||||||
|
];
|
||||||
|
const result = await kbRetriever.rerankChunks('测试', chunks, 3);
|
||||||
|
assert.equal(result.length, 2, 'Should return all chunks when count <= topN');
|
||||||
|
assert.equal(result[0].content, '片段1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENABLE_RERANKER=false 时应返回前 topN 条(按原序)', async () => {
|
||||||
|
setEnv({ ENABLE_RERANKER: 'false' });
|
||||||
|
const chunks = [
|
||||||
|
{ id: '1', content: 'A', score: 0.9 },
|
||||||
|
{ id: '2', content: 'B', score: 0.8 },
|
||||||
|
{ id: '3', content: 'C', score: 0.7 },
|
||||||
|
{ id: '4', content: 'D', score: 0.6 },
|
||||||
|
{ id: '5', content: 'E', score: 0.5 },
|
||||||
|
];
|
||||||
|
const result = await kbRetriever.rerankChunks('测试', chunks, 3);
|
||||||
|
assert.equal(result.length, 3);
|
||||||
|
assert.equal(result[0].content, 'A');
|
||||||
|
assert.equal(result[1].content, 'B');
|
||||||
|
assert.equal(result[2].content, 'C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 RERANKER_ENDPOINT_ID 时应降级为按检索排序取 topN', async () => {
|
||||||
|
setEnv({ VOLC_ARK_RERANKER_ENDPOINT_ID: '' });
|
||||||
|
const chunks = Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
id: `c${i}`, content: `内容${i}`, score: 1 - i * 0.1,
|
||||||
|
}));
|
||||||
|
const result = await kbRetriever.rerankChunks('测试', chunks, 3);
|
||||||
|
assert.equal(result.length, 3);
|
||||||
|
assert.equal(result[0].content, '内容0', 'First chunk should be highest score');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reranker API 超时/失败时应降级返回前 topN', async () => {
|
||||||
|
// 设置一个不存在的 endpoint,会导致 API 调用失败
|
||||||
|
setEnv({ ENABLE_RERANKER: 'true', VOLC_ARK_RERANKER_ENDPOINT_ID: 'invalid_endpoint' });
|
||||||
|
const chunks = [
|
||||||
|
{ id: '1', content: '片段1', score: 0.9 },
|
||||||
|
{ id: '2', content: '片段2', score: 0.8 },
|
||||||
|
{ id: '3', content: '片段3', score: 0.7 },
|
||||||
|
{ id: '4', content: '片段4', score: 0.6 },
|
||||||
|
];
|
||||||
|
const result = await kbRetriever.rerankChunks('测试', chunks, 3);
|
||||||
|
assert.equal(result.length, 3, 'Should fallback to top 3');
|
||||||
|
assert.equal(result[0].content, '片段1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 3. buildRagPayload — RAG payload 组装
|
||||||
|
// ================================================================
|
||||||
|
describe('kbRetriever.buildRagPayload — payload 组装', () => {
|
||||||
|
|
||||||
|
it('无上下文时应只包含 KB 片段', () => {
|
||||||
|
const chunks = [
|
||||||
|
{ content: '片段A', doc_name: '产品手册' },
|
||||||
|
{ content: '片段B', doc_name: 'FAQ' },
|
||||||
|
];
|
||||||
|
const payload = kbRetriever.buildRagPayload(chunks, []);
|
||||||
|
assert.equal(payload.length, 2, 'Should have 2 items (chunks only)');
|
||||||
|
assert.equal(payload[0].title, '产品手册');
|
||||||
|
assert.equal(payload[0].content, '片段A');
|
||||||
|
assert.equal(payload[1].title, 'FAQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有上下文时应在片段前注入上下文条目', () => {
|
||||||
|
const chunks = [{ content: '片段A', doc_name: '' }];
|
||||||
|
const history = [
|
||||||
|
{ role: 'user', content: '小红怎么吃' },
|
||||||
|
{ role: 'assistant', content: '小红每天一包...' },
|
||||||
|
];
|
||||||
|
const payload = kbRetriever.buildRagPayload(chunks, history);
|
||||||
|
assert.equal(payload.length, 2, 'Should have context + 1 chunk');
|
||||||
|
assert.equal(payload[0].title, '对话上下文');
|
||||||
|
assert.ok(payload[0].content.includes('用户: 小红怎么吃'), 'Context should include user message');
|
||||||
|
assert.ok(payload[0].content.includes('助手: 小红每天一包'), 'Context should include assistant message');
|
||||||
|
assert.equal(payload[1].content, '片段A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 doc_name 的片段应使用默认标题"知识库片段N"', () => {
|
||||||
|
const chunks = [
|
||||||
|
{ content: '内容1', doc_name: '' },
|
||||||
|
{ content: '内容2', doc_name: '' },
|
||||||
|
{ content: '内容3', doc_name: '' },
|
||||||
|
];
|
||||||
|
const payload = kbRetriever.buildRagPayload(chunks, []);
|
||||||
|
assert.equal(payload[0].title, '知识库片段1');
|
||||||
|
assert.equal(payload[1].title, '知识库片段2');
|
||||||
|
assert.equal(payload[2].title, '知识库片段3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空 chunks 应返回空数组(无上下文时)', () => {
|
||||||
|
const payload = kbRetriever.buildRagPayload([], []);
|
||||||
|
assert.equal(payload.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空 chunks + 有上下文 应只返回上下文条目', () => {
|
||||||
|
const history = [{ role: 'user', content: '你好' }];
|
||||||
|
const payload = kbRetriever.buildRagPayload([], history);
|
||||||
|
assert.equal(payload.length, 1);
|
||||||
|
assert.equal(payload[0].title, '对话上下文');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5轮对话上下文应完整保留', () => {
|
||||||
|
const history = [];
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
history.push({ role: 'user', content: `问题${i}` });
|
||||||
|
history.push({ role: 'assistant', content: `回答${i}` });
|
||||||
|
}
|
||||||
|
const payload = kbRetriever.buildRagPayload([{ content: '片段', doc_name: '' }], history);
|
||||||
|
const contextContent = payload[0].content;
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
assert.ok(contextContent.includes(`问题${i}`), `Should include question ${i}`);
|
||||||
|
assert.ok(contextContent.includes(`回答${i}`), `Should include answer ${i}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 4. searchAndRerank — 主流程(无API调用的边界测试)
|
||||||
|
// ================================================================
|
||||||
|
describe('kbRetriever.searchAndRerank — 主流程边界', () => {
|
||||||
|
afterEach(() => restoreEnv());
|
||||||
|
|
||||||
|
it('endpoint 未配置时应返回 hit=false + error', async () => {
|
||||||
|
setEnv({ VOLC_ARK_ENDPOINT_ID: 'your_ark_endpoint_id', VOLC_ARK_KNOWLEDGE_ENDPOINT_ID: '' });
|
||||||
|
const result = await kbRetriever.searchAndRerank('测试');
|
||||||
|
assert.equal(result.hit, false);
|
||||||
|
assert.ok(result.reason, 'Should have reason');
|
||||||
|
assert.equal(result.source, 'ark_knowledge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 dataset_ids 时应返回 hit=false', async () => {
|
||||||
|
setEnv({ VOLC_ARK_KNOWLEDGE_BASE_IDS: '' });
|
||||||
|
const result = await kbRetriever.searchAndRerank('测试');
|
||||||
|
assert.equal(result.hit, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('返回结构应包含所有必需字段(或抛出可捕获的异常)', async () => {
|
||||||
|
// 使用假 endpoint,API 调用会失败
|
||||||
|
setEnv({
|
||||||
|
VOLC_ARK_ENDPOINT_ID: 'ep_test',
|
||||||
|
VOLC_ARK_KNOWLEDGE_ENDPOINT_ID: 'ep_test',
|
||||||
|
VOLC_ARK_KNOWLEDGE_BASE_IDS: 'ds_test',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await kbRetriever.searchAndRerank('测试查询');
|
||||||
|
// 如果返回了结果(非抛出),验证结构
|
||||||
|
assert.ok('hit' in result, 'Should have hit field');
|
||||||
|
assert.ok('reason' in result, 'Should have reason field');
|
||||||
|
assert.ok('source' in result, 'Should have source field');
|
||||||
|
assert.ok('latencyMs' in result, 'Should have latencyMs field');
|
||||||
|
assert.equal(result.source, 'ark_knowledge');
|
||||||
|
} catch (err) {
|
||||||
|
// API 调用失败抛出异常也是合理行为(由上层 toolExecutor catch 处理)
|
||||||
|
assert.ok(err instanceof Error, 'Should throw an Error instance');
|
||||||
|
console.log(` ℹ️ searchAndRerank threw as expected: ${err.message.slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 5. hit/no-hit 判定逻辑
|
||||||
|
// ================================================================
|
||||||
|
describe('hit/no-hit 判定 — 基于 reranker score', () => {
|
||||||
|
afterEach(() => restoreEnv());
|
||||||
|
|
||||||
|
it('buildRagPayload 有片段 + score > 0.3 应判为 hit(通过 searchAndRerank 返回值验证)', () => {
|
||||||
|
// 直接验证判定逻辑
|
||||||
|
const highScoreChunks = [{ content: '有效内容', score: 0.8, doc_name: '' }];
|
||||||
|
const payload = kbRetriever.buildRagPayload(highScoreChunks, []);
|
||||||
|
assert.ok(payload.length > 0, 'High score chunks should produce payload');
|
||||||
|
assert.ok(highScoreChunks[0].score >= 0.3, 'Score 0.8 >= 0.3 should be hit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('score < 0.3 的片段应判为 no-hit', () => {
|
||||||
|
const lowScoreChunks = [{ content: '弱相关内容', score: 0.1, doc_name: '' }];
|
||||||
|
assert.ok(lowScoreChunks[0].score < 0.3, 'Score 0.1 < 0.3 should be no-hit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无重排器时 hitThreshold 应为 0.5', () => {
|
||||||
|
setEnv({ ENABLE_RERANKER: 'false' });
|
||||||
|
// 验证逻辑:无重排器时,0.4的分数应该不算hit(阈值0.5)
|
||||||
|
const config = kbRetriever.getConfig();
|
||||||
|
const hitThreshold = config.enableReranker && config.rerankerModel ? 0.3 : 0.5;
|
||||||
|
assert.equal(hitThreshold, 0.5, 'Without reranker, threshold should be 0.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有重排器时 hitThreshold 应为 0.3', () => {
|
||||||
|
setEnv({ ENABLE_RERANKER: 'true', VOLC_ARK_RERANKER_MODEL: 'doubao-seed-rerank' });
|
||||||
|
const config = kbRetriever.getConfig();
|
||||||
|
const hitThreshold = config.enableReranker && config.rerankerModel ? 0.3 : 0.5;
|
||||||
|
assert.equal(hitThreshold, 0.3, 'With reranker, threshold should be 0.3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 6. retrieveChunks — 解析逻辑(模拟response)
|
||||||
|
// ================================================================
|
||||||
|
describe('retrieveChunks — 边界', () => {
|
||||||
|
afterEach(() => restoreEnv());
|
||||||
|
|
||||||
|
it('endpoint 未配置时应返回 error', async () => {
|
||||||
|
setEnv({ VOLC_ARK_ENDPOINT_ID: 'your_ark_endpoint_id', VOLC_ARK_KNOWLEDGE_ENDPOINT_ID: '' });
|
||||||
|
const result = await kbRetriever.retrieveChunks('测试', ['ds1'], 5, 0.1);
|
||||||
|
assert.equal(result.error, 'endpoint_not_configured');
|
||||||
|
assert.equal(result.chunks.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 datasetIds 且环境变量也为空时应返回 error', async () => {
|
||||||
|
setEnv({ VOLC_ARK_KNOWLEDGE_BASE_IDS: '', VOLC_ARK_ENDPOINT_ID: 'ep_valid', VOLC_ARK_KNOWLEDGE_ENDPOINT_ID: 'ep_valid' });
|
||||||
|
const result = await kbRetriever.retrieveChunks('测试', [], 5, 0.1);
|
||||||
|
assert.equal(result.error, 'no_dataset_ids');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n=== kbRetriever 测试加载完成 ===\n');
|
||||||
331
test2/server/tests/test_raw_mode_integration.js
Normal file
331
test2/server/tests/test_raw_mode_integration.js
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* raw 模式集成测试
|
||||||
|
* 覆盖:resolveReply raw/answer 模式切换、ragItems 格式、字幕过滤、缓存双写
|
||||||
|
*
|
||||||
|
* 运行方式: node --test tests/test_raw_mode_integration.js
|
||||||
|
*/
|
||||||
|
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// 加载 .env
|
||||||
|
const envPath = path.join(__dirname, '../.env');
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
fs.readFileSync(envPath, 'utf8').split('\n').forEach(line => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) return;
|
||||||
|
const idx = trimmed.indexOf('=');
|
||||||
|
if (idx > 0) {
|
||||||
|
const key = trimmed.slice(0, idx).trim();
|
||||||
|
let val = trimmed.slice(idx + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (!process.env[key]) process.env[key] = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { normalizeTextForSpeech } = require('../services/realtimeDialogRouting');
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 1. ragItems 格式验证 — raw 模式 vs answer 模式
|
||||||
|
// ================================================================
|
||||||
|
describe('ragItems 格式 — raw 模式 vs answer 模式', () => {
|
||||||
|
|
||||||
|
it('raw 模式 toolResult 应包含 retrieval_mode 标记', () => {
|
||||||
|
const rawToolResult = {
|
||||||
|
query: '小红怎么吃',
|
||||||
|
results: [
|
||||||
|
{ title: '对话上下文', content: '用户: 你好\n助手: 你好!' },
|
||||||
|
{ title: '产品手册', content: '小红Activize Oxyplus...' },
|
||||||
|
{ title: 'FAQ', content: '小红每天一包...' },
|
||||||
|
{ title: '知识库片段3', content: '用40度以下温水冲服...' },
|
||||||
|
],
|
||||||
|
total: 4,
|
||||||
|
source: 'ark_knowledge',
|
||||||
|
hit: true,
|
||||||
|
reason: 'reranked_hit',
|
||||||
|
retrieval_mode: 'raw',
|
||||||
|
top_score: 0.85,
|
||||||
|
chunks_count: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(rawToolResult.retrieval_mode, 'raw');
|
||||||
|
assert.equal(rawToolResult.results.length, 4, 'Should have context + 3 chunks');
|
||||||
|
assert.equal(rawToolResult.results[0].title, '对话上下文', 'First item should be context');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answer 模式 toolResult 不应包含 retrieval_mode 或为 undefined', () => {
|
||||||
|
const answerToolResult = {
|
||||||
|
query: '小红怎么吃',
|
||||||
|
results: [{ title: '方舟知识库检索结果', content: '根据知识库信息,小红Activize每天一包...' }],
|
||||||
|
total: 1,
|
||||||
|
source: 'ark_knowledge',
|
||||||
|
hit: true,
|
||||||
|
reason: 'classified_hit',
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.ok(!answerToolResult.retrieval_mode || answerToolResult.retrieval_mode !== 'raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raw 模式下 ragItems 应透传多条而非合并为单条', () => {
|
||||||
|
const rawResults = [
|
||||||
|
{ title: '对话上下文', content: '用户: 大白怎么吃\n助手: ...' },
|
||||||
|
{ title: '产品手册', content: '片段1内容' },
|
||||||
|
{ title: 'FAQ', content: '片段2内容' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isRawMode = true;
|
||||||
|
let finalRagItems;
|
||||||
|
if (isRawMode) {
|
||||||
|
finalRagItems = rawResults;
|
||||||
|
} else {
|
||||||
|
const replyText = rawResults.map(r => r.content).join(' ');
|
||||||
|
finalRagItems = [{ title: '知识库结果', content: replyText }];
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(finalRagItems.length, 3, 'Raw mode should keep 3 items');
|
||||||
|
assert.equal(finalRagItems[0].title, '对话上下文');
|
||||||
|
assert.equal(finalRagItems[1].title, '产品手册');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answer 模式下 ragItems 应合并为单条并清理前缀', () => {
|
||||||
|
const replyText = '根据知识库信息,小红Activize每天一包冲服';
|
||||||
|
const isRawMode = false;
|
||||||
|
|
||||||
|
let finalRagItems;
|
||||||
|
if (!isRawMode) {
|
||||||
|
const speechText = normalizeTextForSpeech(replyText);
|
||||||
|
const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
||||||
|
finalRagItems = [{ title: '知识库结果', content: cleanedText || speechText }];
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(finalRagItems.length, 1, 'Answer mode should merge into 1 item');
|
||||||
|
assert.ok(!finalRagItems[0].content.startsWith('根据知识库信息'), 'Should strip prefix');
|
||||||
|
assert.ok(finalRagItems[0].content.includes('小红Activize'), 'Should preserve core content');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 2. 字幕过滤 — raw 模式排除"对话上下文"
|
||||||
|
// ================================================================
|
||||||
|
describe('字幕过滤 — raw 模式排除对话上下文', () => {
|
||||||
|
|
||||||
|
it('raw 模式的 ragContent 过滤后字幕不应包含上下文', () => {
|
||||||
|
const ragContent = [
|
||||||
|
{ title: '对话上下文', content: '用户: 你好\n助手: 你好' },
|
||||||
|
{ title: '产品手册', content: '小红每天一包' },
|
||||||
|
{ title: 'FAQ', content: '40度以下温水' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const subtitleItems = ragContent.filter((item) => item.title !== '对话上下文');
|
||||||
|
const ragSubtitleText = (subtitleItems.length > 0 ? subtitleItems : ragContent)
|
||||||
|
.map((item) => item.content).join(' ');
|
||||||
|
|
||||||
|
assert.ok(!ragSubtitleText.includes('用户: 你好'), 'Subtitle should not include context');
|
||||||
|
assert.ok(ragSubtitleText.includes('小红每天一包'), 'Subtitle should include KB content');
|
||||||
|
assert.ok(ragSubtitleText.includes('40度以下温水'), 'Subtitle should include all KB chunks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answer 模式无"对话上下文"条目,字幕应正常', () => {
|
||||||
|
const ragContent = [
|
||||||
|
{ title: '知识库结果', content: '小红Activize每天一包冲服' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const subtitleItems = ragContent.filter((item) => item.title !== '对话上下文');
|
||||||
|
const ragSubtitleText = (subtitleItems.length > 0 ? subtitleItems : ragContent)
|
||||||
|
.map((item) => item.content).join(' ');
|
||||||
|
|
||||||
|
assert.equal(ragSubtitleText, '小红Activize每天一包冲服');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('全部都是上下文条目时应降级显示全部(兜底)', () => {
|
||||||
|
const ragContent = [
|
||||||
|
{ title: '对话上下文', content: '用户: 测试' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const subtitleItems = ragContent.filter((item) => item.title !== '对话上下文');
|
||||||
|
const ragSubtitleText = (subtitleItems.length > 0 ? subtitleItems : ragContent)
|
||||||
|
.map((item) => item.content).join(' ');
|
||||||
|
|
||||||
|
assert.equal(ragSubtitleText, '用户: 测试', 'Should fallback to showing context');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 3. 环境变量模式切换
|
||||||
|
// ================================================================
|
||||||
|
describe('环境变量模式切换', () => {
|
||||||
|
const ENV_ORIG = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ENV_ORIG.mode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE;
|
||||||
|
ENV_ORIG.reranker = process.env.ENABLE_RERANKER;
|
||||||
|
ENV_ORIG.redis = process.env.ENABLE_REDIS_CONTEXT;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (ENV_ORIG.mode !== undefined) process.env.VOLC_ARK_KB_RETRIEVAL_MODE = ENV_ORIG.mode;
|
||||||
|
else delete process.env.VOLC_ARK_KB_RETRIEVAL_MODE;
|
||||||
|
if (ENV_ORIG.reranker !== undefined) process.env.ENABLE_RERANKER = ENV_ORIG.reranker;
|
||||||
|
else delete process.env.ENABLE_RERANKER;
|
||||||
|
if (ENV_ORIG.redis !== undefined) process.env.ENABLE_REDIS_CONTEXT = ENV_ORIG.redis;
|
||||||
|
else delete process.env.ENABLE_REDIS_CONTEXT;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VOLC_ARK_KB_RETRIEVAL_MODE=raw 应使用新链路', () => {
|
||||||
|
process.env.VOLC_ARK_KB_RETRIEVAL_MODE = 'raw';
|
||||||
|
const mode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'answer';
|
||||||
|
assert.equal(mode, 'raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VOLC_ARK_KB_RETRIEVAL_MODE=answer 应使用旧链路', () => {
|
||||||
|
process.env.VOLC_ARK_KB_RETRIEVAL_MODE = 'answer';
|
||||||
|
const mode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'answer';
|
||||||
|
assert.equal(mode, 'answer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VOLC_ARK_KB_RETRIEVAL_MODE 未设置时默认 answer', () => {
|
||||||
|
delete process.env.VOLC_ARK_KB_RETRIEVAL_MODE;
|
||||||
|
const mode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'answer';
|
||||||
|
assert.equal(mode, 'answer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENABLE_RERANKER=false 应关闭重排', () => {
|
||||||
|
process.env.ENABLE_RERANKER = 'false';
|
||||||
|
assert.equal(process.env.ENABLE_RERANKER !== 'false', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENABLE_REDIS_CONTEXT=false 应关闭 Redis 上下文', () => {
|
||||||
|
process.env.ENABLE_REDIS_CONTEXT = 'false';
|
||||||
|
assert.equal(process.env.ENABLE_REDIS_CONTEXT !== 'false', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('所有降级开关独立,互不影响', () => {
|
||||||
|
process.env.VOLC_ARK_KB_RETRIEVAL_MODE = 'raw';
|
||||||
|
process.env.ENABLE_RERANKER = 'false';
|
||||||
|
process.env.ENABLE_REDIS_CONTEXT = 'true';
|
||||||
|
|
||||||
|
assert.equal(process.env.VOLC_ARK_KB_RETRIEVAL_MODE, 'raw', 'mode should be raw');
|
||||||
|
assert.equal(process.env.ENABLE_RERANKER, 'false', 'reranker should be off');
|
||||||
|
assert.equal(process.env.ENABLE_REDIS_CONTEXT, 'true', 'redis context should be on');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 4. normalizeTextForSpeech 对 raw 内容的处理
|
||||||
|
// ================================================================
|
||||||
|
describe('normalizeTextForSpeech — raw 片段处理', () => {
|
||||||
|
|
||||||
|
it('应去除 Markdown 标记', () => {
|
||||||
|
const input = '## 产品介绍\n**小红Activize** 是一款_细胞营养素_';
|
||||||
|
const result = normalizeTextForSpeech(input);
|
||||||
|
assert.ok(!result.includes('##'), 'Should remove heading marks');
|
||||||
|
assert.ok(!result.includes('**'), 'Should remove bold marks');
|
||||||
|
assert.ok(!result.includes('_细胞'), 'Should remove italic marks');
|
||||||
|
assert.ok(result.includes('小红Activize'), 'Should preserve content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('纯文本内容不应被破坏', () => {
|
||||||
|
const input = '小红Activize Oxyplus每天一包,用40度以下温水冲服,搅拌均匀后饮用。';
|
||||||
|
const result = normalizeTextForSpeech(input);
|
||||||
|
assert.equal(result, input, 'Plain text should not be modified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空字符串应返回空', () => {
|
||||||
|
assert.equal(normalizeTextForSpeech(''), '');
|
||||||
|
assert.equal(normalizeTextForSpeech(null), '');
|
||||||
|
assert.equal(normalizeTextForSpeech(undefined), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 5. 端到端数据流验证
|
||||||
|
// ================================================================
|
||||||
|
describe('端到端数据流 — raw 模式完整 ragPayload → S2S', () => {
|
||||||
|
|
||||||
|
it('模拟完整 raw 模式数据流:query → chunks → rerank → payload → ragItems', () => {
|
||||||
|
// Step 1: 模拟检索结果
|
||||||
|
const retrievedChunks = [
|
||||||
|
{ id: 'c1', content: '小红Activize Oxyplus是德国PM的核心产品', score: 0.9, doc_name: '产品手册' },
|
||||||
|
{ id: 'c2', content: '每天一包,40度以下温水冲服', score: 0.85, doc_name: '使用说明' },
|
||||||
|
{ id: 'c3', content: '含有瓜拉纳提取物,提供天然能量', score: 0.75, doc_name: '成分表' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Step 2: 模拟重排(已排序,直接取 top3)
|
||||||
|
const rerankedChunks = retrievedChunks.slice(0, 3);
|
||||||
|
|
||||||
|
// Step 3: 模拟历史
|
||||||
|
const history = [
|
||||||
|
{ role: 'user', content: '你们有什么产品' },
|
||||||
|
{ role: 'assistant', content: '我们有基础三合一,包括大白小红小白...' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Step 4: 构建 payload
|
||||||
|
const kbRetriever = require('../services/kbRetriever');
|
||||||
|
const ragPayload = kbRetriever.buildRagPayload(rerankedChunks, history);
|
||||||
|
|
||||||
|
// 验证 payload 结构
|
||||||
|
assert.equal(ragPayload.length, 4, 'Should be context(1) + chunks(3)');
|
||||||
|
assert.equal(ragPayload[0].title, '对话上下文');
|
||||||
|
assert.ok(ragPayload[0].content.includes('你们有什么产品'));
|
||||||
|
assert.equal(ragPayload[1].title, '产品手册');
|
||||||
|
assert.equal(ragPayload[2].title, '使用说明');
|
||||||
|
assert.equal(ragPayload[3].title, '成分表');
|
||||||
|
|
||||||
|
// Step 5: 模拟 toolResult
|
||||||
|
const toolResult = {
|
||||||
|
results: ragPayload.map(item => ({ title: item.title, content: item.content })),
|
||||||
|
hit: true,
|
||||||
|
retrieval_mode: 'raw',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 6: 模拟 resolveReply 中的 ragItems 构建
|
||||||
|
const ragItems = toolResult.results.filter(i => i && i.content).map(i => ({
|
||||||
|
title: i.title || '知识库结果',
|
||||||
|
content: i.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isRawMode = toolResult.retrieval_mode === 'raw';
|
||||||
|
const finalRagItems = isRawMode ? ragItems : [{ title: '知识库结果', content: ragItems.map(i => i.content).join(' ') }];
|
||||||
|
|
||||||
|
assert.equal(finalRagItems.length, 4, 'Raw mode: 4 items pass-through');
|
||||||
|
|
||||||
|
// Step 7: 模拟字幕过滤
|
||||||
|
const subtitleItems = finalRagItems.filter(item => item.title !== '对话上下文');
|
||||||
|
const subtitle = subtitleItems.map(item => item.content).join(' ');
|
||||||
|
assert.ok(!subtitle.includes('你们有什么产品'), 'Subtitle should exclude context');
|
||||||
|
assert.ok(subtitle.includes('小红Activize'), 'Subtitle should include chunk content');
|
||||||
|
|
||||||
|
// Step 8: 模拟 sendExternalRag 的 JSON 序列化
|
||||||
|
const jsonStr = JSON.stringify(finalRagItems);
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
assert.equal(parsed.length, 4, 'JSON roundtrip should preserve all items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('模拟完整 answer 模式数据流(对比验证)', () => {
|
||||||
|
const toolResult = {
|
||||||
|
results: [{ title: '方舟知识库检索结果', content: '根据知识库信息,小红Activize每天一包冲服' }],
|
||||||
|
hit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ragItems = toolResult.results.filter(i => i && i.content).map(i => ({
|
||||||
|
title: i.title || '知识库结果',
|
||||||
|
content: i.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isRawMode = !!(toolResult.retrieval_mode === 'raw');
|
||||||
|
assert.equal(isRawMode, false, 'Should be answer mode');
|
||||||
|
|
||||||
|
const speechText = normalizeTextForSpeech(ragItems[0].content);
|
||||||
|
const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
||||||
|
const finalRagItems = [{ title: '知识库结果', content: cleanedText || speechText }];
|
||||||
|
|
||||||
|
assert.equal(finalRagItems.length, 1, 'Answer mode: single merged item');
|
||||||
|
assert.ok(!finalRagItems[0].content.startsWith('根据知识库'), 'Prefix should be stripped');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n=== raw模式集成测试加载完成 ===\n');
|
||||||
294
test2/server/tests/test_redis_client.js
Normal file
294
test2/server/tests/test_redis_client.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* redisClient.js 单元测试
|
||||||
|
* 覆盖:连接状态、对话历史读写、KB缓存读写、降级行为、TTL/LTRIM逻辑
|
||||||
|
*
|
||||||
|
* 运行方式: node --test tests/test_redis_client.js
|
||||||
|
* 注意: 需要本地Redis可用(redis://127.0.0.1:6379),否则降级测试仍会通过
|
||||||
|
*/
|
||||||
|
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// 加载 .env
|
||||||
|
const envPath = path.join(__dirname, '../.env');
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
fs.readFileSync(envPath, 'utf8').split('\n').forEach(line => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) return;
|
||||||
|
const idx = trimmed.indexOf('=');
|
||||||
|
if (idx > 0) {
|
||||||
|
const key = trimmed.slice(0, idx).trim();
|
||||||
|
let val = trimmed.slice(idx + 1).trim();
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (!process.env[key]) process.env[key] = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { after } = require('node:test');
|
||||||
|
const redisClient = require('../services/redisClient');
|
||||||
|
|
||||||
|
const TEST_SESSION_ID = `test_session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const TEST_KB_CACHE_KEY = `test_kb_cache_${Date.now()}`;
|
||||||
|
|
||||||
|
// 测试结束后断开 Redis,防止进程挂起
|
||||||
|
after(async () => {
|
||||||
|
await redisClient.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 1. 连接与可用性
|
||||||
|
// ================================================================
|
||||||
|
describe('redisClient — 连接与可用性', () => {
|
||||||
|
it('createClient 应返回客户端实例', () => {
|
||||||
|
const client = redisClient.createClient();
|
||||||
|
assert.ok(client, 'createClient should return a client');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getClient 应返回同一个实例(单例模式)', () => {
|
||||||
|
const c1 = redisClient.getClient();
|
||||||
|
const c2 = redisClient.getClient();
|
||||||
|
assert.strictEqual(c1, c2, 'getClient should return singleton');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isAvailable 应返回 boolean', () => {
|
||||||
|
const result = redisClient.isAvailable();
|
||||||
|
assert.equal(typeof result, 'boolean', 'isAvailable should return boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 2. 对话历史 — pushMessage + getRecentHistory
|
||||||
|
// ================================================================
|
||||||
|
describe('redisClient — 对话历史读写', () => {
|
||||||
|
const sessionId = TEST_SESSION_ID;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await redisClient.clearSession(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await redisClient.clearSession(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushMessage 写入后 getRecentHistory 应能读取', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await redisClient.pushMessage(sessionId, { role: 'user', content: '你好' });
|
||||||
|
assert.equal(ok, true, 'pushMessage should return true');
|
||||||
|
|
||||||
|
const history = await redisClient.getRecentHistory(sessionId, 5);
|
||||||
|
assert.ok(Array.isArray(history), 'getRecentHistory should return array');
|
||||||
|
assert.equal(history.length, 1, 'Should have 1 message');
|
||||||
|
assert.equal(history[0].role, 'user');
|
||||||
|
assert.equal(history[0].content, '你好');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('多条消息应保持时间顺序(最旧在前)', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await redisClient.pushMessage(sessionId, { role: 'user', content: '第1条' });
|
||||||
|
await redisClient.pushMessage(sessionId, { role: 'assistant', content: '第2条' });
|
||||||
|
await redisClient.pushMessage(sessionId, { role: 'user', content: '第3条' });
|
||||||
|
|
||||||
|
const history = await redisClient.getRecentHistory(sessionId, 5);
|
||||||
|
assert.equal(history.length, 3);
|
||||||
|
assert.equal(history[0].content, '第1条', '最旧的应在前面');
|
||||||
|
assert.equal(history[1].content, '第2条');
|
||||||
|
assert.equal(history[2].content, '第3条', '最新的应在后面');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超过10条应自动截断(LTRIM),只保留最近10条', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= 15; i++) {
|
||||||
|
await redisClient.pushMessage(sessionId, { role: i % 2 === 1 ? 'user' : 'assistant', content: `第${i}条` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await redisClient.getRecentHistory(sessionId, 10);
|
||||||
|
assert.ok(history.length <= 10, `Should have at most 10 messages, got ${history.length}`);
|
||||||
|
// 最旧的应该是第6条(前5条被截断)
|
||||||
|
assert.equal(history[0].content, '第6条', '最旧的应该是第6条');
|
||||||
|
assert.equal(history[history.length - 1].content, '第15条', '最新的应该是第15条');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRecentHistory maxRounds 参数应限制返回数量', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
await redisClient.pushMessage(sessionId, { role: i % 2 === 1 ? 'user' : 'assistant', content: `消息${i}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const history2 = await redisClient.getRecentHistory(sessionId, 2);
|
||||||
|
assert.ok(history2.length <= 4, `maxRounds=2 should return at most 4 messages, got ${history2.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearSession 后 getRecentHistory 应返回空', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await redisClient.pushMessage(sessionId, { role: 'user', content: '会被清除' });
|
||||||
|
await redisClient.clearSession(sessionId);
|
||||||
|
const history = await redisClient.getRecentHistory(sessionId, 5);
|
||||||
|
assert.equal(history.length, 0, 'Should be empty after clear');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('消息应包含 ts 时间戳', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
await redisClient.pushMessage(sessionId, { role: 'user', content: '带时间戳' });
|
||||||
|
const after = Date.now();
|
||||||
|
|
||||||
|
const history = await redisClient.getRecentHistory(sessionId, 1);
|
||||||
|
assert.ok(history[0].ts >= before && history[0].ts <= after, 'ts should be within time range');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 3. KB缓存读写
|
||||||
|
// ================================================================
|
||||||
|
describe('redisClient — KB缓存读写', () => {
|
||||||
|
const cacheKey = TEST_KB_CACHE_KEY;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (redisClient.isAvailable()) {
|
||||||
|
try {
|
||||||
|
const client = redisClient.getClient();
|
||||||
|
await client.del(`kb_cache:${cacheKey}`);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setKbCache + getKbCache 应正确读写', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { hit: true, query: '测试', results: [{ content: '测试内容' }] };
|
||||||
|
const ok = await redisClient.setKbCache(cacheKey, result);
|
||||||
|
assert.equal(ok, true, 'setKbCache should return true');
|
||||||
|
|
||||||
|
const cached = await redisClient.getKbCache(cacheKey);
|
||||||
|
assert.ok(cached, 'getKbCache should return data');
|
||||||
|
assert.equal(cached.hit, true);
|
||||||
|
assert.equal(cached.query, '测试');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不存在的key应返回null', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = await redisClient.getKbCache('nonexistent_key_' + Date.now());
|
||||||
|
assert.equal(cached, null, 'Should return null for nonexistent key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 4. 降级行为(Redis不可用时)
|
||||||
|
// ================================================================
|
||||||
|
describe('redisClient — 降级行为', () => {
|
||||||
|
it('pushMessage 在 Redis 不可用时应返回 false 而非报错', async () => {
|
||||||
|
// 即使 Redis 可用,这也验证接口契约
|
||||||
|
const result = await redisClient.pushMessage('fake_session', { role: 'user', content: 'test' });
|
||||||
|
assert.equal(typeof result, 'boolean', 'Should return boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRecentHistory 在不存在的 session 应返回空数组', async () => {
|
||||||
|
const result = await redisClient.getRecentHistory('nonexistent_session_' + Date.now(), 5);
|
||||||
|
if (result === null) {
|
||||||
|
// Redis不可用的降级
|
||||||
|
assert.equal(result, null);
|
||||||
|
} else {
|
||||||
|
assert.ok(Array.isArray(result), 'Should return array');
|
||||||
|
assert.equal(result.length, 0, 'Should be empty for nonexistent session');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearSession 对不存在的 session 不应报错', async () => {
|
||||||
|
const result = await redisClient.clearSession('nonexistent_session_' + Date.now());
|
||||||
|
assert.equal(typeof result, 'boolean', 'Should return boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getKbCache 在 Redis 不可用时应返回 null', async () => {
|
||||||
|
const result = await redisClient.getKbCache('test_key');
|
||||||
|
// 无论 Redis 是否可用,都不应抛出异常
|
||||||
|
assert.ok(result === null || typeof result === 'object', 'Should return null or object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setKbCache 在 Redis 不可用时应返回 false', async () => {
|
||||||
|
const result = await redisClient.setKbCache('test_key', { hit: false });
|
||||||
|
assert.equal(typeof result, 'boolean', 'Should return boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 5. 数据完整性
|
||||||
|
// ================================================================
|
||||||
|
describe('redisClient — 数据完整性', () => {
|
||||||
|
const sessionId = TEST_SESSION_ID + '_integrity';
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await redisClient.clearSession(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('特殊字符消息应正确存取', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialContent = '产品价格:¥299.00 "双引号" \'单引号\' \n换行 \t制表符 emoji🎉';
|
||||||
|
await redisClient.pushMessage(sessionId, { role: 'user', content: specialContent });
|
||||||
|
|
||||||
|
const history = await redisClient.getRecentHistory(sessionId, 1);
|
||||||
|
assert.equal(history[0].content, specialContent, 'Should preserve special characters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空内容消息应正确存取', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await redisClient.pushMessage(sessionId, { role: 'user', content: '' });
|
||||||
|
const history = await redisClient.getRecentHistory(sessionId, 1);
|
||||||
|
assert.equal(history[0].content, '', 'Should handle empty content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('source 字段应正确保存', async () => {
|
||||||
|
if (!redisClient.isAvailable()) {
|
||||||
|
console.log(' ⏭️ Redis不可用,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await redisClient.pushMessage(sessionId, { role: 'assistant', content: '回答', source: 'voice_tool' });
|
||||||
|
const history = await redisClient.getRecentHistory(sessionId, 1);
|
||||||
|
assert.equal(history[0].source, 'voice_tool', 'Should preserve source field');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n=== redisClient 测试加载完成 ===\n');
|
||||||
Reference in New Issue
Block a user