Files
assistant/agent/agents/order.py
wangliang e8e89601a5 feat: 修复订单查询和物流查询功能
主要修改:

1. 订单数据解析修复 (agent/agents/order.py)
   - 修复 Mall API 返回数据的嵌套结构解析
   - 更新字段映射:orderId→order_id, orderProduct→items, statusText→status_text
   - 支持多种商品图片字段:image, pic, thumb, productImg
   - 添加详细的调试日志

2. 物流查询修复 (mcp_servers/order_mcp/server.py)
   - 修复物流接口返回数据结构解析 (data[].trackingCode→tracking_number)
   - 添加 print() 日志用于调试
   - 支持多种字段名映射

3. Chatwoot 集成优化 (agent/integrations/chatwoot.py)
   - 添加 json 模块导入
   - 完善订单卡片和表单展示功能

4. API 请求头优化 (mcp_servers/shared/mall_client.py)
   - 更新 User-Agent 和 Accept 头
   - 修正 Origin 和 Referer 大小写

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-20 19:10:21 +08:00

656 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Order Agent - Handles order-related queries and operations
"""
import json
from typing import Any
from core.state import AgentState, ConversationState, add_tool_call, set_response, update_context
from core.llm import get_llm_client, Message
from utils.logger import get_logger
from integrations.chatwoot import ChatwootClient
logger = get_logger(__name__)
ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
你的职责是帮助用户处理订单相关的问题,包括:
- 订单查询
- 物流跟踪
- 订单修改
- 订单取消
- 发票获取
## 可用工具
1. **get_mall_order** - 从商城 API 查询订单(推荐使用)
- order_id: 订单号(必需)
- 说明:此工具会自动使用用户的身份 token 查询商城订单详情
2. **get_logistics** - 从商城 API 查询物流信息
- order_id: 订单号(必需)
- 说明:查询订单的物流轨迹和配送状态
3. **query_order** - 查询历史订单
- user_id: 用户 ID自动注入
- account_id: 账户 ID自动注入
- order_id: 订单号(可选,不填则查询最近订单)
- date_start: 开始日期(可选)
- date_end: 结束日期(可选)
- status: 订单状态(可选)
4. **modify_order** - 修改订单
- order_id: 订单号
- user_id: 用户 ID自动注入
- modifications: 修改内容address/items/quantity 等)
5. **cancel_order** - 取消订单
- order_id: 订单号
- user_id: 用户 ID自动注入
- reason: 取消原因
6. **get_invoice** - 获取发票
- order_id: 订单号
- invoice_type: 发票类型normal/vat
## 回复格式要求
**重要**:你必须始终返回完整的 JSON 对象,不要包含任何其他文本或解释。
### 格式 1调用工具
当需要使用工具查询信息时,返回:
```json
{
"action": "call_tool",
"tool_name": "get_mall_order",
"arguments": {
"order_id": "202071324"
}
}
```
### 格式 2询问信息
当需要向用户询问更多信息时,返回:
```json
{
"action": "ask_info",
"question": "请提供您的订单号"
}
```
### 格式 3直接回复
当可以直接回答时,返回:
```json
{
"action": "respond",
"response": "您的订单已发货预计3天内到达"
}
```
## 示例对话
用户: "查询订单 202071324"
回复:
```json
{
"action": "call_tool",
"tool_name": "get_mall_order",
"arguments": {
"order_id": "202071324"
}
}
```
用户: "我的订单发货了吗?"
回复:
```json
{
"action": "ask_info",
"question": "请提供您的订单号,以便查询订单状态"
}
```
用户: "帮我查一下订单 202071324 的物流"
回复:
```json
{
"action": "call_tool",
"tool_name": "get_logistics",
"arguments": {
"order_id": "202071324"
}
}
```
## 重要约束
- **必须返回完整的 JSON 对象**,不要只返回部分内容
- **不要添加任何 markdown 代码块标记**(如 \`\`\`json
- **不要添加任何解释性文字**,只返回 JSON
- user_id 和 account_id 会自动注入到 arguments 中,无需手动添加
- 如果用户提供了订单号,优先使用 get_mall_order 工具
- 如果用户想查询物流状态,使用 get_logistics 工具
- 对于敏感操作(取消、修改),确保有明确的订单号
"""
async def order_agent(state: AgentState) -> AgentState:
"""Order agent node
Handles order queries, tracking, modifications, and cancellations.
Args:
state: Current agent state
Returns:
Updated state with tool calls or response
"""
logger.info(
"Order agent processing",
conversation_id=state["conversation_id"],
sub_intent=state.get("sub_intent")
)
state["current_agent"] = "order"
state["agent_history"].append("order")
state["state"] = ConversationState.PROCESSING.value
# Check if we have tool results to process
if state["tool_results"]:
return await _generate_order_response(state)
# Build messages for LLM
messages = [
Message(role="system", content=ORDER_AGENT_PROMPT),
]
# Add conversation history
for msg in state["messages"][-6:]:
messages.append(Message(role=msg["role"], content=msg["content"]))
# Build context info
context_info = f"用户ID: {state['user_id']}\n账户ID: {state['account_id']}\n"
# Add entities if available
if state["entities"]:
context_info += f"已提取的信息: {json.dumps(state['entities'], ensure_ascii=False)}\n"
# Add existing context
if state["context"].get("order_id"):
context_info += f"当前讨论的订单号: {state['context']['order_id']}\n"
user_content = f"{context_info}\n用户消息: {state['current_message']}"
messages.append(Message(role="user", content=user_content))
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.5)
# Parse response
content = response.content.strip()
logger.info(
"LLM response received",
conversation_id=state["conversation_id"],
response_length=len(content),
response_preview=content[:300]
)
# 检查是否是简化的工具调用格式:工具名称\n{参数}
# 例如get_mall_order\n{"order_id": "202071324"}
if "\n" in content and "{" in content:
lines = content.split("\n")
if len(lines) >= 2:
tool_name_line = lines[0].strip()
json_line = "\n".join(lines[1:]).strip()
# 如果第一行看起来像工具名称(不包含 {),且第二行是 JSON
if "{" not in tool_name_line and "{" in json_line:
logger.info(
"Detected simplified tool call format",
tool_name=tool_name_line,
json_preview=json_line[:200]
)
try:
arguments = json.loads(json_line)
# 直接构建工具调用
arguments["user_id"] = state["user_id"]
arguments["account_id"] = state["account_id"]
# Inject user_token if available
if state.get("user_token"):
arguments["user_token"] = state["user_token"]
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
if "order_id" not in arguments and state["entities"].get("order_id"):
arguments["order_id"] = state["entities"]["order_id"]
state = add_tool_call(
state,
tool_name=tool_name_line,
arguments=arguments,
server="order"
)
state["state"] = ConversationState.TOOL_CALLING.value
logger.info(
"Tool call added from simplified format",
tool_name=tool_name_line,
arguments_keys=list(arguments.keys())
)
return state
except json.JSONDecodeError as e:
logger.warning(
"Failed to parse simplified format",
error=str(e),
json_line=json_line[:200]
)
# 清理内容,去除可能的 markdown 代码块标记
# 例如:```json\n{...}\n``` 或 ```\n{...}\n```
if "```" in content:
# 找到第一个 ``` 后的内容
parts = content.split("```")
if len(parts) >= 2:
content = parts[1].strip()
# 去掉可能的 "json" 标记
if content.startswith("json"):
content = content[4:].strip()
# 去掉结尾的 ``` 标记
if content.endswith("```"):
content = content[:-3].strip()
# 尝试提取 JSON 对象(处理周围可能有文本的情况)
json_start = content.find("{")
json_end = content.rfind("}")
if json_start != -1 and json_end != -1 and json_end > json_start:
content = content[json_start:json_end + 1]
logger.info(
"Cleaned content for JSON parsing",
conversation_id=state["conversation_id"],
content_length=len(content),
content_preview=content[:500]
)
try:
result = json.loads(content)
except json.JSONDecodeError as e:
logger.error(
"Failed to parse LLM response as JSON",
conversation_id=state["conversation_id"],
error=str(e),
content_preview=content[:500]
)
# 如果解析失败,尝试将原始内容作为直接回复
state = set_response(state, response.content)
return state
action = result.get("action")
logger.info(
"LLM action parsed",
conversation_id=state["conversation_id"],
action=action,
tool_name=result.get("tool_name")
)
if action == "call_tool":
# Inject user context into arguments
arguments = result.get("arguments", {})
arguments["user_id"] = state["user_id"]
arguments["account_id"] = state["account_id"]
# Inject user_token if available (for Mall API calls)
if state.get("user_token"):
arguments["user_token"] = state["user_token"]
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
if "order_id" not in arguments and state["entities"].get("order_id"):
arguments["order_id"] = state["entities"]["order_id"]
state = add_tool_call(
state,
tool_name=result["tool_name"],
arguments=arguments,
server="order"
)
state["state"] = ConversationState.TOOL_CALLING.value
logger.info(
"Tool call added",
tool_name=result["tool_name"],
arguments_keys=list(arguments.keys())
)
elif action == "ask_info":
state = set_response(state, result["question"])
state["state"] = ConversationState.AWAITING_INFO.value
elif action == "respond":
state = set_response(state, result["response"])
state["state"] = ConversationState.GENERATING.value
elif action == "handoff":
state["requires_human"] = True
state["handoff_reason"] = result.get("reason", "Complex order operation")
return state
except Exception as e:
logger.error("Order agent failed", error=str(e))
state["error"] = str(e)
return state
async def _generate_order_response(state: AgentState) -> AgentState:
"""Generate response based on order tool results"""
# 解析订单数据并尝试使用 form 格式发送
order_data = None
user_message = ""
logistics_data = None
for result in state["tool_results"]:
if result["success"]:
data = result["data"]
tool_name = result["tool_name"]
# 提取订单ID到上下文
if isinstance(data, dict):
if data.get("order_id"):
state = update_context(state, {"order_id": data["order_id"]})
elif data.get("orders") and len(data["orders"]) > 0:
state = update_context(state, {"order_id": data["orders"][0].get("order_id")})
# 处理 get_mall_order 返回的订单数据
if tool_name == "get_mall_order" and isinstance(data, dict):
# MCP 返回结构: {"success": true, "result": {...}}
# result 可能包含: {"success": bool, "order": {...}, "order_id": "...", "error": "..."}
mcp_result = data.get("result", {})
# 检查是否有错误(如未登录)
if mcp_result.get("error") or not mcp_result.get("success"):
logger.warning(
"get_mall_order returned error",
error=mcp_result.get("error"),
require_login=mcp_result.get("require_login")
)
# 设置错误消息到状态中
if mcp_result.get("require_login"):
user_message = mcp_result.get("error", "请先登录账户以查询订单信息")
elif mcp_result.get("order"):
# 有订单数据
order_data = _parse_mall_order_data(mcp_result["order"])
# 如果 order_data 中没有 order_id从外层获取
if not order_data.get("order_id") and mcp_result.get("order_id"):
order_data["order_id"] = mcp_result["order_id"]
else:
logger.warning(
"get_mall_order returned success but no order data",
data_keys=list(data.keys()),
result_keys=list(mcp_result.keys()) if isinstance(mcp_result, dict) else None
)
# 处理 query_order 返回的订单数据
elif tool_name == "query_order" and isinstance(data, dict):
if data.get("orders") and len(data["orders"]) > 0:
order_data = _parse_order_data(data["orders"][0])
if len(data["orders"]) > 1:
user_message = f"找到 {len(data['orders'])} 个订单,显示最新的一个:"
# 处理 get_logistics 返回的物流数据
elif tool_name == "get_logistics" and isinstance(data, dict):
logistics_data = _parse_logistics_data(data)
# 如果之前有订单数据,添加物流信息
if order_data:
order_data["logistics"] = logistics_data
# 尝试使用 Chatwoot cards 格式发送
if order_data:
try:
# 检查是否有有效的 order_id
if not order_data.get("order_id"):
logger.warning(
"No valid order_id in order_data, falling back to text response",
order_data=order_data
)
return await _generate_text_response(state)
chatwoot = ChatwootClient()
conversation_id = state.get("conversation_id")
if conversation_id:
# 记录订单数据(用于调试)
logger.info(
"Preparing to send order card",
conversation_id=conversation_id,
order_id=order_data.get("order_id"),
items_count=len(order_data.get("items", []))
)
# 发送订单卡片(使用默认的"查看订单详情"按钮)
await chatwoot.send_order_card(
conversation_id=conversation_id,
order_data=order_data
)
logger.info(
"Order card sent successfully",
conversation_id=conversation_id,
order_id=order_data.get("order_id")
)
# 设置确认消息
response_text = user_message or "订单详情如下"
state = set_response(state, response_text)
state["state"] = ConversationState.GENERATING.value
return state
except Exception as e:
logger.error(
"Failed to send order card, falling back to text response",
error=str(e),
order_id=order_data.get("order_id")
)
# 降级处理:使用原来的 LLM 生成逻辑
return await _generate_text_response(state)
def _parse_mall_order_data(data: dict[str, Any]) -> dict[str, Any]:
"""解析商城 API 返回的订单数据"""
# 记录原始数据结构(用于调试)
logger.info(
"Parsing mall order data",
data_keys=list(data.keys()),
has_order_id=bool(data.get("order_id")),
has_order_sn=bool(data.get("order_sn")),
has_nested_order=bool(data.get("order")),
order_id_preview=data.get("order_id", data.get("order_sn", "")),
# 如果有 order 字段,记录其内容类型和键
nested_order_type=type(data.get("order")).__name__ if data.get("order") else None,
nested_order_keys=list(data.get("order", {}).keys()) if isinstance(data.get("order"), dict) else None
)
# Mall API 返回结构:外层包含 userId, reqContext 等,实际的订单数据在 order 字段中
# 如果有嵌套的 order 字段,提取出来
actual_order_data = data.get("order", data) if data.get("order") else data
# 记录提取的订单数据结构(用于调试)
logger.info(
"Extracted order data structure",
actual_order_keys=list(actual_order_data.keys()) if isinstance(actual_order_data, dict) else type(actual_order_data).__name__,
has_items=bool(actual_order_data.get("items")),
has_order_items=bool(actual_order_data.get("order_items")),
has_products=bool(actual_order_data.get("products")),
has_orderProduct=bool(actual_order_data.get("orderProduct")),
has_orderGoods=bool(actual_order_data.get("orderGoods")),
has_goods=bool(actual_order_data.get("goods"))
)
order_data = {
"order_id": actual_order_data.get("orderId", actual_order_data.get("order_id", actual_order_data.get("order_sn", ""))),
"status": actual_order_data.get("orderStatusId", actual_order_data.get("status", "unknown")),
"status_text": actual_order_data.get("statusText", actual_order_data.get("status_text", actual_order_data.get("status", ""))),
"total_amount": actual_order_data.get("total", actual_order_data.get("total_amount", actual_order_data.get("order_amount", "0.00"))),
"shipping_fee": actual_order_data.get("shipping_fee", actual_order_data.get("freight_amount", "0")),
}
# 下单时间
if actual_order_data.get("created_at"):
order_data["created_at"] = actual_order_data["created_at"]
elif actual_order_data.get("add_time"):
order_data["created_at"] = actual_order_data["add_time"]
elif actual_order_data.get("dateAdded"):
order_data["created_at"] = actual_order_data["dateAdded"]
# 商品列表 - 尝试多种可能的字段名(优先 orderProduct
items = (
actual_order_data.get("orderProduct") or
actual_order_data.get("items") or
actual_order_data.get("order_items") or
actual_order_data.get("products") or
actual_order_data.get("orderGoods") or
actual_order_data.get("goods") or
[]
)
# 记录商品列表数据结构(用于调试)
if items and len(items) > 0:
first_item = items[0]
logger.info(
"First item structure",
first_item_keys=list(first_item.keys()) if isinstance(first_item, dict) else type(first_item).__name__,
has_image_url=bool(first_item.get("image_url")) if isinstance(first_item, dict) else False,
has_image=bool(first_item.get("image")) if isinstance(first_item, dict) else False,
has_pic=bool(first_item.get("pic")) if isinstance(first_item, dict) else False,
sample_item_data=str(first_item)[:500] if isinstance(first_item, dict) else str(first_item)
)
if items:
order_data["items"] = []
for item in items:
item_data = {
"name": item.get("name", item.get("productName", item.get("product_name", "未知商品"))),
"quantity": item.get("quantity", item.get("num", item.get("productNum", 1))),
"price": item.get("price", item.get("total", item.get("productPrice", item.get("product_price", "0.00"))))
}
# 添加商品图片(支持多种可能的字段名)
image_url = (
item.get("image") or
item.get("image_url") or
item.get("pic") or
item.get("thumb") or
item.get("product_image") or
item.get("pic_url") or
item.get("thumb_url") or
item.get("img") or
item.get("productImg") or
item.get("thumb")
)
if image_url:
item_data["image_url"] = image_url
else:
# 记录没有图片的商品(用于调试)
logger.debug(
"No image found for product",
product_name=item_data.get("name"),
available_keys=list(item.keys())
)
order_data["items"].append(item_data)
# 备注
if actual_order_data.get("remark") or actual_order_data.get("user_remark"):
order_data["remark"] = actual_order_data.get("remark", actual_order_data.get("user_remark", ""))
return order_data
def _parse_order_data(data: dict[str, Any]) -> dict[str, Any]:
"""解析历史订单数据"""
return _parse_mall_order_data(data)
def _parse_logistics_data(data: dict[str, Any]) -> dict[str, Any]:
"""解析物流数据"""
# MCP 返回结构: {"success": true, "result": {...物流数据...}}
mcp_result = data.get("result", data) if data.get("result") else data
logger.info(
"Parsing logistics data",
data_keys=list(data.keys()) if isinstance(data, dict) else None,
has_result_in_data=bool(data.get("result")),
mcp_result_keys=list(mcp_result.keys()) if isinstance(mcp_result, dict) else None,
raw_tracking_number_value=repr(mcp_result.get("tracking_number")) if mcp_result.get("tracking_number") is not None else None,
raw_courier_value=repr(mcp_result.get("courier")) if mcp_result.get("courier") is not None else None,
has_tracking_number=bool(mcp_result.get("tracking_number")),
has_courier=bool(mcp_result.get("courier")),
has_timeline=bool(mcp_result.get("timeline"))
)
return {
"carrier": mcp_result.get("courier", mcp_result.get("carrier", mcp_result.get("express_name", "未知"))),
"tracking_number": mcp_result.get("tracking_number") or "",
"status": mcp_result.get("status"),
"estimated_delivery": mcp_result.get("estimatedDelivery"),
"timeline": mcp_result.get("timeline", [])
}
async def _generate_text_response(state: AgentState) -> AgentState:
"""生成纯文本回复(降级方案)"""
# Build context from tool results
tool_context = []
for result in state["tool_results"]:
if result["success"]:
data = result["data"]
tool_context.append(f"工具 {result['tool_name']} 返回:\n{json.dumps(data, ensure_ascii=False, indent=2)}")
else:
tool_context.append(f"工具 {result['tool_name']} 执行失败: {result['error']}")
prompt = f"""基于以下订单系统返回的信息,生成对用户的回复。
用户问题: {state["current_message"]}
系统返回信息:
{chr(10).join(tool_context)}
请生成一个清晰、友好的回复,包含订单的关键信息(订单号、状态、金额、物流等)。
如果是物流信息,请按时间线整理展示。
只返回回复内容,不要返回 JSON。"""
messages = [
Message(role="system", content="你是一个专业的订单客服助手,请根据系统返回的信息回答用户的订单问题。"),
Message(role="user", content=prompt)
]
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.7)
state = set_response(state, response.content)
return state
except Exception as e:
logger.error("Order response generation failed", error=str(e))
state = set_response(state, "抱歉,处理订单信息时遇到问题。请稍后重试或联系人工客服。")
return state