feat: 添加物流查询功能和完善 token 传递

- 添加 get_logistics 工具查询 Mall API /mall/api/order/parcel
- 修复 Cookie token 传递到 MCP 的问题
- 增强 LLM 客户端超时处理和日志
- 移除 MALL_API_TOKEN,使用用户登录 token
- 更新测试页面使用 setUser 设置用户属性
- 增强 webhook 调试日志
This commit is contained in:
wangliang
2026-01-16 18:36:17 +08:00
parent cd787d608b
commit c4e97cf312
9 changed files with 334 additions and 111 deletions

View File

@@ -38,6 +38,9 @@ ORDER_MCP_URL=http://order_mcp:8002
AFTERSALE_MCP_URL=http://aftersale_mcp:8003 AFTERSALE_MCP_URL=http://aftersale_mcp:8003
PRODUCT_MCP_URL=http://product_mcp:8004 PRODUCT_MCP_URL=http://product_mcp:8004
# ============ Mall API ============
MALL_API_URL=https://apicn.qa1.gaia888.com
# ============ Agent Config ============ # ============ Agent Config ============
LOG_LEVEL=INFO LOG_LEVEL=INFO
MAX_CONVERSATION_STEPS=10 MAX_CONVERSATION_STEPS=10

View File

@@ -25,7 +25,11 @@ ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
- order_id: 订单号(必需) - order_id: 订单号(必需)
- 说明:此工具会自动使用用户的身份 token 查询商城订单详情 - 说明:此工具会自动使用用户的身份 token 查询商城订单详情
2. **query_order** - 查询历史订单 2. **get_logistics** - 从商城 API 查询物流信息
- order_id: 订单号(必需)
- 说明:查询订单的物流轨迹和配送状态
3. **query_order** - 查询历史订单
- user_id: 用户 ID自动注入 - user_id: 用户 ID自动注入
- account_id: 账户 ID自动注入 - account_id: 账户 ID自动注入
- order_id: 订单号(可选,不填则查询最近订单) - order_id: 订单号(可选,不填则查询最近订单)
@@ -33,10 +37,6 @@ ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
- date_end: 结束日期(可选) - date_end: 结束日期(可选)
- status: 订单状态(可选) - status: 订单状态(可选)
3. **track_logistics** - 物流跟踪
- order_id: 订单号
- tracking_number: 物流单号(可选)
4. **modify_order** - 修改订单 4. **modify_order** - 修改订单
- order_id: 订单号 - order_id: 订单号
- user_id: 用户 ID自动注入 - user_id: 用户 ID自动注入
@@ -108,12 +108,25 @@ ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
} }
``` ```
用户: "帮我查一下订单 202071324 的物流"
回复:
```json
{
"action": "call_tool",
"tool_name": "get_logistics",
"arguments": {
"order_id": "202071324"
}
}
```
## 重要约束 ## 重要约束
- **必须返回完整的 JSON 对象**,不要只返回部分内容 - **必须返回完整的 JSON 对象**,不要只返回部分内容
- **不要添加任何 markdown 代码块标记**(如 \`\`\`json - **不要添加任何 markdown 代码块标记**(如 \`\`\`json
- **不要添加任何解释性文字**,只返回 JSON - **不要添加任何解释性文字**,只返回 JSON
- user_id 和 account_id 会自动注入到 arguments 中,无需手动添加 - user_id 和 account_id 会自动注入到 arguments 中,无需手动添加
- 如果用户提供了订单号,优先使用 get_mall_order 工具 - 如果用户提供了订单号,优先使用 get_mall_order 工具
- 如果用户想查询物流状态,使用 get_logistics 工具
- 对于敏感操作(取消、修改),确保有明确的订单号 - 对于敏感操作(取消、修改),确保有明确的订单号
""" """
@@ -204,7 +217,15 @@ async def order_agent(state: AgentState) -> AgentState:
# Inject user_token if available # Inject user_token if available
if state.get("user_token"): if state.get("user_token"):
arguments["user_token"] = state["user_token"] arguments["user_token"] = state["user_token"]
logger.info("Injected user_token into tool call") logger.info(
"Injected user_token into tool call",
token_prefix=state["user_token"][:20] if state["user_token"] else None
)
else:
logger.warning(
"No user_token available in state, MCP will use default token",
conversation_id=state["conversation_id"]
)
# Use entity if available # Use entity if available
if "order_id" not in arguments and state["entities"].get("order_id"): if "order_id" not in arguments and state["entities"].get("order_id"):
@@ -290,7 +311,15 @@ async def order_agent(state: AgentState) -> AgentState:
# Inject user_token if available (for Mall API calls) # Inject user_token if available (for Mall API calls)
if state.get("user_token"): if state.get("user_token"):
arguments["user_token"] = state["user_token"] arguments["user_token"] = state["user_token"]
logger.debug("Injected user_token into tool call") logger.debug(
"Injected user_token into tool call",
token_prefix=state["user_token"][:20] if state["user_token"] else None
)
else:
logger.warning(
"No user_token available in state, MCP will use default token",
conversation_id=state["conversation_id"]
)
# Use entity if available # Use entity if available
if "order_id" not in arguments and state["entities"].get("order_id"): if "order_id" not in arguments and state["entities"].get("order_id"):

View File

@@ -81,12 +81,27 @@ async def classify_intent(state: AgentState) -> AgentState:
# Parse JSON response # Parse JSON response
content = response.content.strip() content = response.content.strip()
# Log raw response for debugging
logger.debug(
"LLM response for intent classification",
response_preview=content[:500] if content else "EMPTY",
content_length=len(content) if content else 0
)
# Handle markdown code blocks # Handle markdown code blocks
if content.startswith("```"): if content.startswith("```"):
content = content.split("```")[1] content = content.split("```")[1]
if content.startswith("json"): if content.startswith("json"):
content = content[4:] content = content[4:]
# Check for empty response
if not content:
logger.warning("LLM returned empty response for intent classification")
state["intent"] = Intent.CUSTOMER_SERVICE.value # Default to customer service
state["intent_confidence"] = 0.5
return state
result = json.loads(content) result = json.loads(content)
# Extract intent # Extract intent

View File

@@ -1,7 +1,8 @@
""" """
ZhipuAI LLM Client for B2B Shopping AI Assistant ZhipuAI LLM Client for B2B Shopping AI Assistant
""" """
from typing import Any, AsyncGenerator, Optional import concurrent.futures
from typing import Any, Optional
from dataclasses import dataclass from dataclasses import dataclass
from zhipuai import ZhipuAI from zhipuai import ZhipuAI
@@ -30,21 +31,19 @@ class LLMResponse:
class ZhipuLLMClient: class ZhipuLLMClient:
"""ZhipuAI LLM Client wrapper""" """ZhipuAI LLM Client wrapper"""
DEFAULT_TIMEOUT = 30 # seconds
def __init__( def __init__(
self, self,
api_key: Optional[str] = None, api_key: Optional[str] = None,
model: Optional[str] = None model: Optional[str] = None,
timeout: Optional[int] = None
): ):
"""Initialize ZhipuAI client
Args:
api_key: ZhipuAI API key, defaults to settings
model: Model name, defaults to settings
"""
self.api_key = api_key or settings.zhipu_api_key self.api_key = api_key or settings.zhipu_api_key
self.model = model or settings.zhipu_model self.model = model or settings.zhipu_model
self.timeout = timeout or self.DEFAULT_TIMEOUT
self._client = ZhipuAI(api_key=self.api_key) self._client = ZhipuAI(api_key=self.api_key)
logger.info("ZhipuAI client initialized", model=self.model) logger.info("ZhipuAI client initialized", model=self.model, timeout=self.timeout)
async def chat( async def chat(
self, self,
@@ -54,32 +53,21 @@ class ZhipuLLMClient:
top_p: float = 0.9, top_p: float = 0.9,
**kwargs: Any **kwargs: Any
) -> LLMResponse: ) -> LLMResponse:
"""Send chat completion request """Send chat completion request"""
Args:
messages: List of chat messages
temperature: Sampling temperature
max_tokens: Maximum tokens to generate
top_p: Top-p sampling parameter
**kwargs: Additional parameters
Returns:
LLM response with content and metadata
"""
formatted_messages = [ formatted_messages = [
{"role": msg.role, "content": msg.content} {"role": msg.role, "content": msg.content}
for msg in messages for msg in messages
] ]
logger.debug( logger.info(
"Sending chat request", "Sending chat request",
model=self.model, model=self.model,
message_count=len(messages), message_count=len(messages),
temperature=temperature temperature=temperature
) )
try: def _make_request():
response = self._client.chat.completions.create( return self._client.chat.completions.create(
model=self.model, model=self.model,
messages=formatted_messages, messages=formatted_messages,
temperature=temperature, temperature=temperature,
@@ -88,9 +76,26 @@ class ZhipuLLMClient:
**kwargs **kwargs
) )
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_make_request)
response = future.result(timeout=self.timeout)
choice = response.choices[0] choice = response.choices[0]
result = LLMResponse( content = choice.message.content
content=choice.message.content,
logger.info(
"Chat response received",
finish_reason=choice.finish_reason,
content_length=len(content) if content else 0,
usage=response.usage.__dict__ if hasattr(response, 'usage') else {}
)
if not content:
logger.warning("LLM returned empty content")
return LLMResponse(
content=content or "",
finish_reason=choice.finish_reason, finish_reason=choice.finish_reason,
usage={ usage={
"prompt_tokens": response.usage.prompt_tokens, "prompt_tokens": response.usage.prompt_tokens,
@@ -99,13 +104,9 @@ class ZhipuLLMClient:
} }
) )
logger.debug( except concurrent.futures.TimeoutError:
"Chat response received", logger.error("Chat request timed out", timeout=self.timeout)
finish_reason=result.finish_reason, raise TimeoutError(f"Request timed out after {self.timeout} seconds")
total_tokens=result.usage["total_tokens"]
)
return result
except Exception as e: except Exception as e:
logger.error("Chat request failed", error=str(e)) logger.error("Chat request failed", error=str(e))
@@ -117,24 +118,14 @@ class ZhipuLLMClient:
tools: list[dict[str, Any]], tools: list[dict[str, Any]],
temperature: float = 0.7, temperature: float = 0.7,
**kwargs: Any **kwargs: Any
) -> tuple[LLMResponse, Optional[list[dict[str, Any]]]]: ) -> tuple[LLMResponse, None]:
"""Send chat completion request with tool calling """Send chat completion request with tool calling"""
Args:
messages: List of chat messages
tools: List of tool definitions
temperature: Sampling temperature
**kwargs: Additional parameters
Returns:
Tuple of (LLM response, tool calls if any)
"""
formatted_messages = [ formatted_messages = [
{"role": msg.role, "content": msg.content} {"role": msg.role, "content": msg.content}
for msg in messages for msg in messages
] ]
logger.debug( logger.info(
"Sending chat request with tools", "Sending chat request with tools",
model=self.model, model=self.model,
tool_count=len(tools) tool_count=len(tools)
@@ -150,40 +141,23 @@ class ZhipuLLMClient:
) )
choice = response.choices[0] choice = response.choices[0]
result = LLMResponse( content = choice.message.content or ""
content=choice.message.content or "",
return LLMResponse(
content=content,
finish_reason=choice.finish_reason, finish_reason=choice.finish_reason,
usage={ usage={
"prompt_tokens": response.usage.prompt_tokens, "prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens, "completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens "total_tokens": response.usage.total_tokens
} }
) ), None
# Extract tool calls if present
tool_calls = None
if hasattr(choice.message, 'tool_calls') and choice.message.tool_calls:
tool_calls = [
{
"id": tc.id,
"type": tc.type,
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
}
for tc in choice.message.tool_calls
]
logger.debug("Tool calls received", tool_count=len(tool_calls))
return result, tool_calls
except Exception as e: except Exception as e:
logger.error("Chat with tools request failed", error=str(e)) logger.error("Chat with tools request failed", error=str(e))
raise raise
# Global LLM client instance
llm_client: Optional[ZhipuLLMClient] = None llm_client: Optional[ZhipuLLMClient] = None

View File

@@ -136,6 +136,26 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
contact = payload.contact or payload.sender contact = payload.contact or payload.sender
user_id = str(contact.id) if contact else "unknown" user_id = str(contact.id) if contact else "unknown"
# Log webhook payload structure for debugging
logger.info(
"Webhook payload structure",
payload_event=payload.event,
has_conversation=bool(conversation),
has_contact=bool(contact),
contact_type=type(contact).__name__ if contact else None,
conversation_id=conversation_id
)
# Log full payload keys for debugging
payload_dict = payload.model_dump()
logger.info(
"Full webhook payload keys",
keys=list(payload_dict.keys()),
conversation_keys=list(payload_dict.get('conversation', {}).keys()) if payload_dict.get('conversation') else [],
contact_keys=list(payload_dict.get('contact', {}).keys()) if payload_dict.get('contact') else [],
sender_keys=list(payload_dict.get('sender', {}).keys()) if payload_dict.get('sender') else []
)
# Get account_id from payload (top-level account object) # Get account_id from payload (top-level account object)
# Chatwoot webhook includes account info at the top level # Chatwoot webhook includes account info at the top level
account_obj = payload.account account_obj = payload.account
@@ -148,13 +168,35 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
if not user_token: if not user_token:
# 1. 尝试从 contact/custom_attributes 获取 # 1. 尝试从 contact/custom_attributes 获取
if contact: if contact:
contact_dict = contact.model_dump() if hasattr(contact, 'model_dump') else contact.__dict__ logger.info(
"Checking contact for token",
contact_id=contact.id if contact else None,
contact_type=type(contact).__name__
)
# 只有 WebhookContact 才有 custom_attributes
if hasattr(contact, 'custom_attributes'):
logger.info(
"Checking contact custom_attributes",
has_custom_attributes=bool(contact.custom_attributes)
)
custom_attrs = contact.custom_attributes or {}
logger.info(
"Contact custom_attributes",
keys=list(custom_attrs.keys()) if custom_attrs else [],
has_jwt_token='jwt_token' in custom_attrs if custom_attrs else False,
has_mall_token='mall_token' in custom_attrs if custom_attrs else False
)
contact_dict = {"custom_attributes": custom_attrs}
user_token = TokenManager.extract_token_from_contact(contact_dict) user_token = TokenManager.extract_token_from_contact(contact_dict)
logger.debug("Extracted token from contact", has_token=bool(user_token)) logger.debug("Extracted token from contact", has_token=bool(user_token))
else:
logger.debug("Contact type is WebhookSender, no custom_attributes available")
# 2. 尝试从 conversation.meta.sender.custom_attributes 获取Chatwoot SDK setUser 设置的位置) # 2. 尝试从 conversation.meta.sender.custom_attributes 获取Chatwoot SDK setUser 设置的位置)
if not user_token and conversation: if not user_token and conversation:
# 记录 conversation 的类型和内容用于调试
logger.debug("Conversation object type", type=str(type(conversation))) logger.debug("Conversation object type", type=str(type(conversation)))
if hasattr(conversation, 'model_dump'): if hasattr(conversation, 'model_dump'):
conv_dict = conversation.model_dump() conv_dict = conversation.model_dump()
@@ -162,12 +204,32 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
logger.debug("Has meta", has_meta='meta' in conv_dict) logger.debug("Has meta", has_meta='meta' in conv_dict)
meta_sender = conv_dict.get('meta', {}).get('sender', {}) meta_sender = conv_dict.get('meta', {}).get('sender', {})
logger.info(
"Conversation meta.sender",
has_sender=bool(meta_sender),
sender_keys=list(meta_sender.keys()) if meta_sender else [],
has_custom_attributes=bool(meta_sender.get('custom_attributes')) if meta_sender else False
)
if meta_sender.get('custom_attributes'): if meta_sender.get('custom_attributes'):
logger.info("Found custom_attributes in meta.sender", keys=list(meta_sender['custom_attributes'].keys()))
user_token = TokenManager.extract_token_from_contact({'custom_attributes': meta_sender['custom_attributes']}) user_token = TokenManager.extract_token_from_contact({'custom_attributes': meta_sender['custom_attributes']})
logger.info("Token found in conversation.meta.sender.custom_attributes", token_prefix=user_token[:20] if user_token else None) logger.info("Token found in conversation.meta.sender.custom_attributes", token_prefix=user_token[:20] if user_token else None)
if user_token: if user_token:
logger.info("JWT token found", user_id=user_id, source="cookie" if cookie_token else "contact") logger.info(
"JWT token found",
user_id=user_id,
source="cookie" if cookie_token else "contact",
token_prefix=user_token[:20] if user_token else None
)
else:
logger.warning(
"No JWT token found from any source",
user_id=user_id,
cookie_token_exists=bool(cookie_token),
contact_type=type(contact).__name__ if contact else None
)
logger.info( logger.info(
"Processing incoming message", "Processing incoming message",
@@ -352,7 +414,9 @@ async def chatwoot_webhook(
# 尝试从请求 Cookie 中获取用户 Token # 尝试从请求 Cookie 中获取用户 Token
user_token = request.cookies.get("token") # 从 Cookie 读取 token user_token = request.cookies.get("token") # 从 Cookie 读取 token
if user_token: if user_token:
logger.info("User token found in request cookies") logger.info("User token found in request cookies", token_prefix=user_token[:20])
else:
logger.debug("No token found in request cookies (this is expected for webhook requests)")
# Verify signature # Verify signature
signature = request.headers.get("X-Chatwoot-Signature", "") signature = request.headers.get("X-Chatwoot-Signature", "")

View File

@@ -198,7 +198,6 @@ services:
HYPERF_API_URL: ${HYPERF_API_URL} HYPERF_API_URL: ${HYPERF_API_URL}
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN} HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
MALL_API_URL: ${MALL_API_URL} MALL_API_URL: ${MALL_API_URL}
MALL_API_TOKEN: ${MALL_API_TOKEN}
MALL_TENANT_ID: ${MALL_TENANT_ID:-2} MALL_TENANT_ID: ${MALL_TENANT_ID:-2}
MALL_CURRENCY_CODE: ${MALL_CURRENCY_CODE:-EUR} MALL_CURRENCY_CODE: ${MALL_CURRENCY_CODE:-EUR}
MALL_LANGUAGE_ID: ${MALL_LANGUAGE_ID:-1} MALL_LANGUAGE_ID: ${MALL_LANGUAGE_ID:-1}

View File

@@ -381,12 +381,36 @@
const token = getCookie('token'); const token = getCookie('token');
window.chatwootSDK.run({ // 初始化配置
const widgetConfig = {
websiteToken: '39PNCMvbMk3NvB7uaDNucc6o', websiteToken: '39PNCMvbMk3NvB7uaDNucc6o',
baseUrl: BASE_URL, baseUrl: BASE_URL,
locale: 'zh_CN', locale: 'zh_CN',
userIdentifier: token || 'web_user_' + Date.now() userIdentifier: token || 'web_user_' + Date.now()
};
window.chatwootSDK.run(widgetConfig);
// 等待 widget 加载完成后设置用户属性
setTimeout(() => {
if (token && window.chatwootSDK.setUser) {
window.chatwootSDK.setUser(
token || 'web_user_' + Date.now(),
{
jwt_token: token,
mall_token: token
}
);
console.log('✅ 已通过 setUser 设置用户属性');
} else if (token && window.$chatwoot) {
// 备用方案:使用 $chatwoot.setCustomAttributes
window.$chatwoot.setCustomAttributes({
jwt_token: token,
mall_token: token
}); });
console.log('✅ 已通过 $chatwoot.setCustomAttributes 设置用户属性');
}
}, 1000);
console.log('✅ Chatwoot Widget 已加载'); console.log('✅ Chatwoot Widget 已加载');
console.log('Locale: zh_CN'); console.log('Locale: zh_CN');

View File

@@ -313,9 +313,28 @@ async def get_mall_order(
订单详情,包含订单号、状态、商品信息、金额、物流信息等 订单详情,包含订单号、状态、商品信息、金额、物流信息等
Order details including order ID, status, items, amount, logistics info, etc. Order details including order ID, status, items, amount, logistics info, etc.
""" """
import logging
logger = logging.getLogger(__name__)
logger.info(
"get_mall_order called",
order_id=order_id,
has_user_token=bool(user_token),
user_token_prefix=user_token[:20] if user_token else None
)
try: try:
# 如果提供 user_token使用用户自己的 token # 必须提供 user_token
if user_token: if not user_token:
logger.error("No user_token provided, user must be logged in")
return {
"success": False,
"error": "用户未登录,请先登录账户以查询订单信息",
"order_id": order_id,
"require_login": True
}
logger.info("Using user token for Mall API request")
client = MallClient( client = MallClient(
api_url=settings.mall_api_url, api_url=settings.mall_api_url,
api_token=user_token, api_token=user_token,
@@ -324,26 +343,120 @@ async def get_mall_order(
language_id=settings.mall_language_id, language_id=settings.mall_language_id,
source=settings.mall_source source=settings.mall_source
) )
else:
# 否则使用默认的 mall 实例
client = mall
result = await client.get_order_by_id(order_id) result = await client.get_order_by_id(order_id)
logger.info(
"Mall API request successful",
order_id=order_id,
result_keys=list(result.keys()) if isinstance(result, dict) else None
)
return { return {
"success": True, "success": True,
"order": result, "order": result,
"order_id": order_id "order_id": order_id
} }
except Exception as e: except Exception as e:
logger.error(
"Mall API request failed",
order_id=order_id,
error=str(e)
)
return { return {
"success": False, "success": False,
"error": str(e), "error": str(e),
"order_id": order_id "order_id": order_id
} }
finally: finally:
# 如果创建了临时客户端,关闭它 # 关闭客户端
if user_token: if 'client' in dir() and client:
await client.close()
@register_tool("get_logistics")
@mcp.tool()
async def get_logistics(
order_id: str,
user_token: str = None,
user_id: str = None,
account_id: str = None
) -> dict:
"""Query logistics tracking information from Mall API
从 Mall API 查询订单物流信息
Args:
order_id: 订单号 (e.g., "201941967")
user_token: 用户 JWT token必需用于身份验证
user_id: 用户 ID自动注入此工具不使用
account_id: 账户 ID自动注入此工具不使用
Returns:
物流信息,包含快递公司、状态、预计送达时间、物流轨迹等
"""
import logging
logger = logging.getLogger(__name__)
logger.info(
"get_logistics called",
order_id=order_id,
has_user_token=bool(user_token)
)
# 必须提供 user_token
if not user_token:
logger.error("No user_token provided for logistics query")
return {
"success": False,
"error": "用户未登录,请先登录账户以查询物流信息",
"order_id": order_id,
"require_login": True
}
try:
client = MallClient(
api_url=settings.mall_api_url,
api_token=user_token,
tenant_id=settings.mall_tenant_id,
currency_code=settings.mall_currency_code,
language_id=settings.mall_language_id,
source=settings.mall_source
)
result = await client.get(
"/mall/api/order/parcel",
params={"orderId": order_id}
)
logger.info(
"Logistics query successful",
order_id=order_id,
has_tracking=bool(result.get("trackingNumber"))
)
return {
"success": True,
"order_id": order_id,
"tracking_number": result.get("trackingNumber"),
"courier": result.get("courier"),
"status": result.get("status"),
"estimated_delivery": result.get("estimatedDelivery"),
"timeline": result.get("timeline", [])
}
except Exception as e:
logger.error(
"Logistics query failed",
order_id=order_id,
error=str(e)
)
return {
"success": False,
"error": str(e),
"order_id": order_id
}
finally:
if 'client' in dir() and client:
await client.close() await client.close()

View File

@@ -61,6 +61,8 @@ class MallClient:
"currency-code": self.currency_code, "currency-code": self.currency_code,
"language-id": self.language_id, "language-id": self.language_id,
"source": self.source, "source": self.source,
"origin": "https://www.qa1.gaia888.com",
"referer": "https://www.qa1.gaia888.com/",
}, },
timeout=30.0 timeout=30.0
) )