diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e47de32 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,556 @@ +# B2B Shopping AI Assistant - Claude 开发上下文 + +> 本文件记录项目架构、技术决策、最近修改历史,便于 Claude Code 继续开发维护 + +--- + +## 项目概览 + +**项目名称**: B2B Shopping AI Assistant +**技术栈**: Python + LangGraph + FastMCP + Chatwoot + Docker +**AI 模型**: 智谱 AI GLM-4-Flash +**主要功能**: 订单查询、产品咨询、售后服务、FAQ 智能问答 + +--- + +## 服务架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 客户端渠道 │ +│ (Website Widget / API / Chatwoot) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Chatwoot (消息平台) │ +│ http://localhost:3000 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ LangGraph Agent (AI 代理层) │ +│ http://localhost:8000 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Router │ │ Product │ │ Order │ │ Aftersale│ │ +│ │ Agent │ │ Agent │ │ Agent │ │ Agent │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MCP Servers (工具服务) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Strapi │ │ Order │ │ Aftersale│ │ Product │ │ +│ │ MCP │ │ MCP │ │ MCP │ │ MCP │ │ +│ │ :8001 │ │ :8002 │ │ :8003 │ │ :8004 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 后端服务 (Hyperf PHP) │ +│ https://api.qa1.gaia888.com │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Orders │ │ Products │ │ Aftersales│ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 目录结构 + +``` +ai/ +├── agent/ # AI Agent 主服务 +│ ├── agents/ # 各业务 Agent 实现 +│ │ ├── router.py # 路由 Agent(意图识别) +│ │ ├── customer_service.py # 客服 Agent(FAQ、公司信息) +│ │ ├── order.py # 订单 Agent +│ │ ├── product.py # 产品 Agent +│ │ └── aftersale.py # 售后 Agent +│ ├── core/ # 核心框架 +│ │ ├── state.py # AgentState 状态管理 +│ │ ├── graph.py # LangGraph 工作流 +│ │ ├── llm.py # LLM 客户端(智谱 AI) +│ │ └── language_detector.py # 多语言检测 +│ ├── integrations/ # 第三方集成 +│ │ ├── chatwoot.py # Chatwoot API 客户端 +│ │ └── hyperf_client.py # Hyperf API 客户端 +│ ├── prompts/ # 提示词模板 +│ ├── utils/ # 工具函数 +│ │ ├── logger.py # 日志工具 +│ │ ├── faq_library.py # 本地 FAQ 库(仅社交问候) +│ │ ├── response_cache.py # Redis 响应缓存 +│ │ ├── cache.py # Redis 缓存客户端 +│ │ └── token_manager.py # Token 管理 +│ ├── webhooks/ # Webhook 处理 +│ │ └── chatwoot_webhook.py # Chatwoot Webhook 接收 +│ ├── config.py # 配置管理 +│ └── main.py # FastAPI 应用入口 +│ +├── mcp_servers/ # MCP 服务集群 +│ ├── strapi_mcp/ # 知识库 MCP (端口 8001) +│ ├── order_mcp/ # 订单 MCP (端口 8002) +│ ├── aftersale_mcp/ # 售后 MCP (端口 8003) +│ ├── product_mcp/ # 产品 MCP (端口 8004) +│ └── shared/ # 共享模块 +│ +├── docker-compose.yml # Docker 编排配置 +├── nginx.conf # Nginx 配置 +├── .env # 环境变量 +└── postgres-with-pgvector.Dockerfile # PostgreSQL Dockerfile +``` + +--- + +## 重要配置文件 + +### 环境变量 (.env) + +```env +# AI 模型 +ZHIPU_API_KEY=87ecd9d88ef549bc817e1feeba226a3b.hfbTWh4Hhdw8Kulh +ZHIPU_MODEL=GLM-4-Flash-250414 +ENABLE_REASONING_MODE=false +REASONING_MODE_FOR_COMPLEX=true + +# Redis +REDIS_HOST=redis +REDIS_PORT=6379 + +# Chatwoot +CHATWOOT_API_URL=http://chatwoot:3000 +CHATWOOT_API_TOKEN=fnWaEeAyC1gw1FYQq6YJMWSj +CHATWOOT_ACCOUNT_ID=2 +CHATWOOT_WEBHOOK_SECRET=b7a12b9c9173718596f02fd912fb59f97891a0e7abb1a5e457b4c8858b2d21b5 + +# Hyperf API +HYPERF_API_URL=https://api.qa1.gaia888.com +HYPERF_API_TOKEN= + +# Mall API +MALL_API_URL=https://apicn.qa1.gaia888.com +MALL_TENANT_ID=2 +MALL_CURRENCY_CODE=EUR +MALL_LANGUAGE_ID=1 +MALL_SOURCE=us.qa1.gaia888.com +``` + +### 服务端口映射 + +| 服务 | 容器名 | 内部端口 | 外部端口 | 说明 | +|------|--------|----------|----------|------| +| Agent | ai_agent | 8000 | 8000 | AI 代理服务 | +| Chatwoot | ai_chatwoot | 3000 | 3000 | 客服平台 | +| Redis | ai_redis | 6379 | - | 缓存/队列 | +| PostgreSQL | ai_postgres | 5432 | - | Chatwoot 数据库 | +| Nginx | ai_nginx | 80 | 8080 | 文档静态服务 | +| Strapi MCP | ai_strapi_mcp | 8001 | 8001 | 知识库服务 | +| Order MCP | ai_order_mcp | 8002 | 8002 | 订单服务 | +| Aftersale MCP | ai_aftersale_mcp | 8003 | 8003 | 售后服务 | +| Product MCP | ai_product_mcp | 8004 | 8004 | 产品服务 | + +--- + +## 关键代码文件 + +### 1. Agent 核心流程 + +**agent/core/graph.py:156-188** +- LangGraph 状态图定义 +- Agent 路由逻辑 +- 条件边和工具调用流程 + +**agent/core/state.py:16-67** +- AgentState 数据结构 +- ConversationState 枚举 +- 核心状态字段定义 + +### 2. Router - 意图识别 + +**agent/agents/router.py:38-57** +- FAQ 快速路径优化(在 LLM 前检查本地 FAQ) +- 多语言意图识别 + +**agent/agents/router.py:73-147** +- LLM 意图分类 prompt +- 支持意图:customer_service, order, product, aftersale + +### 3. Customer Service Agent + +**agent/agents/customer_service.py:40-64** +- Router 级 FAQ 响应复用 +- 本地 FAQ 库备份检查 + +**agent/agents/customer_service.py:72-164** +- 多语言分类关键词(8 种语言:en, nl, de, es, fr, it, tr, zh) +- 自动查询 FAQ 分类:register, order, payment, shipment, return + +### 4. Chatwoot 集成 + +**agent/integrations/chatwoot.py:228-384** +- `send_order_form()` - 使用 content_type="form" 发送订单详情 +- `send_order_card()` - 使用 content_type="cards" 发送订单卡片 +- `send_rich_message()` - 通用富媒体消息发送 + +**agent/webhooks/chatwoot_webhook.py** +- Webhook 接收处理 +- 消息格式转换 +- 并发会话管理 + +### 5. FAQ 系统 + +**agent/utils/faq_library.py:15-49** +- 本地 FAQ 库(仅包含社交问候:你好、谢谢、再见等) +- 业务 FAQ 由 Strapi MCP 处理 +- 支持精确匹配和模糊匹配 + +--- + +## Context7 MCP 集成 + +### 什么是 Context7? + +**Context7 MCP** 是一个为 AI 编码助手和 LLM 提供实时、版本特定的代码文档和示例的服务。它解决了 AI 模型训练数据过时的问题,确保 AI 始终使用最新的官方文档。 + +**核心功能**: +- **实时文档访问**: 从 GitHub 仓库获取最新的官方文档 +- **版本特定**: 支持查询特定版本(如 React 19、Next.js 15)的文档 +- **多语言支持**: 支持 100+ 主流库(前端、后端、数据库、AI/ML) +- **向量搜索**: 使用语义搜索找到最相关的文档片段 + +### 配置方式 + +#### 本项目配置命令 + +```bash +claude mcp add --transport http context7 https://mcp.context7.com/mcp \ + --header "CONTEXT7_API_KEY: ctx7sk-9be917f9-9086-4d85-a3d3-23555aaa2da6" +``` + +#### API 密钥获取 + +1. 访问 [Upstash Console](https://console.upstash.com/) +2. 创建新项目或选择现有项目 +3. 导航至 "Context7" 标签 +4. 复制 API 密钥(格式:`ctx7sk-`) + +### 可用工具 + +#### 1. `get-documentation-context` +获取文档上下文(主要工具) + +**参数**: +```json +{ + "library_id": "react", // 库 ID (必填) + "query": "如何使用 useEffect", // 查询问题 (必填) + "version": "19.0.0" // 指定版本 (可选) +} +``` + +#### 2. `get-docs` +获取库的完整文档列表 + +**参数**: +```json +{ + "library_id": "nextjs", + "version": "15.0.0" +} +``` + +#### 3. `resolve-library-id` +从库名称/URL 获取标准化的 library_id + +**参数**: +```json +{ + "identifier": "react" // 或 GitHub URL、npm 包名等 +} +``` + +**返回**: +```json +{ + "library_id": "react", + "available_versions": ["18.3.1", "19.0.0", "19.1.0"] +} +``` + +#### 4. `search-code` +在文档中搜索代码示例 + +**参数**: +```json +{ + "query": "useEffect cleanup", + "libraries": ["react", "nextjs"], + "limit": 5 +} +``` + +### 支持的库(部分) + +| 类别 | 库 | +|------|-----| +| 前端框架 | React, Vue, Next.js, Nuxt, Svelte | +| 后端框架 | FastAPI, Django, Express, Laravel | +| 数据库 | PostgreSQL, MySQL, MongoDB, Redis | +| 工具库 | Lodash, Axios, Moment.js | +| AI/ML | LangChain, TensorFlow, PyTorch | + +### 使用场景 + +**在开发中使用 Context7**: +- 查询 LangGraph 最新 API 文档 +- 获取 FastAPI 特定版本的代码示例 +- 了解 Docker Compose 最新配置选项 + +**示例**: +``` +用户: LangGraph 如何定义条件边? +Claude: [调用 get-documentation-context] +根据 LangGraph 官方文档,条件边使用 add_conditional_edges()... +``` + +### 相关链接 + +- [Context7 GitHub 仓库](https://github.com/upstash/context7) +- [Context7 API 文档](https://context7.mintlify.app/api-reference/context/get-documentation-context) +- [Upstash Console](https://console.upstash.com/) + +--- + +## 最近修改历史 + +### 2026-01-20 - Form 格式订单详情 + +**修改文件**: `agent/integrations/chatwoot.py` + +**修改内容**: +- 新增 `send_order_form()` 方法,使用 `content_type="form"` 展示订单详情 +- 支持字段类型:text, text_area, select +- 订单字段映射:订单号、状态、下单时间、商品列表、总金额、运费、物流信息、备注 + +**API 参考**: +- [Create New Message - Chatwoot API](https://developers.chatwoot.com/api-reference/messages/create-new-message) +- [Interactive Messages - Chatwoot User Guide](https://www.chatwoot.com/hc/user-guide/articles/1677689344-how-to-use-interactive-messages) + +**数据结构**: +```json +{ + "content": "订单详情", + "content_type": "form", + "content_attributes": { + "items": [ + { + "name": "order_id", + "label": "订单号", + "type": "text", + "default": "123456789" + } + ] + } +} +``` + +### 2026-01-20 - 多语言 FAQ 分类支持 + +**修改文件**: `agent/agents/customer_service.py:72-164` + +**修改内容**: +- 为 5 个业务分类添加 8 种语言关键词(en, nl, de, es, fr, it, tr, zh) +- 分类:register, order, payment, shipment, return + +**示例**: +```python +category_keywords = { + "register": [ + "register", "sign up", "account", # English + "注册", "账号", "登录", "密码", # Chinese + # ... 其他语言 + ] +} +``` + +### 2026-01-20 - FAQ 架构优化 + +**修改文件**: +- `agent/agents/router.py:38-57` +- `agent/utils/faq_library.py:15-49` + +**问题**: 本地 FAQ 库包含业务相关问答,导致返回硬编码数据而非 Strapi CMS 数据 + +**解决方案**: +1. 本地 FAQ 库仅保留社交问候(18 个关键词) +2. Router 层添加 FAQ 快速路径检查 +3. 业务查询自动调用 Strapi MCP 工具 + +**本地 FAQ 覆盖范围**: +- 问候类:你好、hi、hello、hey +- 感谢类:谢谢、thank you、thanks +- 再见类:再见、bye、goodbye +- 时间问候:早上好、下午好、晚上好、good morning + +### 2026-01-20 - Chatwoot PID 文件修复 + +**修改文件**: `docker-compose.yml:64-68` + +**问题**: Chatwoot 容器因残留 PID 文件陷入重启循环 + +**解决方案**: 在启动命令中自动清理 PID 文件 +```yaml +chatwoot: + command: sh -c "rm -f /app/tmp/pids/server.pid && bundle exec rails s -p 3000 -b 0.0.0.0" +``` + +### 2026-01-20 - Docker 镜像拉取优化 + +**修改文件**: `/etc/docker/daemon.json` + +**问题**: Docker Hub 超时导致镜像拉取失败 + +**解决方案**: 添加中国镜像源 +```json +{ + "registry-mirrors": [ + "https://docker.m.daocloud.io", + "https://docker.1panel.live", + "https://dockerhub.icu" + ] +} +``` + +--- + +## 常见操作 + +### 启动服务 + +```bash +# 启动所有服务 +docker-compose up -d + +# 查看服务状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f agent + +# 重启单个服务 +docker-compose restart agent +``` + +### 环境变量生效 + +```bash +# 修改 .env 后,需要重启相关服务 +# Agent 服务依赖的环境变量:ZHIPU_*, REDIS_*, CHATWOOT_*, STRAPI_*, HYPERF_* +docker-compose restart agent + +# Chatwoot 服务依赖的环境变量:CHATWOOT_*, POSTGRES_*, REDIS_* +docker-compose restart chatwoot chatwoot_worker + +# MCP 服务依赖的环境变量:HYPERF_*, STRAPI_* +docker-compose restart strapi_mcp order_mcp aftersale_mcp product_mcp +``` + +### Git 提交 + +```bash +# 查看状态 +git status + +# 添加文件(排除测试文件) +git add agent/integrations/chatwoot.py + +# 提交 +git commit -m "feat: 添加订单详情 form 格式展示支持" + +# 推送 +git push origin main +``` + +--- + +## 技术决策记录 + +### 1. 为什么使用 LangGraph? + +- **状态图工作流**: 支持 Agent 之间的条件跳转和循环 +- **内置检查点**: 支持 conversation history 的持久化 +- **可视化调试**: 可以导出 Mermaid 图表查看工作流 + +### 2. 为什么使用 MCP (Model Context Protocol)? + +- **解耦**: Agent 与业务逻辑分离 +- **复用**: MCP 工具可被多个 Agent 共享 +- **标准化**: 统一的工具调用接口 + +### 3. 为什么 FAQ 分两级? + +- **本地 FAQ 库**: 处理社交问候(你好、谢谢等),避免 LLM 调用 +- **Strapi MCP**: 处理业务 FAQ(注册、订单等),确保数据一致性 + +### 4. 为什么 Router 层检查 FAQ? + +- **性能优化**: 跳过 LLM 意图识别,直接返回 +- **成本降低**: 减少 LLM API 调用 +- **用户体验**: 即时响应常见问候 + +### 5. 为什么集成 Context7 MCP? + +- **解决文档过时**: AI 模型训练数据截止到 2023 年,无法获取最新 API +- **版本特定**: 支持查询特定版本的文档(如 LangGraph 0.3 vs 0.2) +- **代码示例**: 提供实际可运行的代码片段,减少 AI 幻觉 +- **开发效率**: 减少查文档时间,专注于业务逻辑 + +--- + +## 已知问题 + +### 1. Form 类型数据展示 + +**问题**: Chatwoot 的 form 类型主要用于用户输入,用于只读展示时字段可编辑 + +**当前方案**: 使用 `default` 属性预填值,用户可以看到但技术上可修改 + +**未来改进**: +- 研究是否支持只读模式 +- 或考虑使用 cards + JSON 格式展示结构化数据 + +### 2. Token 传递 + +**问题**: 部分 MCP 工具调用可能未正确传递 Chatwoot conversation ID + +**当前方案**: 在 AgentState 中维护 conversation_id + +--- + +## 参考文档 + +### 项目相关 +- [LangGraph 文档](https://langchain-ai.github.io/langgraph/) +- [FastMCP 文档](https://github.com/jlowin/fastmcp) +- [Chatwoot API 文档](https://developers.chatwoot.com/) +- [智谱 AI API 文档](https://open.bigmodel.cn/dev/api) + +### Context7 相关 +- [Context7 GitHub 仓库](https://github.com/upstash/context7) +- [Context7 API 文档](https://context7.mintlify.app/api-reference/context/get-documentation-context) +- [Upstash Console](https://console.upstash.com/) +- [Context7 MCP 教程](https://dev.to/mehmetakar/context7-mcp-tutorial-3he2) + +### 外部参考 +- [热门 MCP Server 详解 - 火山引擎](https://www.volcengine.com/docs/86677/2165252) +- [Context7 MCP 为 Cursor 提供实时文档上下文 - 知乎](https://zhuanlan.zhihu.com/p/1914959847792804088) +- [Context7 MCP Server 介绍 - 二师兄的博客](https://www.cloudesx.com/article/Context7-mcp-server) +- [AI 编程焕新:用 Context7 - 腾讯云](https://cloud.tencent.com/developer/article/2528436) + +--- + +**最后更新**: 2026-01-20 +**维护者**: Claude Code diff --git a/agent/agents/order.py b/agent/agents/order.py index 8d4d045..bef74a2 100644 --- a/agent/agents/order.py +++ b/agent/agents/order.py @@ -7,6 +7,7 @@ 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__) @@ -361,23 +362,271 @@ async def order_agent(state: AgentState) -> AgentState: 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)}") - - # Extract order_id for context - 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")}) else: tool_context.append(f"工具 {result['tool_name']} 执行失败: {result['error']}") - + prompt = f"""基于以下订单系统返回的信息,生成对用户的回复。 用户问题: {state["current_message"]} @@ -388,18 +637,18 @@ async def _generate_order_response(state: AgentState) -> AgentState: 请生成一个清晰、友好的回复,包含订单的关键信息(订单号、状态、金额、物流等)。 如果是物流信息,请按时间线整理展示。 只返回回复内容,不要返回 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, "抱歉,处理订单信息时遇到问题。请稍后重试或联系人工客服。") diff --git a/agent/integrations/chatwoot.py b/agent/integrations/chatwoot.py index a3874eb..43c703b 100644 --- a/agent/integrations/chatwoot.py +++ b/agent/integrations/chatwoot.py @@ -1,6 +1,7 @@ """ Chatwoot API Client for B2B Shopping AI Assistant """ +import json from typing import Any, Optional from dataclasses import dataclass from enum import Enum @@ -172,9 +173,122 @@ class ChatwootClient: self, conversation_id: int, order_data: dict[str, Any], - actions: list[dict[str, Any]] + actions: Optional[list[dict[str, Any]]] = None ) -> dict[str, Any]: - """发送订单卡片消息(Markdown 文本 + 操作按钮) + """发送订单卡片消息(使用 Chatwoot cards 格式) + + 卡片结构: + - 图片:第一件商品的图片 + - 标题:订单号 + 状态 + - 描述:汇总信息 + - 按钮:跳转到订单详情页面 + + Args: + conversation_id: 会话 ID + order_data: 订单数据,包含: + - order_id: 订单号 + - status: 订单状态 + - status_text: 状态文本 + - items: 商品列表 + - total_amount: 总金额 + actions: 操作按钮配置列表(可选) + + Returns: + 发送结果 + + Example: + >>> order_data = { + ... "order_id": "202071324", + ... "status_text": "已发货", + ... "items": [{"name": "商品A", "quantity": 2}], + ... "total_amount": "599.00" + ... } + >>> await chatwoot.send_order_card(123, order_data) + """ + order_id = order_data.get("order_id", "") + status_text = order_data.get("status_text", order_data.get("status", "")) + + # 获取第一件商品的图片 + items = order_data.get("items", []) + media_url = None + if items and len(items) > 0: + first_item = items[0] + media_url = first_item.get("image_url") + + # 构建标题 + title = f"订单 #{order_id} {status_text}" + + # 构建描述 + if items and len(items) > 0: + if len(items) == 1: + items_desc = items[0].get("name", "商品") + else: + items_desc = f"{items[0].get('name', '商品A')} 等共计 {len(items)} 件商品" + description = f"包含 {items_desc},实付 ¥{order_data.get('total_amount', '0.00')}" + else: + description = f"实付 ¥{order_data.get('total_amount', '0.00')}" + + # 构建操作按钮 + card_actions = [] + if actions: + card_actions = actions + else: + # 默认按钮:跳转到订单详情页面 + card_actions = [ + { + "type": "link", + "text": "查看订单详情", + "uri": f"https://www.qa1.gaia888.com/customer/order/detail?orderId={order_id}" + } + ] + + # 构建单个卡片 + card = { + "title": title, + "description": description, + "actions": card_actions + } + + # 如果有图片,添加 media_url + if media_url: + card["media_url"] = media_url + + # 构建 content_attributes + content_attributes = { + "items": [card] + } + + # 记录发送的数据(用于调试) + logger.info( + "Sending order card", + conversation_id=conversation_id, + order_id=order_id, + has_media=bool(media_url), + payload_preview=json.dumps({ + "content": "订单详情", + "content_type": "cards", + "content_attributes": content_attributes + }, ensure_ascii=False, indent=2)[:1000] + ) + + # 发送富媒体消息 + return await self.send_rich_message( + conversation_id=conversation_id, + content="订单详情", + content_type="cards", + content_attributes=content_attributes + ) + + async def send_order_form( + self, + conversation_id: int, + order_data: dict[str, Any], + actions: Optional[list[dict[str, Any]]] = None + ) -> dict[str, Any]: + """发送订单详情表单消息(使用 content_type=form) + + 根据 Chatwoot API 文档实现的 form 格式订单详情展示。 + form 类型支持的字段类型:text, text_area, email, select Args: conversation_id: 会话 ID @@ -188,11 +302,9 @@ class ChatwootClient: - shipping_fee: 运费(可选) - logistics: 物流信息(可选) - remark: 备注(可选) - actions: 操作按钮配置列表,每个按钮包含: - - type: "link" 或 "postback" - - text: 按钮文字 - - uri: 链接地址(type=link 时必需) - - payload: 回传数据(type=postback 时必需) + actions: 操作按钮配置列表(可选),每个按钮包含: + - label: 按钮文字(用于 select 选项的显示) + - value: 按钮值(用于 select 选项的值) Returns: 发送结果 @@ -202,27 +314,127 @@ class ChatwootClient: ... "order_id": "123456789", ... "status": "shipped", ... "status_text": "已发货", + ... "created_at": "2023-10-27 14:30", ... "total_amount": "1058.00", - ... "items": [...] + ... "items": [{"name": "商品A", "quantity": 2, "price": "100.00"}] ... } >>> actions = [ - ... {"type": "link", "text": "查看详情", "uri": "https://..."}, - ... {"type": "postback", "text": "联系客服", "payload": "CONTACT_SUPPORT"} + ... {"label": "查看详情", "value": "VIEW_DETAILS"}, + ... {"label": "联系客服", "value": "CONTACT_SUPPORT"} ... ] - >>> await chatwoot.send_order_card(123, order_data, actions) + >>> await chatwoot.send_order_form(123, order_data, actions) """ - # 生成 Markdown 内容 - markdown_content = format_order_card_markdown(order_data) + # 构建表单字段 + form_items = [] - # 生成按钮卡片 - buttons = create_action_buttons(actions) + # 订单号(只读文本) + form_items.append({ + "name": "order_id", + "label": "订单号", + "type": "text", + "placeholder": "订单号", + "default": order_data.get("order_id", "") + }) - # 发送富媒体消息 + # 订单状态(只读文本) + status_text = order_data.get("status_text", order_data.get("status", "unknown")) + form_items.append({ + "name": "status", + "label": "订单状态", + "type": "text", + "placeholder": "订单状态", + "default": status_text + }) + + # 下单时间(只读文本) + if order_data.get("created_at"): + form_items.append({ + "name": "created_at", + "label": "下单时间", + "type": "text", + "placeholder": "下单时间", + "default": order_data["created_at"] + }) + + # 商品列表(多行文本) + items = order_data.get("items", []) + if items: + items_text = "\n".join([ + f"▫️ {item.get('name', '未知商品')} × {item.get('quantity', 1)} - ¥{item.get('price', '0.00')}" + for item in items + ]) + form_items.append({ + "name": "items", + "label": "商品详情", + "type": "text_area", + "placeholder": "商品列表", + "default": items_text + }) + + # 总金额(只读文本) + form_items.append({ + "name": "total_amount", + "label": "总金额", + "type": "text", + "placeholder": "总金额", + "default": f"¥{order_data.get('total_amount', '0.00')}" + }) + + # 运费(只读文本) + if order_data.get("shipping_fee") is not None: + form_items.append({ + "name": "shipping_fee", + "label": "运费", + "type": "text", + "placeholder": "运费", + "default": f"¥{order_data['shipping_fee']}" + }) + + # 物流信息(多行文本) + logistics = order_data.get("logistics") + if logistics: + logistics_text = ( + f"承运商: {logistics.get('carrier', '未知')}\n" + f"单号: {logistics.get('tracking_number', '未知')}" + ) + form_items.append({ + "name": "logistics", + "label": "物流信息", + "type": "text_area", + "placeholder": "物流信息", + "default": logistics_text + }) + + # 备注(多行文本) + if order_data.get("remark"): + form_items.append({ + "name": "remark", + "label": "备注", + "type": "text_area", + "placeholder": "备注", + "default": order_data["remark"] + }) + + # 操作选项(下拉选择,如果提供了 actions) + if actions: + form_items.append({ + "name": "action_select", + "label": "操作", + "type": "select", + "options": actions + }) + + # 构建 content_attributes + content_attributes = { + "items": form_items + } + + # 发送 form 类型消息 return await self.send_rich_message( conversation_id=conversation_id, - content=markdown_content, - content_type="cards", - content_attributes=buttons + content="订单详情", + content_type="form", + content_attributes=content_attributes ) # ============ Conversations ============ diff --git a/mcp_servers/order_mcp/server.py b/mcp_servers/order_mcp/server.py index 4189d7e..26caec3 100644 --- a/mcp_servers/order_mcp/server.py +++ b/mcp_servers/order_mcp/server.py @@ -315,12 +315,10 @@ async def get_mall_order( """ 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 + f"get_mall_order called: order_id={order_id}, has_user_token={bool(user_token)}, " + f"token_prefix={user_token[:20] if user_token else None}" ) try: @@ -347,9 +345,8 @@ async def get_mall_order( 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 + f"Mall API request successful: order_id={order_id}, " + f"result_keys={list(result.keys()) if isinstance(result, dict) else None}" ) return { @@ -359,9 +356,7 @@ async def get_mall_order( } except Exception as e: logger.error( - "Mall API request failed", - order_id=order_id, - error=str(e) + f"Mall API request failed: order_id={order_id}, error={str(e)}" ) return { "success": False, @@ -395,18 +390,11 @@ async def get_logistics( Returns: 物流信息,包含快递公司、状态、预计送达时间、物流轨迹等 """ - import logging - logger = logging.getLogger(__name__) - - logger.info( - "get_logistics called", - order_id=order_id, - has_user_token=bool(user_token) - ) + print(f"[get_logistics] Called with 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") + print("[get_logistics] ERROR: No user_token provided") return { "success": False, "error": "用户未登录,请先登录账户以查询物流信息", @@ -424,32 +412,50 @@ async def get_logistics( source=settings.mall_source ) + print(f"[get_logistics] Calling Mall API: /mall/api/order/parcel?orderId={order_id}") + 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")) - ) + print(f"[get_logistics] SUCCESS: result_keys={list(result.keys()) if isinstance(result, dict) else type(result).__name__}") + print(f"[get_logistics] Sample data: {str(result)[:500]}") - 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", []) - } + # Mall API 返回结构:{ "total": 1, "data": [{ "trackingCode": "...", "carrier": "...", ... }] } + logistics_list = result.get("data", []) + + if logistics_list and len(logistics_list) > 0: + first_logistics = logistics_list[0] + tracking_number = first_logistics.get("trackingCode", "") + carrier = first_logistics.get("carrier", "未知") + + print(f"[get_logistics] Extracted: tracking_number={tracking_number}, carrier={carrier}") + + return { + "success": True, + "order_id": order_id, + "tracking_number": tracking_number, + "courier": carrier, + "tracking_url": first_logistics.get("trackingUrl", ""), + "status": first_logistics.get("status", ""), + "timeline": [] # 如果 API 返回轨迹信息,可以在这里添加 + } + else: + print(f"[get_logistics] WARNING: No logistics data found in response") + + return { + "success": True, + "order_id": order_id, + "tracking_number": "", + "courier": "暂无物流信息", + "status": "", + "timeline": [] + } except Exception as e: - logger.error( - "Logistics query failed", - order_id=order_id, - error=str(e) - ) + import traceback + print(f"[get_logistics] ERROR: {type(e).__name__}: {str(e)}") + print(f"[get_logistics] TRACEBACK:\n{traceback.format_exc()}") return { "success": False, "error": str(e), diff --git a/mcp_servers/shared/mall_client.py b/mcp_servers/shared/mall_client.py index a91541a..c116ddd 100644 --- a/mcp_servers/shared/mall_client.py +++ b/mcp_servers/shared/mall_client.py @@ -55,14 +55,16 @@ class MallClient: headers={ "Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json", - "Accept": "application/json", + "Accept": "application/json, text/plain, */*", "Device-Type": "pc", "tenant-Id": self.tenant_id, "currency-code": self.currency_code, "language-id": self.language_id, "source": self.source, - "origin": "https://www.qa1.gaia888.com", - "referer": "https://www.qa1.gaia888.com/", + "Origin": "https://www.qa1.gaia888.com", + "Referer": "https://www.qa1.gaia888.com/", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "DNT": "1", }, timeout=30.0 )