diff --git a/bridge/src/agent.js b/bridge/src/agent.js index 582f56d..34ca367 100644 --- a/bridge/src/agent.js +++ b/bridge/src/agent.js @@ -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; } diff --git a/bridge/src/product-query.js b/bridge/src/product-query.js index b98b4cc..4e47b23 100644 --- a/bridge/src/product-query.js +++ b/bridge/src/product-query.js @@ -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 }; diff --git a/bridge/src/tools.js b/bridge/src/tools.js index 6759207..0115dfc 100644 --- a/bridge/src/tools.js +++ b/bridge/src/tools.js @@ -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' diff --git a/bridge/test/agent-lead.test.js b/bridge/test/agent-lead.test.js index e12b6c7..558d64d 100644 --- a/bridge/test/agent-lead.test.js +++ b/bridge/test/agent-lead.test.js @@ -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'); +}); diff --git a/bridge/test/product-query.test.js b/bridge/test/product-query.test.js index 1b83669..8418126 100644 --- a/bridge/test/product-query.test.js +++ b/bridge/test/product-query.test.js @@ -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(''), ''); +}); diff --git a/bridge/test/tools.test.js b/bridge/test/tools.test.js index ee06b37..9f8afbf 100644 --- a/bridge/test/tools.test.js +++ b/bridge/test/tools.test.js @@ -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(() => []),