perf(bridge): 商品检索改用短关键词并恢复分阶段计时
This commit is contained in:
+29
-7
@@ -24,6 +24,7 @@ const { createEscalation } = require('./escalation');
|
||||
const { createExperimentManager } = require('./experiments');
|
||||
const { createTraceWriter } = require('./trace-writer');
|
||||
const { extractLeadFields } = require('./lead-extract');
|
||||
const { extractProductKeyword } = require('./product-query');
|
||||
const crypto = require('crypto');
|
||||
const prom = require('./prom');
|
||||
|
||||
@@ -107,7 +108,7 @@ function createAgent(cfg, deps = {}) {
|
||||
async function runOnce(conv, webhookMessage) {
|
||||
if (!llm.enabled) throw new Error('LLM is not configured');
|
||||
const startedAt = Date.now();
|
||||
const stats = { llmCalls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, toolCalls: [], toolResults: [] };
|
||||
const stats = { llmCalls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, toolCalls: [], toolResults: [], llmMs: 0, toolMs: 0 };
|
||||
const traceSteps = [];
|
||||
const traceId = `${conv.uuid}:${webhookMessage.uuid || ''}`;
|
||||
const profile = resolveSiteProfile(cfg, { inboxId: String(conv.inbox_id || '') });
|
||||
@@ -157,6 +158,8 @@ function createAgent(cfg, deps = {}) {
|
||||
|
||||
// 预检索:政策问题先查 KB,商品问题先查商品,避免模型跳过工具直接编。
|
||||
const intent = quickClassify(currentText);
|
||||
// 商品预检索/强制检索只发清洗后的短关键词(旧 bridge 思路):原文含数量/货币会让商品 RPC 慢 10 倍以上。
|
||||
const productKeyword = intent === INTENTS.PRODUCT_SEARCH ? extractProductKeyword(currentText) : '';
|
||||
const emotion = detectEmotion(currentText);
|
||||
const emotionStrategy = escalation.applyEmotionStrategy(emotion);
|
||||
const maxSteps = emotionStrategy.maxToolTurns || MAX_TOOL_STEPS;
|
||||
@@ -171,11 +174,13 @@ function createAgent(cfg, deps = {}) {
|
||||
let preResult = null;
|
||||
try {
|
||||
let preTool = null;
|
||||
// 商品意图清洗后没有可用商品词(如纯数量/纯问候)→ 跳过预检索,交给 LLM 决定
|
||||
if (intent === INTENTS.POLICY_FAQ) preTool = byName.get('search_kb');
|
||||
else if (intent === INTENTS.PRODUCT_SEARCH) preTool = byName.get('search_products');
|
||||
else if (intent === INTENTS.PRODUCT_SEARCH && productKeyword) preTool = byName.get('search_products');
|
||||
if (preTool) {
|
||||
const started = Date.now();
|
||||
preResult = await preTool.handler({ query: currentText, limit: 5 }, ctx);
|
||||
preResult = await preTool.handler({ query: preTool.name === 'search_products' ? productKeyword : currentText, limit: 5 }, ctx);
|
||||
stats.toolMs += Date.now() - started;
|
||||
stats.toolCalls.push({ name: preTool.name, ms: Date.now() - started, ok: true, forced: true });
|
||||
stats.toolResults.push(preResult);
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'tool', tool: preTool.name, forced: true, ms: Date.now() - started, conversation: conv.uuid, ok: true }));
|
||||
@@ -229,6 +234,7 @@ function createAgent(cfg, deps = {}) {
|
||||
maxTokens: (variant.overriddenParams && variant.overriddenParams.maxTokens) ?? 1200,
|
||||
});
|
||||
stats.llmCalls += 1;
|
||||
stats.llmMs += Date.now() - llmStarted;
|
||||
traceSteps.push({ step_type: 'llm', turn: step, model: llm.model, prompt_tokens: response.usage?.prompt_tokens, completion_tokens: response.usage?.completion_tokens, duration_ms: Date.now() - llmStarted, finish_reason: response.finishReason || null });
|
||||
if (response.usage) {
|
||||
stats.promptTokens += Number(response.usage.prompt_tokens) || 0;
|
||||
@@ -237,16 +243,17 @@ function createAgent(cfg, deps = {}) {
|
||||
}
|
||||
// 兜底(旧 bridge 同款):product_search 意图下首轮 LLM 一个工具都没调时,
|
||||
// 丢弃它凭记忆编的回答,强制查一次真实商品结果再让它重答,避免幻觉商品/链接进入回复。
|
||||
if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && !stats.toolCalls.length) {
|
||||
if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && productKeyword && !stats.toolCalls.length) {
|
||||
const searchTool = byName.get('search_products');
|
||||
if (searchTool) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const forced = await searchTool.handler({ query: currentText, limit: 5 }, ctx);
|
||||
const forced = await searchTool.handler({ query: productKeyword, limit: 5 }, ctx);
|
||||
stats.toolMs += Date.now() - started;
|
||||
stats.toolCalls.push({ name: 'search_products', ms: Date.now() - started, ok: true, forced: true });
|
||||
stats.toolResults.push(forced);
|
||||
traceSteps.push({ step_type: 'tool', turn: step, tool_name: 'search_products', duration_ms: Date.now() - started });
|
||||
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'forced-search-products', type: 'function', function: { name: 'search_products', arguments: JSON.stringify({ query: currentText, limit: 5 }) } }] });
|
||||
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'forced-search-products', type: 'function', function: { name: 'search_products', arguments: JSON.stringify({ query: productKeyword, limit: 5 }) } }] });
|
||||
messages.push({ role: 'tool', tool_call_id: 'forced-search-products', content: markToolResult('search_products', JSON.stringify(forced).slice(0, 6000)) });
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'forced_search_products', conversation: conv.uuid, ok: true }));
|
||||
continue;
|
||||
@@ -274,6 +281,7 @@ function createAgent(cfg, deps = {}) {
|
||||
result = { error: err.code || err.message || 'tool_failed', message: err.message || String(err) };
|
||||
}
|
||||
prom.inc('bridge_ai_tool_calls_total', { tool: name, ok: !(result && result.error) });
|
||||
stats.toolMs += Date.now() - started;
|
||||
traceSteps.push({ step_type: 'tool', turn: step, tool_name: name, duration_ms: Date.now() - started });
|
||||
stats.toolCalls.push({ name, ms: Date.now() - started, ok: !(result && result.error) });
|
||||
stats.toolResults.push(result);
|
||||
@@ -512,13 +520,16 @@ function createAgent(cfg, deps = {}) {
|
||||
audit.uncovered_entities = citation.uncovered_entities;
|
||||
audit.lead_followup = leadFollowup;
|
||||
audit.output_guard = { blocked: guard.blocked, rewritten: guard.rewritten, reasons: guard.reasons, removed_urls: guard.removed_urls, evidence_missing: guard.evidence_missing };
|
||||
// 分阶段耗时(对齐旧 bridge 的 llmMs/toolMs):写入 audit(jsonb,无 DDL)并打一行日志
|
||||
audit.timing = { llm_ms: stats.llmMs, tool_ms: stats.toolMs };
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
await metrics.record({
|
||||
conversationUuid: conv.uuid,
|
||||
inboxId: conv.inbox_id,
|
||||
channel: conv.channel || 'livechat',
|
||||
verified: ctx.verified,
|
||||
model: llm.model,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
latencyMs,
|
||||
llmCalls: stats.llmCalls,
|
||||
promptTokens: stats.promptTokens,
|
||||
completionTokens: stats.completionTokens,
|
||||
@@ -529,6 +540,17 @@ function createAgent(cfg, deps = {}) {
|
||||
});
|
||||
prom.inc('bridge_ai_runs_total');
|
||||
await traceWriter.writeSteps(traceSteps, traceId, conv.uuid).catch(() => {});
|
||||
console.log(JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
agent: 'metrics',
|
||||
conversation: conv.uuid,
|
||||
intent,
|
||||
latency_ms: latencyMs,
|
||||
llm_ms: stats.llmMs,
|
||||
tool_ms: stats.toolMs,
|
||||
llm_calls: stats.llmCalls,
|
||||
reply_length: finalContent.length,
|
||||
}));
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'replied', conversation: conv.uuid, length: finalContent.length, audit }));
|
||||
return finalContent;
|
||||
}
|
||||
|
||||
@@ -39,4 +39,73 @@ function translateProductQuery(query, entries) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { translateProductQuery, normalize };
|
||||
// ---- 商品关键词清洗(对齐旧 bridge:不把客户整句原文丢给商品 RPC)----
|
||||
// 商品 RPC(/product/list、/product_new/autocomplete)对含数量/货币的长句极慢
|
||||
// (实测 "500 stainless steel rings wholesale" 18s vs "stainless steel rings" 0.6s),
|
||||
// 且结果会跑偏,所以调用前只保留 2-4 个商品/类目词。
|
||||
const STOPWORDS = new Set([
|
||||
'the', 'a', 'an', 'and', 'or', 'for', 'to', 'of', 'in', 'on', 'with', 'at', 'by',
|
||||
'do', 'does', 'did', 'is', 'are', 'was', 'be', 'am', 'how', 'what', 'which', 'who',
|
||||
'can', 'could', 'would', 'should', 'you', 'your', 'yours', 'my', 'me', 'i', 'we', 'us', 'it', 'its',
|
||||
'hi', 'hello', 'hey', 'please', 'thanks', 'thank', 'there', 'this', 'that', 'these', 'those',
|
||||
'have', 'has', 'had', 'interested', 'interest', 'any', 'some', 'also', 'about', 'from', 'get',
|
||||
]);
|
||||
|
||||
const TRANSACTION_WORDS = new Set([
|
||||
'buy', 'buying', 'purchase', 'purchasing', 'order', 'orders', 'ordering', 'wholesale', 'wholesaler',
|
||||
'bulk', 'supplier', 'suppliers', 'price', 'prices', 'pricing', 'cost', 'costs', 'cheap', 'cheapest',
|
||||
'deal', 'deals', 'discount', 'discounts', 'qty', 'quantity', 'quantities', 'moq', 'quote', 'quotation',
|
||||
'sample', 'samples', 'bestseller', 'bestsellers', 'trending', 'popular', 'hot', 'sale', 'catalog',
|
||||
'catalogue', 'browse', 'recommend', 'recommendation', 'recommendations', 'available', 'availability',
|
||||
'stock', 'under', 'below', 'above', 'over', 'between', 'less', 'more', 'than', 'each', 'per',
|
||||
'min', 'max', 'minimum', 'maximum', 'fast', 'delivery', 'shipping', 'ship', 'send', 'need', 'needed',
|
||||
'want', 'wanted', 'wanting', 'looking', 'find', 'search', 'show', 'tell', 'give', 'see', 'list',
|
||||
'good', 'best', 'top', 'new', 'sale', 'fashion', 'style', 'cheapest', 'right', 'now',
|
||||
// 售后/流程/联系类词:出现在商品问题里也不是商品词,留着只会拖慢商品 RPC
|
||||
'return', 'returns', 'returned', 'exchange', 'exchanges', 'refund', 'refunds', 'policy', 'warranty',
|
||||
'question', 'questions', 'ask', 'asking', 'inquiry', 'inquiries', 'enquiry', 'within', 'before',
|
||||
'after', 'day', 'days', 'week', 'weeks', 'month', 'months', 'year', 'years', 'accept', 'accepting',
|
||||
'provide', 'providing', 'offer', 'offering', 'allow', 'allowed', 'possible', 'support', 'service',
|
||||
'services', 'customer', 'customers', 'contact', 'email', 'phone', 'address',
|
||||
]);
|
||||
|
||||
const UNITS = new Set([
|
||||
'pc', 'pcs', 'piece', 'pieces', 'unit', 'units', 'item', 'items', 'set', 'sets', 'pair', 'pairs',
|
||||
'kg', 'kgs', 'kilo', 'kilos', 'gram', 'grams', 'g', 'ml', 'liter', 'liters', 'litre', 'litres',
|
||||
'cm', 'mm', 'inch', 'inches', 'ft', 'box', 'boxes', 'carton', 'cartons', 'lot', 'lots',
|
||||
'thousand', 'dozen', 'pack', 'packs', 'pcs.', '件', '条', '个', '只', '套', '对', '双', '箱', '打', '批', '台', '张',
|
||||
]);
|
||||
|
||||
const MONEY = new Set([
|
||||
'dollar', 'dollars', 'usd', 'eur', 'euro', 'euros', 'cent', 'cents', 'yuan', 'rmb', 'cny',
|
||||
'元', '美元', '欧元', '人民币',
|
||||
]);
|
||||
|
||||
// 数量/规格 token:500、10.5、500pcs、10cm、18k
|
||||
const QUANTITY_TOKEN_RE = /^\d+(?:[.,]\d+)?(?:k|pcs|pc|pieces?|units?|sets?|pairs?|kg|g|cm|mm|ml|l|inch(?:es)?|ft)?$/;
|
||||
const MIN_KEEP = 2;
|
||||
const MAX_KEEP = 4;
|
||||
|
||||
/**
|
||||
* 从客户原话里提取用于商品 RPC 的短关键词。
|
||||
* 中文先交给 translateProductQuery 映射类目;没有可用词时返回空串。
|
||||
* @param {string} value 客户原文(或已翻译的类目名)
|
||||
* @returns {string}
|
||||
*/
|
||||
function extractProductKeyword(value) {
|
||||
const q = normalize(value);
|
||||
if (!q) return '';
|
||||
const kept = [];
|
||||
for (const token of q.split(/\s+/)) {
|
||||
if (!token) continue;
|
||||
if (QUANTITY_TOKEN_RE.test(token)) continue;
|
||||
if (UNITS.has(token) || MONEY.has(token) || TRANSACTION_WORDS.has(token) || STOPWORDS.has(token)) continue;
|
||||
if (token.length < MIN_KEEP && !CJK.test(token)) continue;
|
||||
kept.push(token);
|
||||
if (kept.length >= MAX_KEEP) break;
|
||||
}
|
||||
// 纯数量/纯交易词(如 "500 pcs wholesale")剥完是空的 → 交给上层决定是否退回原文
|
||||
return kept.join(' ');
|
||||
}
|
||||
|
||||
module.exports = { translateProductQuery, extractProductKeyword, normalize };
|
||||
|
||||
+7
-3
@@ -6,7 +6,7 @@ const { ToolError } = require('./errors');
|
||||
const { createTwentyClient } = require('./twenty');
|
||||
const { asList, asObject } = require('./rpc');
|
||||
const { createLeadStore } = require('./leads');
|
||||
const { translateProductQuery } = require('./product-query');
|
||||
const { translateProductQuery, extractProductKeyword } = require('./product-query');
|
||||
const { createSiteMap } = require('./sitemap');
|
||||
const { createOpportunityService } = require('./opportunity');
|
||||
const { createMailHistory } = require('./mail-history');
|
||||
@@ -406,7 +406,9 @@ function createTools(cfg, deps = {}) {
|
||||
const profile = resolveSiteProfile(cfg, ctx);
|
||||
// 中文/别名商品词先映射到英文类目,例如 简约不锈钢戒指 -> Rings
|
||||
const translation = translateProductQuery(query, siteEntries(profile));
|
||||
const rpcQuery = translation.query || query;
|
||||
// 商品 RPC 对含数量/货币的长句极慢(实测 18s vs 0.6s),只把清洗后的 2-4 个商品词发给 RPC;
|
||||
// 清洗为空(纯数量/纯交易词)时退回原 query,保持原有兜底行为。
|
||||
const rpcQuery = extractProductKeyword(translation.query || query) || translation.query || query;
|
||||
|
||||
const absoluteUrl = (href) => {
|
||||
if (!href) return null;
|
||||
@@ -510,7 +512,9 @@ function createTools(cfg, deps = {}) {
|
||||
...(total != null ? { total } : {}),
|
||||
source,
|
||||
site: profile.site,
|
||||
...(translation.translated ? { query_used: rpcQuery, translated_from: query, category: translation.category } : {}),
|
||||
// 实际发给商品 RPC 的关键词(清洗后),便于测试服核对;原话仍留在 search_url
|
||||
query_used: rpcQuery,
|
||||
...(translation.translated ? { translated_from: query, category: translation.category } : {}),
|
||||
...(categoryLinks.length && source === 'catalog' ? { category_links: categoryLinks } : {}),
|
||||
search_url: profile.searchUrl + encodeURIComponent(query),
|
||||
...(source === 'site-map'
|
||||
|
||||
@@ -357,3 +357,41 @@ test('agent: extracts lead fields when the LLM skips collect_info (company menti
|
||||
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
|
||||
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Leadfix GmbH'));
|
||||
});
|
||||
|
||||
test('agent: product pre-retrieval sends cleaned keyword instead of the full sentence', async () => {
|
||||
const queries = [];
|
||||
const tools = [{
|
||||
name: 'search_products',
|
||||
description: 'fake',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
handler: async (args) => {
|
||||
queries.push(args.query);
|
||||
return { products: [{ name: 'Steel Ring', price: '€2,50', stock: 10, url: 'https://www.yehwang.com/p/steel-ring.html' }], count: 1 };
|
||||
},
|
||||
}];
|
||||
tools.leadStore = { get: async () => null, save: async () => null };
|
||||
let sent = '';
|
||||
let recorded = null;
|
||||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||||
llm: {
|
||||
enabled: true,
|
||||
model: 'fake',
|
||||
chat: async () => ({ content: 'Steel Ring — €2,50,库存 10 件。', toolCalls: [], usage: null }),
|
||||
},
|
||||
client: {
|
||||
getMessages: async () => [{ id: 1, text_content: 'We need 500 stainless steel rings wholesale.', sender_type: 'contact', private: false }],
|
||||
sendMessage: async (_uuid, content) => { sent = content; },
|
||||
sendPrivateNote: async () => {},
|
||||
},
|
||||
tools,
|
||||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||||
metrics: { record: async (m) => { recorded = m; }, enabled: false },
|
||||
contextManager: { compressHistory: async (_c, h) => h },
|
||||
});
|
||||
const conv = { uuid: 'conv-clean-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||||
await agent.run(conv, { uuid: 'msg-clean-1', text_content: 'We need 500 stainless steel rings wholesale.', content: '...' });
|
||||
assert.deepEqual(queries, ['stainless steel rings']);
|
||||
assert.match(sent, /Steel Ring/);
|
||||
assert.equal(typeof recorded.audit.timing.llm_ms, 'number');
|
||||
assert.equal(typeof recorded.audit.timing.tool_ms, 'number');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { translateProductQuery } = require('../src/product-query');
|
||||
const { translateProductQuery, extractProductKeyword } = require('../src/product-query');
|
||||
|
||||
test('translateProductQuery: Chinese category alias -> English title', () => {
|
||||
const entries = [
|
||||
@@ -20,3 +20,20 @@ test('translateProductQuery: English query unchanged', () => {
|
||||
assert.equal(out.translated, false);
|
||||
assert.equal(out.query, 'hoop earrings');
|
||||
});
|
||||
|
||||
test('extractProductKeyword: drops quantities/currency/transaction words', () => {
|
||||
assert.equal(extractProductKeyword('We need 500 stainless steel rings wholesale under 10 dollars'), 'stainless steel rings');
|
||||
assert.equal(extractProductKeyword('500 rings'), 'rings');
|
||||
assert.equal(extractProductKeyword('do you have gold hoop earrings?'), 'gold hoop earrings');
|
||||
assert.equal(extractProductKeyword('do you accept returns on stainless steel rings'), 'stainless steel rings');
|
||||
});
|
||||
|
||||
test('extractProductKeyword: caps at 4 tokens, keeps CJK', () => {
|
||||
assert.equal(extractProductKeyword('neo stainless steel rings 500 pcs'), 'neo stainless steel rings');
|
||||
assert.equal(extractProductKeyword('简约不锈钢戒指'), '简约不锈钢戒指');
|
||||
});
|
||||
|
||||
test('extractProductKeyword: pure quantity/transaction returns empty', () => {
|
||||
assert.equal(extractProductKeyword('500 pcs wholesale'), '');
|
||||
assert.equal(extractProductKeyword(''), '');
|
||||
});
|
||||
|
||||
@@ -91,6 +91,18 @@ test('search_products: maps RPC items and caps results', async () => {
|
||||
assert.equal(rpc.calls[0].method, '/product/list');
|
||||
});
|
||||
|
||||
test('search_products: sends cleaned short keyword to catalog RPC for long queries', async () => {
|
||||
const rpc = fakeRpc(() => ({ data: { list: [{ spuName: 'Steel Ring', minPriceWithCurrency: '€2,50', spuId: 1 }] } }));
|
||||
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty() });
|
||||
const tool = tools.find((t) => t.name === 'search_products');
|
||||
const out = await tool.handler({ query: 'We need 500 stainless steel rings wholesale under 10 dollars', limit: 3 }, { verified: false });
|
||||
const cmd = rpc.calls[0].params[0];
|
||||
assert.equal(cmd.keyword, 'stainless steel rings');
|
||||
assert.equal(out.query_used, 'stainless steel rings');
|
||||
// 给客户看的兜底搜索链接仍用原话
|
||||
assert.match(out.search_url, /500%20stainless%20steel%20rings/);
|
||||
});
|
||||
|
||||
test('search_products: falls back to site map when catalog returns empty', async () => {
|
||||
const tools = createTools(baseCfg, {
|
||||
rpc: fakeRpc(() => []),
|
||||
|
||||
Reference in New Issue
Block a user