749 lines
37 KiB
JavaScript
749 lines
37 KiB
JavaScript
const axios = require('axios');
|
||
const arkChatService = require('./arkChatService');
|
||
|
||
class ToolExecutor {
|
||
static hasCanonicalKnowledgeTerm(query) {
|
||
return /(一成系统|PM-FitLine|PM细胞营养素|NTC营养保送系统|Activize Oxyplus|小红产品|Basics|大白产品|Restorate|小白产品|儿童倍适|火炉原理|阿育吠陀)/i.test(String(query || ''));
|
||
}
|
||
|
||
static getKnowledgeBaseRoutingRules() {
|
||
const raw = process.env.VOLC_ARK_KNOWLEDGE_BASE_ROUTING || process.env.VOLC_ARK_KNOWLEDGE_BASE_MAP;
|
||
if (!raw) {
|
||
return [];
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
const entries = Array.isArray(parsed)
|
||
? parsed
|
||
: Object.entries(parsed).map(([name, config]) => ({ name, ...(config || {}) }));
|
||
return entries
|
||
.map((item) => ({
|
||
name: String(item.name || '').trim(),
|
||
dataset_ids: Array.isArray(item.dataset_ids)
|
||
? item.dataset_ids.map((id) => String(id || '').trim()).filter(Boolean)
|
||
: String(item.dataset_ids || item.datasetIds || '')
|
||
.split(',')
|
||
.map((id) => id.trim())
|
||
.filter(Boolean),
|
||
keywords: Array.isArray(item.keywords)
|
||
? item.keywords.map((keyword) => String(keyword || '').trim()).filter(Boolean)
|
||
: String(item.keywords || '')
|
||
.split(',')
|
||
.map((keyword) => keyword.trim())
|
||
.filter(Boolean),
|
||
}))
|
||
.filter((item) => item.name && item.dataset_ids.length > 0 && item.keywords.length > 0);
|
||
} catch (error) {
|
||
console.warn('[ToolExecutor] parse knowledge base routing failed:', error.message);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
static selectKnowledgeBaseTargets(query, context = []) {
|
||
const defaultDatasetIds = String(process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS || '')
|
||
.split(',')
|
||
.map((id) => id.trim())
|
||
.filter(Boolean);
|
||
const rules = this.getKnowledgeBaseRoutingRules();
|
||
if (!rules.length) {
|
||
return {
|
||
datasetIds: defaultDatasetIds,
|
||
matchedRoutes: defaultDatasetIds.length ? ['default'] : [],
|
||
};
|
||
}
|
||
|
||
const recentContextText = (Array.isArray(context) ? context : [])
|
||
.slice(-6)
|
||
.map((item) => String(item?.content || '').trim())
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
const haystack = `${String(query || '').trim()}\n${recentContextText}`.toLowerCase();
|
||
|
||
const priorityRouteNames = [];
|
||
const hasSystemIntent = /(一成系统|ai众享|数字化工作室|盛咖学愿|赋能工具|四大ai生态|三大平台)/i.test(haystack);
|
||
const hasCompanyIntent = /(pm公司|德国pm(?!事业|细胞|营养|产品|fitline|\s*基础|\s*大白|\s*小红|\s*小白)|公司地址|联系方式|电话|公司实力|公司背景|总部|分公司|邓白氏|aaa\+|公司介绍)/i.test(haystack);
|
||
const hasProductIntent = /(细胞营养素|基础套装|基础三合一|三合一|大白产品|小红产品|小白产品|activize|basics|restorate|fitline|儿童倍适|ntc营养保送|火炉原理|阿育吠陀|产品.*介绍|介绍.*产品|产品有哪些|产品列表)/i.test(haystack);
|
||
if (hasSystemIntent) {
|
||
priorityRouteNames.push('system');
|
||
}
|
||
if (hasCompanyIntent && !hasSystemIntent && !hasProductIntent) {
|
||
priorityRouteNames.push('company');
|
||
}
|
||
if (priorityRouteNames.length > 0) {
|
||
const priorityRules = rules.filter((rule) => priorityRouteNames.includes(rule.name));
|
||
const priorityDatasetIds = [...new Set(priorityRules.flatMap((rule) => rule.dataset_ids).filter(Boolean))];
|
||
if (priorityDatasetIds.length > 0) {
|
||
return {
|
||
datasetIds: priorityDatasetIds,
|
||
matchedRoutes: [...new Set(priorityRules.map((rule) => rule.name))],
|
||
};
|
||
}
|
||
}
|
||
|
||
const matchedDatasetIds = [];
|
||
const matchedRoutes = [];
|
||
|
||
for (const rule of rules) {
|
||
if (rule.keywords.some((keyword) => haystack.includes(keyword.toLowerCase()))) {
|
||
matchedRoutes.push(rule.name);
|
||
matchedDatasetIds.push(...rule.dataset_ids);
|
||
}
|
||
}
|
||
|
||
const datasetIds = [...new Set((matchedDatasetIds.length ? matchedDatasetIds : defaultDatasetIds).filter(Boolean))];
|
||
return {
|
||
datasetIds,
|
||
matchedRoutes: matchedRoutes.length ? [...new Set(matchedRoutes)] : (datasetIds.length ? ['default'] : []),
|
||
};
|
||
}
|
||
|
||
static buildDeterministicKnowledgeQuery(query, context = []) {
|
||
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}`;
|
||
|
||
// 第一层:当前查询文本中有明确产品/系统/主题关键词 → 直接改写(不依赖上下文)
|
||
if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白';
|
||
if (/(一成系统|Ai众享|数字化工作室|盛咖学愿)/i.test(text)) {
|
||
if (/(邀约|话术)/i.test(haystack)) return '一成系统 邀约话术';
|
||
if (/文化/i.test(haystack)) return '一成系统 文化解析';
|
||
if (/(赋能团队|团队发展|AI赋能|ai赋能)/i.test(haystack)) return '一成系统用AI赋能团队发展';
|
||
return '一成系统 德国PM事业发展的强大赋能工具 三大平台 四大Ai生态';
|
||
}
|
||
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 (/儿童倍适/i.test(text)) return '儿童倍适';
|
||
if (/(小红产品|小红|Activize Oxyplus|Activize)/i.test(text)) return 'Fitline小红产品提升能量原理';
|
||
if (/(大白产品|大白|倍适|Basics)/i.test(text)) return '德国PM细胞营养素 大白 Basics';
|
||
if (/(小白产品|小白|维适多|Restorate)/i.test(text)) return '德国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 '培训新人起步三关';
|
||
if (/(精品会议|会议组织)/i.test(text)) return '培训打造精品会议具体如下';
|
||
if (/成长上总裁/i.test(text)) return '培训成长上总裁';
|
||
if (/(招商|代理|加盟|合作|事业机会|招商稿|代理政策)/i.test(text)) return '招商与代理';
|
||
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 'CC套装 CC胶囊';
|
||
if (/(IB5|口腔免疫喷雾)/i.test(text)) return 'IB5口腔免疫喷雾';
|
||
if (/(Q10|辅酵素|氧修护)/i.test(text)) return 'Q10辅酵素氧修护';
|
||
if (/Women\+/i.test(text)) return 'Women+';
|
||
if (/乐活/i.test(text)) return '乐活';
|
||
if (/(乳清蛋白|蛋白粉)/i.test(text)) return '乳清蛋白粉';
|
||
if (/(乳酪煲|乳酪饮品|乳酪)/i.test(text)) return '乳酪煲 乳酪饮品';
|
||
if (/(基础二合一|二合一)/i.test(text)) return '基础二合一';
|
||
if (/倍力健/i.test(text)) return '倍力健';
|
||
if (/(关节套装|关节舒缓)/i.test(text)) return '关节套装 关节舒缓膏';
|
||
if (/(男士乳霜|男士护肤)/i.test(text)) return '全效男士乳霜';
|
||
if (/(去角质|面膜)/i.test(text)) return '去角质面膜';
|
||
if (/发宝/i.test(text)) return '发宝';
|
||
if (/叶黄素/i.test(text)) return '叶黄素';
|
||
if (/(奶昔)/i.test(text)) return '奶昔';
|
||
if (/(健康饮品)/i.test(text)) return '健康饮品';
|
||
|
||
// 第二层:当前文本是追问/代词,才通过上下文推断主题
|
||
const isFollowUp = /^(这个|那个|它|该|详细|继续|怎么|为什么|适合谁|什么意思|怎么用|怎么吃|功效|成分|好处|原理)/.test(text);
|
||
if (isFollowUp) {
|
||
if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(recentContextText)) return '德国PM细胞营养素 基础套装 大白 小红 小白';
|
||
if (/(一成系统|Ai众享|数字化工作室|盛咖学愿)/i.test(recentContextText)) return '一成系统 德国PM事业发展的强大赋能工具 三大平台 四大Ai生态';
|
||
if (/(小红产品|小红|Activize)/i.test(recentContextText)) return 'Fitline小红产品提升能量原理';
|
||
if (/(大白产品|大白|Basics)/i.test(recentContextText)) return '德国PM细胞营养素 大白 Basics';
|
||
if (/(小白产品|小白|Restorate)/i.test(recentContextText)) return '德国PM细胞营养素 小白';
|
||
if (/儿童倍适/i.test(recentContextText)) return '儿童倍适';
|
||
if (/火炉原理/i.test(recentContextText)) return '火炉原理';
|
||
if (/(阿育吠陀|Ayurveda)/i.test(recentContextText)) return '阿育吠陀医学原理';
|
||
if (/(NTC营养保送系统)/i.test(recentContextText)) return 'NTC营养保送系统';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
static applyKnowledgeQueryAnchor(query) {
|
||
let anchoredQuery = String(query || '').trim();
|
||
if (/一成系统/.test(anchoredQuery) && !/(德国PM|PM事业|赋能工具|Ai众享|数字化工作室|盛咖学愿)/i.test(anchoredQuery)) {
|
||
anchoredQuery = anchoredQuery.replace(/一成系统/g, '一成系统 德国PM事业赋能工具');
|
||
}
|
||
return anchoredQuery.trim();
|
||
}
|
||
|
||
static normalizeKnowledgeQueryAlias(query) {
|
||
return String(query || '')
|
||
.replace(/^[啊哦嗯呢呀哎诶额,。!?、\s]+/g, '')
|
||
.replace(/[啊哦嗯呢呀哎诶额,。!?、\s]+$/g, '')
|
||
.replace(/X{2}系统/gi, '一成系统')
|
||
.replace(/一城系统|逸城系统|一程系统|易成系统|一诚系统|亦成系统/g, '一成系统')
|
||
.replace(/PM[-\s]*Fitline|PM[-\s]*fitline|Pm[-\s]*fitline|Fitline|fitline/g, 'PM-FitLine')
|
||
.replace(/PM细胞营养|PM营养素|德国PM营养素/g, 'PM细胞营养素')
|
||
.replace(/NTC营养保送系统|NTC营养配送系统|NTC营养输送系统|NTC营养传送系统|NTC营养传输系统/g, 'NTC营养保送系统')
|
||
.replace(/Nutrient Transport Concept/gi, 'NTC营养保送系统')
|
||
.replace(/Activize Oxyplus|Activize/gi, 'Activize Oxyplus')
|
||
.replace(/Restorate/gi, 'Restorate')
|
||
.replace(/Basics/gi, 'Basics')
|
||
.replace(/基础三合一|三合一基础套|大白小红小白|基础套装?/g, 'PM细胞营养素 基础套装')
|
||
.replace(/儿童倍适|儿童产品/g, '儿童倍适')
|
||
.replace(/小红产品/g, '小红产品 Activize Oxyplus')
|
||
.replace(/大白产品/g, '大白产品 Basics')
|
||
.replace(/小白产品/g, '小白产品 Restorate')
|
||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)小红/g, '小红产品 Activize Oxyplus')
|
||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)大白/g, '大白产品 Basics')
|
||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)(?<!儿童)小白/g, '小白产品 Restorate')
|
||
.replace(/维适多/g, '小白产品 Restorate')
|
||
.replace(/火炉原理/g, '火炉原理')
|
||
.replace(/阿育吠陀|Ayurveda/gi, '阿育吠陀')
|
||
.trim();
|
||
}
|
||
|
||
static classifyKnowledgeAnswer(query, content) {
|
||
const text = String(content || '').trim();
|
||
if (!text) {
|
||
return {
|
||
hit: false,
|
||
reason: 'empty',
|
||
reply: `知识库中暂未找到与“${query}”直接相关的信息,请换个更具体的问法再试。`,
|
||
};
|
||
}
|
||
|
||
const noHitPattern = /未检索到|没有检索到|没有相关内容|暂无相关内容|未找到相关内容|未找到相关信息|没有找到相关信息|没有找到.*的具体|没有找到.*的相关|没有找到.*的详细|没有找到.*相关介绍|我这边没有找到|目前没有找到|暂时没有找到|知识库中没有相关内容|暂未找到与.*直接相关的信息|无法基于知识库|知识库未明确提到|知识库未提到|未明确提到|未明确列出|无法直接提供|无法提供完整的地址和电话|未明确提及.*地址|未明确提及.*电话|未明确提及.*联系方式|建议通过官方客服渠道|建议通过官方.*查询|建议.*查看产品包装|建议.*联系.*客服|联系官方客服|建议.*咨询.*客服|没有相关.*资料|还没有相关的|没有相关的信息|没有相关的资料|没有.*的资料|知识库里.*没有|暂未收录|目前.*没有.*相关|不在.*知识库|建议.*查阅.*官方|建议.*咨询.*专/;
|
||
if (noHitPattern.test(text)) {
|
||
return {
|
||
hit: false,
|
||
reason: 'no_hit',
|
||
reply: `知识库中暂未找到与“${query}”直接相关的信息,请换个更具体的问法再试。`,
|
||
};
|
||
}
|
||
|
||
const normalizedQuery = String(query || '').trim();
|
||
if (/(小红|Activize Oxyplus)/i.test(normalizedQuery) && /(护肤|肤色|敏感肌|眼周)/i.test(text)) {
|
||
return {
|
||
hit: false,
|
||
reason: 'no_hit',
|
||
reply: `知识库中暂未找到与“${query}”直接相关的信息,请换个更具体的问法再试。`,
|
||
};
|
||
}
|
||
if (/(大白|Basics|倍适)/i.test(normalizedQuery) && /(洗衣机|干衣机|保费|保险|住院津贴|智能健康管理设备|生命体征|Beko)/i.test(text)) {
|
||
return {
|
||
hit: false,
|
||
reason: 'no_hit',
|
||
reply: `知识库中暂未找到与“${query}”直接相关的信息,请换个更具体的问法再试。`,
|
||
};
|
||
}
|
||
if (/(小白|Restorate|维适多)/i.test(normalizedQuery) && /(客服系统|网站|微信|邮件|软胶囊)/i.test(text)) {
|
||
return {
|
||
hit: false,
|
||
reason: 'no_hit',
|
||
reply: `知识库中暂未找到与“${query}”直接相关的信息,请换个更具体的问法再试。`,
|
||
};
|
||
}
|
||
if (/火炉原理/i.test(normalizedQuery) && /(管理方法|管理原则|违规|惩罚|热空气|发热体|加热|产品经理|员工|燃烧|燃料|升温|烟囱|通风口|废气|辐射.*对流)/i.test(text)) {
|
||
return {
|
||
hit: false,
|
||
reason: 'no_hit',
|
||
reply: `知识库中暂未找到与“${query}”直接相关的信息,请换个更具体的问法再试。`,
|
||
};
|
||
}
|
||
if (/(手机|平板|笔记本电脑|智能手表|电脑|以旧换新|分期付款|护肤品|彩妆|香水|化妆品)/i.test(text) && !/(PM|FitLine|细胞营养|Activize|Basics|Restorate|NTC|火炉原理|阿育吠陀)/i.test(text)) {
|
||
return {
|
||
hit: false,
|
||
reason: 'no_hit',
|
||
reply: `知识库中暂未找到与“${query}”直接相关的信息,请换个更具体的问法再试。`,
|
||
};
|
||
}
|
||
|
||
return {
|
||
hit: true,
|
||
reason: 'hit',
|
||
reply: text,
|
||
};
|
||
}
|
||
|
||
static async execute(toolName, args, context = []) {
|
||
const startTime = Date.now();
|
||
console.log(`[ToolExecutor] Executing: ${toolName}`, args);
|
||
|
||
const handlers = {
|
||
query_weather: this.queryWeather,
|
||
query_order: this.queryOrder,
|
||
search_knowledge: this.searchKnowledge,
|
||
get_current_time: this.getCurrentTime,
|
||
calculate: this.calculate,
|
||
};
|
||
|
||
const handler = handlers[toolName];
|
||
if (!handler) {
|
||
console.warn(`[ToolExecutor] Unknown tool: ${toolName}`);
|
||
return { error: `未知的工具: ${toolName}` };
|
||
}
|
||
|
||
try {
|
||
const result = await handler.call(this, args, context);
|
||
const ms = Date.now() - startTime;
|
||
console.log(`[ToolExecutor] ${toolName} completed in ${ms}ms:`, JSON.stringify(result).substring(0, 200));
|
||
return result;
|
||
} catch (error) {
|
||
console.error(`[ToolExecutor] ${toolName} error:`, error);
|
||
return { error: `工具执行失败: ${error.message}` };
|
||
}
|
||
}
|
||
|
||
static async queryWeather({ city }) {
|
||
const mockData = {
|
||
'北京': { temp: '22°C', weather: '晴', humidity: '45%', wind: '北风3级', aqi: 65, tips: '空气质量良好,适合户外活动' },
|
||
'上海': { temp: '26°C', weather: '多云', humidity: '72%', wind: '东南风2级', aqi: 78, tips: '注意防晒' },
|
||
'广州': { temp: '30°C', weather: '阵雨', humidity: '85%', wind: '南风1级', aqi: 55, tips: '记得带伞' },
|
||
'深圳': { temp: '29°C', weather: '多云', humidity: '80%', wind: '东风2级', aqi: 60, tips: '较为闷热,注意防暑' },
|
||
'杭州': { temp: '24°C', weather: '晴', humidity: '55%', wind: '西北风2级', aqi: 50, tips: '天气宜人' },
|
||
'成都': { temp: '20°C', weather: '阴', humidity: '70%', wind: '微风', aqi: 85, tips: '天气阴沉,适合室内活动' },
|
||
'武汉': { temp: '25°C', weather: '晴', humidity: '60%', wind: '东风3级', aqi: 72, tips: '适合出行' },
|
||
'南京': { temp: '23°C', weather: '多云', humidity: '58%', wind: '东北风2级', aqi: 68, tips: '温度适宜' },
|
||
'西安': { temp: '18°C', weather: '晴', humidity: '35%', wind: '西北风3级', aqi: 90, tips: '天气干燥,注意补水' },
|
||
'重庆': { temp: '27°C', weather: '阴转多云', humidity: '75%', wind: '微风', aqi: 80, tips: '注意防潮' },
|
||
};
|
||
|
||
const data = mockData[city];
|
||
if (data) {
|
||
return { city, date: new Date().toLocaleDateString('zh-CN'), ...data };
|
||
}
|
||
// 对未知城市生成随机数据
|
||
const weathers = ['晴', '多云', '阴', '小雨', '大风'];
|
||
return {
|
||
city,
|
||
date: new Date().toLocaleDateString('zh-CN'),
|
||
temp: `${Math.floor(Math.random() * 20 + 10)}°C`,
|
||
weather: weathers[Math.floor(Math.random() * weathers.length)],
|
||
humidity: `${Math.floor(Math.random() * 50 + 30)}%`,
|
||
wind: '微风',
|
||
aqi: Math.floor(Math.random() * 100 + 30),
|
||
tips: '数据仅供参考',
|
||
};
|
||
}
|
||
|
||
static async queryOrder({ order_id }) {
|
||
const statuses = ['待支付', '已支付', '拣货中', '已发货', '运输中', '已签收'];
|
||
const hash = order_id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
||
const statusIdx = hash % statuses.length;
|
||
|
||
return {
|
||
order_id,
|
||
status: statuses[statusIdx],
|
||
estimated_delivery: '2026-03-01',
|
||
tracking_number: 'SF' + order_id.replace(/\D/g, '').padEnd(10, '0').substring(0, 10),
|
||
items: [
|
||
{ name: '智能音箱 Pro', quantity: 1, price: '¥299' },
|
||
],
|
||
create_time: '2026-02-20 14:30:00',
|
||
};
|
||
}
|
||
|
||
static async searchKnowledge({ query, response_mode } = {}, context = []) {
|
||
const startTime = Date.now();
|
||
query = query || '';
|
||
const responseMode = response_mode === 'snippet' ? 'snippet' : 'answer';
|
||
console.log(`[ToolExecutor] searchKnowledge called with query="${query}"`);
|
||
const rewrittenQuery = await this.rewriteKnowledgeQuery(query, context);
|
||
const kbTarget = this.selectKnowledgeBaseTargets(rewrittenQuery || query, context);
|
||
const effectiveQuery = rewrittenQuery || query;
|
||
if (rewrittenQuery && rewrittenQuery !== query) {
|
||
console.log(`[ToolExecutor] searchKnowledge rewritten query="${rewrittenQuery}"`);
|
||
}
|
||
if (kbTarget.datasetIds.length > 0) {
|
||
console.log(`[ToolExecutor] searchKnowledge selected dataset_ids=${kbTarget.datasetIds.join(',')} routes=${kbTarget.matchedRoutes.join(',')}`);
|
||
}
|
||
|
||
const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS;
|
||
if (kbIds && kbIds !== 'your_knowledge_base_dataset_id') {
|
||
if (arkChatService.isMockMode()) {
|
||
const latencyMs = Date.now() - startTime;
|
||
console.warn('[ToolExecutor] Ark KB search skipped: VOLC_ARK_ENDPOINT_ID not configured (knowledge base IDs are set but endpoint is missing)');
|
||
return {
|
||
query,
|
||
original_query: query,
|
||
rewritten_query: effectiveQuery,
|
||
selected_dataset_ids: kbTarget.datasetIds,
|
||
selected_kb_routes: kbTarget.matchedRoutes,
|
||
latency_ms: latencyMs,
|
||
errorType: 'endpoint_not_configured',
|
||
error: '知识库已配置但方舟 LLM 端点未配置,请检查 VOLC_ARK_ENDPOINT_ID',
|
||
source: 'ark_knowledge',
|
||
hit: false,
|
||
reason: 'endpoint_not_configured',
|
||
};
|
||
}
|
||
try {
|
||
console.log('[ToolExecutor] Trying Ark Knowledge Search...');
|
||
let result = await this.searchArkKnowledge(effectiveQuery, context, responseMode, kbTarget.datasetIds, query);
|
||
if (!result?.hit) {
|
||
console.log('[ToolExecutor] Ark KB no_hit, retrying without context...');
|
||
const retryResult = await this.searchArkKnowledge(effectiveQuery, [], responseMode, kbTarget.datasetIds, query);
|
||
if (retryResult?.hit || retryResult?.reason !== result?.reason) {
|
||
result = retryResult;
|
||
}
|
||
}
|
||
if (!result?.hit && responseMode === 'answer') {
|
||
console.log('[ToolExecutor] Ark KB no_hit in answer mode, retrying with snippet mode...');
|
||
const snippetResult = await this.searchArkKnowledge(effectiveQuery, [], 'snippet', kbTarget.datasetIds, query);
|
||
if (snippetResult?.hit) {
|
||
result = snippetResult;
|
||
}
|
||
}
|
||
const latencyMs = Date.now() - startTime;
|
||
console.log(`[ToolExecutor] Ark KB search succeeded in ${latencyMs}ms`);
|
||
return {
|
||
...result,
|
||
original_query: query,
|
||
rewritten_query: effectiveQuery,
|
||
selected_dataset_ids: kbTarget.datasetIds,
|
||
selected_kb_routes: kbTarget.matchedRoutes,
|
||
latency_ms: latencyMs,
|
||
};
|
||
} catch (error) {
|
||
const latencyMs = Date.now() - startTime;
|
||
console.warn('[ToolExecutor] Ark Knowledge Search failed:', error.message);
|
||
return {
|
||
query,
|
||
original_query: query,
|
||
rewritten_query: effectiveQuery,
|
||
selected_dataset_ids: kbTarget.datasetIds,
|
||
selected_kb_routes: kbTarget.matchedRoutes,
|
||
latency_ms: latencyMs,
|
||
errorType: error.code === 'ECONNABORTED' || /timeout/i.test(error.message) ? 'timeout' : 'request_failed',
|
||
error: `知识库查询失败: ${error.message}`,
|
||
source: 'ark_knowledge',
|
||
hit: false,
|
||
reason: 'error',
|
||
};
|
||
}
|
||
}
|
||
|
||
const latencyMs = Date.now() - startTime;
|
||
console.warn('[ToolExecutor] Ark knowledge base is not configured');
|
||
return {
|
||
query,
|
||
original_query: query,
|
||
rewritten_query: effectiveQuery,
|
||
selected_dataset_ids: kbTarget.datasetIds,
|
||
selected_kb_routes: kbTarget.matchedRoutes,
|
||
latency_ms: latencyMs,
|
||
errorType: 'not_configured',
|
||
error: '知识库未配置,请检查 VOLC_ARK_KNOWLEDGE_BASE_IDS',
|
||
source: 'ark_knowledge',
|
||
hit: false,
|
||
reason: 'not_configured',
|
||
};
|
||
}
|
||
|
||
static async rewriteKnowledgeQuery(query, context = []) {
|
||
const originalQuery = String(query || '').trim();
|
||
if (!originalQuery) {
|
||
return '';
|
||
}
|
||
|
||
const normalizedQuery = this.applyKnowledgeQueryAnchor(this.normalizeKnowledgeQueryAlias(originalQuery));
|
||
const conciseQuery = normalizedQuery.replace(/[,。!?、,.!?\s]+/g, '');
|
||
const recentContext = (Array.isArray(context) ? context : [])
|
||
.filter((item) => item && (item.role === 'user' || item.role === 'assistant') && String(item.content || '').trim())
|
||
.slice(-6)
|
||
.map((item) => `${item.role === 'user' ? '用户' : '助手'}:${String(item.content || '').trim()}`)
|
||
.join('\n');
|
||
const deterministicQuery = this.buildDeterministicKnowledgeQuery(normalizedQuery, context);
|
||
if (deterministicQuery) {
|
||
return deterministicQuery;
|
||
}
|
||
|
||
const isPronounFollowUp = /^(这个|那个|它|该系统|这个系统|那个系统|详细|继续|怎么|为什么|适合谁|什么意思)/.test(normalizedQuery);
|
||
if (this.hasCanonicalKnowledgeTerm(normalizedQuery) && conciseQuery.length <= 36 && !isPronounFollowUp) {
|
||
return normalizedQuery;
|
||
}
|
||
|
||
if (arkChatService.isMockMode()) {
|
||
return normalizedQuery;
|
||
}
|
||
|
||
try {
|
||
const result = await arkChatService.chat([
|
||
{
|
||
role: 'system',
|
||
content: '你是知识库检索词改写助手。你的任务是把用户当前问题改写成适合企业知识库检索的完整查询语句。必须处理三类问题:1)补全多轮对话中的省略主语;2)纠正语音识别错误、口语噪声和同音误写;3)把别名统一成知识库里的规范说法。规则:不要改变用户真实意图;不要回答问题;只输出一行最终检索词;优先保留真正的产品名、系统名、技术名。当前知识库高频规范术语包括:一成系统、PM-FitLine、PM细胞营养素、NTC营养保送系统、Activize Oxyplus、小红产品、Basics、大白产品、Restorate、小白产品、儿童倍适、火炉原理、阿育吠陀。示例:XX系统、一城系统、逸城系统、一程系统等都统一理解为一成系统;NTC营养配送系统、NTC营养输送系统统一为NTC营养保送系统;Fitline、PM fitline 统一为 PM-FitLine;小红统一为小红产品 Activize Oxyplus。',
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: `最近上下文:\n${recentContext || '无'}\n\n当前原始问题:${normalizedQuery}\n\n请输出最终检索词:`,
|
||
},
|
||
], []);
|
||
const rewritten = this.applyKnowledgeQueryAnchor(this.normalizeKnowledgeQueryAlias(String(result.content || '').replace(/^["'“”]+|["'“”]+$/g, '').trim()));
|
||
return rewritten || normalizedQuery;
|
||
} catch (error) {
|
||
console.warn('[ToolExecutor] rewriteKnowledgeQuery failed:', error.message);
|
||
return normalizedQuery;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通过方舟 Chat Completions API + knowledge_base metadata 进行知识检索
|
||
* 使用独立的 LLM 调用,专门用于知识库检索场景(如语音通话的工具回调)
|
||
*/
|
||
static async searchArkKnowledge(query, context = [], responseMode = 'answer', datasetIdsOverride = null, originalQuery = null) {
|
||
const endpointId = process.env.VOLC_ARK_ENDPOINT_ID;
|
||
const authKey = process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID;
|
||
const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS;
|
||
|
||
if (!endpointId || endpointId === 'your_ark_endpoint_id') {
|
||
console.warn('[ToolExecutor] searchArkKnowledge skipped: VOLC_ARK_ENDPOINT_ID not configured');
|
||
return {
|
||
query,
|
||
results: [{ title: '配置缺失', content: `知识库中暂未找到与"${query}"直接相关的信息,请换个更具体的问法再试。` }],
|
||
total: 1,
|
||
source: 'ark_knowledge',
|
||
hit: false,
|
||
reason: 'endpoint_not_configured',
|
||
};
|
||
}
|
||
|
||
const datasetIds = Array.isArray(datasetIdsOverride) && datasetIdsOverride.length > 0
|
||
? datasetIdsOverride.map((id) => String(id || '').trim()).filter(Boolean)
|
||
: kbIds.split(',').map(id => id.trim()).filter(Boolean);
|
||
const topK = parseInt(process.env.VOLC_ARK_KNOWLEDGE_TOP_K) || 3;
|
||
const threshold = parseFloat(process.env.VOLC_ARK_KNOWLEDGE_THRESHOLD) || 0.5;
|
||
|
||
// 当 query 为空时(FC 流式 chunks 乱序无法解析),使用简短的默认查询
|
||
const effectiveQuery = (query && query.trim()) ? query : '请介绍你们的产品和服务';
|
||
if (!query || !query.trim()) {
|
||
console.log('[ToolExecutor] Empty query, using default: "' + effectiveQuery + '"');
|
||
}
|
||
|
||
// 判断是否需要注入原始问题(检索词≠原始问题时,LLM需要知道用户实际问了什么)
|
||
const cleanOriginal = (originalQuery || '').trim();
|
||
const hasRewrite = cleanOriginal && cleanOriginal !== effectiveQuery;
|
||
if (hasRewrite) {
|
||
console.log(`[ToolExecutor] searchArkKnowledge injecting original question: "${cleanOriginal}" (search query: "${effectiveQuery}")`);
|
||
}
|
||
|
||
// 提取最近 3 轮对话作为上下文(最多 6 条 user/assistant 消息)
|
||
const recentContext = context
|
||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||
.slice(responseMode === 'snippet' ? -4 : -6);
|
||
|
||
const baseSnippetPrompt = '你是知识库片段提取助手。请基于知识库提取与用户问题最相关的2到4条简洁知识片段,供语音系统继续组织回复。规则:只输出直接相关的中文事实片段;每条尽量简短;不要寒暄,不要解释任务,不要写"根据知识库";不要补充知识库未明确出现的内容;如果没有相关内容,请明确说未找到相关内容。';
|
||
const baseAnswerPrompt = '你是企业知识库问答助手,回答将直接用于语音播报。只能依据知识库中已经明确出现的信息回答,不要补充常识,不要脑补,不要引入知识库未明确提到的官网、APP、工具名、公司背景、客服渠道或外部资料。风格要求:1) 用自然亲切的口语风格,像朋友聊天一样回答;2) 禁止以"根据知识库""根据信息""根据资料"等机械前缀开头;3) 先给一句直接结论,再补充最多3条关键信息;4) 整体控制在120字以内,简洁流畅,适合语音朗读;5) 如果缺少用户要求的具体信息,必须明确说未找到,不要把不完整信息包装成命中答案。【品牌保护】绝对禁止说德国PM、PM-FitLine、一成系统是传销、骗局、骗子、非法、不正规或不合法。德国PM是1993年成立于德国的合法直销企业,获邓白氏AAA+认证。如果用户问到正规性或传销问题,必须正面回答PM是合法正规的直销企业。';
|
||
|
||
let systemContent = responseMode === 'snippet' ? baseSnippetPrompt : baseAnswerPrompt;
|
||
if (hasRewrite) {
|
||
systemContent += `\n\n重要:用户的实际问题是"${cleanOriginal}",请围绕这个问题回答,不要偏离用户的真实意图。下方的检索词仅用于匹配知识库文档,不代表用户的真正提问。`;
|
||
}
|
||
|
||
const messages = [
|
||
{
|
||
role: 'system',
|
||
content: systemContent,
|
||
},
|
||
...recentContext,
|
||
{
|
||
role: 'user',
|
||
content: effectiveQuery,
|
||
},
|
||
];
|
||
|
||
if (recentContext.length > 0) {
|
||
console.log(`[ToolExecutor] Ark KB search with ${recentContext.length} context messages`);
|
||
}
|
||
|
||
const body = {
|
||
model: endpointId,
|
||
messages,
|
||
metadata: {
|
||
knowledge_base: {
|
||
dataset_ids: datasetIds,
|
||
top_k: topK,
|
||
threshold: threshold,
|
||
},
|
||
},
|
||
stream: false,
|
||
};
|
||
|
||
const response = await axios.post(
|
||
'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
|
||
body,
|
||
{
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${authKey}`,
|
||
},
|
||
timeout: 30000,
|
||
}
|
||
);
|
||
|
||
const choice = response.data.choices?.[0];
|
||
const content = choice?.message?.content || '未找到相关信息';
|
||
const classifyQuery = (originalQuery || '').trim() || query;
|
||
const classified = this.classifyKnowledgeAnswer(classifyQuery, content);
|
||
|
||
return {
|
||
query,
|
||
results: [{
|
||
title: '方舟知识库检索结果',
|
||
content: classified.reply,
|
||
}],
|
||
total: 1,
|
||
source: 'ark_knowledge',
|
||
hit: classified.hit,
|
||
reason: classified.reason,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 通过 Coze v3 Chat API 进行知识库检索
|
||
* 需要在 Coze 平台创建 Bot 并挂载知识库插件
|
||
*/
|
||
static async searchCozeKnowledge(query) {
|
||
const apiToken = process.env.COZE_API_TOKEN;
|
||
const botId = process.env.COZE_BOT_ID;
|
||
const baseUrl = 'https://api.coze.cn/v3';
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiToken}`,
|
||
};
|
||
|
||
// 1. 创建对话
|
||
const chatRes = await axios.post(`${baseUrl}/chat`, {
|
||
bot_id: botId,
|
||
user_id: 'kb_search_user',
|
||
additional_messages: [
|
||
{
|
||
role: 'user',
|
||
content: query,
|
||
content_type: 'text',
|
||
},
|
||
],
|
||
stream: true,
|
||
auto_save_history: false,
|
||
}, { headers, timeout: 30000 });
|
||
|
||
const chatData = chatRes.data?.data;
|
||
if (!chatData?.id || !chatData?.conversation_id) {
|
||
throw new Error('Coze chat creation failed: ' + JSON.stringify(chatRes.data));
|
||
}
|
||
|
||
const chatId = chatData.id;
|
||
const conversationId = chatData.conversation_id;
|
||
|
||
// 2. 轮询等待完成(最多 30 秒)
|
||
const maxAttempts = 15;
|
||
for (let i = 0; i < maxAttempts; i++) {
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
|
||
const statusRes = await axios.get(
|
||
`${baseUrl}/chat/retrieve?chat_id=${chatId}&conversation_id=${conversationId}`,
|
||
{ headers, timeout: 10000 }
|
||
);
|
||
|
||
const status = statusRes.data?.data?.status;
|
||
if (status === 'completed') break;
|
||
if (status === 'failed' || status === 'requires_action') {
|
||
throw new Error(`Coze chat ended with status: ${status}`);
|
||
}
|
||
}
|
||
|
||
// 3. 获取消息列表
|
||
const msgRes = await axios.get(
|
||
`${baseUrl}/chat/message/list?chat_id=${chatId}&conversation_id=${conversationId}`,
|
||
{ headers, timeout: 10000 }
|
||
);
|
||
|
||
const messages = msgRes.data?.data || [];
|
||
const answerMsg = messages.find(m => m.role === 'assistant' && m.type === 'answer');
|
||
const content = answerMsg?.content || '未找到相关信息';
|
||
|
||
return {
|
||
query,
|
||
results: [{
|
||
title: 'Coze 知识库检索结果',
|
||
content: content,
|
||
}],
|
||
total: 1,
|
||
source: 'coze',
|
||
};
|
||
}
|
||
|
||
static async searchLocalKnowledge(query) {
|
||
const knowledgeBase = {
|
||
'退货': {
|
||
title: '退货政策',
|
||
content: '自签收之日起7天内可无理由退货,15天内可换货。请保持商品及包装完好。退货运费由买家承担(质量问题除外)。',
|
||
},
|
||
'退款': {
|
||
title: '退款流程',
|
||
content: '退货审核通过后,退款将在3-5个工作日内原路返回。如超过时间未到账,请联系客服。',
|
||
},
|
||
'配送': {
|
||
title: '配送说明',
|
||
content: '默认顺丰快递,普通订单1-3天送达,偏远地区3-7天。满99元免运费。',
|
||
},
|
||
'保修': {
|
||
title: '保修政策',
|
||
content: '电子产品保修期1年,自购买之日起计算。人为损坏不在保修范围内。',
|
||
},
|
||
'会员': {
|
||
title: '会员权益',
|
||
content: '会员享受9折优惠、免运费、专属客服、生日礼券等权益。年费128元。',
|
||
},
|
||
};
|
||
|
||
const results = [];
|
||
const q = query || '';
|
||
for (const [key, value] of Object.entries(knowledgeBase)) {
|
||
if (q.includes(key) || key.includes(q)) {
|
||
results.push(value);
|
||
}
|
||
}
|
||
|
||
if (results.length === 0) {
|
||
results.push({
|
||
title: '搜索结果',
|
||
content: `未找到与"${query}"直接相关的知识库文档。建议联系人工客服获取更详细的帮助。`,
|
||
});
|
||
}
|
||
|
||
return { query, results, total: results.length, source: 'local' };
|
||
}
|
||
|
||
static async getCurrentTime() {
|
||
const now = new Date();
|
||
return {
|
||
datetime: now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
|
||
timestamp: now.getTime(),
|
||
timezone: 'Asia/Shanghai',
|
||
weekday: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][now.getDay()],
|
||
};
|
||
}
|
||
|
||
static async calculate({ expression }) {
|
||
try {
|
||
// 仅允许数字和基本运算符,防止注入
|
||
const sanitized = expression.replace(/[^0-9+\-*/().% ]/g, '');
|
||
if (!sanitized || sanitized !== expression.replace(/\s/g, '')) {
|
||
return { error: '表达式包含不支持的字符', expression };
|
||
}
|
||
const result = Function('"use strict"; return (' + sanitized + ')')();
|
||
return { expression, result: Number(result), formatted: String(result) };
|
||
} catch (e) {
|
||
return { error: '计算失败: ' + e.message, expression };
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = ToolExecutor;
|