// Chatwoot → glm-5.2 (ark) → Twenty CRM Bot Bridge // 接收 Chatwoot 的 message_created webhook,用 glm-5.2 作为销售助理回复, // 并在信息足够时把机会写进 Twenty CRM 的 Opportunity。 // // 纯 Node 内置模块,无依赖。Node 24 内置 fetch / http。 const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); // ---------- 配置加载(环境变量优先,fallback 配置文件)---------- const CFG_DIR = __dirname; const loadCfg = (f) => JSON.parse(fs.readFileSync(path.join(CFG_DIR, f), 'utf8')); const cw = loadCfg('chatwoot.config.json'); const llm = loadCfg('llm.config.json'); const twenty = loadCfg('twenty.config.json'); // 容器内用容器名访问,本地用 localhost;环境变量覆盖 if (process.env.CW_BASE_URL) cw.baseURL = process.env.CW_BASE_URL; if (process.env.TWENTY_BASE_URL) twenty.baseURL = process.env.TWENTY_BASE_URL; // QDRANT_URL 优先用环境变量(下面 search 部分读) const PORT = process.env.PORT || 4000; // 已为每个会话创建过机会的记录,避免重复 const createdOpps = new Set(); // 会话级锁,防止并发消息重复触发 LLM const locks = new Set(); // ---------- LLM ---------- const PROVIDER = llm.providers.aliyun; const PROVIDER_TYPE = PROVIDER.type || 'openai'; const LLM_URL = PROVIDER.baseUrl + (PROVIDER_TYPE === 'anthropic' ? '/v1/messages' : '/chat/completions'); const LLM_KEY = PROVIDER.apiKey; const MODEL = PROVIDER.model; // qwen3 系列默认开 thinking(慢),客服求快关闭;tool calling 在关闭后仍正常 const ENABLE_THINKING = PROVIDER.enableThinking !== undefined ? PROVIDER.enableThinking : false; // 统一 agent 人设:一个稳定角色,不再在 sales/KB 间切 system prompt。 // LLM 自主调用 tools(search_kb / search_products / escalate_human) 决定如何作答。 const AGENT_SYSTEM = `You are the official assistant for Yehwang, a Netherlands-based (Dutch-registered) B2B wholesaler serving retailers across Europe. **Company facts (do NOT make these up):** - Head office: Galvanistraat 90, 6716 AE Ede, The Netherlands. Phone: +31(0)318 668 171. - Showroom: World Fashion Centre (WFC), Koningin Wilhelminaplein 29, Tower 3 - 1st floor, 1062 HJ Amsterdam. Phone: +31(0)6 3228 5555. - Three sales channels: physical wholesale showroom (WFC Amsterdam), B2B website (yehwang.com), and a dropshipping platform (dropshipping.yehwang.com). - Products sourced from: Netherlands warehouse (in-stock goods) and manufacturing facilities in China (Factory-Direct). Do NOT mention any other countries. - 100,000+ live SKUs across women's jewelry, accessories, clothing (own label: Gimme), packing/display. - Prices are wholesale unit prices, public and transparent on the site after login. - Shipping: Netherlands warehouse via PostNL/DHL; Factory-Direct ships from manufacturing facilities worldwide. You handle BOTH pre-sales (qualifying leads) AND customer-service policy questions. **LANGUAGE (most important): ALWAYS reply in the SAME language the customer used. If they write Chinese, reply in Chinese; German -> German; French -> French; etc. Even if tool results (product names, KB answers) are in English, you MUST write your reply text in the customer's language. Default to English only if the customer's language is unclear.** Use the provided tools. Tool discipline is critical: - search_kb: You MUST call this FIRST (before answering) for ANY question about rules/policies/FAQ — registration, account, password, login, minimum order, cancel, pre-order, payment, VAT, invoice, shipping cost, delivery time, returns, refunds, defective/broken items, membership, discounts, dropshipping, factory-direct, materials, discolor, jewelry care, etc. NEVER answer such questions from memory — always retrieve the official answer via search_kb first. If the customer's message contains ANY policy-related word, call search_kb. - search_products: call when the customer wants to find/see/buy specific products. **Use EXACT product names returned — never translate or fabricate.** Pass the customer's intent as a natural search query (e.g. "gold necklace", "新品", "earrings") — the search engine handles trending/new/best-selling on its own. If results are empty, tell the customer honestly. - escalate_human: call when the question is outside your tools' scope or you can't confidently answer. ALSO call this IMMEDIATELY (do NOT search_kb or search_products first) if the customer: explicitly asks to speak to a human ("转人工"/"真人"/"人工客服"/"speak to human"/"real person"), expresses anger or frustration, makes a formal complaint, or repeatedly says the same thing without being helped. In these cases, apologize briefly, explain you're transferring them, and call escalate_human. - search_orders: call when the customer asks about an EXISTING order - "where is my order", "order status", "tracking", "when will it arrive", "has it shipped". Provide the order number if the customer mentioned it; otherwise use the customer's email to look up recent orders. Return concrete tracking numbers and delivery estimates. - search_inventory: call when the customer asks about REAL-TIME stock or price for a SPECIFIC item they want to order - "is SKU xxx in stock", "how many available", "can you fulfill 500 pieces". This is for pre-order stock verification. Note: search_products is for BROWSING the catalog (finding items); search_inventory is for checking real-time stock/price on a SPECIFIC item before confirming an order. RULES: - **Escalation (critical): When the customer asks for a human, expresses frustration, or you need to escalate, you MUST call the escalate_human tool immediately. Do NOT just say "I'll transfer you" in text — the tool does the real handoff. Call the tool FIRST, then the system will handle the rest.** - Reply in the SAME language the customer uses (default English). Keep replies concise and natural (usually 2-4 sentences). Do not repeat earlier phrasing. - For policy questions: answer using ONLY what search_kb returns. Cite concrete figures (e.g. "minimum order EUR 30", "5-10 business days express"). Do NOT add source URLs yourself — the system appends them automatically. If search_kb results don't cover it, say you're unsure and offer to escalate to a human. - For pre-sales: this is an ONLINE B2B platform with PUBLIC transparent prices (no quotes). We support CUSTOMIZATION (materials, colors, sizes, packaging, private label). You CANNOT create accounts — guide customers to register themselves. **For bulk/product+quantity queries: call search_products FIRST to show items, then pivot to lead qualification (check stock, mention Factory-Direct if insufficient, ask for company name + contact). Only mention discounts when the customer asks about pricing/bulk/discounts — do NOT push discount tiers proactively. When asked, the tiers are: volume discounts (€200→5%, €500→7%, €1,000→10%, €2,000→15%, €3,000→20%, €5,000→22%, €10,000→25%) and membership (Bronze €1+ 0%, Silver €200+ 1%, Gold €1,000+ 2%, Platinum €4,000+ 3%, Diamond €10,000+ 4%, VIP €20,000+ 5%). Discounts stack: volume first, then membership on top. Collect: company name → contact → product/quantity. Track what's known; never re-ask. Once complete, guide them to register at https://www.yehwang.com.** - Our website is https://www.yehwang.com. Always write the FULL URL with https:// protocol, never bare "www.yehwang.com". - Contacts: helpdesk@yehwang.com / +31 318 668 171 (website & in-stock orders); service@yehwang.com (Factory-Direct orders).`; // callLLM: 支持 tool use(OpenAI function calling)。返回 {text, toolCalls, raw}。 // - toolCalls: [{id, name, arguments}] 当 LLM 决定调工具时 // - text: 纯文本回复(无 tool call 时) async function callLLM(messages, systemOverride, maxTokens = 4096, tools = null) { let body, headers; if (PROVIDER_TYPE === 'anthropic') { body = { model: MODEL, max_tokens: maxTokens, messages }; if (systemOverride) body.system = systemOverride; headers = { 'x-api-key': LLM_KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }; } else { // OpenAI-compatible (DashScope qwen) const msgs = []; if (systemOverride) msgs.push({ role: 'system', content: systemOverride }); msgs.push(...messages); body = { model: MODEL, max_tokens: maxTokens, messages: msgs }; // qwen3 关闭 thinking 求快(tool calling 仍正常) body.enable_thinking = ENABLE_THINKING; if (tools && tools.length) body.tools = tools; headers = { 'Authorization': `Bearer ${LLM_KEY}`, 'content-type': 'application/json' }; } const resp = await fetch(LLM_URL, { method: 'POST', headers, body: JSON.stringify(body) }); const text = await resp.text(); let data; try { data = JSON.parse(text); } catch (e) { console.error('[llm] resp.json fail:', e.message, '| text:', text.slice(0, 300)); throw e; } if (!resp.ok) throw new Error('LLM HTTP ' + resp.status + ': ' + JSON.stringify(data).slice(0, 400)); const choice = data.choices && data.choices[0]; const msg = choice && choice.message; const toolCalls = (msg && msg.tool_calls || []).map((tc) => { let args = {}; try { args = JSON.parse(tc.function.arguments || '{}'); } catch { args = {}; } return { id: tc.id, name: tc.function.name, arguments: args }; }); const replyText = ((msg && msg.content) || '').trim(); return { text: replyText, toolCalls, finishReason: choice && choice.finish_reason }; } // ---------- Chatwoot API ---------- const cwHeaders = () => ({ 'api_access_token': cw.accessToken, 'Content-Type': 'application/json', }); async function fetchConversationMessages(conversationId, limit = 20) { // 只拉最近 limit 条(客服场景只需近期上下文),减少序列化/网络延迟 const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/messages?sort=desc&per_page=${limit}`; const resp = await fetch(url, { headers: cwHeaders() }); const data = await resp.json(); // data.payload 或 data 直接是数组;desc 拉取后反转回时间正序 const list = Array.isArray(data) ? data : (data.payload || []); return list.reverse(); } async function sendChatwootReply(conversationId, content) { const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/messages`; const resp = await fetch(url, { method: 'POST', headers: cwHeaders(), body: JSON.stringify({ content, message_type: 'outgoing', private: false }), }); return resp.json(); } // typing indicator:让 widget 立即显示“客服正在输入”,消除无响应体感 async function setTyping(conversationId, on) { try { const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/toggle_typing_status`; await fetch(url, { method: 'POST', headers: cwHeaders(), body: JSON.stringify({ typing_status: on ? 'on' : 'off' }) }); } catch {} } // 真 handoff:把会话从 bot 转给人类客服(改 assignee),bot 不再抢答。 // 对标 Chatwoot 企业版 Captain 的“转人工”。assigneeId 省略时只 toggle 状态为 open+bot 默认转交。 async function handoffToHuman(conversationId, note) { try { // 1. 先发一条 note 给客户(说明转人工) // 2. 切换会话状态为 open(确保人类能接) + 清掉 bot assignee const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}`; const resp = await fetch(url, { method: 'PATCH', headers: cwHeaders(), body: JSON.stringify({ status: 'open', assignee_agent_bot_id: null }), }); console.log(`[handoff] conv=${conversationId} -> ${resp.status} ${note || ''}`); return resp.ok; } catch (e) { console.error('[handoff err]', e.message); return false; } } // 列出账号下可用的客服 agent(人类),供 handoff 分配 async function listHumanAgents() { try { const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/agents`; const resp = await fetch(url, { headers: cwHeaders() }); const d = await resp.json(); return d.payload || d || []; } catch { return []; } } // 上传图片到 Chatwoot(下载后用文件上传,避免 external_url 超时) async function uploadImageToChatwoot(imageUrl) { const encodedUrl = imageUrl.replace(/ /g, '%20'); const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/upload`; const r = await fetch(encodedUrl); const buf = Buffer.from(await r.arrayBuffer()); const ctype = r.headers.get('content-type') || 'image/jpeg'; const ext = ctype.includes('png') ? 'png' : 'jpg'; // 构造 multipart/form-data const form = new FormData(); form.append('attachment', new Blob([buf], { type: ctype }), `product.${ext}`); const resp = await fetch(url, { method: 'POST', headers: { 'api_access_token': cw.accessToken }, body: form }); const d = await resp.json(); if (!d.blob_id) throw new Error('upload err: ' + JSON.stringify(d).slice(0, 150)); return d.blob_id; } // 发带商品图片附件的消息 async function sendProductImageMessage(conversationId, imageUrl, caption) { try { const blobId = await uploadImageToChatwoot(imageUrl); const url = `${cw.baseURL}/api/v1/accounts/${cw.accountId}/conversations/${conversationId}/messages`; const resp = await fetch(url, { method: 'POST', headers: { ...cwHeaders() }, body: JSON.stringify({ content: caption || '', message_type: 'outgoing', private: false, attachments: [blobId] }), }); return resp.json(); } catch (e) { console.error('[img msg err]', e.message); return null; } } // 通用 GraphQL 执行 async function gql(query) { const resp = await fetch(`${twenty.baseURL}${twenty.graphqlPath}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${twenty.apiKey}` }, body: JSON.stringify({ query }), }); const data = await resp.json(); if (data.errors) throw new Error('Twenty errors: ' + JSON.stringify(data.errors).slice(0, 300)); return data.data; } // 创建公司 async function createCompany(name, domain) { const f = [`name: "${escapeGQL(name)}"`]; if (domain) { const url = domain.startsWith('http') ? domain : 'https://' + domain; f.push(`domainName: {primaryLinkUrl:"${escapeGQL(url)}"}`); } const d = await gql(`mutation { createCompany(data:{ ${f.join(', ')} }){ id name } }`); return d.createCompany; } // 按公司名查现有公司(去重),返回 id 或 null async function findCompanyByName(name) { const d = await gql(`{ companies(first:1, filter:{ name:{ eq:"${escapeGQL(name)}" } }){ edges{ node{ id } } } }`).catch(() => ({ companies: { edges: [] } })); const edges = (d.companies && d.companies.edges) || []; return edges[0] ? edges[0].node.id : null; } // 按邮箱查现有联系人(去重),返回 id 或 null async function findPersonByEmail(email) { const d = await gql(`{ people(first:1, filter:{ emails:{ primaryEmail:{ eq:"${escapeGQL(email)}" } } } ){ edges{ node{ id } } } }`).catch(() => ({ people: { edges: [] } })); const edges = (d.people && d.people.edges) || []; return edges[0] ? edges[0].node.id : null; } // 从 Twenty duplicate 错误里取已存在记录的 id function extractConflictId(errMsg) { const m = String(errMsg).match(/conflictingRecordId":"([a-f0-9-]+)/); return m ? m[1] : null; } // 创建联系人 {firstName, lastName, email, phone, companyId} async function createPerson(p) { const parts = []; const fn = p.firstName || ''; const ln = p.lastName || ''; parts.push(`name: {firstName:"${escapeGQL(fn)}", lastName:"${escapeGQL(ln)}"}`); if (p.email) parts.push(`emails: {primaryEmail:"${escapeGQL(p.email)}"}`); if (p.phone) parts.push(`phones: {primaryPhoneNumber:"${escapeGQL(p.phone)}", primaryPhoneCountryCode:""}`); if (p.companyId) parts.push(`companyId: "${p.companyId}"`); const d = await gql(`mutation { createPerson(data:{ ${parts.join(', ')} }){ id firstName lastName emails{ primaryEmail } } }`); return d.createPerson; } // 创建商机,关联公司+联系人 async function createOpportunity(opp) { // opp: { name, amount?, closeDate?, stage?, companyId?, personId?, summary?, conversationLink? } const VALID_STAGES = ['NEW', 'SCREENING', 'MEETING', 'PROPOSAL', 'CUSTOMER']; const stage = VALID_STAGES.includes(opp.stage) ? opp.stage : 'NEW'; // 机会名:公司-需求摘要-会话编号,salesperson 在 Twenty 里能看到完整信息 const nameText = opp.conversationId ? `${opp.name} (会话#${opp.conversationId})` : opp.name; const fields = [`name: "${escapeGQL(nameText)}"`]; if (opp.amount) { const micros = Math.round(Number(opp.amount) * 1000000); fields.push(`amount: {amountMicros: ${micros}, currencyCode: "${opp.currency || 'EUR'}"}`); } if (opp.closeDate) fields.push(`closeDate: "${opp.closeDate}"`); fields.push(`stage: ${stage}`); if (opp.companyId) fields.push(`companyId: "${opp.companyId}"`); if (opp.personId) fields.push(`pointOfContact: {connect:"${opp.personId}"}`); const d = await gql(`mutation { createOpportunity(data:{ ${fields.join(', ')} }){ id name stage amount{ amountMicros currencyCode } companyId } }`); return d.createOpportunity; } // 完整建一条线索:公司 -> 联系人 -> 商机(全部关联),公司/联系人去重复用 async function createLeadInCRM(info) { // info: { company, domain, contactName, email, phone, oppName, amount, currency, stage, summary, conversationLink } let companyId = null; let companyCreated = false; if (info.company) { companyId = await findCompanyByName(info.company); if (!companyId) { try { const c = await createCompany(info.company, info.domain || null); companyId = c ? c.id : null; companyCreated = true; } catch (e) { companyId = extractConflictId(e.message) || await findCompanyByName(info.company); } } } let personId = null; let personCreated = false; if (info.email) { personId = await findPersonByEmail(info.email); } if (!personId && (info.contactName || info.email)) { const names = splitName(info.contactName || ''); try { const p = await createPerson({ firstName: names.firstName, lastName: names.lastName, email: info.email || null, phone: info.phone || null, companyId, }); personId = p ? p.id : null; personCreated = true; } catch (e) { personId = extractConflictId(e.message); if (personId) console.log(`[dedup] 复用已有联系人 ${personId}`); else console.error('[person err]', e.message); } } const opp = await createOpportunity({ name: info.oppName, amount: info.amount, currency: info.currency || 'EUR', stage: info.stage || 'NEW', companyId, personId, conversationId: info.conversationId || null, }); // 自动化工作流:商机创建后自动生成跟进任务(3 天后到期,关联到商机) if (opp && opp.id) { try { const dueDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); const taskTitle = `跟进商机: ${info.oppName || opp.name}`; const taskBody = `这是由 AI 客服系统自动创建的跟进任务。\n商机来源: Chatwoot 会话 #${info.conversationId || 'N/A'}\n客户: ${info.company || '未知'}${info.contactName ? ' (' + info.contactName + ')' : ''}\n需求: ${info.summary || opp.name}\n\n请在 3 个工作日内联系客户确认细节并发送报价。`; const blocknote = JSON.stringify([{ id: 't1', type: 'paragraph', props: {}, content: [{ type: 'text', text: taskBody, styles: {} }], children: [] }]); const escBlock = blocknote.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const td = await gql(`mutation { createTask(data:{ title:"${escapeGQL(taskTitle)}" bodyV2:{ blocknote: "${escBlock}" } dueAt:"${dueDate}" }){ id } }`); const taskId = td.createTask && td.createTask.id; if (taskId) { await gql(`mutation { createTaskTarget(data:{ taskId:"${taskId}" targetOpportunityId:"${opp.id}" }){ id } }`); console.log(`[workflow] conv=${info.conversationId} opp=${opp.id} -> 自动创建跟进任务 ${taskId} (3天后到期)`); } } catch (e) { console.error('[workflow err] 自动创建任务失败:', e.message.slice(0, 100)); } } return { companyId, personId, opportunityId: opp ? opp.id : null, companyCreated, personCreated }; } // 把聊天记录推送到 Twenty 的 opportunity note async function addChatToOpportunity(conversationId, opportunityId) { try { const msgs = await fetchConversationMessages(conversationId); if (!msgs.length) return; const lines = msgs .filter((m) => m.content && typeof m.content === 'string' && m.content.trim()) .map((m) => { const role = m.message_type === 0 || m.message_type === 'incoming' ? '客户' : '客服'; const txt = stripHtml(m.content).slice(0, 300); return `[${role}] ${txt}`; }); const chatText = lines.join('\n\n---\n\n'); const blocknote = JSON.stringify([ { id: '1', type: 'paragraph', props: {}, content: [{ type: 'text', text: chatText, styles: {} }], children: [] }, ]); const esc = blocknote.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); // 两步:先建 note,再建 noteTarget 关联到 opportunity const q1 = `mutation { createNote(data:{ bodyV2: { blocknote: "${esc}" } }){ id } }`; const d1 = await gql(q1); const noteId = d1.createNote?.id; if (!noteId) { console.error('[note] 创建失败'); return; } const q2 = `mutation { createNoteTarget(data:{ noteId: "${noteId}", targetOpportunityId: "${opportunityId}" }){ id } }`; await gql(q2); console.log(`[note] conv=${conversationId} opp=${opportunityId} -> ${noteId}`); } catch (e) { console.error('[note err]', e.message.slice(0, 100)); } } function splitName(full) { full = (full || '').trim(); if (!full) return { firstName: '', lastName: '' }; const sp = full.split(/[\s.]+/).filter(Boolean); if (sp.length >= 2) return { firstName: sp[0], lastName: sp.slice(1).join(' ') }; return { firstName: full, lastName: '' }; } function escapeGQL(s) { return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' '); } // web-widget 消息带
等 HTML,去掉后给 LLM 更干净
function stripHtml(s) {
return String(s)
.replace(/
/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/ /g, ' ')
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.trim();
}
// 修夏 LLM 生成的链接:markdown 链接的 URL 部分缺协议时补 https://
// [text](www.yehwang.com) -> [text](https://www.yehwang.com)
// 不动已有协议(http(s)://, mailto:, #锚点)或商品链接(均为完整 URL)
function fixupLinks(text) {
return String(text).replace(
/\]\((?!https?:\/\/|mailto:|#|[a-z][a-z0-9+.-]*:)([^)\s]+)\)/gi,
'](https://$1)'
);
}
// ---------- 客服知识库 RAG(共享 postgres pgvector + SiliconFlow embedding)----------
// 将 customer-service-kb/*.md 按 Q&A 切块向量化(见 index_kb.js),客服系统据此
// 准确回答注册/下单/支付/物流/退货/会员等政策类问题,避免 LLM 编造政策。
const { Pool } = require('pg');
const EMB_CFG = JSON.parse(fs.readFileSync(path.join(CFG_DIR, 'kb.config.json'), 'utf8'));
const KB_PG_URL = process.env.PG_URL || 'postgres://postgres:postgres@localhost:5432/products';
const kbPool = new Pool({ connectionString: KB_PG_URL, max: 2 });
const KB_MODEL = EMB_CFG.model || 'text-embedding-v4';
const KB_DIM = EMB_CFG.dim || 1024;
// DashScope key 支持环境变量覆盖(避免明文入库)
if (process.env.DASHSCOPE_API_KEY) EMB_CFG.apiKey = process.env.DASHSCOPE_API_KEY;
// 单条文本向量化(用于查询)
// query embedding LRU 缓存:重复问法不重复调 DashScope,热问法秒回
const _embedCache = new Map();
const _EMBED_CACHE_MAX = 200;
async function embedQuery(text) {
const key = text.slice(0, 200);
if (_embedCache.has(key)) return _embedCache.get(key);
const resp = await fetch(`${EMB_CFG.baseURL}/embeddings`, {
method: 'POST',
headers: { Authorization: `Bearer ${EMB_CFG.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: KB_MODEL, input: text, encoding_format: 'float', dimensions: KB_DIM }),
});
const d = await resp.json();
if (!d.data || !d.data[0] || !d.data[0].embedding) throw new Error('embedQuery err: ' + JSON.stringify(d).slice(0, 200));
const vec = d.data[0].embedding;
if (_embedCache.size >= _EMBED_CACHE_MAX) _embedCache.delete(_embedCache.keys().next().value);
_embedCache.set(key, vec);
return vec;
}
// 轻量查询翻译:Qwen3-Embedding-0.6B 对德/法/西/意等欧洲语言召回偏弱,
// 当原文检索命中不够且 query 非中文时,先翻成英文再检索,大幅提升多语言召回。
// 只输出一句英文,不增加后续回复的语言负担(回复仍用客户原文语言)。
// 轻量查询翻译(dashscope qwen-turbo):text-embedding-v4 多语言召回已足够强(实测 Recall@3 100%),
// 翻译重检仅作冷门问法兑底(几乎不会触发)。用 qwen-turbo 比 Qwen2.5-72B 更轻快省成本。
async function translateQueryToEnglish(query) {
try {
const resp = await fetch(`${EMB_CFG.baseURL}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${EMB_CFG.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: EMB_CFG.translateModel || 'qwen-turbo',
max_tokens: 80,
messages: [
{ role: 'system', content: 'Translate the user message into a concise English search query for a B2B jewelry wholesaler FAQ. Output ONLY the English translation, no explanation, no quotes.' },
{ role: 'user', content: query },
],
}),
});
const d = await resp.json();
const t = (d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content || '').trim();
return t.replace(/^["']|["']$/g, '');
} catch (e) {
console.error('[kb translate err]', e.message);
return null;
}
}
// 检索知识库,返回 [{question, answer, category, score}]
// 多语言查询召回不足时自动翻译成英文重检,合并两次结果。
async function searchKB(query, topK = 4) {
try {
const vecStr = (v) => '[' + v.join(',') + ']';
const runQ = async (q) => {
const vec = await embedQuery(q);
const r = await kbPool.query(
`SELECT question, answer, category, section, source_file, source_urls, 1 - (text_vec <=> $1::vector) AS score
FROM kb_docs ORDER BY text_vec <=> $1::vector LIMIT $2`,
[vecStr(vec), topK]
);
return r.rows.map((row) => ({ question: row.question, answer: row.answer, category: row.category, section: row.section, source: row.source_file, sourceUrls: row.source_urls || [], score: Number(row.score) }));
};
let hits = await runQ(query);
const topScore = hits.length ? hits[0].score : 0;
// 召回不足时翻译成英文重检(含中文:中文术语如"一件代发=dropshipping"翻译后召回更准)。
// 仅跳过明显英文 query,避免无谓的 LLM 调用。
const looksEnglish = /^[\x00-\x7F]+$/.test(query) && /\b(the|how|what|can|do|is|are|my|to|of|and|for|with|you|return|order|ship|pay|vat|invoice|register|account|discount|membership|dropship|refund|delivery)\b/i.test(query);
if (topScore < 0.55 && !looksEnglish && query.trim().length > 1) {
const en = await translateQueryToEnglish(query);
if (en && en.toLowerCase() !== query.toLowerCase()) {
const enHits = await runQ(en);
// 合并去重(按 question),保留较高 score
const map = new Map();
for (const h of [...hits, ...enHits]) {
const prev = map.get(h.question);
if (!prev || h.score > prev.score) map.set(h.question, h);
}
hits = [...map.values()].sort((a, b) => b.score - a.score).slice(0, topK);
}
}
return hits;
} catch (e) {
console.error('[kb err]', e.message);
return [];
}
}
// productKeywords / formatKBContext 已退役:tool-use 架构下 LLM 自主调 search_products/search_kb,
// 不再需要正则判断产品词,KB 片段拼接也移入 executeTool。
// ---------- 商品向量检索(共享 postgres pgvector + SiliconFlow embedding)----------
const RECOMMEND_URL = process.env.RECOMMEND_URL || 'http://recommend.internal.yw.com';
async function searchRecommend(query, userId, topK = 5) {
try {
const params = "limit=" + topK + "&in_stock=true&site=eu&detail=true";
const url = userId
? RECOMMEND_URL + "/recommend/" + encodeURIComponent(userId) + "?" + params
: RECOMMEND_URL + "/search?q=" + encodeURIComponent(query) + "&" + params;
const resp = await fetch(url);
if (!resp.ok) return null;
const data = await resp.json();
if (!data.recommendations || !data.recommendations.length) return null;
return { total: data.total, source: userId ? "personalized" : "search", items: data.recommendations };
} catch (e) {
console.error("[recommend err]", e.message);
return null;
}
}
// ---------- 订单/客户查询(对接 trading-service)----------
// trading-service 是 Yehwang 的交易微服务(Hyperf/PHP,端口 9501/9512)
// 订单接口(ERP 端,无需登录态):
// GET admin/mall/api/trading/order/list?no=订单号 订单列表
// GET admin/mall/api/trading/order/detail?orderId=X 订单详情
// GET admin/mall/api/trading/order/getTrackPreSaleOrder?orderId=X 物流追踪
// 返回格式: { code: 200, message: "success", data: {...} }
const ORDER_API_URL = process.env.ORDER_API_URL || ''; // trading-service base URL
const ORDER_API_KEY = process.env.ORDER_API_KEY || '';
// 查询订单状态/物流。args: {orderNumber?, customerEmail?, trackingNumber?}
async function searchOrders(args) {
if (!ORDER_API_URL) return { text: 'Order query API (trading-service) is not configured.', items: null };
try {
const headers = { 'Content-Type': 'application/json' };
if (ORDER_API_KEY) headers['Authorization'] = `Bearer ${ORDER_API_KEY}`;
// 优先用订单号查询
if (args.orderNumber) {
// 先查列表(用 no 过滤)
const listUrl = `${ORDER_API_URL}/admin/mall/api/trading/order/list?no=${encodeURIComponent(args.orderNumber)}&page=1&page_size=5`;
const listResp = await fetch(listUrl, { headers });
if (!listResp.ok) return { text: `Order query failed (HTTP ${listResp.status}).`, items: null };
const listData = await listResp.json();
const orders = (listData.data && listData.data.list) || listData.data || [];
if (!orders.length) return { text: `No order found with number "${args.orderNumber}".`, items: null };
// 对第一个匹配的订单查详情
const orderId = orders[0].orderId || orders[0].order_id || orders[0].id;
return await fetchOrderDetail(ORDER_API_URL, orderId, headers, orders[0]);
}
// 用客户邮箱查询(ERP 列表接口支持 customerEmail 参数)
if (args.customerEmail) {
const listUrl = `${ORDER_API_URL}/admin/mall/api/trading/order/list?customerEmail=${encodeURIComponent(args.customerEmail)}&page=1&page_size=5`;
const listResp = await fetch(listUrl, { headers });
if (!listResp.ok) return { text: `Order query failed (HTTP ${listResp.status}).`, items: null };
const listData = await listResp.json();
const orders = (listData.data && listData.data.list) || listData.data || [];
if (!orders.length) return { text: `No orders found for email "${args.customerEmail}".`, items: null };
// 返回最近订单列表(不逐一查详情,避免太多请求)
const lines = orders.slice(0, 5).map((o, i) => {
const no = o.orderNo || o.order_no || o.no || '?';
const status = o.orderStatus || o.order_status || o.status || 'unknown';
const total = o.totalPrice || o.total_price || o.total || '';
const date = o.dateAdded || o.date_added || o.createdAt || '';
return `${i + 1}. Order #${no} | Status: ${status}${total ? ` | Total: ${total}` : ''}${date ? ` | Date: ${date}` : ''}`;
});
return { text: `Found ${orders.length} order(s) for ${args.customerEmail}:\n${lines.join('\n')}`, items: orders };
}
return { text: 'Please provide an order number or customer email to look up the order.', items: null };
} catch (e) {
console.error("[orders err]", e.message);
return { text: 'Order query encountered an error (trading-service). Please try again or contact helpdesk@yehwang.com.', items: null };
}
}
// 查询单个订单详情 + 物流追踪
async function fetchOrderDetail(baseUrl, orderId, headers, listInfo) {
// 并行查详情 + 物流
const [detailRes, trackRes] = await Promise.allSettled([
fetch(`${baseUrl}/admin/mall/api/trading/order/detail?orderId=${encodeURIComponent(orderId)}`, { headers }),
fetch(`${baseUrl}/admin/mall/api/trading/order/getTrackPreSaleOrder?orderId=${encodeURIComponent(orderId)}`, { headers }),
]);
let detail = null, tracking = null;
if (detailRes.status === 'fulfilled' && detailRes.value.ok) {
const dj = await detailRes.value.json();
detail = dj.data || dj;
}
if (trackRes.status === 'fulfilled' && trackRes.value.ok) {
const tj = await trackRes.value.json();
tracking = tj.data || tj;
}
if (!detail && !listInfo) return { text: `No order detail found for order ID ${orderId}.`, items: null };
const d = detail || listInfo;
const lines = [];
lines.push(`Order #${d.orderNo || d.order_no || d.no || orderId}`);
lines.push(` Status: ${d.orderStatus || d.order_status || d.status || 'unknown'}`);
lines.push(` Shipping status: ${d.shippingStatus || d.shipping_status || 'N/A'}`);
if (d.totalPrice || d.total_price) lines.push(` Total: ${d.totalPrice || d.total_price}`);
if (d.dateAdded || d.date_added) lines.push(` Date: ${d.dateAdded || d.date_added}`);
if (d.shippingMethod || d.shipping_method) lines.push(` Shipping method: ${d.shippingMethod || d.shipping_method}`);
// 物流信息
if (tracking) {
const parcels = Array.isArray(tracking) ? tracking : (tracking.list || [tracking]);
parcels.forEach((p, i) => {
const tn = p.trackingNumber || p.tracking_number || '';
const sc = p.shippingCode || p.shipping_code || '';
const sm = p.shippingMethod || p.shipping_method || '';
const sa = p.shippingAt || p.shipping_at || '';
if (tn || sm) {
lines.push(` Parcel ${i + 1}: ${sm} ${tn}${sa ? ` (shipped: ${sa})` : ''}`);
}
});
}
// 商品明细
const items = d.products || d.items || [];
if (items.length) {
lines.push(' Items:');
items.slice(0, 5).forEach(it => {
lines.push(` - ${it.name || it.productName || 'item'} (SKU: ${it.sku || it.model || '?'}) x${it.quantity || 1} @ ${it.price || ''}`);
});
}
return { text: lines.join('\n'), items: { detail, tracking, listInfo } };
}
// ---------- 库存/价格查询(对接 eta-service)----------
// eta-service 是 Yehwang 的库存微服务(Hyperf/PHP,端口 9501/9701)
// 接口:GET /eta/stock-summary?product_id=X 库存汇总
// GET /eta/query?product_id=X ETA + 可用数量
const INVENTORY_API_URL = process.env.INVENTORY_API_URL || ''; // eta-service base URL
const INVENTORY_API_KEY = process.env.INVENTORY_API_KEY || '';
// 查询实时库存/ETA。args: {sku?, productName?}
// eta-service 用 product_id(数字),sku 可能有字母前缀,尝试解析数字部分
async function searchInventory(args) {
if (!INVENTORY_API_URL) return { text: 'Inventory query API (eta-service) is not configured.', items: null };
try {
const headers = { 'Content-Type': 'application/json' };
if (INVENTORY_API_KEY) headers['Authorization'] = `Bearer ${INVENTORY_API_KEY}`;
// 从 sku 或 productName 中提取 product_id
// eta-service 的 product_id 是数字;客户可能给 "NECK-001" 或纯数字 "12345"
let productId = null;
if (args.sku) {
const numMatch = String(args.sku).match(/\d+/);
if (numMatch) productId = numMatch[0];
}
if (!productId && args.productName) {
const numMatch = String(args.productName).match(/\d+/);
if (numMatch) productId = numMatch[0];
}
if (!productId) {
return { text: `Could not extract a product ID from "${args.sku || args.productName}". Please provide a valid product ID or SKU number.`, items: null };
}
// 并行查库存汇总 + ETA
const [stockRes, etaRes] = await Promise.allSettled([
fetch(`${INVENTORY_API_URL}/eta/stock-summary?product_id=${encodeURIComponent(productId)}`, { headers }),
fetch(`${INVENTORY_API_URL}/eta/query?product_id=${encodeURIComponent(productId)}`, { headers }),
]);
// eta-service 返回: { code: 200, message: "success", data: {...} }
let stockData = null, etaData = null;
if (stockRes.status === 'fulfilled' && stockRes.value.ok) {
const sj = await stockRes.value.json();
stockData = sj.data || sj;
}
if (etaRes.status === 'fulfilled' && etaRes.value.ok) {
const ej = await etaRes.value.json();
etaData = ej.data || ej;
}
if (!stockData && !etaData) {
return { text: `No inventory data found for product ID ${productId}.`, items: null };
}
// 格式化为 LLM 可读文本
const lines = [];
if (stockData) {
const instant = stockData.instantStock != null ? stockData.instantStock : 'N/A';
const inTransit = stockData.inTransitQuantity != null ? stockData.inTransitQuantity : 'N/A';
const reserved = stockData.reservedQuantity != null ? stockData.reservedQuantity : 'N/A';
const frozen = stockData.frozenQuantity != null ? stockData.frozenQuantity : 'N/A';
const available = (typeof instant === 'number' && typeof reserved === 'number') ? Math.max(0, instant - reserved) : 'N/A';
lines.push(`Stock Summary (product_id: ${productId}):`);
lines.push(` Instant stock: ${instant}`);
lines.push(` Available (instant - reserved): ${available}`);
lines.push(` In transit: ${inTransit}`);
lines.push(` Reserved: ${reserved}`);
lines.push(` Frozen: ${frozen}`);
}
if (etaData) {
lines.push(`ETA Info:`);
lines.push(` ETA date: ${etaData.etaDate || 'N/A'}`);
lines.push(` Available quantity: ${etaData.availableQuantity != null ? etaData.availableQuantity : 'N/A'}`);
lines.push(` Status: ${etaData.status || 'N/A'}`);
lines.push(` Confidence: ${etaData.confidence != null ? etaData.confidence : 'N/A'}`);
}
return { text: lines.join('\n'), items: { stock: stockData, eta: etaData } };
} catch (e) {
console.error("[inventory err]", e.message);
return { text: 'Inventory query encountered an error (eta-service). Please try again or contact helpdesk@yehwang.com.', items: null };
}
}
const EXTRACT_SYSTEM = `You are an information extractor. Based on a customer service conversation (which may be in English, German, French, Spanish, Italian, Dutch, or Chinese), judge whether enough sales-lead info has been collected.
Judge ready=true only if the conversation clearly contains a [company name] AND a [product/need of interest] AND [contact info — at least an email or phone, OR a contact person's name] — in ANY supported language.
IMPORTANT for the "contact" field: If at any point the bot asked for a contact name (联系人姓名/联系人/your name) and the customer replied with a short word (like "tiema", "John", "张三", a nickname), THAT is the contact name — put it in the "contact" field. Do not leave it empty just because it doesn't look like a full name.
For fields not present in the conversation, leave them empty — do NOT make anything up.
The "name" and "summary" fields should ALWAYS be in ENGLISH (regardless of customer language), so they can be used as CRM data.
Output ONLY one JSON object, nothing else, no markdown code fences:
{
"ready": true|false,
"opportunity": {
"name": "