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