Author SHA1 Message Date
wangliang 3b5314b2a5 chore(libredesk): 升级到 v2.9.0 2026-10-26 13:12:38 +08:00
wangliang a0d386b752 fix(bridge): 商品搜索结果按库存优先排序 2026-10-23 15:19:24 +08:00
wangliang 74d13ec181 feat(prompt): 一件代发优先引用代发来源 2026-10-23 15:15:47 +08:00
wangliang dfc3b497f5 feat(kb): 新增一件代发 FAQ 知识库 2026-10-23 15:11:01 +08:00
wangliang 8ae8d2b468 docs(runbook): 补充 WhatsApp 回调 override 与多栈号码的坑 2026-10-23 15:03:57 +08:00
wangliang 093b970284 fix(bridge): 转人工提示只输出单一语言并补齐 es/tr 2026-10-23 15:03:57 +08:00
wangliang 7a9ecd0b8d fix(bridge): 商品价格按站点币种返回 2026-10-23 15:02:26 +08:00
wangliang 751d9d63cd fix(bridge): 漏调 collect_info 时补抽线索 2026-10-23 14:36:47 +08:00
wangliang 4902cc632f fix(bridge): 拦截商机引用泄露 2026-10-23 14:35:00 +08:00
wangliang 22de20e718 fix(bridge): 商机拦截话术不使用购物车 2026-10-23 14:25:03 +08:00
wangliang 9ce3120648 fix(bridge): 商机漏建兜底与匿名线索 2026-10-23 14:22:14 +08:00
wangliang 22d96edee6 fix(bridge): 商品链接兜底与强制检索 2026-10-23 13:15:37 +08:00
wangliang 4d7b89492b docs: disable agent email notifications due to bounces 2026-10-23 12:40:12 +08:00
wangliang ad1576588e docs: record WhatsApp callback switched to test domain 2026-10-23 12:31:00 +08:00
wangliang 888612924f docs: record test email/agents and WhatsApp callback status 2026-10-23 12:27:56 +08:00
wangliang 51c62715a5 fix: allow email inbox setup from SMTP env without old mail config 2026-10-23 12:26:23 +08:00
wangliang c6669b236a docs: record test environment bridge cutover 2026-10-23 12:12:04 +08:00
37 changed files with 1225 additions and 79 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ POSTGRES_PASSWORD=<change-me>
POSTGRES_DB=<change-me>
POSTGRES_IMAGE=pgvector/pgvector:pg18
REDIS_IMAGE=redis:8.8-alpine
LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0-rc.10
LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0
LIBREDESK_SYSTEM_USER_PASSWORD=<change-me>
LIBREDESK_ENC_KEY=<change-me>
LIBREDESK_BASE_URL=<change-me>
+2
View File
@@ -13,3 +13,5 @@ var/
# 子项目自带的 git 历史
libredesk/.git/
.agent-credentials
+4 -4
View File
@@ -3,11 +3,11 @@
**完全新建**的一套 CRM 客服栈:LibreDesk(平台 + 内置 AI)+ Twenty(CRM)+ bridge(业务工具/集成)。
与旧项目 `/home/dev/code/crm/crm-ai-demo` 完全隔离:独立目录、独立 Postgres/Redis、独立端口,不迁旧数据。
## 当前状态(2026-10-21)
## 当前状态(2026-10-26)
| 组件 | 状态 |
|---|---|
| LibreDesk v2.9.0-rc.10 | ✅ 运行中 :9000(System 密码见 `.env`) |
| LibreDesk v2.9.0 | ✅ 运行中 :9000(System 密码见 `.env`) |
| Twenty v2.30.1(全新实例) | ✅ 运行中 :3100,数据库迁移完成(71 张表) |
| bridge | ✅ 运行中 :4100,4 个工具,单测 18/18 通过 |
| Postgres / Redis | ✅ :5440 / :6390 healthy |
@@ -53,7 +53,7 @@ Twenty :3100 ── 公司/联系人/商机/备注/任务
| 组件 | 地址 | 镜像/实现 |
|---|---|---|
| LibreDesk | http://localhost:9000 | `libredesk:v2.9.0-rc.10`(含 WhatsApp) |
| LibreDesk | http://localhost:9000 | `libredesk:v2.9.0`(含 WhatsApp) |
| Twenty | http://localhost:3100 | `twentycrm/twenty:v2.30.1`(全新实例) |
| bridge | http://localhost:4100 | Node 24,自研 |
| Postgres | localhost:5440 | pgvector/pg18,库 `libredesk` / `twenty` / `bridge` |
@@ -220,6 +220,6 @@ cd bridge && npm test
## 关键约束
- **镜像直接使用 Docker Hub**:`libredesk/libredesk`、`pgvector/pgvector`、`redis`、`twentycrm/twenty`;如网络受限可在 Docker daemon 配自己的 registry mirror
- **WhatsApp 仅 v2.9 RC 支持**,需要 Meta Cloud API 凭据;RC 版供联调,不直接上生产
- **WhatsApp 自 v2.9.0 起支持**,需要 Meta Cloud API 凭据;当前运行 v2.9.0 正式版
- **LibreDesk AI 仍是 beta**,建议低流量验证
- 旧栈(Chatwoot/bridge/Twenty :3000)保持运行,可并行对照,互不影响
+83 -8
View File
@@ -3,13 +3,88 @@
"timeoutMs": 15000,
"maxFaqsPerPage": 50,
"pages": [
{ "url": "https://www.yehwang.com/register.html", "file": "01-registration-account.md", "title": "Registration & Account", "category": "registration" },
{ "url": "https://www.yehwang.com/order.html", "file": "02-ordering.md", "title": "Ordering", "category": "ordering" },
{ "url": "https://www.yehwang.com/pre-order.html", "file": "03-pre-order.md", "title": "Pre-Order", "category": "pre-order" },
{ "url": "https://www.yehwang.com/payment.html", "file": "04-payment-vat-invoice.md", "title": "Payment, VAT & Invoices", "category": "payment" },
{ "url": "https://www.yehwang.com/shipment.html", "file": "05-shipping-delivery.md", "title": "Shipping & Delivery", "category": "shipping" },
{ "url": "https://www.yehwang.com/returns.html", "file": "06-returns-refunds.md", "title": "Returns & Refunds", "category": "returns" },
{ "url": "https://www.yehwang.com/factory-direct.html", "file": "07-factory-direct.md", "title": "Factory-Direct", "category": "factory-direct" },
{ "url": "https://www.yehwang.com/other-questions-1987986538", "file": "10-products-and-jewelry-care.md", "title": "Other Questions", "category": "other" }
{
"url": "https://www.yehwang.com/register.html",
"file": "01-registration-account.md",
"title": "Registration & Account",
"category": "registration"
},
{
"url": "https://www.yehwang.com/order.html",
"file": "02-ordering.md",
"title": "Ordering",
"category": "ordering"
},
{
"url": "https://www.yehwang.com/pre-order.html",
"file": "03-pre-order.md",
"title": "Pre-Order",
"category": "pre-order"
},
{
"url": "https://www.yehwang.com/payment.html",
"file": "04-payment-vat-invoice.md",
"title": "Payment, VAT & Invoices",
"category": "payment"
},
{
"url": "https://www.yehwang.com/shipment.html",
"file": "05-shipping-delivery.md",
"title": "Shipping & Delivery",
"category": "shipping"
},
{
"url": "https://www.yehwang.com/returns.html",
"file": "06-returns-refunds.md",
"title": "Returns & Refunds",
"category": "returns"
},
{
"url": "https://www.yehwang.com/factory-direct.html",
"file": "07-factory-direct.md",
"title": "Factory-Direct",
"category": "factory-direct"
},
{
"url": "https://www.yehwang.com/other-questions-1987986538",
"file": "10-products-and-jewelry-care.md",
"title": "Other Questions",
"category": "other"
},
{
"url": "https://dropshipping.yehwang.com/faq/register",
"file": "12-dropshipping-register.md",
"title": "Dropshipping — Register",
"category": "dropshipping-register",
"extractor": "nuxt"
},
{
"url": "https://dropshipping.yehwang.com/faq/order",
"file": "13-dropshipping-order.md",
"title": "Dropshipping — Order",
"category": "dropshipping-order",
"extractor": "nuxt"
},
{
"url": "https://dropshipping.yehwang.com/faq/payment",
"file": "14-dropshipping-payment.md",
"title": "Dropshipping — Payment",
"category": "dropshipping-payment",
"extractor": "nuxt"
},
{
"url": "https://dropshipping.yehwang.com/faq/shipment",
"file": "15-dropshipping-shipment.md",
"title": "Dropshipping — Shipment",
"category": "dropshipping-shipment",
"extractor": "nuxt"
},
{
"url": "https://dropshipping.yehwang.com/faq/returns",
"file": "16-dropshipping-returns.md",
"title": "Dropshipping — Returns",
"category": "dropshipping-returns",
"extractor": "nuxt"
}
]
}
+3
View File
@@ -5,6 +5,7 @@
"www": {
"site": "www",
"tenantId": 2,
"currencyCode": "EUR",
"searchUrl": "https://www.yehwang.com/search.html?q=",
"siteMap": "site-map.json",
"language": "en"
@@ -12,6 +13,7 @@
"us": {
"site": "us",
"tenantId": 2,
"currencyCode": "USD",
"searchUrl": "https://us.yehwang.com/search.html?q=",
"siteMap": "site-map-us.json",
"language": "en"
@@ -19,6 +21,7 @@
"eu": {
"site": "www",
"tenantId": 2,
"currencyCode": "EUR",
"searchUrl": "https://www.yehwang.com/search.html?q=",
"siteMap": "site-map.json",
"language": "en"
+30
View File
@@ -0,0 +1,30 @@
---
title: Dropshipping — Register
category: dropshipping-register
source_url:
- https://dropshipping.yehwang.com/faq/register
last_updated: 2026-10-23
keywords: [dropshipping, dropship, register, registration, account, shopify, business buyer]
---
# Dropshipping — Register
> 📎 Source: https://dropshipping.yehwang.com/faq/register · 抓取 2026-10-23
### Q: Who can register as a customer at Yehwang dropshipping?
Yehwang Dropshipping is a B2B wholesale supplier specializing in providing high-quality products for resellers and businesses. We welcome the following types of customers:Ambitious e-commerce beginners, Social media influencers, Startup e-commerce brands, Existing online sellers, Offline jewelry & gift shops. Note: Registration is reserved for business buyers only. We do not serve retail consumers.
### Q: I do not have a company but I do want to purchase your products, is this possible?
No, unfortunately this won’t be possible.Please follow the steps below: Application Submission: Fill in the required company information Review & Activation: Once our team approves your submitted materials, we will grant access permissions based on your specific business circumstances. Please note: The most critical requirement for this process is that your Shopify store must have successfully authorized and integrated with Yehwang. Services cannot be activated without this completed connection.
### Q: How can I register an account with you?
Hit the 'Register' button at the top right corner on our website. Fill out all your details and check if the supplied information is correct. If everything looks good, hit the ‘Create account’ button.
### Q: How to Connect Your Shopify Store to Yehwang dropshipping?
1. Set Up a Shopify Store: Ensure you have an active Shopify store ready for selling. 2. Account Registration: Register for a Yehwang account and log in. 3. Submit Your Application: Complete the partnership application form to proceed. 4. Account Activation: Your dropshipping permissions will be activated. 5. Start Selling: Sync Yehwang products to your Shopify store and begin selling!
### Q: Is uninstalling Yehwang dropshipping reversible?
If You Uninstall YEHWANG Dropshipping From Your Store, Please Note: Once the store is removed from the current YEHWANG Dropshipping account, all the data of that store on YEHWANG Dropshipping will be lost and it is not reversible.
### Q: I forgot my password, now what?
It only takes a few steps to reset your password. Select "Forgot Password" on the login page. Then fill in your email address. We will send a password modification link to the email you filled in. Click the link to reset your password. If you encounter any problems while resetting your password, contact us at helpdesk@yehwang.com or call us on +31 0610422670
+39
View File
@@ -0,0 +1,39 @@
---
title: Dropshipping — Order
category: dropshipping-order
source_url:
- https://dropshipping.yehwang.com/faq/order
last_updated: 2026-10-23
keywords: [dropshipping, dropship, order, ordering, MOQ, minimum order, product list, import]
---
# Dropshipping — Order
> 📎 Source: https://dropshipping.yehwang.com/faq/order · 抓取 2026-10-23
### Q: How Do I Start Selling with Yehwang Dropshipping?
Step 1:Connect Your Store Connect your online store to add items to your store and sync orders. Step 2:Adjust Your Store Setting Edit your pricing markups and order settings from our platform's settings page. by default, product price markup is 2x product price. Step 3:Add Products To Your Import List Search for products from YEHWANG Dropshipping Step 4:Sync Products To Your Store Publish products to your online store and start selling right away!
### Q: Sync Orders From Your Store
YEHWANG Dropshipping synchronizes your store orders and ships them synchronously after payment
### Q: Will the customer know that this order is shipped from Yehwang?
All packages are shipped in unbranded, neutral packaging without any Yehwang identifiers, ensuring customers receive items as if shipped directly from your store. If you have your own brand logo, please contact us.
### Q: Do you support private label packaging?
Yes, we offer private label and customized packaging solutions. The following conditions apply: Customized stamp: €30 per order, available for customers with 100+ orders per month. Enterprise dropshipping solutions: Custom solutions are available for larger-volume dropshipping businesses. Custom packaging materials: Minimum order quantity (MOQ) starts from 5,000 pieces. Private label products: Minimum order quantity (MOQ) starts from 120 pieces. Please contact our sales team to discuss the possibilities and find a solution that best suits your business.
### Q: What is the minimum order value?
There’s no minimum order quantity—order a single item or in bulk, whichever suits your needs. Perfect for testing new products!
### Q: Which Products Support Dropshipping?
Our delivery platform spans 50+ core categories, specializing in fashion, lifestyle, and creative products. With a curated selection of 10,000+ SKUs, we empower diverse business models—from e-commerce retail and cross-border distribution to boutique stores—ensuring seamless adaptation to your unique needs.
### Q: I did not receive an confirmation for my order.
You will receive an order confirmation immediately after placing your order. If you didn’t receive an email please check your spam folder. Still no e-mail? Unfortunately it looks like something went wrong when you filled out your details. Check and change your details through your account. If you did not receive an order confirmation despite all details being correct then please contact our customer service through service@yehwang.com. We will contact you as soon as possible.
### Q: Is it possible to cancel or adjust my order after placing it?
It is not possible to cancel finalized orders. Items in your confirmed order cannot be exchanged nor can items be added to a finalized order. Any additions to your order need to be placed on a separate order.
### Q: Do you charge a service fee for dropshipping orders?
Yes. A €1.50 service fee applies to each dropshipping order. For orders with 3 or more products, an additional €0.20 fee is charged per product.
+27
View File
@@ -0,0 +1,27 @@
---
title: Dropshipping — Payment
category: dropshipping-payment
source_url:
- https://dropshipping.yehwang.com/faq/payment
last_updated: 2026-10-23
keywords: [dropshipping, dropship, payment, pay, invoice, VAT, currency, balance, top-up]
---
# Dropshipping — Payment
> 📎 Source: https://dropshipping.yehwang.com/faq/payment · 抓取 2026-10-23
### Q: What are the payment options at Yehwang dropshipping?
We provide the following payment methods for orders placed in our online store: - iDeal; - Paypal; - Creditcard; - VISA; - Sofort; - Giropay; - Bancontact;
### Q: Is it possible to pay by bank transfer?
It is not possible to pay by bank transfer. Payment can be made through our website.
### Q: I registered my account with a valid VAT number, why do you still charge VAT?
1. Are you a Dutch customer and is your selected delivery address in the Netherlands? Then you always pay VAT over your orders 2. Check if your VAT number is listed in the My Account details. Do you have a valid VAT number and this number is not listed in My Account? Please contact us at service@yehwang.com 3. Select your delivery address and check if VAT is still charged. If VAT is still charged please contact us at service@yehwang.com
### Q: My company falls within the VAT exemption regulation. How can I receive an invoice with VAT from you?
If you have a valid VAT number and want to pay VAT over your orders with us, please contact us before placing your first order. We will then ensure that you are properly registered in our system and you can start ordering with us.
### Q: Can I have an invoice adjusted?
Unfortunately this is not possible. Once your order has been placed, we can no longer change the information on your invoice.
+30
View File
@@ -0,0 +1,30 @@
---
title: Dropshipping — Shipment
category: dropshipping-shipment
source_url:
- https://dropshipping.yehwang.com/faq/shipment
last_updated: 2026-10-23
keywords: [dropshipping, dropship, shipment, shipping, delivery, tracking, warehouse, dispatch]
---
# Dropshipping — Shipment
> 📎 Source: https://dropshipping.yehwang.com/faq/shipment · 抓取 2026-10-23
### Q: What shipping options do you offer?
For all deliveries we offer two shipment options: PostNL or DHL. You will find the actual shipping rate in the overview of your cart during check-out. For PostNL shipping rates check our other FAQ on shipping charges. If your order reaches the threshold for free shipping and you select DHL as your courier, then the PostNL fee will be deducted from the DHL fee and you only pay the surcharge. Global DHL transit times can be found here. You can find an indication of global transit times via PostNL here.
### Q: When will my order arrive?
Orders that we receive before 3 P.M. on business days will be shipped by us within 1-3 business days. We do not ship during weekends. Orders that are placed on Friday after 3 P.M. will ship the following Monday. During clearance periods, it may occur that orders don't ship within the 1-3 working days, this could take up to a few days later. After your order ships, you will receive an email containing the Track and Trace link to track the status of your order. Please note that transit times vary per country. Please consult the site of the carrier listed in the Track and Trace link to get an up to date ETA. Global DHL transit times can be found here. You can find an indication of global transit times via PostNL here. Please note that PostNL lead times are an indication only and often times unfortunately experience some delay which could take up to 1 -2 weeks depending on the delivery address.
### Q: Will I receive a track and trace code?
Yes, you will receive a track and trace code by email from Postnl. If you have not received this code you can contact us at service@yehwang.com. Do not forget to mention you order number.
### Q: How does the Brexit affect shipping?
Yehwang dropshipping ships worldwide and the Brexit does not affect this. We still ship to the UK. However, on orders over GBP 135 (excl. VAT and shipping costs) UK customs might charge you import duties and taxes. These costs are to be paid by the customer, Yehwang dropshipping doesn't cover these charges.
### Q: I am working with a Dutch forwarder to export my order to an address outside the EU. You still charge VAT over my order. How can I get the VAT amount refunded?
The VAT will be paid by you in the beginning. After the order has been delivered to your Dutch forwarder, you will receive a proof of export from them. This means that the cargo has left the Netherlands. Please send a copy of this document with your IBAN number to helpdesk@yehwang.com and we will ensure that the VAT is refunded asap.
### Q: My package has not been delivered, what now?
How unfortunate that your package was not delivered. You may contact us via service@yehwang.com. Please don't forget to include your order number. We will initiate an investigation with the carrier to find out where the missing package is. The investigation may take 1 to 3 business days. If the carrier declares the package lost, we will refund the purchase amount. We will proceed with the refund once there is evidence and contact with the carrier that the package was not delivered.
+30
View File
@@ -0,0 +1,30 @@
---
title: Dropshipping — Returns
category: dropshipping-returns
source_url:
- https://dropshipping.yehwang.com/faq/returns
last_updated: 2026-10-23
keywords: [dropshipping, dropship, returns, refund, return, defective, after-sales]
---
# Dropshipping — Returns
> 📎 Source: https://dropshipping.yehwang.com/faq/returns · 抓取 2026-10-23
### Q: Return period (14 or 30 days)
Under our standard B2B policy, Yehwang does not automatically accept consumer returns within 14 or 30 days, as we operate as a wholesale supplier. In a dropshipping setup, the retailer (you) remains the contractual party toward the end customer and is therefore responsible for complying with EU/UK withdrawal regulations. We only offer you 7 days non-reasonable return from you. The same return policy on our website.
### Q: Where should customer returns be sent?
Operationally, the most efficient model is for customer returns to be sent back to you first. This allows you to: Inspect product condition * Verify compliance with your return policy * Consolidate returns if needed Direct returns to Yehwang are generally not standard in dropshipping services. You need handle all your return request directly on your shopify website.
### Q: If returns are sent to Yehwang (upon agreement)
Returns cannot be processed if we receive the direct return from your end user, because there is no order for us to track, please be sure to avoid this by making your return policy clear enough on your website.
### Q: Refund handling
In a dropshipping model: * You refund your customer directly * Yehwang can issue a credit note to you once the returned goods are received and approved (if agreed under the cooperation terms within 7 days)
### Q: Conditions for return acceptance
If returns are accepted by Yehwang, the following conditions apply: * Unworn, unused items * Original packaging intact * No signs of wear, damage, or alteration * Within agreed timeframe Restocking conditions may apply depending on volume and frequency.
### Q: Defective or damaged items
For defective or transit-damaged products: * Clear photos and order reference are required * Claims must be submitted within a short timeframe after delivery * Once verified, we will offer replacement, credit, or refund depending on the case
+83 -2
View File
@@ -10,11 +10,12 @@ const { createMetrics } = require('./metrics');
const { auditAnswer } = require('./audit');
const { markdownToHtml } = require('./markdown');
const { guardReply } = require('./output-guard');
const { appendMissingProductLinks } = require('./reply-links');
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');
@@ -141,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 }));
@@ -230,6 +235,26 @@ function createAgent(cfg, deps = {}) {
stats.completionTokens += Number(response.usage.completion_tokens) || 0;
stats.totalTokens += Number(response.usage.total_tokens) || 0;
}
// 兜底(旧 bridge 同款):product_search 意图下首轮 LLM 一个工具都没调时,
// 丢弃它凭记忆编的回答,强制查一次真实商品结果再让它重答,避免幻觉商品/链接进入回复。
if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && !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);
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: '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;
} catch (err) {
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'forced_search_products', conversation: conv.uuid, ok: false, error: err.message }));
}
}
}
if (response.toolCalls && response.toolCalls.length) {
messages.push({ role: 'assistant', content: response.content || '', tool_calls: response.toolCalls });
for (const call of response.toolCalls) {
@@ -272,8 +297,24 @@ function createAgent(cfg, deps = {}) {
if (!finalContent) {
finalContent = '抱歉,我这边暂时无法处理,请您稍后再试。';
}
// 确定性链接兜底(旧 bridge buildProductListText 同款):LLM 漏写工具返回的真实商品/类目 URL 时,
// 由代码在回复末尾补齐,保证客户至少拿到真实可点链接(放在护栏前,补的链接同样过白名单校验)。
try {
const backfill = appendMissingProductLinks({
content: finalContent,
toolResults: stats.toolResults,
lang: detectLanguage(currentText),
html: /<(p|br|a|ul|ol|strong|div)\b/i.test(finalContent),
});
if (backfill.appended) {
finalContent = backfill.content;
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'link_backfill', conversation: conv.uuid, appended: backfill.appended }));
}
} catch (err) {
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) {
@@ -291,6 +332,46 @@ function createAgent(cfg, deps = {}) {
}
}
// 商机兜底(旧 bridge leadPipeline.maybeCreateLead 同款):槽位齐全但 LLM 没调 create_opportunity 时,
// 由代码直接建商机,避免“回复说已记录/会创建、CRM 里却没有”的漏建。
// 不要求邮箱验证(政策 A):公司 + 联系人(姓名/邮箱/电话)+ 需求即可。
try {
const createTool = byName.get('create_opportunity');
const calledThisTurn = (stats.toolCalls || []).some((t) => t.name === 'create_opportunity');
if (leadStore && createTool && !calledThisTurn) {
const latest = await leadStore.get(conv.uuid);
const slots = (latest && latest.slots) || {};
const hasContact = Boolean(slots.contactName || slots.email || slots.phone);
const ready = Boolean(slots.company && slots.productNeed && hasContact);
if (ready && !(latest && latest.opportunityId)) {
const started = Date.now();
const created = await createTool.handler({
company: slots.company,
contactName: slots.contactName,
email: slots.email,
productNeed: slots.productNeed,
quantity: slots.quantity,
budget: slots.budget,
closeDate: slots.closeDate,
phone: slots.phone,
}, ctx);
stats.toolCalls.push({ name: 'create_opportunity', ms: Date.now() - started, ok: true, auto: true });
stats.toolResults.push(created);
console.log(JSON.stringify({
ts: new Date().toISOString(),
agent: 'lead_auto_create',
conversation: conv.uuid,
created: Boolean(created && created.created),
reason: created && created.reason,
opportunity: created && created.opportunity_id,
verified: ctx.verified,
}));
}
}
} catch (err) {
console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `lead auto create failed: ${err.message}` }));
}
// post-LLM 兜底:LLM 没问公司/联系方式时,代码强制追加多语言追问
let leadFollowup = false;
try {
+1 -1
View File
@@ -8,7 +8,7 @@ function loadSites(env) {
const fallback = {
defaultSite: 'www',
profiles: {
www: { site: 'www', tenantId: null, languageId: null, currencyCode: 'USD', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
www: { site: 'www', tenantId: null, languageId: null, currencyCode: 'EUR', searchUrl: 'https://www.yehwang.com/search.html?q=', siteMap: 'site-map.json' },
},
inboxes: {},
};
+9 -2
View File
@@ -17,17 +17,24 @@ function createEscalation({ client, handoffUserId = 1 }) {
const text = String(reply || '').replace(/\s+$/, '');
const hints = {
zh: '您想转人工客服吗?',
en: 'Would you like to speak to a human agent?',
de: 'Möchten Sie mit einem Mitarbeiter sprechen?',
fr: 'Souhaitez-vous parler à un conseiller ?',
it: 'Vuole parlare con un operatore?',
nl: 'Wilt u met een medewerker spreken?',
es: '¿Desea hablar con un agente humano?',
tr: 'Bir insan temsilcisiyle görüşmek ister misiniz?',
};
// 只输出一种语言:按用户输入判断语言,识别不到时用英文;不再拼英文兜底。
let lang = null;
if (/[\u4e00-\u9fff]/.test(query)) lang = 'zh';
else if (/[ğışç]/i.test(query) || /\b(merhaba|istiyorum|konuşmak|temsilci|fiyat|ürün|sipariş)\b/i.test(query)) lang = 'tr';
else if (/[äöüß]/i.test(query)) lang = 'de';
else if (/[éèêëàâùûîïôç]/i.test(query)) lang = 'fr';
else if (/[ñ¿¡]/i.test(query) || /\b(hola|quiero|hablar|agente|puedo|gracias|precio|pedido)\b/i.test(query)) lang = 'es';
else if (/\b(vorrei|parlare|operatore|desidera|posso|prezzo|ordine)\b/i.test(query)) lang = 'it';
else if (/[éèêëàâùûîïôç]/i.test(query) || /\b(bonjour|merci|conseiller|commande|livraison|prix|voudrais)\b/i.test(query)) lang = 'fr';
else if (/\b(hoe|voor|wilt|bent|geen|verzending|levering|bestelling)\b/i.test(query)) lang = 'nl';
const option = lang ? `\n\n${hints[lang]} / Would you like to speak to a human agent?` : '\n\nWould you like to speak to a human agent?';
const option = `\n\n${hints[lang] || hints.en}`;
return text + option;
}
+49 -6
View File
@@ -2,6 +2,7 @@
const fs = require('fs');
const path = require('path');
const vm = require('node:vm');
function stripHtml(value) {
return String(value || '')
@@ -40,6 +41,47 @@ function extractFaqFromLdJson(html, max = 50) {
return [];
}
// Nuxt SSR 页面(如 dropshipping.yehwang.com)没有 FAQPage JSON-LD,
// FAQ 数据内嵌在 window.__NUXT__ 的 payload 里(data[].info.content = [{title, content(HTML)}])。
// 在隔离 sandbox 里求值这段自执行脚本,只取回返回对象,不接触宿主全局。
function extractNuxtPayload(html) {
const m = String(html || '').match(/__NUXT__=([\s\S]*?)<\/script>/);
if (!m) return null;
try {
return vm.runInNewContext(m[1].replace(/;\s*$/, ''), Object.create(null), { timeout: 2000 });
} catch {
return null;
}
}
function extractFaqFromNuxt(html, max = 50) {
const payload = extractNuxtPayload(html);
if (!payload || !payload.data) return [];
const out = [];
const seen = new Set();
const walk = (node) => {
if (out.length >= max) return;
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
if (!node || typeof node !== 'object') return;
if (typeof node.title === 'string' && typeof node.content === 'string') {
const q = stripHtml(node.title);
const a = stripHtml(node.content);
if (q && a && !seen.has(q)) {
seen.add(q);
out.push({ q, a });
}
}
for (const value of Object.values(node)) {
if (value && typeof value === 'object') walk(value);
}
};
walk(payload.data);
return out;
}
function loadFaqConfig(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -53,20 +95,21 @@ function createFaqFetcher(config = {}) {
const timeoutMs = config.timeoutMs || 15000;
const max = config.maxFaqsPerPage || 50;
async function fetchPage(url) {
const res = await fetch(url, {
async function fetchPage(page) {
const res = await fetch(page.url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', Accept: 'text/html,application/xhtml+xml' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`fetch ${url} failed: HTTP ${res.status}`);
return extractFaqFromLdJson(await res.text(), max);
if (!res.ok) throw new Error(`fetch ${page.url} failed: HTTP ${res.status}`);
const html = await res.text();
return page.extractor === 'nuxt' ? extractFaqFromNuxt(html, max) : extractFaqFromLdJson(html, max);
}
async function fetchAll() {
const out = [];
for (const page of pages) {
try {
const questions = await fetchPage(page.url);
const questions = await fetchPage(page);
out.push({ ...page, questions });
} catch (err) {
out.push({ ...page, questions: [], error: err.message });
@@ -78,4 +121,4 @@ function createFaqFetcher(config = {}) {
return { fetchPage, fetchAll, pages };
}
module.exports = { createFaqFetcher, loadFaqConfig, extractFaqFromLdJson, stripHtml };
module.exports = { createFaqFetcher, loadFaqConfig, extractFaqFromLdJson, extractFaqFromNuxt, extractNuxtPayload, stripHtml };
+5
View File
@@ -10,12 +10,17 @@ const INTENTS = {
GENERAL: 'general',
};
// 商品类目名词:只发一个类目词(如“戒指”)也算商品检索。
// 不能只靠动词(想买/推荐)判断,否则模型会拿上一轮上下文里的商品编造名称和链接。
const PRODUCT_NOUN_RE = /戒指|项链|手链|手镯|耳环|耳钉|耳坠|吊坠|饰品|首饰|珠宝|发饰|发夹|脚链|胸针|\brings?\b|\bnecklaces?\b|\bbracelets?\b|\bbangles?\b|\bearrings?\b|\bpendants?\b|\bjewell?ery\b|\bketting(?:en)?\b|\boorbellen\b|\barmband(?:en)?\b|\bhalsketten?\b|\bohrringe(?:n)?\b|\bschmuck\b|\bcolliers?\b|\bbagues?\b|\bbijoux\b|\banillos?\b|\bpulseras?\b|\bpendientes\b/i;
function quickClassify(query) {
const q = String(query || '').toLowerCase();
if (/转人工|真人|人工客服|speak\s+(to|with)\s+(a\s+)?(human|person|agent)|talk\s+(to|with)\s+(a\s+)?(human|person|agent)|real person|mit einem menschen|parler.*humain|persoon spreken/i.test(q)) return INTENTS.ESCALATE;
if (/订单|物流|tracking|order status|where is my order|发货|到货|快递|bestelling|levering|volgen/i.test(q)) return INTENTS.ORDER_TRACKING;
if (/库存|stock|in stock|available|有货|库存量|can you fulfill|voorraad|beschikbaar/i.test(q)) return INTENTS.INVENTORY_CHECK;
if (/想买|找.*产品|show me|do you have|do you carry|looking for|推荐|recommend|catalog|browse|查找|看看|suchen|finden|aanbevelen/i.test(q)) return INTENTS.PRODUCT_SEARCH;
if (PRODUCT_NOUN_RE.test(q)) return INTENTS.PRODUCT_SEARCH;
if (/退货|refund|return|取消|cancel|vat|invoice|运费|shipping cost|交付|delivery time|会员|membership|折扣|discount|注册|register|账号|account|支付|payment|预购|pre.?order|一件代发|dropship|起订|minimum order|retour|btw|factuur|verzendkosten/i.test(q)) return INTENTS.POLICY_FAQ;
if (/采购|bulk|wholesale|批发|quote|报价|großhandel|groothandel/i.test(q)) return INTENTS.SALES_LEAD;
return INTENTS.GENERAL;
+11 -1
View File
@@ -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 };
+1 -1
View File
@@ -57,7 +57,7 @@ function createOpportunityService({ cfg, twenty, leadStore, client }) {
function summaryFor({ ctx, slots, email, profile }) {
return [
`Company: ${slots.company}`,
`Contact: ${slots.contactName || '(not provided)'} <${email}>`,
`Contact: ${slots.contactName || '(not provided)'}${email ? ` <${email}>` : ''}`,
`Need: ${slots.productNeed}`,
slots.quantity ? `Quantity: ${slots.quantity}` : null,
slots.budget ? `Budget: ${slots.budget}` : null,
+22 -4
View File
@@ -17,7 +17,7 @@ const ACTION_RULES = [
{ type: 'pdf_invoice', re: /(?:PDF|预发票|proforma|invoice).{0,15}(?:已生成|已发送|attached|generated)|(?:attached|generated).{0,15}(?:pdf|invoice|proforma)/i },
{ type: 'team_notify', re: /(?:团队|销售|同事).{0,12}(?:已通知|已联系|会联系|已收到|已转)|team\s+(?:has\s+been|was)\s+notified|notified\s+(?:the\s+)?team|sales\s+team\s+has\s+been\s+notified/i },
{ type: 'opportunity_create', re: /opportunity\s+(?:created|has been created)|商机(?:已|已经)?(?:创建|建立)|opportunity\s+id/i, requiresTool: 'create_opportunity' },
{ type: 'internal_crm', re: /\bCRM\b|商机|opportunity\s+(?:id|created|recorded|has been)|created\s+an?\s+opportunity/i },
{ type: 'internal_crm', re: /\bCRM\b|商机|opportunity\s+(?:id|created|recorded|has been|reference|name|number|no\.?|#)|created\s+an?\s+opportunity|\bopportunity\b\s*[::]/i },
];
const STOCK_RE = /(?:有货|库存|现货|缺货|in stock|out of stock|available|backorder|restock|pre-?order)/i;
@@ -98,6 +98,13 @@ function actionClarification(lang, hasOpportunity) {
return `${hasOpportunity ? 'Your request has been noted; a relevant colleague will follow up.\n\n' : ''}Note: I can't place orders, create order numbers, or send emails/PDFs from this chat; please complete the order in your account cart: https://www.yehwang.com/cart.`;
}
// CRM/商机类措辞被拦时:只把真实发生了的事(需求已记录、同事跟进)告诉客户,
// 不要拼接“无法下单/购物车付款”这类误导性的订单话术。
function crmClarification(lang, hasOpportunity) {
if (lang === 'zh') return hasOpportunity ? '您的需求已记录,相关同事会与您联系。' : '相关同事会与您联系。';
return hasOpportunity ? 'Your request has been noted; a relevant colleague will follow up.' : 'A relevant colleague will follow up.';
}
function replaceUnsupported(content, amounts, stockNumbers, lang) {
let out = String(content || '');
const priceText = lang === 'zh' ? '价格以商品页为准' : 'price shown on the product page';
@@ -120,14 +127,23 @@ function escapeRe(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// 链接被删后不允许留下“点不动的链接文字”:通用 CTA(查看详情/View details…)整段去掉。
const CTA_LABEL_RE = /^(?:[👉➡️→]\s*)?(?:查看详情|查看商品|查看产品|点击查看|点此查看|立即查看|了解更多|详情|view\s+details?|see\s+details?|view\s+product|learn\s+more|shop\s+now|click\s+here)\s*[::]?\s*$/i;
const CTA_LABEL_SOURCE = '(?:[👉➡️→]\\s*)?(?:查看详情|查看商品|查看产品|点击查看|点此查看|立即查看|了解更多|view\\s+details?|see\\s+details?|view\\s+product|learn\\s+more|shop\\s+now|click\\s+here)\\s*[::]\\s*';
function removeUrls(content, urls) {
let out = String(content || '');
const labelOrNothing = (text) => (CTA_LABEL_RE.test(String(text).trim()) ? '' : text);
for (const url of urls) {
const e = escapeRe(url);
out = out.replace(new RegExp('\\[([^\\]]*)\\]\\(' + e + '\\)', 'g'), '$1');
out = out.replace(new RegExp('<a\\b[^>]*href=["\\\']' + e + '["\\\'][^>]*>([\\s\\S]*?)<\\/a>', 'gi'), '$1');
out = out.replace(new RegExp('\\[([^\\]]*)\\]\\(' + e + '\\)', 'g'), (_, text) => labelOrNothing(text));
out = out.replace(new RegExp('<a\\b[^>]*href=["\\\']' + e + '["\\\'][^>]*>([\\s\\S]*?)<\\/a>', 'gi'), (_, text) => labelOrNothing(text));
out = out.split(url).join('');
}
// 删除链接后残留的 “→ 查看详情:” 片段(连同前面的 <br>,避免出现空行)
out = out.replace(new RegExp('(?:<br\\s*\\/?>)?\\s*' + CTA_LABEL_SOURCE + '(?=<br\\s*\\/?>|<\\/p>|\\n|$)', 'gi'), '');
out = out.replace(/(?:<br\s*\/?>)?\s*[👉➡️→]\s*(?=<br\s*\/?>|<\/p>|\n|$)/gi, '');
out = out.replace(/<p>\s*<\/p>/gi, '').replace(/<p>\s*(?:<br\s*\/?>\s*)+/gi, '<p>');
return out.replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
}
@@ -146,6 +162,7 @@ function guardReply({ content, toolResults, toolCalls, lang = 'en' }) {
blocked = true;
rewritten = true;
reasons.push(...violations.map((v) => v.type));
const crmOnly = violations.every((v) => v.type === 'internal_crm' || v.type === 'opportunity_create');
// HTML 回复不做句子切割,避免破坏标签,直接安全话术
if (/<[a-z][^>]*>/i.test(raw)) {
out = safeReply(lang, { hasOpportunity });
@@ -154,7 +171,8 @@ function guardReply({ content, toolResults, toolCalls, lang = 'en' }) {
.filter((sentence) => !detectActionClaims(sentence).some((h) => !h.requiresTool || !hasTool(toolCalls, h.requiresTool)))
.join('')
.trim();
out = isMeaningful(kept) ? `${kept}\n\n${actionClarification(lang, hasOpportunity)}` : safeReply(lang, { hasOpportunity });
const clarification = crmOnly ? crmClarification(lang, hasOpportunity) : actionClarification(lang, hasOpportunity);
out = isMeaningful(kept) ? `${kept}\n\n${clarification}` : (crmOnly ? crmClarification(lang, hasOpportunity) : safeReply(lang, { hasOpportunity }));
}
}
+6 -5
View File
@@ -14,16 +14,17 @@ LANGUAGE (most important): ALWAYS reply in the same language as the customer's l
TOOL DISCIPLINE:
- search_kb: call this FIRST for ANY question about policies/FAQ — registration, account, password, minimum order, cancel, pre-order, payment, VAT, invoice, shipping cost, delivery time, returns, refunds, defective items, membership, discounts, dropshipping, factory-direct, materials, discolor, jewelry care. Answer only from the returned content; do not answer policies from memory. If nothing is returned, say you need to check with the team.
- search_products: any product-finding request (recommend/show/find/buy products). Use exact product names from results; include prices, stock and full URLs when available. If a category link is returned, include it as a full clickable URL. If results are empty, say so honestly.
- search_products: any product-finding request (recommend/show/find/buy products). A bare product/category word or short phrase is a product request too (e.g. 戒指, 项链, rings, necklace, earrings): call search_products before answering. Use exact product names from results; include prices, stock and full URLs when available. If a category link is returned, include it as a full clickable URL. If results are empty, say so honestly. Never reuse product names, prices or links from earlier turns unless they are in THIS turn's tool results.
- search_orders: only for the verified customer's own orders. If the customer gives an order number, pass it to get full detail (status, items, totals, address, shipping). Never accept another person's email; the identity comes from the platform.
- search_inventory: real-time stock/ETA for a specific product; prefer product_id returned by search_products.
- collect_info: whenever you learn any lead details (company, contact name, phone, product need, quantity, budget, close date), call it immediately so nothing is lost. Record productNeed exactly as the customer stated, never invent or substitute.
- collect_info: whenever you learn any lead details (company, contact name, email, phone, product need, quantity, budget, close date), call it immediately so nothing is lost. Record productNeed exactly as the customer stated, never invent or substitute.
- search_email_history: only for a verified customer asking about their own past emails (quotes, previous inquiries, "I emailed you before"). Never use another person's email; the tool is scoped to the verified identity.
- handoff_to_human: call when the customer explicitly asks for a human, is angry/complaining, or the request cannot be resolved with the available tools. Use it instead of only saying "I'll transfer you".
- create_opportunity: only after company + contact name + product need are all known; call once per customer. If it returns created=false, do not create another. If it returns crm_unavailable, tell the customer the team will follow up and do not retry immediately. NEVER tell the customer about CRM, opportunity IDs, internal records, assigned users, or tool results. If it was created, only say their request has been noted and a relevant colleague will contact them.
- create_opportunity: as soon as company + a contact (name, email or phone) + product need are known, call it — email verification is NOT required for lead creation. Call it exactly once per customer. If it returns created=false, do not create another. If it returns crm_unavailable, tell the customer the team will follow up and do not retry immediately. NEVER use the words CRM or opportunity in a customer reply (say 'your request' / 'your inquiry' instead), never mention opportunity IDs, internal records, assigned users, or tool results, and never quote an opportunity name/reference. Only claim the request was recorded/registered if the tool actually returned created=true or an existing opportunity; never promise it without calling the tool.
- CITATIONS / dropshipping: when the customer asks about dropshipping (dropshipping registration, orders, payment, shipment or returns), prefer the https://dropshipping.yehwang.com/faq/* URLs that appear in the search_kb source_urls. Do NOT replace them with similar www.yehwang.com pages; only fall back to www.yehwang.com when no dropshipping source is available. Cite the URL that actually supports the answer.
VERIFICATION (important):
- Order lookup and opportunity creation require a verified customer.
- Order lookup requires a verified customer. Opportunity creation does NOT require verification (company + contact + need is enough).
- If the customer is not verified and asks for private order data: ask for their email, call send_verification_code({email}), tell them a code was sent, then wait for the code and call verify_email_code({code}).
- Do not call send_verification_code until the customer provides an email. Never invent an email address.
- The code expires in 10 minutes and allows 5 attempts. If verification fails, ask them to check the code or request a new one.
@@ -31,7 +32,7 @@ VERIFICATION (important):
SALES FLOW:
- For clear buying intent (product + quantity/budget) but missing company or contact, ask for the missing pieces naturally in the same reply.
- Do not create an opportunity until the required three fields exist.
- Create the opportunity only after company + contact + product need exist; do not create it for a vague inquiry.
- Do not proactively push discounts; if asked, volume/membership tiers are available (mention only when asked).
ANTI-HALLUCINATION:
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// 旧 bridge(crm-ai-demo)的确定性商品链接兜底:LLM 回答后面由代码追加真实商品/类目链接。
// 新 bridge 把 Markdown 转成 HTML 发给 LibreDesk,所以这里同时支持 Markdown 与 HTML 两种模式。
const NO_MATCH_RE = /(?:no exact match|no matching|don'?t carry|do not carry|not available|out of stock|nicht verfügbar|pas disponible|niet beschikbaar|未找到|没有找到|暂无|无货|缺货)/i;
const LABELS = {
Chinese: { title: '可直接查看:', details: '商品详情', stock: (n) => `${n} 件` },
English: { title: 'Click to view:', details: 'Product details', stock: (n) => `${n} pcs` },
German: { title: 'Hier ansehen:', details: 'Produktdetails', stock: (n) => `${n} Stk.` },
French: { title: 'À voir ici :', details: 'Détails du produit', stock: (n) => `${n} pièces` },
Dutch: { title: 'Hier bekijken:', details: 'Productdetails', stock: (n) => `${n} stuks` },
Spanish: { title: 'Ver aquí:', details: 'Detalles del producto', stock: (n) => `${n} piezas` },
Italian: { title: 'Vedi qui:', details: 'Dettagli prodotto', stock: (n) => `${n} pezzi` },
};
const LANG_ALIASES = {
zh: 'Chinese', cn: 'Chinese', chinese: 'Chinese',
en: 'English', english: 'English',
de: 'German', german: 'German',
fr: 'French', french: 'French',
nl: 'Dutch', dutch: 'Dutch',
es: 'Spanish', spanish: 'Spanish',
it: 'Italian', italian: 'Italian',
};
function labelsFor(lang) {
const key = LANG_ALIASES[String(lang || '').toLowerCase()];
return LABELS[key] || LABELS.English;
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeMarkdownLabel(text) {
return String(text || '').replace(/[[\]\\]/g, ' ').replace(/\s+/g, ' ').trim();
}
/** 从工具结果里收集真实商品/类目链接(保持出现顺序,按 URL 去重)。 */
function collectProductLinks(toolResults) {
const out = [];
const seen = new Set();
const push = (item) => {
if (!item || typeof item !== 'object') return;
const url = String(item.url || item.product_url || '').trim();
if (!/^https?:\/\//i.test(url) || seen.has(url)) return;
seen.add(url);
out.push({
name: String(item.name || item.title || '').trim() || null,
url,
price: item.price != null && item.price !== '' ? String(item.price) : null,
stock: item.stock != null && item.stock !== '' ? item.stock : null,
});
};
for (const result of toolResults || []) {
if (!result || typeof result !== 'object') continue;
if (Array.isArray(result.products)) result.products.forEach(push);
if (Array.isArray(result.category_links)) result.category_links.forEach(push);
}
return out;
}
/**
* 把工具结果里有、但回复里没写的真实链接补到末尾。
* - 回复已明确说“没有/缺货”时不补(避免自相矛盾,沿用旧 bridge 行为);
* - 已包含的 URL 自动去重;
* - html=true 时输出 <a>,否则输出 Markdown 链接(会再经 markdownToHtml 转 HTML)。
*/
function appendMissingProductLinks({ content, toolResults, lang = 'English', html = false, limit = 3 }) {
const base = String(content || '');
if (!base.trim() || NO_MATCH_RE.test(base)) return { content: base, appended: 0 };
const missing = collectProductLinks(toolResults).filter((item) => !base.includes(item.url));
if (!missing.length) return { content: base, appended: 0 };
const labels = labelsFor(lang);
// 有货的排前面(stock=0 的真实商品只在没有别的可选时才补),保持工具返回的相对顺序。
const stockRank = (item) => {
if (item.stock == null) return 1;
const n = Number(item.stock);
if (!Number.isFinite(n)) return 1;
return n > 0 ? 0 : 2;
};
const ordered = missing
.map((item, index) => ({ item, index }))
.sort((a, b) => stockRank(a.item) - stockRank(b.item) || a.index - b.index)
.map((x) => x.item);
const top = ordered.slice(0, Math.max(1, Number(limit) || 3));
const lines = top.map((item) => {
const text = item.name || labels.details;
const meta = [item.price, item.stock != null ? labels.stock(item.stock) : null].filter(Boolean).join(' | ');
if (html) {
const anchor = `<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
return `<li>${anchor}${meta ? ` — ${escapeHtml(meta)}` : ''}</li>`;
}
return `- [${escapeMarkdownLabel(text)}](${item.url})${meta ? ` — ${meta}` : ''}`;
});
const block = html
? `<p>${escapeHtml(labels.title)}</p><ul>${lines.join('')}</ul>`
: `${labels.title}\n${lines.join('\n')}`;
return { content: `${base.trimEnd()}\n\n${block}`, appended: top.length };
}
module.exports = { appendMissingProductLinks, collectProductLinks, labelsFor, NO_MATCH_RE };
+19 -13
View File
@@ -221,12 +221,13 @@ const TOOL_DEFS = [
{
name: 'collect_info',
description:
'Record customer lead details incrementally across turns (company, contact name, phone, product need, quantity, budget, close date). Call this whenever the customer provides any of these; it persists them for the conversation so the final create_opportunity does not lose earlier facts. Does not create a CRM record.',
'Record customer lead details incrementally across turns (company, contact name, email, phone, product need, quantity, budget, close date). Call this whenever the customer provides any of these; it persists them for the conversation so the final create_opportunity does not lose earlier facts. Does not create a CRM record.',
parameters: {
type: 'object',
properties: {
company: { type: 'string', description: 'customer company name' },
contactName: { type: 'string', description: 'contact person full name' },
email: { type: 'string', description: 'contact email if the customer provided one' },
phone: { type: 'string', description: 'phone number if stated' },
productNeed: { type: 'string', description: 'what the customer asked for, in their own words' },
quantity: { type: 'string', description: 'quantity if stated' },
@@ -240,7 +241,7 @@ const TOOL_DEFS = [
{
name: 'send_verification_code',
description:
'Send a 6-digit email verification code to the customer. Only call after the customer provides their email address and private data (orders/opportunities) requires verification. Never invent an email address.',
'Send a 6-digit email verification code to the customer. Only call after the customer provides their email address and private data (orders) requires verification. Never invent an email address.',
parameters: {
type: 'object',
properties: {
@@ -253,7 +254,7 @@ const TOOL_DEFS = [
{
name: 'verify_email_code',
description:
'Verify the 6-digit code the customer received by email. Call after the customer replies with the code. On success the conversation becomes verified for private order/opportunity tools.',
'Verify the 6-digit code the customer received by email. Call after the customer replies with the code. On success the conversation becomes verified for private order tools.',
parameters: {
type: 'object',
properties: {
@@ -279,12 +280,13 @@ const TOOL_DEFS = [
{
name: 'create_opportunity',
description:
'Create a CRM opportunity in Twenty after you have collected the 3 required elements: company, contact name, and product/need. Call it exactly once per customer; if it returns created=false an opportunity already exists. Requires verified contact.',
'Create a CRM opportunity in Twenty once you know the company, a contact (name, email or phone) and the product/need. Call it exactly once per customer; if it returns created=false an opportunity already exists. Email verification is NOT required for lead creation.',
parameters: {
type: 'object',
properties: {
company: { type: 'string', description: 'customer company name' },
contactName: { type: 'string', description: 'contact person full name' },
email: { type: 'string', description: 'contact email if the customer provided one' },
productNeed: { type: 'string', description: 'what the customer asked for, in their own words' },
quantity: { type: 'string', description: 'quantity if stated' },
budget: { type: 'string', description: 'budget if stated, e.g. "€500"' },
@@ -293,7 +295,7 @@ const TOOL_DEFS = [
},
required: ['company', 'contactName', 'productNeed'],
},
requiresVerification: true,
requiresVerification: false,
},
];
@@ -341,7 +343,7 @@ function createTools(cfg, deps = {}) {
if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required');
const existing = await leadStore.get(conversationUuid);
const slots = { ...(existing ? existing.slots : {}) };
for (const key of ['company', 'contactName', 'phone', 'productNeed', 'quantity', 'budget', 'closeDate']) {
for (const key of ['company', 'contactName', 'email', 'phone', 'productNeed', 'quantity', 'budget', 'closeDate']) {
const value = args && args[key] != null ? String(args[key]).trim() : '';
if (value) slots[key] = value;
}
@@ -419,7 +421,8 @@ function createTools(cfg, deps = {}) {
const cmd = {
keyword: rpcQuery,
page: 1,
pageSize: limit,
// 多取候选,下面按库存排序后再截断到 limit,避免缺货商品占满前几条
pageSize: limit * 4,
languageId: profile.languageId,
tenantId: profile.tenantId,
currencyCode: profile.currencyCode || '',
@@ -437,7 +440,6 @@ function createTools(cfg, deps = {}) {
const list = Array.isArray(paging) ? paging : (paging.data || paging.list || []);
total = result.total != null ? result.total : (paging.total != null ? paging.total : null);
items = (Array.isArray(list) ? list : [])
.slice(0, limit)
.map((p) => {
const name = p.spuName || p.name || p.title || null;
const sku = p.sku || p.model || (Array.isArray(p.skus) && p.skus[0] ? p.skus[0].productId : null);
@@ -456,7 +458,13 @@ function createTools(cfg, deps = {}) {
mark_price: p.markPriceWithCurrency || null,
};
})
.filter((p) => p.name);
.filter((p) => p.name)
// 有库存优先:stock>0 → 0,未知 → 1,缺货 → 2;sort 稳定,保留 RPC 原顺序
.sort((a, b) => {
const rank = (p) => (p.stock > 0 ? 0 : p.stock === null ? 1 : 2);
return rank(a) - rank(b);
})
.slice(0, limit);
} catch (err) {
catalogError = err.message;
}
@@ -673,11 +681,9 @@ function createTools(cfg, deps = {}) {
}
async function createOpportunity(args, ctx) {
if (!ctx.verified) {
throw new ToolError(403, 'verification_required', 'Creating an opportunity requires a verified contact. Ask the customer to verify their email first.');
}
// 线索建商机不要求邮箱验证(对齐旧 bridge leadPipeline):公司 + 联系人 + 需求即可。
// 订单/邮箱历史仍要求 verified,邮箱只从身份头取;商机允许用客户在对话里给的 email 做去重/联系人。
const email = String(ctx.email || '').trim().toLowerCase();
if (!email) throw new ToolError(400, 'missing_email', 'The verified contact has no email on file.');
if (!cfg.twenty.apiKey) throw new ToolError(503, 'not_configured', 'TWENTY_API_KEY is not configured');
const conversationUuid = String(ctx.conversationUuid || '');
if (!conversationUuid) throw new ToolError(400, 'missing_conversation', 'conversation context is required');
+296
View File
@@ -3,6 +3,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createAgent } = require('../src/agent');
const { createTools } = require('../src/tools');
function fakeTools() {
const tools = [{
@@ -61,3 +62,298 @@ test('agent: output guard blocks fabricated order claim before send', async () =
assert.match(sent, /无法在聊天中直接下单/);
assert.doesNotMatch(sent, /已为您创建正式订单草稿/);
});
test('agent: bare Chinese product word forces search_products before replying', async () => {
const queries = [];
const tools = [{
name: 'search_products',
description: 'fake',
parameters: { type: 'object', properties: {} },
handler: async (args) => {
queries.push(args.query);
return { products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }], count: 1 };
},
}];
tools.leadStore = { get: async () => null, save: async () => null };
let sent = '';
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => ({ content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null }),
},
client: {
getMessages: async () => [{ id: 1, text_content: '戒指', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
},
tools,
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
});
const conv = { uuid: 'conv-ring-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-ring-1', text_content: '戒指', content: '戒指' });
assert.deepEqual(queries, ['戒指']);
assert.match(sent, /Triple Rings Ring/);
});
test('agent: backfills real product links when the LLM omits them', async () => {
const tools = [{
name: 'search_products',
description: 'fake',
parameters: { type: 'object', properties: {} },
handler: async () => ({
products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }],
count: 1,
}),
}];
tools.leadStore = { get: async () => null, save: async () => null };
let sent = '';
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => ({ content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null }),
},
client: {
getMessages: async () => [{ id: 1, text_content: '戒指', 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-backfill-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-backfill-1', text_content: '戒指', content: '戒指' });
assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/);
assert.match(sent, /可直接查看/);
});
test('agent: forces search_products when the LLM answers without tools', async () => {
const queries = [];
let calls = 0;
const tools = [{
name: 'search_products',
description: 'fake',
parameters: { type: 'object', properties: {} },
handler: async (args) => {
calls += 1;
queries.push(args.query);
if (calls === 1) throw new Error('rpc_timeout'); // 预检索失败 → 走事后强制注入
return {
products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }],
count: 1,
};
},
}];
tools.leadStore = { get: async () => null, save: async () => null };
let llmCalls = 0;
let sent = '';
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => {
llmCalls += 1;
if (llmCalls === 1) {
return { content: 'Stainless Steel Ring Set — $9.99 → 查看详情:https://www.yehwang.com/fake-ring', toolCalls: [], usage: null };
}
return { content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: '戒指', 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-force-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-force-1', text_content: '戒指', content: '戒指' });
assert.equal(calls, 2, 'pre-tool fails once, forced injection retries');
assert.deepEqual(queries, ['戒指', '戒指']);
assert.doesNotMatch(sent, /Stainless Steel Ring Set/);
assert.doesNotMatch(sent, /fake-ring/);
assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/);
});
// ---- 商机兜底:LLM 只调 collect_info 不调 create_opportunity 时,代码自动建商机 ----
function leadTools(twenty) {
const rpc = { async call() { return []; } };
return createTools(
{
recommendUrl: 'jsonrpc://127.0.0.1:1', orderApiUrl: '', inventoryApiUrl: '', rpcTimeoutMs: 50,
siteSearchUrl: 'https://www.yehwang.com/search.html?q=',
twenty: { baseURL: 'http://localhost:1', graphqlPath: '/graphql', workspaceId: 'w', apiKey: 'k' },
},
{ rpc, twenty, client: { sendPrivateNote: async () => {}, assignUser: async () => {} } }
);
}
function leadTwenty() {
const calls = [];
return {
calls,
async findPersonByEmail(email) { calls.push(['findPersonByEmail', email]); return null; },
async findOpportunitiesByPersonId() { return []; },
async createPerson(p) { calls.push(['createPerson', p]); return { id: 'person-1' }; },
async findCompanyByName() { return null; },
async createCompany(n) { calls.push(['createCompany', n]); return { id: 'company-1' }; },
async createOpportunity(o) { calls.push(['createOpportunity', o]); return { id: 'opp-auto-1', name: o.name }; },
async updateOpportunity(id, patch) { calls.push(['updateOpportunity', id, patch]); return { id }; },
async updateOpportunityStage(id, stage) { calls.push(['updateOpportunityStage', id, stage]); return { id, stage }; },
async createNote(c) { calls.push(['createNote', c]); return 'note-1'; },
async linkNoteToOpportunity(n, o) { calls.push(['linkNoteToOpportunity', n, o]); return { id: 'nt' }; },
async createTask(t, b, d, o) { calls.push(['createTask', t, o]); return 'task-1'; },
};
}
function leadAgentDeps(twenty, tools, contact) {
let llmCalls = 0;
let sent = '';
return {
get sent() { return sent; },
deps: {
llm: {
enabled: true,
model: 'fake',
chat: async () => {
llmCalls += 1;
if (llmCalls === 1) {
return {
content: '',
toolCalls: [{
id: 'tc-collect-1',
function: {
name: 'collect_info',
arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces for our shops', quantity: '500' }),
},
}],
usage: null,
};
}
return { content: 'Your request has been noted; a colleague will follow up.', toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => (contact.type === 'contact' ? contact.email : null), sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
},
conv: { uuid: 'conv-auto-opp-1', inbox_id: '1', channel: 'livechat', contact },
};
}
test('agent: auto-creates opportunity for a verified single-message lead', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
const ctx = leadAgentDeps(twenty, tools, { type: 'contact', email: 'buyer@test-customer.nl' });
ctx.conv.uuid = 'conv-auto-opp-verified';
const agent = createAgent({ sites: [], defaultSite: 'www' }, ctx.deps);
await agent.run(ctx.conv, { uuid: 'msg-auto-1', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', content: '...' });
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.match(twenty.calls.find((c) => c[0] === 'createOpportunity')[1].name, /Acme GmbH/);
assert.match(ctx.sent, /noted/i);
});
test('agent: auto-creates opportunity for an unverified single-message lead (policy A)', async () => {
const twenty = leadTwenty();
const tools = leadTools(twenty);
const ctx = leadAgentDeps(twenty, tools, { type: 'visitor' });
ctx.conv.uuid = 'conv-auto-opp-anon';
const agent = createAgent({ sites: [], defaultSite: 'www' }, ctx.deps);
await agent.run(ctx.conv, { uuid: 'msg-auto-2', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', content: '...' });
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Acme GmbH'));
});
test('agent: does not auto-create when the LLM already created the opportunity', 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: '',
toolCalls: [{ id: 'tc-1', function: { name: 'collect_info', arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces' }) } }],
usage: null,
};
}
if (llmCalls === 2) {
return {
content: '',
toolCalls: [{ id: 'tc-2', function: { name: 'create_opportunity', arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces' }) } }],
usage: null,
};
}
return { content: 'Your request has been noted; a colleague will follow up.', toolCalls: [], usage: null };
},
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => 'buyer@test-customer.nl', sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async () => {}, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
};
const conv = { uuid: 'conv-opp-once', inbox_id: '1', channel: 'livechat', contact: { type: 'contact', email: 'buyer@test-customer.nl' } };
const agent = createAgent({ sites: [], defaultSite: 'www' }, deps);
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'));
});
+12 -1
View File
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { extractFaqFromLdJson } = require('../src/faq-fetcher');
const { extractFaqFromLdJson, extractFaqFromNuxt } = require('../src/faq-fetcher');
const { updateMarkdownFile } = require('../src/faq-update');
test('faq: extracts FAQPage JSON-LD questions', () => {
@@ -38,3 +38,14 @@ test('faq: normalized question match avoids duplicate append', () => {
assert.equal((out.match(/### Q:/g) || []).length, 1);
assert.match(out, /new/);
});
test('faq: extracts FAQ from Nuxt SSR payload (no JSON-LD)', () => {
const html = '<html><script>__NUXT__=(function(a){return {data:[{info:{content:[{title:"Q1?",content:"<p>A1</p>"},{title:"Q2?",content:"<p>A2 &amp; more</p>"}]}}]}})();</script></html>';
const qs = extractFaqFromNuxt(html);
assert.deepEqual(qs, [{ q: 'Q1?', a: 'A1' }, { q: 'Q2?', a: 'A2 & more' }]);
});
test('faq: nuxt extractor tolerates missing/broken payload', () => {
assert.deepEqual(extractFaqFromNuxt('<html>no payload</html>'), []);
assert.deepEqual(extractFaqFromNuxt('<script>__NUXT__=not-js;</script>'), []);
});
+23
View File
@@ -0,0 +1,23 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { quickClassify, INTENTS } = require('../src/intent');
test('intent: bare product category word is a product search', () => {
for (const q of ['戒指', '项链', '耳环', '手链', '珠宝', 'rings', 'necklace', 'earrings', 'oorbellen', 'halskette']) {
assert.equal(quickClassify(q), INTENTS.PRODUCT_SEARCH, q);
}
});
test('intent: product noun inside a sentence is a product search', () => {
assert.equal(quickClassify('有没有不锈钢戒指'), INTENTS.PRODUCT_SEARCH);
assert.equal(quickClassify('do you sell stainless steel rings'), INTENTS.PRODUCT_SEARCH);
});
test('intent: other intents keep priority', () => {
assert.equal(quickClassify('我的订单到哪了'), INTENTS.ORDER_TRACKING);
assert.equal(quickClassify('我要转人工'), INTENTS.ESCALATE);
assert.equal(quickClassify('怎么退货'), INTENTS.POLICY_FAQ);
assert.equal(quickClassify('你好'), INTENTS.GENERAL);
});
+9 -1
View File
@@ -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); // 没有公司/联系人信号
});
+68
View File
@@ -140,3 +140,71 @@ test('guard: rewrites unsupported price but keeps product name', () => {
assert.doesNotMatch(r.content, /fake-a/);
assert.match(r.content, /价格以商品页为准/);
});
test('guard: drops dangling CTA text when a hallucinated product link is removed', () => {
const r = guardReply({
content: '<p>🔹 <strong>Stainless Steel Ring Set</strong> — $2.88<br>✅ 库存:充足<br>→ 查看详情:https://www.yehwang.com/stainless-steel-ring-set-12-pcs</p>',
toolResults: [],
toolCalls: [],
lang: 'zh',
});
assert.equal(r.blocked, true);
assert.ok(r.reasons.includes('stock_price_without_tool'));
assert.deepEqual(r.removed_urls, ['https://www.yehwang.com/stainless-steel-ring-set-12-pcs']);
assert.doesNotMatch(r.content, /查看详情/);
assert.doesNotMatch(r.content, /stainless-steel-ring-set-12-pcs/);
assert.match(r.content, /价格以商品页为准/);
});
test('guard: generic CTA label is removed together with the unverified URL', () => {
const r = guardReply({
content: '商品A — [查看详情](https://www.yehwang.com/fake-a)',
toolResults: [{ products: [{ name: '商品A' }] }],
toolCalls: [{ name: 'search_products', ok: true }],
lang: 'zh',
});
assert.equal(r.blocked, true);
assert.deepEqual(r.removed_urls, ['https://www.yehwang.com/fake-a']);
assert.doesNotMatch(r.content, /查看详情/);
assert.match(r.content, /商品A/);
});
test('guard: keeps meaningful anchor text when the URL is removed', () => {
const r = guardReply({
content: 'You can read [the product care guide](https://www.yehwang.com/fake-guide) for details.',
toolResults: [],
toolCalls: [],
lang: 'en',
});
assert.equal(r.blocked, true);
assert.doesNotMatch(r.content, /fake-guide/);
assert.match(r.content, /the product care guide/);
});
test('guard: CRM wording is replaced with a colleague follow-up, not a cart note', () => {
const r = guardReply({
content: 'Great — 500 stainless steel rings for your shops.\nI have created an opportunity for your request.',
toolResults: [{ created: true, opportunity_id: 'opp-1' }],
toolCalls: [{ name: 'create_opportunity', ok: true }],
lang: 'en',
});
assert.equal(r.blocked, true);
assert.ok(r.reasons.includes('internal_crm'));
assert.match(r.content, /stainless steel rings/i);
assert.match(r.content, /request has been noted/i);
assert.doesNotMatch(r.content, /account cart/i);
});
test('guard: strips leaked opportunity reference labels', () => {
const r = guardReply({
content: '🎯\n**Opportunity reference**: *Acme GmbH — 500 rings*\nOur sales team will contact you shortly.',
toolResults: [{ created: true, opportunity_id: 'opp-1' }],
toolCalls: [{ name: 'create_opportunity', ok: true }],
lang: 'en',
});
assert.equal(r.blocked, true);
assert.ok(r.reasons.includes('internal_crm'));
assert.doesNotMatch(r.content, /opportunity/i);
assert.match(r.content, /sales team will contact you/i);
assert.doesNotMatch(r.content, /account cart/i);
});
+12
View File
@@ -0,0 +1,12 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { SYSTEM_PROMPT, buildSystemPrompt } = require('../src/prompts');
test('prompt: dropshipping answers prefer dropshipping source urls', () => {
assert.match(SYSTEM_PROMPT, /dropshipping\.yehwang\.com\/faq\//);
assert.match(SYSTEM_PROMPT, /Do NOT replace them with similar www\.yehwang\.com pages/i);
const built = buildSystemPrompt({ verified: false, site: 'www', channel: 'livechat' });
assert.match(built, /dropshipping\.yehwang\.com\/faq\//);
});
+69
View File
@@ -0,0 +1,69 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { appendMissingProductLinks, collectProductLinks } = require('../src/reply-links');
const TOOL_RESULTS = [{
products: [
{ name: 'Triple Rings Ring', url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html', price: '$2.88', stock: 26 },
{ name: 'Aurora Rings Ring', url: 'https://www.yehwang.com/aurora-rings-ring-silver-color-color-17549.html', price: '$3.17', stock: 63 },
],
category_links: [{ name: 'Rings', url: 'https://www.yehwang.com/jewelry/rings' }],
}];
test('reply-links: appends real product/category links as markdown', () => {
const r = appendMissingProductLinks({ content: 'Here are some rings.', toolResults: TOOL_RESULTS, lang: 'English' });
assert.equal(r.appended, 3);
assert.match(r.content, /\[Triple Rings Ring\]\(https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html\)/);
assert.match(r.content, /\[Rings\]\(https:\/\/www\.yehwang\.com\/jewelry\/rings\)/);
assert.match(r.content, /Click to view:/);
assert.match(r.content, /26 pcs/);
});
test('reply-links: skips links already written by the model', () => {
const content = 'See https://www.yehwang.com/triple-rings-ring-gold-color-17590.html for details.';
const r = appendMissingProductLinks({ content, toolResults: TOOL_RESULTS, lang: 'English' });
assert.equal(r.appended, 2);
assert.equal((r.content.match(/triple-rings-ring-gold-color-17590/g) || []).length, 1);
});
test('reply-links: respects the limit', () => {
const r = appendMissingProductLinks({ content: 'Rings:', toolResults: TOOL_RESULTS, lang: 'English', limit: 1 });
assert.equal(r.appended, 1);
assert.doesNotMatch(r.content, /Aurora Rings Ring/);
});
test('reply-links: html mode emits clickable anchors', () => {
const r = appendMissingProductLinks({ content: '<p>Rings:</p>', toolResults: TOOL_RESULTS, lang: 'Chinese', html: true });
assert.equal(r.appended, 3);
assert.match(r.content, /<a href="https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html"/);
assert.match(r.content, /可直接查看:/);
assert.doesNotMatch(r.content, /\[Triple Rings Ring\]\(/);
});
test('reply-links: does not backfill when the reply already says there is no match', () => {
const r = appendMissingProductLinks({ content: 'No exact matches found.', toolResults: TOOL_RESULTS, lang: 'English' });
assert.equal(r.appended, 0);
assert.equal(r.content, 'No exact matches found.');
});
test('reply-links: collects only http(s) urls and dedupes', () => {
const links = collectProductLinks([
{ products: [{ name: 'A', url: 'https://a.example/1' }, { name: 'B', url: 'https://a.example/1' }] },
{ category_links: [{ name: 'C', url: 'https://c.example/2' }, { name: 'bad', url: '/relative/3' }] },
]);
assert.deepEqual(links.map((l) => l.url), ['https://a.example/1', 'https://c.example/2']);
});
test('reply-links: prefers in-stock products over out-of-stock ones', () => {
const toolResults = [{ products: [
{ name: 'Out', url: 'https://a.example/out', price: '$1.00', stock: 0 },
{ name: 'In', url: 'https://a.example/in', price: '$2.00', stock: 26 },
{ name: 'Backorder', url: 'https://a.example/bo', price: '$3.00', stock: 0 },
] }];
const r = appendMissingProductLinks({ content: 'Rings:', toolResults, lang: 'English', limit: 2 });
assert.equal(r.appended, 2);
assert.match(r.content, /\[In\]\(https:\/\/a\.example\/in\)/);
assert.ok(r.content.indexOf('a.example/in') < r.content.indexOf('a.example/out'), 'in-stock link comes first');
});
+69 -3
View File
@@ -487,15 +487,54 @@ test('search_email_history: scopes to verified email, ignores args.email', async
assert.equal(out.count, 0);
});
test('create_opportunity: requires verified contact', async () => {
test('create_opportunity: unverified lead is created from contact name (policy A)', async () => {
const twenty = fakeTwenty();
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
const tool = tools.find((t) => t.name === 'create_opportunity');
const out = await tool.handler(
{ company: 'Anon Co', contactName: 'Anna Klein', productNeed: '500 rings' },
{ verified: false, conversationUuid: `conv-anon-${Date.now()}` }
);
assert.equal(out.created, true);
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
const note = twenty.calls.find((c) => c[0] === 'createNote');
assert.match(note[1], /Contact: Anna Klein/);
assert.doesNotMatch(note[1], /<>/);
});
test('create_opportunity: unverified lead keeps the email provided in chat', async () => {
const twenty = fakeTwenty();
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
const tool = tools.find((t) => t.name === 'create_opportunity');
const out = await tool.handler(
{ company: 'Anon Co', contactName: 'Anna Klein', email: 'anna@anon.test', productNeed: '500 rings' },
{ verified: false, conversationUuid: `conv-anon-mail-${Date.now()}` }
);
assert.equal(out.created, true);
const person = twenty.calls.find((c) => c[0] === 'createPerson');
assert.equal(person[1].email, 'anna@anon.test');
});
test('create_opportunity: still requires company + contact + need', async () => {
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
const tool = tools.find((t) => t.name === 'create_opportunity');
await assert.rejects(
() => tool.handler({ company: 'A', contactName: 'B', productNeed: 'C' }, { verified: false }),
(err) => err instanceof ToolError && err.status === 403
() => tool.handler({ company: 'A', productNeed: 'C' }, { verified: false, conversationUuid: 'conv-missing' }),
(err) => err instanceof ToolError && err.status === 400
);
});
test('collect_info: stores the email slot', async () => {
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty: fakeTwenty() });
const tool = tools.find((t) => t.name === 'collect_info');
const out = await tool.handler(
{ company: 'Acme BV', contactName: 'John Doe', email: 'john@acme.test', productNeed: 'gold hoops' },
{ conversationUuid: 'conv-collect-email', verified: false }
);
assert.equal(out.slots.email, 'john@acme.test');
assert.deepEqual(out.missing, []);
});
test('create_opportunity: dedupes by existing opportunity', async () => {
const twenty = fakeTwenty({ personId: 'person-1', existingOpps: [{ id: 'opp-old', name: 'Old', stage: 'NEW' }] });
const tools = createTools(baseCfg, { rpc: fakeRpc(() => []), twenty });
@@ -530,3 +569,30 @@ test('create_opportunity: creates opportunity + note + follow-up task', async ()
const personIdx = twenty.calls.findIndex((c) => c[0] === 'createPerson');
assert.ok(companyIdx >= 0 && personIdx > companyIdx, 'company must be created before person');
});
test('sites.json: www/eu -> EUR, us -> USD', () => {
const sites = require('../config/sites.json');
assert.equal(sites.profiles.www.currencyCode, 'EUR');
assert.equal(sites.profiles.eu.currencyCode, 'EUR');
assert.equal(sites.profiles.us.currencyCode, 'USD');
});
test('search_products: passes the site currency to the product RPC', async () => {
const rpc = fakeRpc(() => []);
const cfg = {
...baseCfg,
sites: {
defaultSite: 'www',
profiles: { www: { site: 'www', tenantId: 2, languageId: 1, currencyCode: 'EUR', searchUrl: 'https://www.yehwang.com/search.html?q=' } },
inboxes: { '1': 'www' },
},
};
const tools = createTools(cfg, { rpc, twenty: fakeTwenty() });
const tool = tools.find((t) => t.name === 'search_products');
await tool.handler({ query: 'rings' }, { inboxId: '1' });
const call = rpc.calls[0];
assert.equal(call.method, '/product/list');
assert.equal(call.params[0].currencyCode, 'EUR');
assert.equal(call.params[0].tenantBaseCurrency, 'EUR');
assert.equal(call.params[0].reqContext.currencyCode, 'EUR');
});
+1 -1
View File
@@ -48,7 +48,7 @@ services:
# ---------------------------------------------------------------- LibreDesk
libredesk:
image: ${LIBREDESK_IMAGE:-libredesk/libredesk:v2.9.0-rc.10}
image: ${LIBREDESK_IMAGE:-libredesk/libredesk:v2.9.0}
container_name: crmnew-libredesk
restart: unless-stopped
ports:
+1 -1
View File
@@ -1,6 +1,6 @@
# Chatwoot → LibreDesk 对照表
迁移旧 `bridge` 时按此表逐项替换。字段/事件均已对照 LibreDesk 源码与文档核对(v2.8.0 / v2.9.0-rc)。
迁移旧 `bridge` 时按此表逐项替换。字段/事件均已对照 LibreDesk 源码与文档核对(v2.8.0 / v2.9.0)。
## 1. HTTP API
+1 -1
View File
@@ -12,7 +12,7 @@
| 业务 RPC | Hyperf JSON-RPC over TCP | 商品推荐、订单、库存/ETA、会员 | — |
| imap-id-proxy | Node 24 | 给 LibreDesk 的 IMAP 连接注入 ID 命令,兼容 163 | 仅本地 POC |
\* WhatsApp 需 LibreDesk v2.9 RC;当前镜像 `v2.9.0-rc.10`。
\* WhatsApp 自 LibreDesk v2.9.0 起支持;当前镜像 `v2.9.0`。
## 2. 数据流
+64 -9
View File
@@ -11,7 +11,7 @@
| 组件 | 容器 | 宿主端口 | 状态 |
|---|---|---|---|
| LibreDesk v2.9.0-rc.10(客服工作台 + 内置 AI) | `crmnew-libredesk` | `9000` | ✅ healthy |
| LibreDesk v2.9.0(渠道/工作台) | `crmnew-libredesk` | `9000` | ✅ healthy |
| Twenty CRM v2.30.1(server) | `crmnew-twenty-server` | `3100` | ✅ healthy |
| Twenty worker | `crmnew-twenty-worker` | — | ✅ running |
| bridge(业务工具 / RPC 集成) | `crmnew-bridge` | `4100` | ✅ healthy |
@@ -42,7 +42,7 @@ grep '^TOOLS_SHARED_KEY=' .env # bridge 工具密钥
| 项 | 仓库默认 | 本环境 | 原因 |
|---|---|---|---|
| `LIBREDESK_IMAGE` | `libredesk/libredesk:v2.9.0-rc.10` | `libredesk/libredesk:v2.9.0-rc.10-**arm64**` | 宿主是 ARM64,`v2.9.0-rc.10` 只有 amd64;官方另发了 `-arm64` 同版本 tag,实测原生 `linux/arm64` 运行 |
| `LIBREDESK_IMAGE` | `libredesk/libredesk:v2.9.0` | `libredesk/libredesk:v2.9.0-**arm64**` | 宿主是 ARM64;`v2.9.0-arm64` 的 digest 与多架构 `v2.9.0` 的 arm64 子镜像一致,显式 arm64 tag 沿用原部署习惯 |
| `LIBREDESK_BASE_URL` | `http://localhost:9000` | `https://wangliang-crm.dev.gaia888.com` | 走宿主 nginx 的 HTTPS |
| `TWENTY_SERVER_URL` | `http://192.168.31.61:3100` | `https://wangliang-twenty.dev.gaia888.com` | 前端 `REACT_APP_SERVER_BASE_URL` 用的就是它(已核对页面内联配置) |
| `RECOMMEND_URL` | 测试服地址 | `jsonrpc://wangliang-dev:9503` | 用 **本工作区** 的 product-service |
@@ -74,7 +74,14 @@ RPC 走 **容器网络直连**:compose 把 `bridge` 同时接入外部网络 `
create_opportunity`。
- 默认助理 `Yehwang Assistant`(#1,10 个工具、引用与 handoff 开启)。
- Live chat Inbox `Website`(#1,uuid `ad25d7a4-722d-4561-aebf-9d8f60952d09`)。
- Automation:新会话自动分配给助理(#1,enabled)。
- Automation:Live chat 规则 1 已关闭(AI Assistant);新会话由 bridge webhook 接管。
- **2026-10-23 切流完成**:
- bridge webhook `Bridge AI` 已创建并启用(`message.created` → `http://bridge:4100/libredesk/webhook`)
- LibreDesk API key 已生成并写入 `.env`
- `Wholesale Support` 团队(id=1,Round robin,System 为成员)
- WhatsApp 规则改为 `assign_team=Wholesale Support`,启用
- `IDENTITY_CORS_ORIGIN` 改为 `https://wangliang-crm.dev.gaia888.com`
- bridge AI 端到端已验证,`bridge_ai_metrics` 开始有数据
### Twenty
- Workspace `Yehwang`:`2ff5a56b-95d8-40e1-85cf-529ad3a70ed7`(`ACTIVE`)。
@@ -154,7 +161,7 @@ docker run --rm -v /etc/nginx/conf.d:/c redis:7-alpine rm -f /c/zz-wangliang-crm
| ~~AI Provider(DashScope)~~ | ✅ **已配置**(qwen-plus-latest / text-embedding-v4),端到端已验证 | 换 key:`DASHSCOPE_API_KEY=sk-xxx node scripts/configure-ai.js` |
| **SMTP / 163 邮件** | `SMTP_*` 为空,OTP 与邮件渠道不可用 | 填 `.env` 后 `node scripts/configure-email.js` + `node scripts/add-email-inbox.js` |
| ~~知识库 RAG(search_kb)~~ | ✅ **已配置**:快照导入 `bridge` 库(594 行),`RAG_PG_URL` 已指向,`search_kb` 可用 | 更新:`FORCE=1 bash scripts/import-kb-snapshot.sh` 或 `node scripts/sync-kb-online.js --local-only --force` |
| **WhatsApp 渠道** | 无 Meta 凭据 | 填 `WA_*` 后 `scripts/add-whatsapp-inbox.js`;需要公网回调(本栈已有 HTTPS,可不用 cloudflared) |
| **WhatsApp 渠道** | `WA_*` 已存在,inbox 已存在;规则已指向人工团队 | 仍需确认 Meta callback 是否指向测试域名,并补测试号/模板 |
| **public-gateway / cloudflared** | 未启动 | 需要 Cloudflare 临时隧道时再 `docker compose up -d public-gateway cloudflared` |
> `IDENTITY_DEV_MODE=true` 只适合 dev;上生产前改回 `false`。
@@ -199,8 +206,7 @@ Necklace Star `$5.70`/110,与 RPC 直查一致(首次调试时的编价问
`price=null`/`stock=null` 时必须说“暂不可用”并建议转人工,禁止估算/换算币种。
- 复测:同一条问题,工具调用 612ms 返回真实商品,回复价格/库存与 RPC 完全一致。
⚠️ 仍存在的结构性问题:LibreDesk 内置 AI 的回复**不经过** bridge 的 `输出护栏`(output_guard)。
若后续要硬性拦截虚假价格/链接,需要走 bridge 自己的 agent 路径,或在 LibreDesk 侧加后置校验。
已改为 bridge agent 回复,输出护栏、Citation、飞轮、质量指标都生效;LibreDesk 内置 AI 仅保留作回滚。
验证完已把 `log_level` 恢复为 `info`。
@@ -210,12 +216,11 @@ Necklace Star `$5.70`/110,与 RPC 直查一致(首次调试时的编价问
`node scripts/quality-report.js 1`:自动建表成功。
```text
table_exists = bridge_ai_metrics;当前 rows=0
table_exists = bridge_ai_metrics;切流后已有数据
{"summary":{"runs":0,"errors":0,...},"tools":[],"hours":1}
```
⚠️ 表建好了,但**会一直是 0 行**:`bridge_ai_metrics` 只在 bridge 自己的 agent 路径(webhook 编排)里写入,
而本栈的 AI 回复走的是 LibreDesk 内置 assistant。要让质量报表有数据,需要改用 bridge agent 或另接埋点。
✅ 切流后 `bridge_ai_metrics` 由 bridge agent 写入;当前已有 Live chat AI 运行记录。
### 5.2 全量 sitemap ✅
官网 `sitemap.xml` 受阿里云 WAF 的 JS 挑战保护(直接 fetch 返回 4KB 挑战 HTML、0 个 `<loc>`)。
@@ -342,3 +347,53 @@ RAG_PG_URL="postgres://crmnew:${PW}@127.0.0.1:5440/bridge" node scripts/kb-versi
- `search_kb` 在没有 reranker 时会忽略 `limit`(上游改为先取 8 条给 reranker)→ 现在无 reranker 时尊重 `limit`;
- parent/child 可能命中同一篇 → 按 `question+answer` 去重后再重排;
- 同时把 `createLLM + createReranker` 接进 HTTP 工具入口,让 LibreDesk 直接调 `search_kb` 也能享受重排。
## 11. 2026-10-23 后续配置
### Live chat / bridge AI
- bridge webhook `Bridge AI`:`message.created` → `http://bridge:4100/libredesk/webhook`(启用)
- LibreDesk 规则 1(AI Assistant):关闭
- bridge API key:已生成并写入 `.env`
- bridge AI e2e:最低起订量问题回复正常,`bridge_ai_metrics` 已有数据
- `IDENTITY_CORS_ORIGIN`:已从 `*` 改为 `https://wangliang-crm.dev.gaia888.com`
### 人工团队
- `Wholesale Support`(id=1,Round robin)
- 成员:
- System(id=1)
- agent1@yehwang.com(id=11)
- agent2@yehwang.com(id=12)
- 两个 agent 的临时密码写在测试服 `.agent-credentials`(600,已在 `.gitignore` 忽略)
- 登录验证:两个 agent 均 200
### Email
- SMTP 已使用本地 163 凭据配置
- Email Inbox `Email 163`(id=3,IMAP 走 `imap-proxy:1993`)
- Automation #3:email → `assign_team=Wholesale Support`
- ⚠️ 本机测试服和本地共用同一个 163 邮箱,两个 LibreDesk 会同时轮询,可能重复/抢占邮件;长期测试建议用独立测试邮箱
### WhatsApp
- `WA_INBOX_ID=2`
- `PUBLIC_BASE_URL=https://wangliang-crm.dev.gaia888.com`
- 规则 #2:WhatsApp → `assign_team=Wholesale Support`
- **Meta callback 已改到测试域名**:`https://wangliang-crm.dev.gaia888.com/webhooks/whatsapp/2`,GET 验证 challenge 200。
- 因为一个 Meta App 只能有一个 callback,本机 WhatsApp 已暂停接收;
- 如需本地和测试同时接收,需要独立 Meta App / 测试号。
### 坐席邮件通知
- agent1/agent2 的**邮件通知已关闭**(assignment/new_reply/SLA 等 email 渠道全部 `false`),仅保留站内通知。
- 原因:`agent1@yehwang.com` / `agent2@yehwang.com` 在 `yehwang.com` 邮件服务器上被 550 拒绝,导致“New conversation assigned to you”退信。
- OTP 邮件不受影响,bridge OTP 已实测可发送并送达 163 收件箱。
## 11. 2026-10-26 LibreDesk 升级记录(rc.10 → v2.9.0)
- 目标:`libredesk/libredesk:v2.9.0`(2026-10-22 正式版);本环境用 `libredesk/libredesk:v2.9.0-arm64`(digest 与多架构 `v2.9.0` 的 arm64 子镜像一致)。
- 升级前:`pg_dump -Fc libredesk` → `backups/libredesk-before-v2.9.0-2026-10-26.dump`(已验证可 `pg_restore -l`);记录旧镜像 image ID `sha256:03df145b…`(rc.10-arm64)。
- 变更范围:rc.10 → v2.9.0 无 DB migration、无 config 变更(仅 UI / 邮件模板 / widget / campaign 修复),数据库 schema 保持 57 张表。
- 操作:改 `.env` 的 `LIBREDESK_IMAGE` → `docker compose up -d --force-recreate libredesk`(启动链自动 `--install --upgrade`,预期打印 `no upgrades to run`)。
- 回滚:切回 `libredesk/libredesk:v2.9.0-rc.10-arm64` 并重建;无 schema 变更,不需要恢复数据库。
+4 -4
View File
@@ -198,10 +198,10 @@ const payload = {
接完后旧桥的 `bridge/mail/`(IMAP 轮询 + 自动回复)可以退役;邮件摘要改为定时调 LibreDesk 报表 API。
## 8. WhatsApp(当前 v2.9.0-rc.10 已可用)
## 8. WhatsApp(v2.9.0 已可用)
> **版本现状**:本套已运行 `v2.9.0-rc.10`,`channels` 枚举为 `email / livechat / whatsapp`,`whatsapp_templates` 表已就绪。
> RC 属于预发布版本,生产使用前评估稳定性;回滚见 README「回滚到稳定版」。
> **版本现状**:本套已运行 `v2.9.0` 正式版(2026-10-22 发布,2026-10-26 从 rc.10 升级),`channels` 枚举为 `email / livechat / whatsapp`,`whatsapp_templates` 表已就绪。
> 升级前已 `pg_dump` 备份;本次无 DB migration,回滚见 `docs/runbook.md`「回滚」。
1. Meta 侧准备:Business Account、WABA ID、Phone Number ID、Permanent Access Token、App Secret、Webhook Verify Token
2. `Admin → Inboxes → New inbox → WhatsApp`,填上述凭据
@@ -209,7 +209,7 @@ const payload = {
4. 模板:`whatsapp_templates` 表 + 后台模板管理;24 小时窗口外的主动消息必须用已审核模板
5. **AI 对 WhatsApp 的支持文档未覆盖**(文档只写 email + live chat),需实测 assistant 是否在 WhatsApp 会话触发
切换镜像:`.env` 里改 `LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0-rc.9`(若 Docker Hub 无该 tag,则按官方文档从 `v2.9.0-rc.9` 源码构建)。
切换镜像:`.env` 里改 `LIBREDESK_IMAGE` 后 `docker compose up -d --force-recreate libredesk`;升级前先 `pg_dump` 备份,并记录当前镜像 digest 便于回滚。
## 9. 验收清单
+4 -4
View File
@@ -91,11 +91,11 @@
| RAG 降级 | EU 语种召回下降 | 迁移后用 golden 问题抽检,必要时保留旧 RAG 服务 |
| 单工作区 | 多站点只能多 inbox,联系人共享 | 用 JWT 的 site 属性 + 隐私口径评审 |
## 7. 版本核实(2026-10-21)
## 7. 版本核实(2026-10-26 更新)
- LibreDesk 稳定版:**v2.8.0**(2026-08-22),渠道 `email/livechat`
- 预发布:**v2.9.0-rc.10**(2026-10-21),新增 WhatsApp(Meta Cloud API + 模板)
- 本套环境已切到 rc.10;稳定版 v2.8.0 仍无 WhatsApp;官方文档暂无 WhatsApp 配置页(仅 release notes / 权限表提及)
- LibreDesk 稳定版:**v2.9.0**(2026-10-22),渠道 `email/livechat/whatsapp`
- 升级前为预发布 **v2.9.0-rc.10**(2026-10-21);rc.10 → v2.9.0 无 DB migration、无 config 变更
- 本套环境已于 2026-10-26 升到 v2.9.0(升级前 `pg_dump` 备份,回滚只需切回 rc.10 镜像)
- AI Assistant / AI tools / verified contact 为 v2.8 已有能力,标注 beta
+17 -5
View File
@@ -160,20 +160,29 @@ docker run --rm -v crm-new_twenty-data:/data -v "$PWD/backups":/backup alpine \
# 1. 备份
docker exec crmnew-postgres pg_dump -U crmnew -d libredesk > backups/libredesk-before-upgrade.sql
# 2. 修改 .env 的 LIBREDESK_IMAGE 后重建
# 2. 记录升级前镜像(回滚基线;tag 可能被上游重推,务必留 digest/image ID)
docker image inspect libredesk/libredesk:<旧 tag> --format '{{.Id}} {{.RepoDigests}}'
# 3. 修改 .env 的 LIBREDESK_IMAGE 后重建
docker compose up -d --force-recreate libredesk
# 3. 容器启动命令会自动执行 --upgrade;确认日志
# 4. 容器启动命令会自动执行 --upgrade;确认日志
docker compose logs --tail=50 libredesk
```
> 2026-10-26 升级记录(rc.10 → v2.9.0,无 DB migration / 无 config 变更):
> 本机 amd64 升级前 image ID `sha256:abdc41e7…` → 升级后 `sha256:e7aa3bac…`;
> 测试服 arm64 升级前 image ID `sha256:03df145b…` → 升级后 `sha256:147d0081…`。
## 回滚
```bash
# 切回稳定版
sed -i 's#^LIBREDESK_IMAGE=.*#LIBREDESK_IMAGE=libredesk/libredesk:latest#' .env
# 本次 rc.10 → v2.9.0 无 schema 变更:切回 tag 即可,无需恢复数据库。
# 若要精确回滚,先确认本地仍缓存升级前镜像(image ID 见上方升级记录),再切 tag:
sed -i 's#^LIBREDESK_IMAGE=.*#LIBREDESK_IMAGE=libredesk/libredesk:v2.9.0-rc.10#' .env
docker compose up -d --force-recreate libredesk
# 跨大版本回滚数据库需恢复备份(升级前 pg_dump)
# 跨大版本回滚(有 migration 时)才需要按升级前 pg_dump 恢复数据库
```
## 端口冲突排查
@@ -199,3 +208,6 @@ ss -ltnp | grep -E ':(9000|3100|4100|5440|6390)\s'
| WhatsApp webhook 收不到 | 没有公网 HTTPS / Meta 回调地址不对 | 本地联调跑 `bash scripts/public-https.sh`;测试/生产使用正式域名,不要用 Quick Tunnel |
| 公网访问到管理后台 | 网关白名单配置 | `services/public-gateway` 只放行 `/webhooks/whatsapp/*`、`/hc/*`、`/widget*`、`/static/public/*`,其余 404 |
| AI 回复"系统暂时无法查询订单" | RPC 不可达,bridge 8s 超时 | 本地开发跑 `bash scripts/rpc-tunnel.sh --apply`;测试/生产检查内网 RPC 地址/网络策略,不要用开发机隧道 |
| WhatsApp 真实消息收不到,但 Meta "Send to My Server" 测试事件能到 | WABA 级 `override_callback_uri` 优先于 App 级 callback,且 Meta 对 override 的验证常返回 403(与目标 URL 无关) | `GET /<WABA_ID>/subscribed_apps` 确认 override;`DELETE /<WABA_ID>/subscribed_apps` 后重新 `POST`(不带 override)清掉,确认 App 级 callback 指向目标域名,再发真实消息验证 |
| 多个栈共用同一个 WhatsApp 号,消息被旧环境接走(旧环境 AI 回复、新环境无日志) | crm-new 与旧 crm-ai-demo 配置了同一 `WA_PHONE_NUMBER_ID` / WABA,旧栈 tunnel 仍在接收 | 停旧栈 WhatsApp 接收(停旧 tunnel / 禁用旧 inbox),或给新环境申请独立测试号;不要让两套同时接同一个号 |
| 新建 WhatsApp inbox 后 webhook 验证 403 `verify token mismatch` | 创建接口传入的 `webhook_verify_token` 可能未真正生效,inbox 仍是默认值;`GET /api/v1/inboxes/<id>` 返回的是掩码值,不能用来核对 | 用 `PUT /api/v1/inboxes/<id>` 带完整 config 重新保存一次,再请求 `GET /webhooks/whatsapp/<id>?hub.mode=subscribe&hub.verify_token=...&hub.challenge=...` 确认返回 200 |
+11 -1
View File
@@ -78,7 +78,17 @@ const data = (j) => (j && j.data !== undefined ? j.data : j);
async function main() {
const env = loadEnv(path.join(ROOT, '.env'));
const old = JSON.parse(fs.readFileSync(OLD_MAIL_CFG, 'utf8'));
const old = fs.existsSync(OLD_MAIL_CFG)
? JSON.parse(fs.readFileSync(OLD_MAIL_CFG, 'utf8'))
: {
imap: { auth: { user: env.SMTP_USER, pass: env.SMTP_PASS } },
smtp: {
host: env.SMTP_HOST || 'smtp.163.com',
port: Number(env.SMTP_PORT || 465),
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
},
};
if (!old.imap?.auth?.user || !old.imap?.auth?.pass) throw new Error('缺少 IMAP/SMTP 凭据(设置 SMTP_USER/SMTP_PASS 或提供旧 mail.config.json)');
const base = (env.LIBREDESK_BASE_URL || 'http://localhost:9000').replace(/\/+$/, '');
const c = createClient(base);
await c.post('/api/v1/auth/login', { email: env.LIBREDESK_SYSTEM_EMAIL || 'System', password: env.LIBREDESK_SYSTEM_USER_PASSWORD });