diff --git a/bridge/src/agent.js b/bridge/src/agent.js index 7435b1d..582f56d 100644 --- a/bridge/src/agent.js +++ b/bridge/src/agent.js @@ -15,7 +15,7 @@ const { quickClassify, detectEmotion, INTENTS } = require('./intent'); const { createContextManager } = require('./context'); const { createReranker } = require('./reranker'); const { detectInjection, markToolResult, INJECTION_CAUTION_PROMPT } = require('./security'); -const { hasBuyingIntent, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('./lead-intent'); +const { hasBuyingIntent, looksLikeLead, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('./lead-intent'); const { createLlmCache } = require('./llm-cache'); const { checkCitation } = require('./citation-checker'); const { createFlywheelWriter } = require('./flywheel'); @@ -142,10 +142,14 @@ function createAgent(cfg, deps = {}) { } const preSlots = (leadState && leadState.slots) || {}; const buyingIntent = hasBuyingIntent(currentText); + const leadSignal = looksLikeLead(currentText); const preMissing = missingLeadContacts(preSlots, ctx.email); if (buyingIntent && preMissing.length) { systemContent += `\n\nLEAD COMPLETION (hard rule): the customer shows buying intent but we still miss ${preMissing.join(', ')}. At the END of your reply, ask naturally for the missing piece(s) in the customer's language. Do not end without asking.`; } + if (leadSignal) { + systemContent += '\n\nLEAD CAPTURE (hard rule): the customer message already contains lead details (company and/or contact and/or product need). You MUST call collect_info with those exact values BEFORE writing the reply; do not ask again for details the customer already gave. After collect_info returns ready=true, call create_opportunity with the same values.'; + } messages.unshift({ role: 'system', content: systemContent }); if (injection.level === 'high') { console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'injection_detected', conversation: conv.uuid, matches: injection.matches.length })); @@ -310,7 +314,7 @@ function createAgent(cfg, deps = {}) { console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `link backfill failed: ${err.message}` })); } // collect_info 兜底:消息含明显 lead 信息但 LLM 没调 collect_info 时,补抽槽位 - if (buyingIntent && leadStore && !(stats.toolCalls || []).some((t) => t.name === 'collect_info')) { + if ((buyingIntent || leadSignal) && leadStore && !(stats.toolCalls || []).some((t) => t.name === 'collect_info')) { try { const latest = await leadStore.get(conv.uuid); if (!latest || !latest.opportunityId) { diff --git a/bridge/src/lead-intent.js b/bridge/src/lead-intent.js index 126e546..a39932f 100644 --- a/bridge/src/lead-intent.js +++ b/bridge/src/lead-intent.js @@ -14,11 +14,21 @@ const ASK = { en: '\n\nBy the way, could you share your company name and a contact email? That way we can set up your wholesale account and follow up on your request.', }; +// 公司/联系人信号:客户主动报公司名(含法律后缀)、邮箱、电话、联系人等。 +// 命中它但数量单位不规整(如 "500 stainless steel rings")时,也要触发 lead 抽取兜底, +// 否则 LLM 漏调 collect_info 时槽位会为空,商机就建不出来。 +const LEAD_SIGNAL_RE = /(@|\+?\d[\d\s().-]{6,}\d|gmbh|b\.?\s?v\.?\b|ltd\b|llc\b|inc\b|sarl|srl\b|limited\b|company|corporation|we are|we're|our company|contact\s*(?:name|person)?\s*[::]|\u516c\u53f8|\u8054\u7cfb\u4eba|\u91c7\u8d2d|wholesale|bulk)/i; + function hasBuyingIntent(text) { const value = String(text || ''); return PRODUCT_RE.test(value) && QUANTITY_RE.test(value); } +function looksLikeLead(text) { + const value = String(text || ''); + return PRODUCT_RE.test(value) && LEAD_SIGNAL_RE.test(value); +} + function missingLeadContacts(slots = {}, knownEmail = '') { const missing = []; if (!slots.company) missing.push('company'); @@ -51,4 +61,4 @@ function appendLeadFollowup(reply, lang) { return text + buildLeadFollowup(lang); } -module.exports = { hasBuyingIntent, missingLeadContacts, detectLanguage, buildLeadFollowup, alreadyAsksForLead, appendLeadFollowup }; +module.exports = { hasBuyingIntent, looksLikeLead, LEAD_SIGNAL_RE, missingLeadContacts, detectLanguage, buildLeadFollowup, alreadyAsksForLead, appendLeadFollowup }; diff --git a/bridge/test/agent-lead.test.js b/bridge/test/agent-lead.test.js index d4e0d52..e12b6c7 100644 --- a/bridge/test/agent-lead.test.js +++ b/bridge/test/agent-lead.test.js @@ -321,3 +321,39 @@ test('agent: does not auto-create when the LLM already created the opportunity', await agent.run(conv, { uuid: 'msg-once', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces.', content: '...' }); assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1); }); + +test('agent: extracts lead fields when the LLM skips collect_info (company mentioned, unit-less quantity)', async () => { + const twenty = leadTwenty(); + const tools = leadTools(twenty); + let sent = ''; + let llmCalls = 0; + const deps = { + llm: { + enabled: true, + model: 'fake', + chat: async () => { + llmCalls += 1; + if (llmCalls === 1) { + return { content: 'Could you share your contact details so we can follow up?', toolCalls: [], usage: null }; + } + // extractLeadFields 的调用:返回 JSON + return { content: JSON.stringify({ company: 'Leadfix GmbH', contactName: 'Anna Klein', productNeed: '500 stainless steel rings', quantity: '500' }), toolCalls: [], usage: null }; + }, + }, + client: { + getMessages: async () => [{ id: 1, text_content: 'We are Leadfix GmbH, contact Anna Klein. We need 500 stainless steel rings.', 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 () => {}, enabled: false }, + contextManager: { compressHistory: async (_c, h) => h }, + }; + const conv = { uuid: 'conv-lead-extract', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } }; + const agent = createAgent({ sites: [], defaultSite: 'www' }, deps); + await agent.run(conv, { uuid: 'msg-lead-extract', text_content: 'We are Leadfix GmbH, contact Anna Klein. We need 500 stainless steel rings.', content: '...' }); + assert.equal(llmCalls >= 2, true); + assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1); + assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Leadfix GmbH')); +}); diff --git a/bridge/test/lead-intent.test.js b/bridge/test/lead-intent.test.js index c1ad0e8..06d2e6b 100644 --- a/bridge/test/lead-intent.test.js +++ b/bridge/test/lead-intent.test.js @@ -2,7 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { hasBuyingIntent, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('../src/lead-intent'); +const { hasBuyingIntent, looksLikeLead, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('../src/lead-intent'); test('lead-intent: product + quantity is buying intent', () => { assert.equal(hasBuyingIntent('I want 500 pieces of stainless steel rings'), true); @@ -26,3 +26,11 @@ test('lead-intent: detects existing ask in reply', () => { assert.equal(alreadyAsksForLead('Could you share your company name and email?'), true); assert.equal(alreadyAsksForLead('Here are some rings.'), false); }); + +test('lead-intent: company/contact signal counts as a lead even without unit-quantity', () => { + // 单位不规整(500 stainless steel rings)时 hasBuyingIntent 为 false,但已是明确线索 + assert.equal(hasBuyingIntent('We are Acme GmbH, contact Anna Klein. We need 500 stainless steel rings.'), false); + assert.equal(looksLikeLead('We are Acme GmbH, contact Anna Klein. We need 500 stainless steel rings.'), true); + assert.equal(looksLikeLead('Our company is Test Buyer BV, contact name Jan Jansen.'), false); // 没有商品词 + assert.equal(looksLikeLead('Do you have rings?'), false); // 没有公司/联系人信号 +});