Compare commits

...

14 Commits

Author SHA1 Message Date
wangliang
432a789198 优化商品 2026-01-28 19:00:13 +08:00
wangliang
965b11316e feat: 添加图片搜索功能和 Qwen 模型支持
图片搜索功能(以图搜图):
- Chatwoot webhook 检测图片搜索消息 (content_type="search_image")
- 从 content_attributes.url 提取图片 URL
- 调用 Mall API 图片搜索接口 (/mall/api/spu?searchImageUrl=...)
- 支持嵌套和顶层 URL 位置提取
- Product Agent 添加 fast path 直接调用图片搜索工具
- 防止无限循环(使用后清除 context.image_search_url)

Qwen 模型支持:
- 添加 LLM provider 选择(zhipu/qwen)
- 实现 QwenLLMClient 类(基于 DashScope SDK)
- 添加 dashscope>=1.14.0 依赖
- 修复 API key 设置(直接设置 dashscope.api_key)
- 更新 .env.example 和 docker-compose.yml 配置

其他优化:
- 重构 Chatwoot 集成代码(删除冗余)
- 优化 Product Agent prompt
- 增强 Customer Service Agent 多语言支持

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-27 19:10:06 +08:00
wangliang
754804219f feat: 添加生产环境部署配置和文档
## 新增文件

### 部署文档
-  DEPLOYMENT.md - 生产环境部署指南
-  .env.production.example - 生产环境变量配置模板

### 生产环境配置
-  docker-compose.prod.yml - 生产环境 Docker Compose 配置
-  docker-compose.yml - 更新开发环境配置

## 配置说明

### 生产环境优化
- 使用生产级配置参数
- 优化资源限制和重启策略
- 添加健康检查和监控

### 环境变量模板
- 提供完整的生产环境配置示例
- 包含所有必需的环境变量
- 添加安全配置说明

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-27 14:01:13 +08:00
wangliang
c8f26b6f9f chore: 清理 scripts 目录,保留核心运维和部署脚本
## 变更内容

### 保留的核心运维脚本
-  start.sh - 启动服务
-  stop.sh - 停止服务
-  init-pgvector.sql - 数据库初始化

### 保留的部署工具
-  deploy-production.sh - 生产环境部署
-  backup-production.sh - 生产环境备份
-  set-contact-token.sh - 设置联系令牌
-  set-remote-contact-token.sh - 设置远程令牌
-  verify-contact-token.sh - 验证令牌

### 删除的临时调试脚本
-  debug-webhook.sh - 实时监控日志
-  check-conversations.sh - 检查会话
-  check-chatwoot-config.sh - 检查配置
-  verify-webhook.sh - 验证webhook
-  update-chatwoot-webhook.sh - 更新webhook

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-27 13:59:12 +08:00
wangliang
0f13102a02 fix: 改进错误处理和清理测试代码
## 主要修复

### 1. JSON 解析错误处理
- 修复所有 Agent 的 LLM 响应解析失败时返回原始内容的问题
- 当 JSON 解析失败时,返回友好的兜底消息而不是原始文本
- 影响文件: customer_service.py, order.py, product.py, aftersale.py

### 2. FAQ 快速路径修复
- 修复 customer_service.py 中变量定义顺序问题
- has_faq_query 在使用前未定义导致 NameError
- 添加详细的错误日志记录

### 3. Chatwoot 集成改进
- 添加响应内容调试日志
- 改进错误处理和日志记录

### 4. 订单查询优化
- 将订单列表默认返回数量从 10 条改为 5 条
- 统一 MCP 工具层和 Mall Client 层的默认值

### 5. 代码清理
- 删除所有测试代码和示例文件
- 刋试文件包括: test_*.py, test_*.html, test_*.sh
- 删除测试目录: tests/, agent/tests/, agent/examples/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-27 13:15:58 +08:00
wangliang
f4e77f39ce fix: 修复 Mall API 数据提取逻辑和添加配置字段
## 问题 1: Settings 缺少 Mall API 配置
**错误**: `'Settings' object has no attribute 'mall_api_url'`

**原因**: Settings 类只有 hyperf 配置,缺少 Mall API 相关字段

**解决方案**: 添加 Mall API 配置字段(第 20-25 行)
```python
mall_api_url: str
mall_tenant_id: str = "2"
mall_currency_code: str = "EUR"
mall_language_id: str = "1"
mall_source: str = "us.qa1.gaia888.com"
```

## 问题 2: Mall API 数据结构解析错误
**现象**: 商品搜索始终返回 0 个商品

**原因**: Mall API 返回的数据结构与预期不符

**Mall API 实际返回**:
```json
{
  "total": 0,
  "data": {
    "data": [],  // ← 商品列表在这里
    "isClothesClassification": false,
    "ad": {...}
  }
}
```

**代码原来查找**: `result.get("list", [])` 

**修复后查找**: `result["data"]["data"]` 

**解决方案**: 修改数据提取逻辑(第 317-323 行)
```python
if "data" in result and isinstance(result["data"], dict):
    products = result["data"].get("data", [])
else:
    products = result.get("list", [])  # 向后兼容
total = result.get("total", 0)
```

## 调试增强
添加 print 调试语句:
- 第 292 行:打印调用参数
- 第 315 行:打印 Mall API 返回结果

便于诊断 API 调用问题。

## 测试结果

修复前:
```
'Settings' object has no attribute 'mall_api_url'
```

修复后:
```json
{
  "success": true,
  "products": [],
  "total": 0,
  "keyword": "61607"
}
```

 工具调用成功
⚠️ 返回 0 商品(可能是关键词无匹配)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 18:43:46 +08:00
wangliang
54eefba6f8 fix: 修复 JSON 解析导致的 tool_name 丢失问题
## 问题
商品搜索时工具名丢失,导致 404 错误:
```
HTTP Request: POST http://product_mcp:8004/tools/ "HTTP/1.1 404 Not Found"
```

URL 应该是 `/tools/search_products` 但实际是 `/tools/`(工具名丢失)

## 根本原因
当 LLM 返回带 ```json``` 代码块格式的 JSON 时:

```
```json
{
  "action": "call_tool",
  "tool_name": "search_products",
  "arguments": {"keyword": "ring"}
}
```
```

解析逻辑处理后:
1. 移除 ```` → 得到 `json\n{\n...`
2. 移除 `json` → 得到 `\n{\n...`
3. 内容以换行符开头,不是 `{`
4. 被误判为非 JSON 格式(`tool_name\n{args}`)
5. 按换行符分割,第一行为空 → `tool_name = ""`

## 解决方案
**第 189 行**:添加 `content.strip()` 去除前后空白

```python
if content.startswith("```"):
    content = content.split("```")[1]
    if content.startswith("json"):
        content = content[4:]
    # Remove leading/trailing whitespace after removing code block markers
    content = content.strip()  # ← 新增
```

## 额外改进
**第 217-224 行**:添加工具调用日志

```python
logger.info(
    "Product agent calling tool",
    tool_name=tool_name,
    arguments=arguments,
    conversation_id=state["conversation_id"]
)
```

便于调试工具调用问题。

## 测试验证

修复前:
```
tool_name = ""  (空字符串)
URL: /tools/     (缺少工具名)
```

修复后:
```
tool_name = "search_products"  (正确)
URL: /tools/search_products     (完整路径)
```

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 18:36:27 +08:00
wangliang
74c28eb838 fix: 添加工具注册表和 HTTP 路由,修复 MCP 工具调用 404 错误
## 问题
Product MCP 工具调用返回 404:
```
POST /tools/search_products HTTP/1.1 404 Not Found
```

## 根本原因
1. Product MCP 缺少 `/tools/{tool_name}` HTTP 路由
2. FastMCP 的 `mcp.http_app()` 默认不暴露此路由
3. Order MCP 有自定义路由处理,Product MCP 没有

## 解决方案

### 1. 添加工具注册表
**位置**: 第 32-41 行

```python
# Tool registry for HTTP access
_tools: Dict[str, Any] = {}

def register_tool(name: str):
    """Decorator to register tool in _tools dict"""
    def decorator(func):
        _tools[name] = func
        return func
    return decorator
```

### 2. 为所有工具添加注册装饰器
**修改的工具**:
- `get_product_detail`
- `recommend_products`
- `get_quote`
- `check_inventory`
- `get_categories`
- `search_products`
- `health_check`

**示例**:
```python
@register_tool("search_products")
@mcp.tool()
async def search_products(...):
```

### 3. 添加 HTTP 路由处理
**位置**: 第 352-401 行

参考 Order MCP 实现,添加:
- `/tools/{tool_name}` POST 路由
- 工具调用逻辑:`tool_obj.run(arguments)`
- 结果提取和 JSON 解析
- 错误处理(404, 400, 500)

### 4. 配置路由列表
**位置**: 第 407-415 行

```python
routes = [
    Route('/health', health_check, methods=['GET']),
    Route('/tools/{tool_name}', execute_tool, methods=['POST'])
]
```

## 测试结果

```bash
curl -X POST http://localhost:8004/tools/search_products \
  -H "Content-Type: application/json" \
  -d '{"keyword": "ring"}'
```

返回:
```json
{
  "success": true,
  "result": {
    "success": false,
    "error": "用户未登录,请先登录账户以搜索商品",
    "products": [],
    "total": 0,
    "require_login": true
  }
}
```

 工具调用成功(user_token 缺失是预期行为)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 18:32:16 +08:00
wangliang
ad7f30d54c fix: 删除旧的 search_products 工具,解决工具名冲突
## 问题
Product MCP 启动时出现警告:
```
WARNING Tool already exists: search_products
```

导致工具调用时返回 404 错误:
```
POST /tools/search_products HTTP/1.1 404 Not Found
```

## 根本原因
Product MCP 中有两个同名工具:
1. **第 40-99 行**:旧的 `search_products`(使用 Hyperf API)
2. **第 292-378 行**:新的 `search_products`(使用 Mall API)

FastMCP 无法注册同名工具,导致注册失败。

## 解决方案
删除旧的 `search_products` 工具定义(第 40-99 行),保留新的使用 Mall API 的版本。

## 修改内容
**文件**: mcp_servers/product_mcp/server.py
- 删除第 40-99 行(旧的 search_products 工具)
- 保留第 291 行开始的新的 search_products 工具

## 影响
- 移除了基于 Hyperf API 的旧搜索功能
- 所有商品搜索统一使用 Mall API
- 不再支持复杂过滤条件(category, brand, price_range 等)
- 简化为关键词搜索,返回商品卡片格式

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 18:25:06 +08:00
wangliang
7b676f8015 refactor: 将 search_spu_products 重命名为 search_products
## 修改内容

### 1. Product Agent prompt
- 将工具名从 `search_spu_products` 改为 `search_products`
- 更新所有示例代码
- 保持功能说明不变(Mall API SPU 搜索)

### 2. Product Agent 代码
**文件**: agent/agents/product.py

**修改**:
- 第 24 行:工具名改为 `search_products`
- 第 65、77 行:示例中的工具名更新
- 第 219-230 行:注入逻辑改为检查 `search_products`
- 第 284 行:工具结果检查改为 `search_products`
- 第 279-333 行:变量名 `spu_products` → `products`
- 第 280 行:`has_spu_search_result` → `has_product_search_result`

### 3. Product MCP Server
**文件**: mcp_servers/product_mcp/server.py

**修改**:
- 第 292 行:函数名 `search_spu_products` → `search_products`
- 第 300 行:文档字符串更新
- 功能完全相同,只是重命名

### 4. 移除映射逻辑
- 移除了 `search_products` → `search_spu_products` 的工具名映射
- 保留了 `query` → `keyword` 的参数映射(向后兼容)

## 好处

1. **简化命名**:`search_products` 比 `search_spu_products` 更简洁
2. **统一接口**:与系统中其他搜索工具命名一致
3. **降低复杂度**:减少名称长度和冗余

## 向后兼容

参数映射保留:
```python
# 仍然支持旧参数名
{"query": "ring"} → {"keyword": "ring"}
```

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 18:22:23 +08:00
wangliang
1aeb17fcce refactor: 移除 search_products 工具,统一使用 search_spu_products
## 修改内容

### 1. 简化 Product Agent prompt
- 移除 `search_products` 工具说明
- 移除工具选择警告和说明
- 只保留 `search_spu_products` 作为唯一商品搜索工具
- 调整工具序号 1-5

### 2. 添加工具名自动映射
**位置**:第 195-201 行(非 JSON 格式),第 221-227 行(JSON 格式)

**功能**:
- 自动将 `search_products` 转换为 `search_spu_products`
- 防止 LLM 缓存或习惯导致的旧工具调用
- 添加日志记录映射操作

**示例**:
```python
# LLM 返回
{"tool_name": "search_products", "arguments": {"query": "ring"}}

# 自动转换为
{"tool_name": "search_spu_products", "arguments": {"keyword": "ring"}}
```

### 3. 添加参数自动映射
**位置**:第 240-246 行

**功能**:
- 自动将 `query` 参数转换为 `keyword` 参数
- 兼容 LLM 使用旧参数名的情况

**示例**:
```python
# LLM 返回
{"arguments": {"query": "ring"}}

# 自动转换为
{"arguments": {"keyword": "ring"}}
```

## 优势

1. **简化逻辑**:LLM 只有一个搜索工具可选,不会选错
2. **向后兼容**:即使 LLM 调用旧工具,也能自动转换
3. **参数兼容**:支持旧参数名 `query`,自动转为 `keyword`
4. **可观测性**:所有映射操作都有日志记录

## 预期效果
- LLM 调用 `search_spu_products`(Mall API)
- 返回商品卡片到 Chatwoot
- 即使调用旧工具也能正常工作

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 18:19:12 +08:00
wangliang
e58c3f0caf fix: 修复 Product Agent LLM 响应格式解析和工具选择问题
## 问题 1: LLM 返回非标准 JSON 格式

**现象**:
LLM 返回:`search_products\n{"query": "ring"}`
期望格式:`{"action": "call_tool", "tool_name": "...", "arguments": {...}}`

**原因**:
LLM 有时会返回简化格式 `tool_name\n{args}`,导致 JSON 解析失败

**解决方案**:
添加格式兼容逻辑(第 172-191 行):
- 检测 `\n` 分隔的格式
- 解析工具名和参数
- 转换为标准 JSON 结构

## 问题 2: LLM 选择错误的搜索工具

**现象**:
LLM 选择 `search_products`(Hyperf API)而非 `search_spu_products`(Mall API)

**原因**:
Prompt 中工具说明不够突出,LLM 优先选择第一个工具

**解决方案**:
1. 在 prompt 开头添加醒目警告(第 22-29 行):
   - ⚠️ 强调必须使用 `search_spu_products`
   - 标注适用场景
   - 添加  标记推荐工具

2. 添加具体示例(第 78-89 行):
   - 展示正确的工具调用格式
   - 示例:搜索 "ring" 应使用 `search_spu_products`

## 修改内容

### agent/agents/product.py:172-191
添加非标准格式兼容逻辑

### agent/agents/product.py:14-105
重写 PRODUCT_AGENT_PROMPT:
- 开头添加工具选择警告
- 突出 `search_spu_products` 优先级
- 添加具体使用示例
- 标注各工具适用场景

## 预期效果
1. 兼容 LLM 的简化格式输出
2. LLM 优先选择 `search_spu_products` 进行商品搜索
3. 返回 Mall API 数据并以 Chatwoot cards 展示

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 18:17:37 +08:00
wangliang
15a4bdeb75 fix: 为 search_spu_products 工具注入 user_token 参数
## 问题
即使更新了 Product Agent prompt,LLM 仍然调用 search_products 而非 search_spu_products

## 根本原因
search_spu_products 工具需要 user_token 参数(Mall API 认证必需),
但 product_agent 函数中没有注入此参数,导致工具调用失败或被忽略

## 修改内容

### agent/agents/product.py:169-173
在工具调用前注入 user_token、user_id、account_id 参数:

```python
# Inject context for SPU product search (Mall API)
if result["tool_name"] == "search_spu_products":
    arguments["user_token"] = state.get("user_token")
    arguments["user_id"] = state["user_id"]
    arguments["account_id"] = state["account_id"]
```

## 参数来源
- user_token: 从 Chatwoot webhook 提取(contact.custom_attributes.jwt_token)
- user_id: 从 AgentState 获取
- account_id: 从 AgentState 获取

## 预期效果
LLM 现在可以成功调用 search_spu_products 工具,
返回 Mall API 商品数据并以 Chatwoot cards 格式展示

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 18:10:36 +08:00
wangliang
fa2c8f8102 fix: 更新 Product Agent prompt 添加 search_spu_products 工具说明
## 问题
搜索商品时返回错误的工具调用 search_products 而非 search_spu_products

## 根本原因
Product Agent 的 PRODUCT_AGENT_PROMPT 中没有列出 search_spu_products 工具,
导致 LLM 不知道可以使用 Mall API 的 SPU 搜索工具

## 修改内容

### agent/agents/product.py
- 将 search_spu_products 设为第一个工具(推荐使用)
- 说明此工具使用 Mall API 搜索商品 SPU,支持用户 token 认证,返回卡片格式展示
- 原有的 search_products 标记为高级搜索工具(使用 Hyperf API)
- 调整工具序号 1-6

### docs/PRODUCT_SEARCH_SERVICE.md
- 添加 Product Agent Prompt 更新说明章节
- 调整章节序号

## 预期效果
LLM 现在应该优先使用 search_spu_products 工具进行商品搜索,
返回 Mall API 的商品数据并以 Chatwoot cards 格式展示

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 17:50:29 +08:00
42 changed files with 3464 additions and 2254 deletions

View File

@@ -1,8 +1,22 @@
# B2B Shopping AI Assistant Platform - Environment Variables
# ============ AI Model ============
# LLM Provider: 'zhipu' (default) or 'qwen'
LLM_PROVIDER=zhipu
# ZhipuAI (智谱 AI)
ZHIPU_API_KEY=your_zhipu_api_key
ZHIPU_MODEL=glm-4
ZHIPU_MODEL=glm-4-flash
# Qwen (通义千问) - Alternative provider
QWEN_API_KEY=your_qwen_dashscope_api_key
QWEN_MODEL=qwen-omni-turbo
# Available Qwen models:
# - qwen-omni-turbo (多模态,支持图片/语音)
# - qwen-plus (通用模型)
# - qwen-turbo (高速模型)
# - qwen-long (长文本)
# - qwen-max (最强模型)
# ============ Redis ============
REDIS_HOST=redis

81
.env.production.example Normal file
View File

@@ -0,0 +1,81 @@
# B2B Shopping AI Assistant - Production Environment Variables
# 生产环境配置示例 - 请复制为 .env.production 并填写真实值
# ============ AI Model ============
# 智谱 AI API Key
ZHIPU_API_KEY=your_zhipu_api_key_here
# 模型名称
ZHIPU_MODEL=GLM-4-Flash-250414
# 推理模式(开启会消耗更多 token但更智能
ENABLE_REASONING_MODE=false
# 复杂查询启用推理模式
REASONING_MODE_FOR_COMPLEX=true
# ============ Redis ============
# Redis 密码(必须设置强密码)
REDIS_PASSWORD=your_secure_redis_password_here
# Redis 主机
REDIS_HOST=redis
# Redis 端口
REDIS_PORT=6379
# Redis 数据库编号
REDIS_DB=0
# ============ Chatwoot (生产环境) ============
# Chatwoot API URL生产环境地址
CHATWOOT_API_URL=https://chatwoot.yourdomain.com
# Chatwoot API Token从 Chatwoot 后台生成)
CHATWOOT_API_TOKEN=your_chatwoot_api_token_here
# Chatwoot Webhook Secret配置在 Chatwoot webhook 设置中)
CHATWOOT_WEBHOOK_SECRET=your_webhook_secret_here
# Chatwoot Account ID
CHATWOOT_ACCOUNT_ID=1
# ============ Strapi CMS (FAQ/Knowledge Base) ============
# Strapi API URL
STRAPI_API_URL=https://cms.yourdomain.com
# Strapi API Token
STRAPI_API_TOKEN=your_strapi_api_token_here
# ============ Hyperf PHP API ============
# Hyperf API URL生产环境
HYPERF_API_URL=https://api.gaia888.com
# Hyperf API Token
HYPERF_API_TOKEN=your_hyperf_api_token_here
# ============ Mall API ============
# Mall API URL生产环境
MALL_API_URL=https://apicn.gaia888.com
# 租户 ID
MALL_TENANT_ID=2
# 货币代码
MALL_CURRENCY_CODE=EUR
# 语言 ID
MALL_LANGUAGE_ID=1
# 来源域名
MALL_SOURCE=www.gaia888.com
# ============ Frontend URLs ============
# 前端 URL用于生成订单详情链接
FRONTEND_URL=https://www.gaia888.com
# ============ Monitoring ============
# Sentry DSN错误监控可选
SENTRY_DSN=
# Grafana 管理员密码
GRAFANA_ADMIN_PASSWORD=your_grafana_password_here
# ============ Application Config ============
# 日志级别WARNING 或 ERROR
LOG_LEVEL=WARNING
# 最大对话步数
MAX_CONVERSATION_STEPS=10
# 对话超时时间(秒)
CONVERSATION_TIMEOUT=3600
# ============ Security Notes ============
# 1. 所有密钥和密码都必须使用强密码
# 2. 生产环境不要使用默认密码
# 3. 定期轮换 API Token 和密钥
# 4. 使用环境变量管理工具(如 AWS Secrets Manager、Vault 等)
# 5. 不要将 .env.production 文件提交到 Git 仓库

381
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,381 @@
# B2B Shopping AI Assistant - 生产环境部署指南
## 📋 目录
- [部署前准备](#部署前准备)
- [快速部署](#快速部署)
- [详细配置](#详细配置)
- [监控与维护](#监控与维护)
- [故障排查](#故障排查)
- [升级策略](#升级策略)
---
## 🚀 部署前准备
### 系统要求
- **操作系统**: Linux (推荐 Ubuntu 20.04+)
- **Docker**: 20.10+
- **Docker Compose**: 2.0+
- **CPU**: 4 核心以上
- **内存**: 8GB 以上
- **磁盘**: 50GB 以上
### 网络要求
- **开放端口**:
- `8000`: Agent 服务
- `8001-8004`: MCP 服务
- `3000-3001`: Chatwoot (如果部署在同一服务器)
- `9090`: Prometheus (可选)
- `3001`: Grafana (可选)
### 外部依赖
1. **Chatwoot**: 需要提前部署并配置好
2. **Strapi CMS**: 用于 FAQ 和知识库管理
3. **Hyperf API**: 后端业务 API
4. **Redis**: 使用 Docker Compose 内置,或外部 Redis 实例
---
## ⚡ 快速部署
### 1. 准备配置文件
```bash
# 复制环境变量模板
cp .env.production.example .env.production
# 编辑配置文件
vim .env.production
```
**必须配置的关键参数**:
```env
# AI 模型
ZHIPU_API_KEY=your_actual_api_key
# Redis 密码(必须修改)
REDIS_PASSWORD=your_strong_password_here
# Chatwoot
CHATWOOT_API_URL=https://your-chatwoot.com
CHATWOOT_API_TOKEN=your_chatwoot_token
CHATWOOT_WEBHOOK_SECRET=your_webhook_secret
# API 地址
HYPERF_API_URL=https://api.yourdomain.com
MALL_API_URL=https://apicn.yourdomain.com
STRAPI_API_URL=https://cms.yourdomain.com
```
### 2. 执行部署
```bash
# 使用部署脚本(推荐)
./scripts/deploy-production.sh
# 或手动部署
docker-compose -f docker-compose.prod.yml up -d
```
### 3. 验证部署
```bash
# 检查服务状态
docker-compose -f docker-compose.prod.yml ps
# 健康检查
curl http://localhost:8000/health
# 查看日志
docker-compose -f docker-compose.prod.yml logs -f agent
```
---
## 🔧 详细配置
### 生产环境与开发环境差异
| 配置项 | 开发环境 | 生产环境 |
|--------|---------|---------|
| 日志级别 | INFO | WARNING |
| 代码挂载 | 是(支持热更新) | 否(使用镜像) |
| 资源限制 | 无 | 有限制 |
| 健康检查 | 基础 | 完整 |
| 日志轮转 | 无 | 有10MB x 3 |
| 重启策略 | unless-stopped | always |
| Redis 密码 | 无 | 必须 |
### 资源配置
生产环境默认资源配置(可在 `docker-compose.prod.yml` 中调整):
| 服务 | CPU 限制 | 内存限制 | CPU 预留 | 内存预留 |
|------|---------|---------|---------|---------|
| Agent | 2 核 | 2GB | 0.5 核 | 512MB |
| MCP 服务 | 0.5 核 | 512MB | 0.25 核 | 256MB |
### 健康检查
所有服务都配置了健康检查:
- **Agent**: 每 30 秒检查 `/health` 端点
- **MCP 服务**: 每 30 秒检查 `/health` 端点
- **Redis**: 每 10 秒 ping 检查
---
## 📊 监控与维护
### 启用监控(可选)
```bash
# 启动 Prometheus + Grafana
docker-compose -f docker-compose.prod.yml --profile monitoring up -d
```
监控服务地址:
- **Prometheus**: http://localhost:9090
- **Grafana**: http://localhost:3001 (默认用户名/密码: admin/admin)
### 日志管理
```bash
# 查看实时日志
docker-compose -f docker-compose.prod.yml logs -f
# 查看特定服务日志
docker-compose -f docker-compose.prod.yml logs -f agent
# 查看最近 100 行日志
docker-compose -f docker-compose.prod.yml logs --tail=100 agent
# 导出日志
docker-compose -f docker-compose.prod.yml logs agent > agent.log
```
日志文件位置:
- **Agent 日志**: `agent_logs_prod` volume
- **日志轮转**: 每个日志文件最大 10MB保留 3 个文件
### 数据备份
```bash
# 备份 Redis 数据
docker run --rm \
-v ai_redis_data_prod:/data \
-v $(pwd)/backups:/backup \
alpine tar czf /backup/redis-$(date +%Y%m%d).tar.gz /data
# 备份 Grafana 配置
docker run --rm \
-v ai_grafana_data:/data \
-v $(pwd)/backups:/backup \
alpine tar czf /backup/grafana-$(date +%Y%m%d).tar.gz /data
```
### 性能监控
使用 Docker 命令监控资源使用:
```bash
# 查看容器资源使用情况
docker stats
# 查看特定容器
docker stats ai_agent_prod
```
---
## 🔍 故障排查
### 常见问题
#### 1. 服务无法启动
**检查日志**:
```bash
docker-compose -f docker-compose.prod.yml logs agent
```
**常见原因**:
- 环境变量未配置或配置错误
- 端口被占用
- 依赖服务未启动
#### 2. API 调用失败
**检查步骤**:
1. 验证 API Token 是否正确
2. 检查网络连接
3. 查看 API 服务状态
```bash
# 测试 API 连接
docker exec ai_agent_prod curl https://api.yourdomain.com/health
```
#### 3. Redis 连接失败
**检查 Redis 容器**:
```bash
docker exec ai_redis_prod redis-cli -a YOUR_PASSWORD ping
```
#### 4. 内存不足
**解决方案**:
- 增加 Docker 内存限制
- 减少并发数
- 优化模型调用
### 服务重启
```bash
# 重启所有服务
docker-compose -f docker-compose.prod.yml restart
# 重启特定服务
docker-compose -f docker-compose.prod.yml restart agent
# 强制重新创建(更新环境变量后)
docker-compose -f docker-compose.prod.yml up -d --force-recreate agent
```
### 完全重置
⚠️ **警告**: 此操作会删除所有容器和数据卷
```bash
# 停止并删除所有容器
docker-compose -f docker-compose.prod.yml down -v
# 重新部署
./scripts/deploy-production.sh
```
---
## 🔄 升级策略
### 滚动升级(零停机)
```bash
# 1. 拉取最新代码
git pull origin main
# 2. 构建新镜像
docker-compose -f docker-compose.prod.yml build
# 3. 逐个重启服务
docker-compose -f docker-compose.prod.yml up -d --no-deps agent
# 4. 验证新版本
curl http://localhost:8000/health
```
### 蓝绿部署
```bash
# 1. 部署新版本到不同端口
docker-compose -f docker-compose.prod.yml up -d
# 2. 切换流量(修改 Nginx 配置)
# 3. 停止旧版本
docker-compose -f docker-compose.prod.yml down
```
### 回滚
```bash
# 回滚到上一个版本
git checkout PREVIOUS_VERSION_TAG
# 重新构建
docker-compose -f docker-compose.prod.yml build
# 重启服务
docker-compose -f docker-compose.prod.yml up -d
```
---
## 🔐 安全建议
### 环境变量管理
1. **使用密钥管理服务**:
- AWS Secrets Manager
- HashiCorp Vault
- Azure Key Vault
2. **文件权限**:
```bash
chmod 600 .env.production
```
3. **不要提交敏感信息**:
```bash
# .gitignore
.env.production
.env
```
### 网络安全
1. **使用防火墙限制端口访问**
2. **配置 HTTPS/TLS**
3. **使用 VPN 或专线连接服务**
4. **定期更新 Docker 镜像**
### API 安全
1. **定期轮换 API Token**
2. **限制 API Token 权限**
3. **监控异常调用**
4. **设置速率限制**
---
## 📝 部署检查清单
部署前确认:
- [ ] 所有环境变量已配置
- [ ] Redis 密码已设置
- [ ] API Token 已验证
- [ ] 端口未被占用
- [ ] DNS 解析已配置
- [ ] SSL 证书已安装
- [ ] 监控已配置
- [ ] 备份策略已制定
- [ ] 回滚方案已准备
部署后验证:
- [ ] 所有容器正常运行
- [ ] 健康检查通过
- [ ] API 调用成功
- [ ] 日志正常输出
- [ ] 资源使用正常
- [ ] 监控数据正常
---
## 🆘 获取帮助
- **文档**: `/docs` 目录
- **Issues**: GitHub Issues
- **日志**: `docker-compose logs`
- **监控**: Grafana Dashboard (如果启用)
---
**最后更新**: 2026-01-26

View File

@@ -141,8 +141,15 @@ async def aftersale_agent(state: AgentState) -> AgentState:
return state
except json.JSONDecodeError:
state = set_response(state, response.content)
except json.JSONDecodeError as e:
logger.error(
"Failed to parse aftersale agent LLM response as JSON",
error=str(e),
conversation_id=state.get("conversation_id"),
raw_content=response.content[:500] if response.content else "EMPTY"
)
# Don't use raw content as response - use fallback instead
state = set_response(state, "抱歉,我无法理解您的请求。请尝试重新表述或联系人工客服。")
return state
except Exception as e:

View File

@@ -66,7 +66,47 @@ async def customer_service_agent(state: AgentState) -> AgentState:
# Get detected language
locale = state.get("detected_language", "en")
# Auto-detect category and query FAQ
# Check if we have already queried FAQ
tool_calls = state.get("tool_calls", [])
has_faq_query = any(tc.get("tool_name") in ["query_faq", "search_knowledge_base"] for tc in tool_calls)
# ========== ROUTING: Use sub_intent from router if available ==========
# Router already classified the intent, use it for direct FAQ query
sub_intent = state.get("sub_intent")
# Map sub_intent to FAQ category
sub_intent_to_category = {
"register_inquiry": "register",
"order_inquiry": "order",
"payment_inquiry": "payment",
"shipment_inquiry": "shipment",
"return_inquiry": "return",
"policy_inquiry": "return", # Policy queries use return FAQ
}
# Check if we should auto-query FAQ based on sub_intent
if sub_intent in sub_intent_to_category and not has_faq_query:
category = sub_intent_to_category[sub_intent]
logger.info(
f"Auto-querying FAQ based on sub_intent: {sub_intent} -> category: {category}",
conversation_id=state["conversation_id"]
)
state = add_tool_call(
state,
tool_name="query_faq",
arguments={
"category": category,
"locale": locale,
"limit": 5
},
server="strapi"
)
state["state"] = ConversationState.TOOL_CALLING.value
return state
# ========================================================================
# Auto-detect category and query FAQ (fallback if sub_intent not available)
message_lower = state["current_message"].lower()
# 定义分类关键词支持多语言en, nl, de, es, fr, it, tr, zh
@@ -163,17 +203,13 @@ async def customer_service_agent(state: AgentState) -> AgentState:
],
}
# 检测分类
# 检测分类(仅在未通过 sub_intent 匹配时使用)
detected_category = None
for category, keywords in category_keywords.items():
if any(keyword in message_lower for keyword in keywords):
detected_category = category
break
# 检查是否已经查询过 FAQ
tool_calls = state.get("tool_calls", [])
has_faq_query = any(tc.get("tool_name") in ["query_faq", "search_knowledge_base"] for tc in tool_calls)
# 如果检测到分类且未查询过 FAQ自动查询
if detected_category and not has_faq_query:
logger.info(
@@ -233,43 +269,107 @@ async def customer_service_agent(state: AgentState) -> AgentState:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.7)
# Log raw response for debugging
logger.info(
"Customer service LLM response",
conversation_id=state["conversation_id"],
response_preview=response.content[:300] if response.content else "EMPTY",
response_length=len(response.content) if response.content else 0
)
# Parse response
content = response.content.strip()
# Handle markdown code blocks
if content.startswith("```"):
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
parts = content.split("```")
if len(parts) >= 2:
content = parts[1]
if content.startswith("json"):
content = content[4:]
content = content.strip()
result = json.loads(content)
action = result.get("action")
try:
result = json.loads(content)
action = result.get("action")
if action == "call_tool":
# Add tool call to state
state = add_tool_call(
state,
tool_name=result["tool_name"],
arguments=result.get("arguments", {}),
server="strapi"
if action == "call_tool":
# Add tool call to state
state = add_tool_call(
state,
tool_name=result["tool_name"],
arguments=result.get("arguments", {}),
server="strapi"
)
state["state"] = ConversationState.TOOL_CALLING.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", "User request")
else:
# Unknown action, treat as plain text response
logger.warning(
"Unknown action in LLM response",
action=action,
conversation_id=state["conversation_id"]
)
state = set_response(state, response.content)
return state
except json.JSONDecodeError as e:
# JSON parsing failed - try alternative format: "tool_name\n{args}"
logger.error(
"Failed to parse LLM response as JSON",
error=str(e),
raw_content=content[:500],
conversation_id=state["conversation_id"]
)
state["state"] = ConversationState.TOOL_CALLING.value
elif action == "respond":
state = set_response(state, result["response"])
state["state"] = ConversationState.GENERATING.value
# Handle non-JSON format: "tool_name\n{args}"
if '\n' in content and not content.startswith('{'):
lines = content.split('\n', 1)
tool_name = lines[0].strip()
args_json = lines[1].strip() if len(lines) > 1 else '{}'
elif action == "handoff":
state["requires_human"] = True
state["handoff_reason"] = result.get("reason", "User request")
try:
arguments = json.loads(args_json) if args_json else {}
logger.info(
"Customer service agent calling tool (alternative format)",
tool_name=tool_name,
arguments=arguments,
conversation_id=state["conversation_id"]
)
return state
except json.JSONDecodeError:
# LLM returned plain text, use as response
state = set_response(state, response.content)
return state
state = add_tool_call(
state,
tool_name=tool_name,
arguments=arguments,
server="strapi"
)
state["state"] = ConversationState.TOOL_CALLING.value
return state
except json.JSONDecodeError:
# Args parsing also failed
logger.warning(
"Failed to parse tool arguments",
tool_name=tool_name,
args_json=args_json[:200],
conversation_id=state["conversation_id"]
)
state = set_response(state, "抱歉,我无法理解您的请求。请尝试重新表述或联系人工客服。")
return state
else:
# Not a recognized format
state = set_response(state, "抱歉,我无法理解您的请求。请尝试重新表述或联系人工客服。")
return state
except Exception as e:
logger.error("Customer service agent failed", error=str(e))
logger.error("Customer service agent failed", error=str(e), exc_info=True)
state["error"] = str(e)
return state
@@ -277,11 +377,59 @@ async def customer_service_agent(state: AgentState) -> AgentState:
async def _generate_response_from_results(state: AgentState) -> AgentState:
"""Generate response based on tool results"""
# Build context from tool results
# Build context from tool results - extract only essential info to reduce prompt size
tool_context = []
for result in state["tool_results"]:
if result["success"]:
tool_context.append(f"Tool {result['tool_name']} returned:\n{json.dumps(result['data'], ensure_ascii=False, indent=2)}")
tool_name = result['tool_name']
data = result['data']
# Extract only essential information based on tool type
if tool_name == "get_company_info":
# Extract key contact info only
contact = data.get('contact', {})
emails = contact.get('email', [])
if isinstance(emails, list) and emails:
email_str = ", ".join(emails[:3]) # Max 3 emails
else:
email_str = str(emails) if emails else "N/A"
phones = contact.get('phone', [])
if isinstance(phones, list) and phones:
phone_str = ", ".join(phones[:2]) # Max 2 phones
else:
phone_str = str(phones) if phones else "N/A"
address = contact.get('address', {})
address_str = f"{address.get('city', '')}, {address.get('country', '')}".strip(', ')
summary = f"Contact Information: Emails: {email_str} | Phones: {phone_str} | Address: {address_str} | Working hours: {contact.get('working_hours', 'N/A')}"
tool_context.append(summary)
elif tool_name == "query_faq" or tool_name == "search_knowledge_base":
# Extract FAQ items summary
faqs = data.get('faqs', []) if isinstance(data, dict) else []
if faqs:
faq_summaries = [f"- Q: {faq.get('question', '')[:50]}... A: {faq.get('answer', '')[:50]}..." for faq in faqs[:3]]
summary = f"Found {len(faqs)} FAQ items:\n" + "\n".join(faq_summaries)
tool_context.append(summary)
else:
tool_context.append("No FAQ items found")
elif tool_name == "get_categories":
# Extract category names only
categories = data.get('categories', []) if isinstance(data, dict) else []
category_names = [cat.get('name', '') for cat in categories[:5] if cat.get('name')]
summary = f"Available categories: {', '.join(category_names)}"
if len(categories) > 5:
summary += f" (and {len(categories) - 5} more)"
tool_context.append(summary)
else:
# For other tools, include concise summary (limit to 200 chars)
data_str = json.dumps(data, ensure_ascii=False)[:200]
tool_context.append(f"Tool {tool_name} returned: {data_str}...")
else:
tool_context.append(f"Tool {result['tool_name']} failed: {result['error']}")
@@ -292,7 +440,8 @@ User question: {state["current_message"]}
Tool returned information:
{chr(10).join(tool_context)}
Please generate a friendly and professional response. If the tool did not return useful information, honestly inform the user and suggest other ways to get help.
Please generate a friendly and professional response in Chinese. Keep it concise but informative.
If the tool did not return useful information, honestly inform the user and suggest other ways to get help.
Return only the response content, do not return JSON."""
messages = [
@@ -302,11 +451,12 @@ Return only the response content, do not return JSON."""
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.7)
# Lower temperature for faster response
response = await llm.chat(messages, temperature=0.3)
state = set_response(state, response.content)
return state
except Exception as e:
logger.error("Response generation failed", error=str(e))
state = set_response(state, "Sorry, there was a problem processing your request. Please try again later or contact customer support.")
state = set_response(state, "抱歉,处理您的请求时出现问题。请稍后重试或联系人工客服。")
return state

View File

@@ -64,8 +64,8 @@ ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
2. **get_mall_order_list** - 从商城 API 查询订单列表(推荐使用)
- user_token: 用户 token自动注入
- page: 页码(可选,默认 1
- limit: 每页数量(可选,默认 10
- 说明:查询用户的所有订单,按时间倒序排列
- limit: 每页数量(可选,默认 5
- 说明:查询用户的所有订单,按时间倒序排列,返回最近的 5 个订单
3. **get_logistics** - 从商城 API 查询物流信息
- order_id: 订单号(必需)
@@ -339,8 +339,8 @@ async def order_agent(state: AgentState) -> AgentState:
error=str(e),
content_preview=content[:500]
)
# 如果解析失败,尝试将原始内容作为直接回复
state = set_response(state, response.content)
# Don't use raw content as response - use fallback instead
state = set_response(state, "抱歉,我无法理解您的请求。请尝试重新表述或联系人工客服。")
return state
action = result.get("action")
@@ -381,6 +381,14 @@ async def order_agent(state: AgentState) -> AgentState:
if tool_name in ["get_mall_order", "get_logistics", "query_order"]:
arguments["order_id"] = state["entities"]["order_id"]
# Force limit=5 for order list queries (unless explicitly set)
if tool_name == "get_mall_order_list" and "limit" not in arguments:
arguments["limit"] = 5
logger.debug(
"Forced limit=5 for order list query",
conversation_id=state["conversation_id"]
)
state = add_tool_call(
state,
tool_name=result["tool_name"],
@@ -730,8 +738,11 @@ def _parse_mall_order_data(data: dict[str, Any]) -> dict[str, Any]:
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", ""))
# 物流信息(如果有)
if actual_order_data.get("parcels") and len(actual_order_data.get("parcels", [])) > 0:
# 物流信息(如果有)- 添加 has_parcels 标记用于判断是否显示物流按钮
has_parcels = actual_order_data.get("parcels") and len(actual_order_data.get("parcels", [])) > 0
order_data["has_parcels"] = has_parcels
if has_parcels:
# parcels 是一个数组,包含物流信息
first_parcel = actual_order_data["parcels"][0] if isinstance(actual_order_data["parcels"], list) else actual_order_data["parcels"]
if isinstance(first_parcel, dict):

View File

@@ -22,18 +22,19 @@ PRODUCT_AGENT_PROMPT = """你是一个专业的 B2B 商品顾问助手。
## 可用工具
1. **search_products** - 搜索商品
- query: 搜索关键词
- filters: 过滤条件category, price_range, brand 等
- sort: 排序方式price_asc/price_desc/sales/latest
- page: 页码
- page_size: 每页数量
- keyword: 搜索关键词(商品名称、编号等)
- page_size: 每页数量(默认 5最大 100
- page: 页码(默认 1
- 说明:此工具使用 Mall API 搜索商品 SPU支持用户 token 认证,返回卡片格式展示
2. **get_product_detail** - 获取商品详情
- product_id: 商品ID
3. **recommend_products** - 智能推荐
- context: 推荐上下文(可包含当前查询、浏览历史等
- limit: 推荐数量
3. **recommend_products** - 智能推荐(心动清单/猜你喜欢)
- page_size: 推荐数量(默认 6最大 100
- page: 页码(默认 1
- warehouse_id: 仓库ID默认 2
- 说明:此工具使用 Mall API /mall/api/loveList 接口,需要用户 token 认证,系统会自动注入用户 token
4. **get_quote** - B2B 询价
- product_id: 商品ID
@@ -57,6 +58,43 @@ PRODUCT_AGENT_PROMPT = """你是一个专业的 B2B 商品顾问助手。
}
```
**示例**
用户说:"搜索 ring"
返回:
```json
{
"action": "call_tool",
"tool_name": "search_products",
"arguments": {
"keyword": "ring"
}
}
```
用户说:"查找手机"
返回:
```json
{
"action": "call_tool",
"tool_name": "search_products",
"arguments": {
"keyword": "手机"
}
}
```
用户说:"推荐一些商品"
返回:
```json
{
"action": "call_tool",
"tool_name": "recommend_products",
"arguments": {
"page_size": 6
}
}
```
当需要向用户询问更多信息时:
```json
{
@@ -80,6 +118,17 @@ PRODUCT_AGENT_PROMPT = """你是一个专业的 B2B 商品顾问助手。
- 报价通常有有效期
## 商品推荐策略
**重要规则:推荐 vs 搜索**
- **泛泛推荐**"推荐一些商品""推荐一下""有什么好推荐的" → 使用 recommend_products
- **具体商品推荐**"推荐ring相关的商品""推荐手机""推荐一些珠宝" → 使用 search_products (提取关键词ring、手机、珠宝)
- **商品搜索**"搜索ring""找ring商品" → 使用 search_products
**说明**
- 如果用户推荐请求中包含具体的商品关键词(如 ring、手机、珠宝等使用 search_products 进行精准搜索
- 只有在泛泛请求推荐时才使用 recommend_products基于用户行为的个性化推荐
**其他推荐依据**
- 根据用户采购历史推荐
- 根据当前查询语义推荐
- 根据企业行业特点推荐
@@ -114,6 +163,35 @@ async def product_agent(state: AgentState) -> AgentState:
state["agent_history"].append("product")
state["state"] = ConversationState.PROCESSING.value
# ========== FAST PATH: Image Search ==========
# Check if this is an image search request
image_search_url = state.get("context", {}).get("image_search_url")
if image_search_url:
logger.info(
"Image search detected, calling search_products_by_image",
conversation_id=state["conversation_id"],
image_url=image_search_url[:100] + "..." if len(image_search_url) > 100 else image_search_url
)
# 直接调用图片搜索工具
state = add_tool_call(
state,
tool_name="search_products_by_image",
arguments={
"image_url": image_search_url,
"page_size": 6,
"page": 1
},
server="product"
)
# 清除 image_search_url 防止无限循环
state["context"]["image_search_url"] = None
state["state"] = ConversationState.TOOL_CALLING.value
return state
# ==============================================
# Check if we have tool results to process
if state["tool_results"]:
return await _generate_product_response(state)
@@ -124,7 +202,8 @@ async def product_agent(state: AgentState) -> AgentState:
]
# Add conversation history
for msg in state["messages"][-6:]:
# 只保留最近 2 条历史消息以减少 token 数量和响应时间
for msg in state["messages"][-2:]:
messages.append(Message(role=msg["role"], content=msg["content"]))
# Build context info
@@ -148,24 +227,101 @@ async def product_agent(state: AgentState) -> AgentState:
# Parse response
content = response.content.strip()
# Log raw LLM response for debugging
logger.info(
"Product agent LLM response",
response_length=len(content),
response_preview=content[:200],
conversation_id=state["conversation_id"]
)
if content.startswith("```"):
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
# Remove leading/trailing whitespace after removing code block markers
content = content.strip()
# Handle non-JSON format: "tool_name\n{args}"
if '\n' in content and not content.startswith('{'):
lines = content.split('\n', 1)
tool_name = lines[0].strip()
args_json = lines[1].strip() if len(lines) > 1 else '{}'
try:
arguments = json.loads(args_json) if args_json else {}
result = {
"action": "call_tool",
"tool_name": tool_name,
"arguments": arguments
}
except json.JSONDecodeError:
# If args parsing fails, use empty dict
result = {
"action": "call_tool",
"tool_name": tool_name,
"arguments": {}
}
else:
# Standard JSON format
result = json.loads(content)
result = json.loads(content)
action = result.get("action")
if action == "call_tool":
arguments = result.get("arguments", {})
tool_name = result.get("tool_name", "")
# Inject context for recommendation
if result["tool_name"] == "recommend_products":
logger.info(
"Product agent calling tool",
tool_name=tool_name,
arguments=arguments,
conversation_id=state["conversation_id"]
)
# Inject context for product search (Mall API)
if tool_name == "search_products":
arguments["user_token"] = state.get("user_token")
arguments["user_id"] = state["user_id"]
arguments["account_id"] = state["account_id"]
# Set default page_size if not provided
if "page_size" not in arguments:
arguments["page_size"] = 6
# Set default page if not provided
if "page" not in arguments:
arguments["page"] = 1
# Map "query" parameter to "keyword" for compatibility
if "query" in arguments and "keyword" not in arguments:
arguments["keyword"] = arguments.pop("query")
logger.info(
"Parameter mapped: query -> keyword",
conversation_id=state["conversation_id"]
)
# Inject context for recommendation
if tool_name == "recommend_products":
arguments["user_token"] = state.get("user_token")
# 如果没有提供 page_size使用默认值 6
if "page_size" not in arguments:
arguments["page_size"] = 6
# 如果没有提供 warehouse_id使用默认值 2
if "warehouse_id" not in arguments:
arguments["warehouse_id"] = 2
logger.info(
"Product agent recommend_products after injection",
user_token_present="user_token" in arguments,
user_token_preview=arguments.get("user_token", "")[:20] + "..." if arguments.get("user_token") else None,
arguments=arguments,
conversation_id=state["conversation_id"]
)
# Inject context for quote
if result["tool_name"] == "get_quote":
if tool_name == "get_quote":
arguments["account_id"] = state["account_id"]
# Use entity if available
@@ -177,7 +333,7 @@ async def product_agent(state: AgentState) -> AgentState:
state = add_tool_call(
state,
tool_name=result["tool_name"],
tool_name=tool_name,
arguments=arguments,
server="product"
)
@@ -193,8 +349,15 @@ async def product_agent(state: AgentState) -> AgentState:
return state
except json.JSONDecodeError:
state = set_response(state, response.content)
except json.JSONDecodeError as e:
logger.error(
"Failed to parse product agent LLM response as JSON",
error=str(e),
conversation_id=state.get("conversation_id"),
raw_content=response.content[:500] if response.content else "EMPTY"
)
# Don't use raw content as response - use fallback instead
state = set_response(state, "抱歉,我无法理解您的请求。请尝试重新表述或联系人工客服。")
return state
except Exception as e:
@@ -206,11 +369,160 @@ async def product_agent(state: AgentState) -> AgentState:
async def _generate_product_response(state: AgentState) -> AgentState:
"""Generate response based on product tool results"""
# 特殊处理:如果是 search_products、recommend_products 或 search_products_by_image 工具返回,直接发送商品卡片
has_product_result = False
products = []
result_source = None # "search", "recommend" 或 "image_search"
# 添加日志:查看所有工具结果
import json as json_module
logger.info(
"All tool results",
tool_results_count=len(state.get("tool_results", [])),
tool_results=json_module.dumps(state.get("tool_results", []), ensure_ascii=False, indent=2)[:2000]
)
for result in state["tool_results"]:
logger.info(
"Processing tool result",
tool_name=result["tool_name"],
success=result["success"],
data_keys=list(result.get("data", {}).keys()) if isinstance(result.get("data"), dict) else "not a dict",
data_preview=json_module.dumps(result.get("data"), ensure_ascii=False)[:500]
)
if result["success"] and result["tool_name"] in ["search_products", "recommend_products", "search_products_by_image"]:
data = result["data"]
if isinstance(data, dict) and data.get("success"):
# MCP 返回的数据结构: {"success": true, "result": {"success": true, "products": [...]}}
# 需要从 result.result 中提取实际数据
inner_data = data.get("result", data)
products = inner_data.get("products", [])
keyword = inner_data.get("keyword", "")
has_product_result = True
if result["tool_name"] == "recommend_products":
result_source = "recommend"
elif result["tool_name"] == "search_products_by_image":
result_source = "image_search"
else:
result_source = "search"
logger.info(
f"Product {result_source} results found",
products_count=len(products),
keyword=keyword,
products_preview=json_module.dumps(products[:2], ensure_ascii=False, indent=2) if products else "[]"
)
break
# 如果有商品结果直接发送商品卡片product_list 格式)
if has_product_result and products:
try:
from integrations.chatwoot import ChatwootClient
from core.language_detector import detect_language
# 检测语言
detected_language = state.get("detected_language", "en")
# 发送商品列表
chatwoot = ChatwootClient(account_id=int(state.get("account_id", 1)))
conversation_id = state.get("conversation_id")
if conversation_id:
await chatwoot.send_product_cards(
conversation_id=int(conversation_id),
products=products,
language=detected_language
)
logger.info(
f"Product {result_source} cards sent successfully",
conversation_id=conversation_id,
products_count=len(products),
language=detected_language,
result_source=result_source
)
# 清空响应,避免重复发送
state = set_response(state, "")
state["state"] = ConversationState.GENERATING.value
return state
except Exception as e:
logger.error(
f"Failed to send product {result_source} cards, falling back to text response",
error=str(e),
products_count=len(products),
result_source=result_source
)
# 常规处理:生成文本响应
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)}")
tool_name = result['tool_name']
data = result['data']
# Extract only essential information based on tool type
if tool_name == "search_products" or tool_name == "recommend_products":
products = data.get("products", []) if isinstance(data, dict) else []
if products:
# Keep top 5 products with more details
product_items = []
for p in products[:5]: # Increased from 3 to 5
name = p.get('product_name', 'N/A')
price = p.get('price', 'N/A')
special_price = p.get('special_price')
spu_id = p.get('spu_id', '')
# Show special price if available
if special_price and float(special_price) > 0:
price_str = f"原价: {price}, 特价: {special_price}"
else:
price_str = str(price)
# Format: [ID] Name - Price
if spu_id:
product_items.append(f"- [{spu_id}] {name} - {price_str}")
else:
product_items.append(f"- {name} - {price_str}")
summary = f"Found {len(products)} products:\n" + "\n".join(product_items)
# Add note if there are more products
if len(products) > 5:
summary += f"\n(and {len(products) - 5} more products, visit website for full selection)"
tool_context.append(summary)
else:
tool_context.append("No products found")
elif tool_name == "get_product_detail":
product = data.get("product", {}) if isinstance(data, dict) else {}
name = product.get("product_name", product.get("name", "N/A"))
price = product.get("price", "N/A")
stock = product.get("stock", product.get("stock_status", "N/A"))
summary = f"Product: {name} | Price: {price} | Stock: {stock}"
tool_context.append(summary)
elif tool_name == "check_inventory":
inventory = data.get("inventory", []) if isinstance(data, dict) else []
inv_summaries = [f"{inv.get('product_id', 'N/A')}: {inv.get('quantity', 'N/A')} available" for inv in inventory[:3]]
summary = "Inventory status:\n" + "\n".join(inv_summaries)
tool_context.append(summary)
elif tool_name == "get_pricing":
product_id = data.get("product_id", "N/A")
unit_price = data.get("unit_price", "N/A")
total_price = data.get("total_price", "N/A")
summary = f"Quote for {product_id}: Unit: {unit_price} | Total: {total_price}"
tool_context.append(summary)
else:
# For other tools, include concise summary (limit to 200 chars)
data_str = json.dumps(data, ensure_ascii=False)[:200]
tool_context.append(f"工具 {tool_name} 返回: {data_str}...")
# Extract product context
if isinstance(data, dict):
@@ -246,7 +558,8 @@ async def _generate_product_response(state: AgentState) -> AgentState:
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.7)
# Lower temperature for faster response
response = await llm.chat(messages, temperature=0.3)
state = set_response(state, response.content)
return state

View File

@@ -106,7 +106,7 @@ async def classify_intent(state: AgentState) -> AgentState:
content = response.content.strip()
# Log raw response for debugging
logger.debug(
logger.info(
"LLM response for intent classification",
response_preview=content[:500] if content else "EMPTY",
content_length=len(content) if content else 0

View File

@@ -10,8 +10,17 @@ class Settings(BaseSettings):
"""Application settings loaded from environment variables"""
# ============ AI Model ============
zhipu_api_key: str = Field(..., description="ZhipuAI API Key")
zhipu_model: str = Field(default="glm-4", description="ZhipuAI Model name")
llm_provider: str = Field(default="zhipu", description="LLM provider: 'zhipu' or 'qwen'")
# ZhipuAI (智谱 AI)
zhipu_api_key: str = Field(default="", description="ZhipuAI API Key")
zhipu_model: str = Field(default="glm-4-flash", description="ZhipuAI Model name")
# Qwen (通义千问)
qwen_api_key: str = Field(default="", description="Qwen/DashScope API Key")
qwen_model: str = Field(default="qwen-omni-turbo", description="Qwen Model name")
# 通用配置
enable_reasoning_mode: bool = Field(default=False, description="Enable AI reasoning/thinking mode (slower but more thoughtful)")
reasoning_mode_for_complex: bool = Field(default=True, description="Enable reasoning mode only for complex queries")

View File

@@ -1,8 +1,9 @@
"""
ZhipuAI LLM Client for B2B Shopping AI Assistant
LLM Client for B2B Shopping AI Assistant
Supports both ZhipuAI and Qwen (DashScope)
"""
import concurrent.futures
from typing import Any, Optional
from typing import Any, Optional, Union
from dataclasses import dataclass
from zhipuai import ZhipuAI
@@ -154,7 +155,8 @@ class ZhipuLLMClient:
)
# Determine if reasoning mode should be used
use_reasoning = enable_reasoning if enable_reasoning is not None else self._should_use_reasoning(formatted_messages)
# 强制禁用深度思考模式以提升响应速度2026-01-26
use_reasoning = False # Override all settings to disable thinking mode
if use_reasoning:
logger.info("Reasoning mode enabled for this request")
@@ -275,12 +277,168 @@ class ZhipuLLMClient:
raise
llm_client: Optional[ZhipuLLMClient] = None
llm_client: Optional[Union[ZhipuLLMClient, "QwenLLMClient"]] = None
def get_llm_client() -> ZhipuLLMClient:
"""Get or create global LLM client instance"""
def get_llm_client() -> Union[ZhipuLLMClient, "QwenLLMClient"]:
"""Get or create global LLM client instance based on provider setting"""
global llm_client
if llm_client is None:
llm_client = ZhipuLLMClient()
provider = settings.llm_provider.lower()
if provider == "qwen":
llm_client = QwenLLMClient()
else:
llm_client = ZhipuLLMClient()
return llm_client
# ============ Qwen (DashScope) LLM Client ============
try:
from dashscope import Generation
DASHSCOPE_AVAILABLE = True
except ImportError:
DASHSCOPE_AVAILABLE = False
logger.warning("DashScope SDK not installed. Qwen models will not be available.")
class QwenLLMClient:
"""Qwen (DashScope) LLM Client wrapper"""
DEFAULT_TIMEOUT = 60 # seconds
def __init__(
self,
api_key: Optional[str] = None,
model: Optional[str] = None,
timeout: Optional[int] = None
):
if not DASHSCOPE_AVAILABLE:
raise ImportError("DashScope SDK is not installed. Install it with: pip install dashscope")
self.api_key = api_key or settings.qwen_api_key
self.model = model or settings.qwen_model
self.timeout = timeout or self.DEFAULT_TIMEOUT
# 设置 API key 到 DashScope SDK
# 必须直接设置 dashscope.api_key环境变量可能不够
import dashscope
dashscope.api_key = self.api_key
logger.info(
"Qwen client initialized",
model=self.model,
timeout=self.timeout,
api_key_prefix=self.api_key[:10] + "..." if len(self.api_key) > 10 else self.api_key
)
async def chat(
self,
messages: list[Message],
temperature: float = 0.7,
max_tokens: int = 2048,
top_p: float = 0.9,
use_cache: bool = True,
**kwargs: Any
) -> LLMResponse:
"""Send chat completion request with caching support"""
formatted_messages = [
{"role": msg.role, "content": msg.content}
for msg in messages
]
# Try cache first
if use_cache:
try:
cache = get_response_cache()
cached_response = await cache.get(
model=self.model,
messages=formatted_messages,
temperature=temperature
)
if cached_response is not None:
logger.info(
"Returning cached response",
model=self.model,
response_length=len(cached_response)
)
return LLMResponse(
content=cached_response,
finish_reason="cache_hit",
usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
)
except Exception as e:
logger.warning("Cache check failed", error=str(e))
logger.info(
"Sending chat request",
model=self.model,
message_count=len(messages),
temperature=temperature
)
def _make_request():
response = Generation.call(
model=self.model,
messages=formatted_messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
result_format="message",
**kwargs
)
return response
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_make_request)
response = future.result(timeout=self.timeout)
# Qwen API 响应格式
if response.status_code != 200:
raise Exception(f"Qwen API error: {response.message}")
content = response.output.choices[0].message.content
finish_reason = response.output.choices[0].finish_reason
usage = response.usage
logger.info(
"Chat response received",
finish_reason=finish_reason,
content_length=len(content) if content else 0,
usage=usage
)
if not content:
logger.warning("LLM returned empty content")
# Cache the response
if use_cache and content:
try:
cache = get_response_cache()
await cache.set(
model=self.model,
messages=formatted_messages,
response=content,
temperature=temperature
)
except Exception as e:
logger.warning("Failed to cache response", error=str(e))
return LLMResponse(
content=content or "",
finish_reason=finish_reason,
usage={
"prompt_tokens": usage.input_tokens,
"completion_tokens": usage.output_tokens,
"total_tokens": usage.total_tokens
}
)
except concurrent.futures.TimeoutError:
logger.error("Chat request timed out", timeout=self.timeout)
raise TimeoutError(f"Request timed out after {self.timeout} seconds")
except Exception as e:
logger.error("Chat request failed", error=str(e))
raise

View File

@@ -155,6 +155,109 @@ def get_field_label(field_key: str, language: str = "en") -> str:
return ORDER_FIELD_LABELS[language].get(field_key, ORDER_FIELD_LABELS["en"].get(field_key, field_key))
# 订单状态多语言映射
ORDER_STATUS_LABELS = {
"zh": { # 中文
"0": "已取消",
"1": "待支付",
"2": "已支付",
"3": "已发货",
"4": "已签收",
"15": "已完成",
"100": "超时取消",
"unknown": "未知"
},
"en": { # English
"0": "Cancelled",
"1": "Pending Payment",
"2": "Paid",
"3": "Shipped",
"4": "Delivered",
"15": "Completed",
"100": "Timeout Cancelled",
"unknown": "Unknown"
},
"nl": { # Dutch (荷兰语)
"0": "Geannuleerd",
"1": "Wachtend op betaling",
"2": "Betaald",
"3": "Verzonden",
"4": "Geleverd",
"15": "Voltooid",
"100": "Time-out geannuleerd",
"unknown": "Onbekend"
},
"de": { # German (德语)
"0": "Storniert",
"1": "Zahlung ausstehend",
"2": "Bezahlt",
"3": "Versandt",
"4": "Zugestellt",
"15": "Abgeschlossen",
"100": "Zeitüberschreitung storniert",
"unknown": "Unbekannt"
},
"es": { # Spanish (西班牙语)
"0": "Cancelado",
"1": "Pago pendiente",
"2": "Pagado",
"3": "Enviado",
"4": "Entregado",
"15": "Completado",
"100": "Cancelado por tiempo límite",
"unknown": "Desconocido"
},
"fr": { # French (法语)
"0": "Annulé",
"1": "En attente de paiement",
"2": "Payé",
"3": "Expédié",
"4": "Livré",
"15": "Terminé",
"100": "Annulé pour expiration",
"unknown": "Inconnu"
},
"it": { # Italian (意大利语)
"0": "Annullato",
"1": "In attesa di pagamento",
"2": "Pagato",
"3": "Spedito",
"4": "Consegnato",
"15": "Completato",
"100": "Annullato per timeout",
"unknown": "Sconosciuto"
},
"tr": { # Turkish (土耳其语)
"0": "İptal edildi",
"1": "Ödeme bekleniyor",
"2": "Ödendi",
"3": "Kargolandı",
"4": "Teslim edildi",
"15": "Tamamlandı",
"100": "Zaman aşımı iptal edildi",
"unknown": "Bilinmiyor"
}
}
def get_status_label(status_code: str, language: str = "en") -> str:
"""获取指定语言的订单状态标签
Args:
status_code: 状态码(如 "0", "1", "2" 等)
language: 语言代码(默认 "en"
Returns:
对应语言的状态标签
"""
if language not in ORDER_STATUS_LABELS:
language = "en" # 默认使用英文
return ORDER_STATUS_LABELS[language].get(
str(status_code),
ORDER_STATUS_LABELS["en"].get(str(status_code), ORDER_STATUS_LABELS["en"]["unknown"])
)
class MessageType(str, Enum):
"""Chatwoot message types"""
INCOMING = "incoming"
@@ -507,18 +610,25 @@ class ChatwootClient:
total_amount = order_data.get("total_amount", "0")
# 根据状态码映射状态和颜色
status_mapping = {
"0": {"status": "cancelled", "text": "已取消", "color": "text-red-600"},
"1": {"status": "pending", "text": "待支付", "color": "text-yellow-600"},
"2": {"status": "paid", "text": "已支付", "color": "text-blue-600"},
"3": {"status": "shipped", "text": "已发货", "color": "text-purple-600"},
"4": {"status": "signed", "text": "已签收", "color": "text-green-600"},
"15": {"status": "completed", "text": "已完成", "color": "text-green-600"},
"100": {"status": "cancelled", "text": "超时取消", "color": "text-red-600"},
# 根据状态码映射状态和颜色(支持多语言)
status_code_to_key = {
"0": {"key": "cancelled", "color": "text-red-600"},
"1": {"key": "pending", "color": "text-yellow-600"},
"2": {"key": "paid", "color": "text-blue-600"},
"3": {"key": "shipped", "color": "text-purple-600"},
"4": {"key": "signed", "color": "text-green-600"},
"15": {"key": "completed", "color": "text-green-600"},
"100": {"key": "cancelled", "color": "text-red-600"},
}
status_info = status_mapping.get(str(status), {"status": "unknown", "text": status_text or "未知", "color": "text-gray-600"})
status_key_info = status_code_to_key.get(str(status), {"key": "unknown", "color": "text-gray-600"})
status_label = get_status_label(str(status), language)
status_info = {
"status": status_key_info["key"],
"text": status_label,
"color": status_key_info["color"]
}
# 构建商品列表
items = order_data.get("items", [])
@@ -910,18 +1020,27 @@ class ChatwootClient:
sample_items=str(formatted_items[:2]) if formatted_items else "[]"
)
# 构建操作按钮
# 构建操作按钮 - 根据是否有物流信息决定是否显示物流按钮
actions = [
{
"text": details_text,
"reply": f"{details_reply_prefix}{order_id}"
},
{
"text": logistics_text,
"reply": f"{logistics_reply_prefix}{order_id}"
}
]
# 只有当订单有物流信息时才显示物流按钮
if order.get("has_parcels", False):
actions.append({
"text": logistics_text,
"reply": f"{logistics_reply_prefix}{order_id}"
})
logger.debug(
f"Built {len(actions)} actions for order {order_id}",
has_parcels=order.get("has_parcels", False),
actions_count=len(actions)
)
# 构建单个订单
order_data = {
"orderNumber": order_id,
@@ -964,6 +1083,129 @@ class ChatwootClient:
return response.json()
async def send_product_cards(
self,
conversation_id: int,
products: list[dict[str, Any]],
language: str = "en"
) -> dict[str, Any]:
"""发送商品搜索结果(使用 product_list 格式)
Args:
conversation_id: 会话 ID
products: 商品列表,每个商品包含:
- spu_id: SPU ID
- spu_sn: SPU 编号
- product_name: 商品名称
- product_image: 商品图片 URL
- price: 价格
- special_price: 特价(可选)
- href: 商品链接路径(可选)
language: 语言代码en, nl, de, es, fr, it, tr, zh默认 en
Returns:
发送结果
Example:
>>> products = [
... {
... "spu_id": "12345",
... "product_name": "Product A",
... "product_image": "https://...",
... "price": "99.99",
... "href": "/product/detail/12345"
... }
... ]
>>> await chatwoot.send_product_cards(123, products, language="zh")
"""
client = await self._get_client()
# 获取前端域名
frontend_url = settings.frontend_url.rstrip('/')
# 构建商品列表
product_list = []
for product in products:
product_name = product.get("product_name", "Unknown Product")
product_image = product.get("product_image", "")
price = product.get("price", "0")
special_price = product.get("special_price")
href = product.get("href", "")
# 价格显示(如果有特价则显示特价,否则显示原价)
try:
if special_price and float(special_price) > 0:
price_num = float(special_price)
else:
price_num = float(price) if price else 0
# 根据语言选择货币符号
if language == "zh":
price_text = f"¥{price_num:.2f}"
else:
price_text = f"{price_num:.2f}"
except (ValueError, TypeError):
price_text = str(price) if price else ("¥0.00" if language == "zh" else "€0.00")
# 构建商品对象
product_obj = {
"image": product_image,
"name": product_name,
"price": price_text,
"target": "_blank"
}
# 如果有 href添加完整 URL
if href:
product_obj["url"] = f"{frontend_url}{href}"
else:
# 如果没有 href使用 spu_id 构建默认链接
spu_id = product.get("spu_id", "")
if spu_id:
product_obj["url"] = f"{frontend_url}/product/detail?spuId={spu_id}"
product_list.append(product_obj)
# 构建标题
if language == "zh":
title = "找到以下商品"
else:
title = "Found following products"
# 构建 content_attributes
content_attributes = {
"title": title,
"products": product_list,
"actions": []
}
# 发送 product_list 类型消息
payload = {
"content": "",
"content_type": "product_list",
"message_type": 1,
"content_attributes": content_attributes
}
# 输出完整的 payload 用于调试
import json as json_module
logger.info(
"Sending product list to Chatwoot",
conversation_id=conversation_id,
products_count=len(product_list),
language=language,
payload=json_module.dumps(payload, ensure_ascii=False, indent=2)
)
response = await client.post(
f"/conversations/{conversation_id}/messages",
json=payload
)
response.raise_for_status()
return response.json()
# ============ Conversations ============
async def get_conversation(self, conversation_id: int) -> dict[str, Any]:

View File

@@ -12,10 +12,16 @@ system_prompt: |
## Available Tools
**search_products** - Search for products
- query: Search keywords
- category: Product category (optional)
- filters: {attribute: value} (optional)
**search_products** - Search for products by keyword
- keyword: Search keywords (required)
- page_size: Number of results (optional, default: 6)
- page: Page number (optional, default: 1)
**recommend_products** - Get personalized product recommendations (Love List/心动清单)
- page_size: Number of recommendations (optional, default: 6)
- page: Page number (optional, default: 1)
- warehouse_id: Warehouse ID (optional, default: 2)
- Note: Requires user authentication token, uses Mall API /mall/api/loveList
**get_product_details** - Get detailed product information
- product_id: Product ID or SKU
@@ -28,18 +34,22 @@ system_prompt: |
- product_id: Product ID
- quantity: Quantity for pricing (optional, for tiered pricing)
**recommend_products** - Get product recommendations
- category: Product category
- limit: Number of recommendations
## Important Rules
1. **Product Recognition**:
- Product search/产品搜索/找产品/商品 → Use search_products
- 泛泛推荐(推荐/推荐商品/猜你喜欢/心动清单,无具体关键词) → Use recommend_products
- 具体商品推荐推荐ring/推荐手机,有具体关键词) → Use search_products
- Product search/产品搜索/找商品/搜索商品 + 具体关键词 → Use search_products
- Price/价格/报价/多少钱 → Use get_pricing
- Stock/库存/有没有货/现货 → Use check_stock
- Product details/产品详情/产品信息/产品规格 → Use get_product_details
- Recommendation/推荐/推荐产品 → Use recommend_products
2. **Recommendation vs Search**:
- "推荐一些商品"、"推荐一下"、"有什么好推荐的"(无具体关键词) → Use recommend_products
- "推荐ring相关的商品"、"推荐手机"、"推荐一些珠宝"(有具体关键词) → Use search_products (keyword: "ring"/"手机"/"珠宝")
- "搜索ring"、"找ring商品" → Use search_products (keyword: "ring")
**规则**: 如果推荐请求中包含具体的商品关键词(如 ring、手机、珠宝等使用 search_products 进行精准搜索。只有在泛泛请求推荐时才使用 recommend_products。
2. For B2B customers, prioritize wholesale/bulk pricing information
3. Always check stock availability before suggesting purchases
@@ -51,17 +61,24 @@ system_prompt: |
- For Chinese inquiries, respond in Chinese
- For English inquiries, respond in English
## ⚠️ CRITICAL: Response Format
**You MUST ALWAYS respond with valid JSON. Never respond with plain text.**
## Tool Call Format
When you need to use a tool, respond with EXACTLY this JSON format:
```json
{
"action": "call_tool",
"tool_name": "tool_name",
"arguments": {"parameter": "value"}
"tool_name": "search_products",
"arguments": {
"query": "product name"
}
}
```
Or to respond directly:
When you can answer directly:
```json
{
"action": "respond",
@@ -69,6 +86,14 @@ system_prompt: |
}
```
**IMPORTANT:**
- Your entire response must be a valid JSON object
- Do NOT include any text outside the JSON
- Do NOT use markdown code blocks
- Always include "action" field
- "action" must be either "call_tool" or "respond"
- For "call_tool", you must include "tool_name" and "arguments"
tool_descriptions:
search_products: "Search for products by keywords or category"
get_product_details: "Get detailed product information"

View File

@@ -51,6 +51,8 @@ system_prompt: |
## Output Format
⚠️ **CRITICAL**: You MUST return a valid JSON object. Do NOT chat with the user. Do NOT provide explanations outside the JSON.
Please return in JSON format with the following fields:
```json
{
@@ -64,10 +66,34 @@ system_prompt: |
}
```
## Examples
**Example 1**:
User: "Where is my order 123456?"
Response:
```json
{"intent": "order", "confidence": 0.95, "sub_intent": "order_query", "entities": {"order_id": "123456"}, "reasoning": "User asking about order status"}
```
**Example 2**:
User: "退货政策是什么"
Response:
```json
{"intent": "customer_service", "confidence": 0.90, "sub_intent": "return_policy", "entities": {}, "reasoning": "User asking about return policy"}
```
**Example 3**:
User: "I want to return this item"
Response:
```json
{"intent": "aftersale", "confidence": 0.85, "sub_intent": "return_request", "entities": {}, "reasoning": "User wants to return an item"}
```
## Notes
- If intent is unclear, confidence should be lower
- If unable to determine intent, return "unknown"
- Entity extraction should be accurate, don't fill in fields that don't exist
- **ALWAYS return JSON, NEVER return plain text**
tool_descriptions:
classify: "Classify user intent and extract entities"

View File

@@ -9,6 +9,7 @@ langchain-core>=0.1.0
# AI Model SDK
zhipuai>=2.0.0
dashscope>=1.14.0
# Async utilities
sniffio>=1.3.0
anyio>=4.0.0

View File

@@ -1,55 +0,0 @@
"""
测试端点 - 用于测试退货 FAQ
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from core.graph import process_message
router = APIRouter(prefix="/test", tags=["test"])
class TestRequest(BaseModel):
"""测试请求"""
conversation_id: str
user_id: str
account_id: str
message: str
history: list = []
context: dict = {}
@router.post("/faq")
async def test_faq(request: TestRequest):
"""测试 FAQ 回答
简化的测试端点,用于测试退货相关 FAQ
"""
try:
# 调用处理流程
result = await process_message(
conversation_id=request.conversation_id,
user_id=request.user_id,
account_id=request.account_id,
message=request.message,
history=request.history,
context=request.context
)
return {
"success": True,
"response": result.get("response"),
"intent": result.get("intent"),
"tool_calls": result.get("tool_calls", []),
"step_count": result.get("step_count", 0)
}
except Exception as e:
import traceback
traceback.print_exc()
return {
"success": False,
"error": str(e),
"response": None
}

View File

@@ -6,6 +6,7 @@ import hashlib
from typing import Any, Optional
from fastapi import APIRouter, Request, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from config import settings
@@ -75,6 +76,7 @@ class ChatwootWebhookPayload(BaseModel):
message_type: Optional[str] = None
content_type: Optional[str] = None
private: Optional[bool] = False
content_attributes: Optional[dict] = None # 图片搜索 URL 等额外属性
conversation: Optional[WebhookConversation] = None
sender: Optional[WebhookSender] = None
contact: Optional[WebhookContact] = None
@@ -132,7 +134,15 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
conversation_id = str(conversation.id)
content = payload.content
if not content:
# 检查是否是图片搜索消息(在过滤空 content 之前)
# 图片搜索消息的 content 通常是空的,但需要处理
is_image_search = (
payload.content_type == "search_image" or
payload.content_type == 17
)
# 只有非图片搜索消息才检查 content 是否为空
if not content and not is_image_search:
logger.debug("Empty message content, skipping")
return
@@ -282,19 +292,7 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
channel=conversation.channel if conversation else None
)
# 识别消息渠道(邮件、网站等)
message_channel = conversation.channel if conversation else "Channel"
is_email = message_channel == "Email"
# 邮件渠道特殊处理
if is_email:
logger.info(
"Email channel detected",
conversation_id=conversation_id,
sender_email=contact.email if contact else None
)
# Load conversation context from cache
# Load conversation context from cache (需要在图片搜索检测之前)
cache = get_cache_manager()
await cache.connect()
@@ -307,10 +305,125 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
if mall_token:
context["mall_token"] = mall_token
# 图片 URL 可能在两个位置:
# 1. payload.content_attributes.url (顶层)
# 2. payload.conversation.messages[0].content_attributes.url (嵌套)
image_url = None
# 首先尝试从顶层获取
if payload.content_attributes:
image_url = payload.content_attributes.get("url")
# 如果顶层没有,尝试从 conversation.messages[0] 获取
if not image_url and conversation and hasattr(conversation, 'model_dump'):
conv_dict = conversation.model_dump()
messages = conv_dict.get('messages', [])
if messages and len(messages) > 0:
first_message = messages[0]
msg_content_attrs = first_message.get('content_attributes', {})
image_url = msg_content_attrs.get('url')
if is_image_search and image_url:
logger.info(
"Image search detected",
conversation_id=conversation_id,
image_url=image_url[:100] + "..." if len(image_url) > 100 else image_url
)
# 创建 Chatwoot client
from integrations.chatwoot import ChatwootClient
chatwoot = ChatwootClient(account_id=int(account_id))
# 开启 typing status
try:
await chatwoot.toggle_typing_status(
conversation_id=conversation.id,
typing_status="on"
)
except Exception as e:
logger.warning(
"Failed to enable typing status for image search",
conversation_id=conversation_id,
error=str(e)
)
# 直接调用图片搜索工具
# 修改 content 为图片搜索指令
search_content = f"[图片搜索] 请根据这张图片搜索相似的商品"
# 使用特殊标记让 Product Agent 知道这是图片搜索
context["image_search_url"] = image_url
final_state = await process_message(
conversation_id=conversation_id,
user_id=user_id,
account_id=account_id,
message=search_content,
history=history,
context=context,
user_token=user_token,
mall_token=mall_token
)
# 获取响应并发送
response = final_state.get("response", "")
if response:
await chatwoot.send_message(
conversation_id=conversation.id,
content=response
)
logger.info(
"Image search response sent",
conversation_id=conversation_id,
response_length=len(response)
)
# 关闭 typing status
await chatwoot.toggle_typing_status(
conversation_id=conversation.id,
typing_status="off"
)
await chatwoot.close()
# Update cache
await cache.add_message(conversation_id, "user", search_content)
await cache.add_message(conversation_id, "assistant", response)
# Save context
new_context = final_state.get("context", {})
new_context["last_intent"] = final_state.get("intent")
await cache.set_context(conversation_id, new_context)
logger.info(
"Image search message processed successfully",
conversation_id=conversation_id,
intent=final_state.get("intent")
)
return JSONResponse(
content={"status": "success"},
status_code=200
)
# 识别消息渠道(邮件、网站等)
message_channel = conversation.channel if conversation else "Channel"
is_email = message_channel == "Email"
# 添加渠道信息到 context让 Agent 知道是邮件还是网站)
context["channel"] = message_channel
context["is_email"] = is_email
# 邮件渠道特殊处理
if is_email:
logger.info(
"Email channel detected",
conversation_id=conversation_id,
sender_email=contact.email if contact else None
)
# 创建 Chatwoot client提前创建以便开启 typing status
from integrations.chatwoot import ChatwootClient
chatwoot = ChatwootClient(account_id=int(account_id))
@@ -350,6 +463,15 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
if response is None:
response = "抱歉,我暂时无法处理您的请求。请稍后重试或联系人工客服。"
# Log the response content for debugging
logger.info(
"Preparing to send response to Chatwoot",
conversation_id=conversation_id,
response_length=len(response) if response else 0,
response_preview=response[:200] if response else None,
has_response=bool(response)
)
# Create Chatwoot client已在前面创建这里不需要再次创建
# chatwoot 已在 try 块之前创建
@@ -359,6 +481,10 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
conversation_id=conversation.id,
content=response
)
logger.info(
"Response sent to Chatwoot successfully",
conversation_id=conversation_id
)
# 关闭 typing status隐藏"正在输入..."
try:

336
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,336 @@
version: '3.8'
services:
# ============ Infrastructure ============
# Redis (Cache & Queue) - 生产环境配置
redis:
image: redis:7-alpine
container_name: ai_redis_prod
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-prod_redis_password_2024}
volumes:
- redis_data_prod:/data
networks:
- ai_network_prod
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-prod_redis_password_2024}", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: always
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============ AI Agent Layer ============
# LangGraph Agent Main Service - 生产环境
agent:
build:
context: ./agent
dockerfile: Dockerfile
args:
- ENVIRONMENT=production
image: ai-agent:latest
container_name: ai_agent_prod
environment:
# AI Model
ZHIPU_API_KEY: ${ZHIPU_API_KEY}
ZHIPU_MODEL: ${ZHIPU_MODEL:-GLM-4-Flash-250414}
ENABLE_REASONING_MODE: ${ENABLE_REASONING_MODE:-false}
REASONING_MODE_FOR_COMPLEX: ${REASONING_MODE_FOR_COMPLEX:-true}
# Redis
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-prod_redis_password_2024}
REDIS_DB: 0
# Chatwoot (生产环境)
CHATWOOT_API_URL: ${CHATWOOT_API_URL}
CHATWOOT_API_TOKEN: ${CHATWOOT_API_TOKEN}
CHATWOOT_WEBHOOK_SECRET: ${CHATWOOT_WEBHOOK_SECRET}
CHATWOOT_ACCOUNT_ID: ${CHATWOOT_ACCOUNT_ID:-1}
# External APIs
STRAPI_API_URL: ${STRAPI_API_URL}
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}
HYPERF_API_URL: ${HYPERF_API_URL}
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
# Mall API
MALL_API_URL: ${MALL_API_URL}
MALL_TENANT_ID: ${MALL_TENANT_ID:-2}
MALL_CURRENCY_CODE: ${MALL_CURRENCY_CODE:-EUR}
MALL_LANGUAGE_ID: ${MALL_LANGUAGE_ID:-1}
MALL_SOURCE: ${MALL_SOURCE:-www.gaia888.com}
# Frontend URLs
FRONTEND_URL: ${FRONTEND_URL:-https://www.gaia888.com}
# MCP Servers
STRAPI_MCP_URL: http://strapi_mcp:8001
ORDER_MCP_URL: http://order_mcp:8002
AFTERSALE_MCP_URL: http://aftersale_mcp:8003
PRODUCT_MCP_URL: http://product_mcp:8004
# Config
LOG_LEVEL: ${LOG_LEVEL:-WARNING}
MAX_CONVERSATION_STEPS: ${MAX_CONVERSATION_STEPS:-10}
CONVERSATION_TIMEOUT: ${CONVERSATION_TIMEOUT:-3600}
# Production specific
ENVIRONMENT: production
SENTRY_DSN: ${SENTRY_DSN}
ports:
- "8000:8000"
volumes:
- agent_logs_prod:/app/logs
depends_on:
redis:
condition: service_healthy
strapi_mcp:
condition: service_started
order_mcp:
condition: service_started
aftersale_mcp:
condition: service_started
product_mcp:
condition: service_started
networks:
- ai_network_prod
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '0.5'
memory: 512M
# ============ MCP Servers ============
# Strapi MCP (FAQ/Knowledge Base) - 生产环境
strapi_mcp:
build:
context: ./mcp_servers/strapi_mcp
dockerfile: Dockerfile
image: ai-strapi-mcp:latest
container_name: ai_strapi_mcp_prod
environment:
STRAPI_API_URL: ${STRAPI_API_URL}
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}
LOG_LEVEL: ${LOG_LEVEL:-WARNING}
ENVIRONMENT: production
ports:
- "8001:8001"
volumes:
- ./mcp_servers/shared:/app/shared:ro
networks:
- ai_network_prod
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
# Order MCP - 生产环境
order_mcp:
build:
context: ./mcp_servers/order_mcp
dockerfile: Dockerfile
image: ai-order-mcp:latest
container_name: ai_order_mcp_prod
environment:
HYPERF_API_URL: ${HYPERF_API_URL}
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
MALL_API_URL: ${MALL_API_URL}
MALL_TENANT_ID: ${MALL_TENANT_ID:-2}
MALL_CURRENCY_CODE: ${MALL_CURRENCY_CODE:-EUR}
MALL_LANGUAGE_ID: ${MALL_LANGUAGE_ID:-1}
MALL_SOURCE: ${MALL_SOURCE:-www.gaia888.com}
LOG_LEVEL: ${LOG_LEVEL:-WARNING}
ENVIRONMENT: production
ports:
- "8002:8002"
volumes:
- ./mcp_servers/shared:/app/shared:ro
networks:
- ai_network_prod
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
interval: 30s
timeout: 10s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
# Aftersale MCP - 生产环境
aftersale_mcp:
build:
context: ./mcp_servers/aftersale_mcp
dockerfile: Dockerfile
image: ai-aftersale-mcp:latest
container_name: ai_aftersale_mcp_prod
environment:
HYPERF_API_URL: ${HYPERF_API_URL}
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
LOG_LEVEL: ${LOG_LEVEL:-WARNING}
ENVIRONMENT: production
ports:
- "8003:8003"
volumes:
- ./mcp_servers/shared:/app/shared:ro
networks:
- ai_network_prod
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8003/health"]
interval: 30s
timeout: 10s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
# Product MCP - 生产环境
product_mcp:
build:
context: ./mcp_servers/product_mcp
dockerfile: Dockerfile
image: ai-product-mcp:latest
container_name: ai_product_mcp_prod
environment:
HYPERF_API_URL: ${HYPERF_API_URL}
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
LOG_LEVEL: ${LOG_LEVEL:-WARNING}
ENVIRONMENT: production
ports:
- "8004:8004"
volumes:
- ./mcp_servers/shared:/app/shared:ro
networks:
- ai_network_prod
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8004/health"]
interval: 30s
timeout: 10s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
# ============ Monitoring (Optional) ============
# Prometheus - 指标收集
prometheus:
image: prom/prometheus:latest
container_name: ai_prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
networks:
- ai_network_prod
restart: always
profiles:
- monitoring
# Grafana - 可视化监控
grafana:
image: grafana/grafana:latest
container_name: ai_grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
ports:
- "3001:3000"
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources:ro
networks:
- ai_network_prod
restart: always
profiles:
- monitoring
networks:
ai_network_prod:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
volumes:
redis_data_prod:
agent_logs_prod:
prometheus_data:
grafana_data:

View File

@@ -29,8 +29,11 @@ services:
container_name: ai_agent
environment:
# AI Model
LLM_PROVIDER: ${LLM_PROVIDER:-zhipu}
ZHIPU_API_KEY: ${ZHIPU_API_KEY}
ZHIPU_MODEL: ${ZHIPU_MODEL:-glm-4}
ZHIPU_MODEL: ${ZHIPU_MODEL:-glm-4-flash}
QWEN_API_KEY: ${QWEN_API_KEY}
QWEN_MODEL: ${QWEN_MODEL:-qwen-omni-turbo}
# Redis
REDIS_HOST: redis
REDIS_PORT: 6379
@@ -149,9 +152,16 @@ services:
context: ./mcp_servers/product_mcp
dockerfile: Dockerfile
container_name: ai_product_mcp
env_file:
- .env
environment:
HYPERF_API_URL: ${HYPERF_API_URL}
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
MALL_API_URL: ${MALL_API_URL}
MALL_TENANT_ID: ${MALL_TENANT_ID:-2}
MALL_CURRENCY_CODE: ${MALL_CURRENCY_CODE:-EUR}
MALL_LANGUAGE_ID: ${MALL_LANGUAGE_ID:-1}
MALL_SOURCE: ${MALL_SOURCE:-us.qa1.gaia888.com}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports:
- "8004:8004"

View File

@@ -0,0 +1,408 @@
# 商品搜索服务实现文档
## 功能概述
添加了基于 Mall API 的商品搜索服务,支持根据关键词搜索 SPU 商品,并以 Chatwoot cards 格式展示搜索结果。
## 技术架构
```
用户发送搜索请求
Router Agent 识别商品意图
Product Agent 处理
调用 Product MCP 工具: search_spu_products
MallClient 调用 Mall API: /mall/api/spu
返回商品列表
发送 Chatwoot Cards 展示商品
```
## 修改的文件
### 1. MallClient - SPU 搜索 API
**文件**: `mcp_servers/shared/mall_client.py:267-306`
**新增方法**:
```python
async def search_spu_products(
self,
keyword: str,
page_size: int = 60,
page: int = 1
) -> dict[str, Any]
```
**功能**: 调用 Mall API `/mall/api/spu` 接口搜索商品
### 2. Product MCP - SPU 搜索工具
**文件**: `mcp_servers/product_mcp/server.py:291-378`
**新增工具**: `search_spu_products`
**参数**:
- `keyword` (必需): 搜索关键词
- `page_size`: 每页数量(默认 60最大 100
- `page`: 页码(默认 1
- `user_token` (必需): 用户 JWT token用于 Mall API 认证
- `user_id`: 用户 ID自动注入
- `account_id`: 账户 ID自动注入
**返回数据格式**:
```json
{
"success": true,
"products": [
{
"spu_id": "12345",
"spu_sn": "61607",
"product_name": "Product Name",
"product_image": "https://...",
"price": "99.99",
"special_price": "89.99",
"stock": 100,
"sales_count": 50
}
],
"total": 156,
"keyword": "61607"
}
```
### 3. Chatwoot 集成 - 商品卡片发送
**文件**: `agent/integrations/chatwoot.py:1086-1243`
**新增方法**:
```python
async def send_product_cards(
self,
conversation_id: int,
products: list[dict[str, Any]],
language: str = "en"
) -> dict[str, Any]
```
**功能**: 发送商品搜索结果卡片到 Chatwoot
**卡片数据结构**:
```json
{
"content": "Found 3 products",
"content_type": "cards",
"content_attributes": {
"items": [
{
"title": "Product Name",
"description": "Special: €89.99 | Stock: 100 | Sold: 50",
"media_url": "https://...",
"actions": [
{
"type": "link",
"text": "View Details",
"uri": "https://www.gaia888.com/product/detail?spuId=12345"
},
{
"type": "link",
"text": "Buy Now",
"uri": "https://www.gaia888.com/product/detail?spuId=12345"
}
]
}
]
}
}
```
### 4. Product Agent - 搜索结果处理
**文件**: `agent/agents/product.py:206-313`
**修改内容**: 在 `_generate_product_response` 方法中添加特殊处理逻辑
**逻辑**:
1. 检测是否为 `search_spu_products` 工具返回
2. 如果是,直接调用 `send_product_cards` 发送商品卡片
3. 如果失败,降级到文本响应
### 5. Product Agent - Prompt 更新
**文件**: `agent/agents/product.py:22-52`
**修改内容**: 更新 PRODUCT_AGENT_PROMPT 可用工具列表
**更新**:
-`search_spu_products` 设为第一个工具(推荐使用)
- 说明此工具使用 Mall API 搜索商品 SPU支持用户 token 认证,返回卡片格式展示
- 原有的 `search_products` 标记为高级搜索工具(使用 Hyperf API
### 6. Docker Compose - 环境变量配置
**文件**: `docker-compose.yml:146-170`
**修改内容**: 为 Product MCP 添加 Mall API 相关环境变量和 env_file
```yaml
product_mcp:
env_file:
- .env
environment:
MALL_API_URL: ${MALL_API_URL}
MALL_TENANT_ID: ${MALL_TENANT_ID:-2}
MALL_CURRENCY_CODE: ${MALL_CURRENCY_CODE:-EUR}
MALL_LANGUAGE_ID: ${MALL_LANGUAGE_ID:-1}
MALL_SOURCE: ${MALL_SOURCE:-us.qa1.gaia888.com}
```
## 使用方式
### 用户在 Chatwoot 中搜索商品
**示例对话**:
```
用户: 搜索 61607
用户: 我想找手机
用户: 查找电脑产品
```
### Agent 调用流程
1. **Router Agent** 识别商品搜索意图
2. **Product Agent** 接收请求
3. **LLM** 决定调用 `search_spu_products` 工具
4. **Product MCP** 执行工具调用:
- 从 state 获取 `user_token`(用户的 JWT token
- 创建 MallClient 实例
- 调用 Mall API `/mall/api/spu?keyword=xxx&pageSize=60&page=1`
- 解析返回结果
5. **Product Agent** 接收工具结果
6. **Chatwoot 集成** 发送商品卡片
## 商品卡片展示
### 中文界面
```
┌─────────────────────────────────┐
│ 找到 3 个商品 │
├─────────────────────────────────┤
│ ┌─────────────────────────────┐ │
│ │ [图片] │ │
│ │ Product Name │ │
│ │ 特价: €89.99 | 库存: 100 │ │
│ │ [查看详情] [立即购买] │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ [图片] │ │
│ │ Product Name 2 │ │
│ │ €99.99 | 库存: 50 │ │
│ │ [查看详情] [立即购买] │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
```
### 英文界面
```
┌─────────────────────────────────┐
│ Found 3 products │
├─────────────────────────────────┤
│ ┌─────────────────────────────┐ │
│ │ [Image] │ │
│ │ Product Name │ │
│ │ Special: €89.99 | Stock: 100 │ │
│ │ [View Details] [Buy Now] │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
```
## 多语言支持
商品卡片支持以下语言:
| 语言 | 代码 | 示例描述 |
|------|------|----------|
| 中文 | zh | 特价: €89.99 \| 库存: 100 |
| 英语 | en | Special: €89.99 \| Stock: 100 |
| 荷兰语 | nl | Aanbieding: €89.99 \| Voorraad: 100 |
| 德语 | de | Angebot: €89.99 \| Lager: 100 |
| 西班牙语 | es | Oferta: €89.99 \| Stock: 100 |
| 法语 | fr | Spécial: €89.99 \| Stock: 100 |
| 意大利语 | it | Offerta: €89.99 \| Stock: 100 |
| 土耳其语 | tr | Özel: €89.99 \| Stok: 100 |
## API 接口说明
### Mall API: 搜索 SPU 商品
**端点**: `GET /mall/api/spu`
**请求参数**:
```
keyword: 搜索关键词(商品名称、编号等)
pageSize: 每页数量(默认 60最大 100
page: 页码(默认 1
```
**请求头**:
```
Authorization: Bearer {user_token}
Content-Type: application/json
tenant-Id: 2
currency-code: EUR
language-id: 1
source: us.qa1.gaia888.com
```
**响应格式**:
```json
{
"code": 200,
"msg": "success",
"result": {
"list": [
{
"spuId": "12345",
"spuSn": "61607",
"productName": "Product Name",
"productImage": "https://...",
"price": "99.99",
"specialPrice": "89.99",
"stock": 100,
"salesCount": 50
}
],
"total": 156
}
}
```
## 配置要求
### 环境变量
`.env` 文件中配置:
```env
# 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
# 前端 URL用于生成商品详情链接
FRONTEND_URL=https://www.gaia888.com
```
### 必需条件
1. **用户认证**: 商品搜索需要用户登录,获取 JWT token
2. **Token 注入**: Agent 会自动从 Chatwoot webhook 中提取 `user_token`
3. **网络访问**: Agent 需要能够访问 Mall API`apicn.qa1.gaia888.com`
## 测试
### 1. 测试脚本
运行测试脚本(需要提供有效的 user token:
```bash
python test_product_search.py
```
### 2. 在 Chatwoot 中测试
1. 打开 Chatwoot 对话框
2. 发送搜索请求,例如:
- "搜索 61607"
- "我想找手机"
- "查找商品:电脑"
### 3. 查看 MCP 工具
访问 Product MCP 健康检查:
```bash
curl http://localhost:8004/health
```
预期响应:
```json
{
"status": "healthy",
"service": "product_mcp",
"version": "1.0.0"
}
```
## 故障排查
### 问题 1: 返回 "用户未登录"
**原因**: 缺少有效的 `user_token`
**解决方案**:
1. 确保用户已在 Chatwoot 中登录
2. 检查 webhook 是否正确提取 `user_token`
3. 查看日志:`docker-compose logs -f agent`
### 问题 2: 返回空商品列表
**原因**:
- 关键词不匹配
- Mall API 返回空结果
**解决方案**:
1. 尝试不同的关键词
2. 检查 Mall API 是否可访问
3. 查看 Mall API 响应日志
### 问题 3: 卡片无法显示
**原因**:
- 商品图片 URL 无效
- Chatwoot 不支持 cards 格式
**解决方案**:
1. 检查 `product_image` 字段是否为有效 URL
2. 验证 Chatwoot API 版本是否支持 cards
3. 查看 Chatwoot 集成日志
## 性能优化
### 已实现的优化
1. **分页限制**: 默认返回 60 个商品,避免数据过大
2. **用户认证**: 使用用户 token 而不是全局 API token更安全
3. **错误处理**: 优雅降级到文本响应
### 未来可优化
1. **缓存热门搜索**: 缓存常见关键词的搜索结果
2. **并行搜索**: 支持多关键词并行搜索
3. **智能推荐**: 基于搜索历史智能推荐
## 相关文件清单
| 文件 | 说明 |
|------|------|
| `mcp_servers/shared/mall_client.py` | Mall API 客户端(新增 SPU 搜索方法) |
| `mcp_servers/product_mcp/server.py` | Product MCP新增 SPU 搜索工具) |
| `agent/integrations/chatwoot.py` | Chatwoot 集成(新增商品卡片方法) |
| `agent/agents/product.py` | Product Agent新增卡片处理逻辑 |
| `docker-compose.yml` | 容器配置Product MCP 环境变量) |
## 版本历史
- **2026-01-26**: 初始版本
- 添加 Mall API SPU 搜索支持
- 添加 Chatwoot cards 商品展示
- 支持多语言商品卡片
- 集成用户认证
---
**文档版本**: 1.0
**最后更新**: 2026-01-26
**维护者**: Claude Code

View File

@@ -1,337 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>B2B AI 助手 - 调试版本</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.left-panel, .right-panel {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 10px;
grid-column: 1 / -1;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 30px;
grid-column: 1 / -1;
}
h2 {
color: #667eea;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
margin-top: 0;
}
.log-container {
background: #1e1e1e;
color: #00ff00;
padding: 15px;
border-radius: 5px;
height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.log-entry {
margin: 5px 0;
padding: 3px 0;
}
.log-info { color: #00ff00; }
.log-warn { color: #ffaa00; }
.log-error { color: #ff4444; }
.log-success { color: #44ff44; }
.status-box {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin: 10px 0;
border-radius: 5px;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #ddd;
}
.status-item:last-child {
border-bottom: none;
}
.status-label {
font-weight: bold;
color: #667eea;
}
.status-value {
color: #333;
}
.test-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin: 15px 0;
}
.test-btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.test-btn:hover {
background: #5568d3;
}
.test-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.clear-btn {
background: #ff4444;
}
.clear-btn:hover {
background: #dd3333;
}
</style>
</head>
<body>
<h1>🤖 B2B AI 智能客服助手 - 调试面板</h1>
<p class="subtitle">实时监控 Widget 状态和消息流</p>
<div class="container">
<div class="left-panel">
<h2>📊 连接状态</h2>
<div class="status-box">
<div class="status-item">
<span class="status-label">Chatwoot 服务:</span>
<span class="status-value" id="chatwootStatus">检查中...</span>
</div>
<div class="status-item">
<span class="status-label">Widget SDK:</span>
<span class="status-value" id="widgetStatus">未加载</span>
</div>
<div class="status-item">
<span class="status-label">WebSocket:</span>
<span class="status-value" id="wsStatus">未连接</span>
</div>
<div class="status-item">
<span class="status-label">当前会话:</span>
<span class="status-value" id="conversationId"></span>
</div>
<div class="status-item">
<span class="status-label">Website Token:</span>
<span class="status-value" id="websiteToken">39PNCMvbMk3NvB7uaDNucc6o</span>
</div>
</div>
<h2>🧪 测试操作</h2>
<div class="test-buttons">
<button class="test-btn" onclick="checkChatwootService()">检查服务</button>
<button class="test-btn" onclick="refreshWidget()">刷新 Widget</button>
<button class="test-btn" onclick="getConversationInfo()">获取会话信息</button>
<button class="test-btn clear-btn" onclick="clearLogs()">清除日志</button>
</div>
<h2>📝 快速测试问题(点击复制到剪贴板)</h2>
<div class="test-buttons">
<button class="test-btn" onclick="sendTestMessage('你好')">👋 你好</button>
<button class="test-btn" onclick="sendTestMessage('查询订单 202071324')">📦 查询订单</button>
<button class="test-btn" onclick="sendTestMessage('如何退货?')">❓ 如何退货</button>
<button class="test-btn" onclick="sendTestMessage('营业时间')">🕐 营业时间</button>
</div>
<p style="color: #666; font-size: 14px; margin-top: 10px;">
💡 提示:点击按钮后,在右下角聊天窗口中按 Ctrl+V 粘贴并发送
</p>
</div>
<div class="right-panel">
<h2>📋 实时日志</h2>
<div class="log-container" id="logContainer">
<div class="log-entry log-info">[系统] 日志系统已启动...</div>
</div>
</div>
</div>
<script>
const logContainer = document.getElementById('logContainer');
function addLog(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = `log-entry log-${type}`;
logEntry.textContent = `[${timestamp}] ${message}`;
logContainer.appendChild(logEntry);
logContainer.scrollTop = logContainer.scrollHeight;
}
function clearLogs() {
logContainer.innerHTML = '<div class="log-entry log-info">[系统] 日志已清除</div>';
}
// 检查 Chatwoot 服务
async function checkChatwootService() {
addLog('检查 Chatwoot 服务状态...', 'info');
const statusEl = document.getElementById('chatwootStatus');
try {
const response = await fetch('http://localhost:3000', { mode: 'no-cors' });
statusEl.textContent = '✅ 运行中';
statusEl.style.color = '#28a745';
addLog('✅ Chatwoot 服务运行正常', 'success');
} catch (error) {
statusEl.textContent = '❌ 无法访问';
statusEl.style.color = '#dc3545';
addLog(`❌ 无法连接到 Chatwoot: ${error.message}`, 'error');
}
}
// 发送测试消息 - 直接复制到剪贴板
function sendTestMessage(message) {
addLog(`📋 已复制消息到剪贴板: "${message}"`, 'info');
addLog('→ 请在右下角聊天窗口中粘贴并发送', 'warn');
// 复制到剪贴板
navigator.clipboard.writeText(message).then(() => {
// 可选:自动打开 Widget
if (window.$chatwoot && window.$chatwoot.toggle) {
try {
window.$chatwoot.toggle('open');
addLog('✅ 聊天窗口已打开', 'success');
} catch (e) {
addLog('⚠️ 无法自动打开聊天窗口', 'warn');
}
}
}).catch(err => {
addLog(`❌ 复制失败: ${err.message}`, 'error');
});
}
// 刷新 Widget
function refreshWidget() {
addLog('刷新 Widget...', 'info');
location.reload();
}
// 获取会话信息
function getConversationInfo() {
if (window.$chatwoot) {
try {
const info = window.$chatwoot.getConversationInfo();
addLog(`会话信息: ${JSON.stringify(info)}`, 'info');
} catch (error) {
addLog(`无法获取会话信息: ${error.message}`, 'warn');
}
}
}
// 页面加载时检查服务
window.addEventListener('load', function() {
setTimeout(checkChatwootService, 1000);
});
// ==================== Chatwoot Widget 配置 ====================
window.chatwootSettings = {
"position": "right",
"type": "expanded_bubble",
"launcherTitle": "Chat with us"
};
(function(d,t) {
var BASE_URL = "http://localhost:3000";
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
g.src = BASE_URL + "/packs/js/sdk.js";
g.async = true;
g.onload = function() {
addLog('Chatwoot SDK 文件已加载', 'success');
document.getElementById('widgetStatus').textContent = '✅ 已加载';
window.chatwootSDK.run({
websiteToken: '39PNCMvbMk3NvB7uaDNucc6o',
baseUrl: BASE_URL
});
addLog('Website Token: 39PNCMvbMk3NvB7uaDNucc6o', 'info');
addLog('Base URL: ' + BASE_URL, 'info');
// 监听 Widget 就绪事件
setTimeout(function() {
if (window.$chatwoot) {
addLog('✅ Chatwoot Widget 已初始化', 'success');
document.getElementById('wsStatus').textContent = '✅ 已连接';
// 设置用户信息(可选)
window.$chatwoot.setUser('debug_user_' + Date.now(), {
email: 'debug@example.com',
name: 'Debug User'
});
addLog('用户信息已设置', 'info');
} else {
addLog('❌ Widget 初始化失败', 'error');
document.getElementById('widgetStatus').textContent = '❌ 初始化失败';
}
}, 2000);
};
g.onerror = function() {
addLog('❌ Chatwoot SDK 加载失败', 'error');
document.getElementById('widgetStatus').textContent = '❌ 加载失败';
};
s.parentNode.insertBefore(g, s);
})(document, "script");
// 监听网络错误
window.addEventListener('error', function(e) {
if (e.message.includes('404')) {
addLog(`⚠️ 404 错误: ${e.filename}`, 'warn');
}
});
// 拦截 fetch 请求
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = args[0];
// 记录发送到 Chatwoot API 的请求
if (typeof url === 'string' && url.includes('localhost:3000')) {
const method = args[1]?.method || 'GET';
addLog(`API 请求: ${method} ${url}`, 'info');
}
return originalFetch.apply(this, args).then(response => {
// 记录错误响应
if (!response.ok && url.includes('localhost:3000')) {
addLog(`API 响应: ${response.status} ${response.statusText} - ${url}`, 'error');
}
return response;
});
};
addLog('调试系统已初始化', 'success');
</script>
<!-- Chatwoot Widget 会自动加载 -->
</body>
</html>

View File

@@ -1,262 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>B2B AI 助手 - 测试页面</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
background: white;
border-radius: 10px;
padding: 40px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 10px;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 30px;
}
.info-box {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px 20px;
margin: 20px 0;
border-radius: 5px;
}
.info-box h3 {
margin-top: 0;
color: #667eea;
}
.feature-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 30px 0;
}
.feature-card {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
text-align: center;
}
.feature-card h4 {
color: #667eea;
margin-bottom: 10px;
}
.test-questions {
background: #fff3cd;
border: 1px solid #ffc107;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.test-questions h3 {
margin-top: 0;
color: #856404;
}
.question-list {
list-style: none;
padding: 0;
}
.question-list li {
background: white;
margin: 10px 0;
padding: 12px 15px;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s;
border: 1px solid #ddd;
}
.question-list li:hover {
background: #667eea;
color: white;
transform: translateX(5px);
}
.status {
text-align: center;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
font-weight: bold;
}
.status.online {
background: #d4edda;
color: #155724;
}
.status.testing {
background: #fff3cd;
color: #856404;
}
</style>
</head>
<body>
<div class="container">
<h1>🤖 B2B AI 智能客服助手</h1>
<p class="subtitle">基于 LangGraph + MCP 的智能客服系统</p>
<div class="status online">
✅ 系统状态:所有服务运行正常
</div>
<div id="tokenStatus" class="status testing" style="display: none;">
🍪 Token 状态:检测中...
</div>
<div class="info-box">
<h3>📝 如何测试</h3>
<ol>
<li>点击右下角的聊天图标打开对话窗口</li>
<li>输入你的名字开始对话</li>
<li>尝试下面的问题测试 AI 能力</li>
<li>查看 AI 如何理解并回答你的问题</li>
</ol>
</div>
<div class="test-questions">
<h3>💬 推荐测试问题</h3>
<p style="color: #666; margin-bottom: 15px;">点击以下问题直接复制到聊天窗口:</p>
<ul class="question-list">
<li onclick="copyQuestion(this.textContent)">🕐 你们的营业时间是什么?</li>
<li onclick="copyQuestion(this.textContent)">📦 我的订单 202071324 怎么样了?</li>
<li onclick="copyQuestion(this.textContent)">🔍 查询订单 202071324</li>
<li onclick="copyQuestion(this.textContent)">📞 如何联系客服?</li>
<li onclick="copyQuestion(this.textContent)">🛍️ 我想退换货</li>
<li onclick="copyQuestion(this.textContent)">📦 订单 202071324 的物流信息</li>
</ul>
</div>
<div class="feature-list">
<div class="feature-card">
<h4>🎯 智能意图识别</h4>
<p>自动识别客户需求并分类</p>
</div>
<div class="feature-card">
<h4>📚 知识库查询</h4>
<p>快速检索 FAQ 和政策文档</p>
</div>
<div class="feature-card">
<h4>📦 订单管理</h4>
<p>查询订单、售后等服务</p>
</div>
<div class="feature-card">
<h4>🔄 多轮对话</h4>
<p>支持上下文理解的连续对话</p>
</div>
</div>
<div class="info-box">
<h3>🔧 技术栈</h3>
<ul>
<li><strong>前端:</strong>Chatwoot 客户支持平台</li>
<li><strong>AI 引擎:</strong>LangGraph + 智谱 AI (GLM-4.5)</li>
<li><strong>知识库:</strong>Strapi CMS + MCP</li>
<li><strong>业务系统:</strong>Hyperf PHP API</li>
<li><strong>缓存:</strong>Redis</li>
<li><strong>容器:</strong>Docker Compose</li>
</ul>
</div>
</div>
<script>
function copyQuestion(text) {
// 移除表情符号
const cleanText = text.replace(/^[^\s]+\s*/, '');
navigator.clipboard.writeText(cleanText).then(() => {
alert('问题已复制!请粘贴到聊天窗口中发送。');
});
}
// ==================== Cookie Token 读取 ====================
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(";").shift();
return null;
}
function checkToken() {
const token = getCookie('token');
const statusDiv = document.getElementById('tokenStatus');
if (token) {
statusDiv.style.display = 'block';
statusDiv.className = 'status online';
statusDiv.innerHTML = `✅ Token 已找到 | 长度: ${token.length} 字符 | 前缀: ${token.substring(0, 20)}...`;
// 存储到 window 供后续使用
window._chatwootUserToken = token;
console.log('✅ Token 已从 Cookie 读取');
} else {
statusDiv.style.display = 'block';
statusDiv.className = 'status testing';
statusDiv.innerHTML = '⚠️ 未找到 Token | 请确保已登录商城 | Cookie 名称: token';
console.warn('⚠️ 未找到 Token订单查询功能可能无法使用');
}
}
// 页面加载时检查 Token
window.addEventListener('load', function() {
setTimeout(checkToken, 1000);
});
</script>
<!-- Chatwoot Widget - 官方集成方式 -->
<script>
(function(d,t) {
var BASE_URL="http://localhost:3000";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: '39PNCMvbMk3NvB7uaDNucc6o',
baseUrl: BASE_URL,
locale: 'zh_CN',
userIdentifier: getCookie('token') || 'web_user_' + Date.now()
});
const userToken = getCookie('token');
console.log('✅ Chatwoot Widget 已加载 (官方集成方式)');
console.log('Base URL:', BASE_URL);
console.log('Website Token: 39PNCMvbMk3NvB7uaDNucc6o');
console.log('Locale: zh_CN');
console.log('User Identifier:', userToken || 'web_user_' + Date.now());
// 设置用户信息(可选)
setTimeout(function() {
const token = getCookie('token');
if (token && window.$chatwoot) {
window.$chatwoot.setUser('user_' + Date.now(), {
email: 'user@example.com',
name: 'Website User',
phone_number: ''
});
console.log('✅ 用户信息已设置');
} else if (!token) {
console.warn('⚠️ 未找到 Token');
}
}, 2000);
}
g.onerror=function(){
console.error('❌ Chatwoot SDK 加载失败');
console.error('请确保 Chatwoot 运行在: ' + BASE_URL);
}
})(document,"script");
</script>
</body>
</html>

View File

@@ -1,425 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>会话 ID 检查工具</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196F3;
padding: 15px 20px;
margin: 20px 0;
border-radius: 5px;
}
.info-box h3 {
margin-top: 0;
color: #2196F3;
}
.data-display {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 15px;
margin: 15px 0;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.data-label {
font-weight: bold;
color: #495057;
margin-bottom: 10px;
}
.data-value {
color: #212529;
background: white;
padding: 10px;
border-radius: 3px;
word-break: break-all;
}
button {
background: #2196F3;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
margin: 5px;
}
button:hover {
background: #0b7dda;
}
button.danger {
background: #dc3545;
}
button.danger:hover {
background: #c82333;
}
.instructions {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px 20px;
margin: 20px 0;
border-radius: 5px;
}
.instructions ol {
margin: 10px 0;
padding-left: 20px;
}
.instructions li {
margin: 8px 0;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<h1>🔍 Chatwoot 会话 ID 检查工具</h1>
<div class="instructions">
<h3>📝 使用说明</h3>
<ol>
<li>打开浏览器开发者工具(按 F12</li>
<li>切换到 Console控制台标签</li>
<li>点击下面的"显示会话信息"按钮</li>
<li>在 Console 中查看当前的 conversation_id</li>
<li>将这个 ID 与 Agent 日志中的 conversation_id 对比</li>
</ol>
</div>
<div class="info-box">
<h3>🎯 操作按钮</h3>
<button onclick="showConversationInfo()">显示会话信息</button>
<button onclick="checkWidgetStatus()">检查 Widget 状态</button>
<button onclick="checkToken()">检查 Token</button>
<button onclick="testOrderAPI()">测试订单 API</button>
<button onclick="clearLocalStorage()" class="danger">清除本地存储(重新开始)</button>
</div>
<div class="info-box">
<h3>📊 信息显示</h3>
<div class="data-display">
<div class="data-label">Widget SDK 状态:</div>
<div class="data-value" id="widgetStatus">未初始化</div>
</div>
<div class="data-display">
<div class="data-label">当前会话 ID:</div>
<div class="data-value" id="conversationId">未知</div>
</div>
<div class="data-display">
<div class="data-label">Token 状态:</div>
<div class="data-value" id="tokenStatus">未检查</div>
</div>
<div class="data-display">
<div class="data-label">订单 API 测试结果:</div>
<div class="data-value" id="orderApiResult">未测试</div>
</div>
<div class="data-display">
<div class="data-label">本地存储数据:</div>
<div class="data-value" id="localStorageData"></div>
</div>
</div>
<div class="instructions">
<h3>💡 问题排查</h3>
<p><strong>如果看不到 AI 回复:</strong></p>
<ol>
<li>点击"清除本地存储"按钮</li>
<li>刷新页面Ctrl+Shift+R</li>
<li>在右下角聊天窗口重新发送消息</li>
<li>查看 Agent 日志: <code>docker logs ai_agent --tail 50</code></li>
<li>对比 Console 中的 conversation_id 与日志中的是否一致</li>
</ol>
</div>
</div>
<script>
// 获取 Cookie 中的 token
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(";").shift();
return null;
}
// 检查 Token
function checkToken() {
console.log('======================================');
console.log('Token 检查');
console.log('======================================');
const token = getCookie('token');
const tokenStatusDiv = document.getElementById('tokenStatus');
if (token) {
console.log('✅ Token 已找到');
console.log('Token 长度:', token.length);
console.log('Token 前缀:', token.substring(0, 50) + '...');
tokenStatusDiv.textContent = `✅ 已找到 | 长度: ${token.length} | 前缀: ${token.substring(0, 30)}...`;
tokenStatusDiv.style.color = '#28a745';
} else {
console.log('❌ 未找到 Token');
console.log('Cookie 名称: token');
tokenStatusDiv.textContent = '❌ 未找到 | Cookie 名称: token';
tokenStatusDiv.style.color = '#dc3545';
}
console.log('所有 Cookie:', document.cookie);
console.log('======================================');
}
// 测试订单 API
async function testOrderAPI() {
console.log('======================================');
console.log('测试订单 API');
console.log('======================================');
const token = getCookie('token');
const resultDiv = document.getElementById('orderApiResult');
if (!token) {
console.error('❌ 未找到 Token无法调用 API');
resultDiv.textContent = '❌ 未找到 Token';
resultDiv.style.color = '#dc3545';
alert('❌ 未找到 Token请先确保已登录商城');
return;
}
const orderId = '202071324';
const apiUrl = `https://apicn.qa1.gaia888.com/mall/api/order/show?orderId=${orderId}`;
console.log('API URL:', apiUrl);
console.log('Authorization:', `Bearer ${token.substring(0, 30)}...`);
resultDiv.textContent = '🔄 请求中...';
resultDiv.style.color = '#ffc107';
try {
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'accept': 'application/json, text/plain, */*',
'accept-language': 'zh-CN,zh;q=0.9',
'authorization': `Bearer ${token}`,
'currency-code': 'EUR',
'device-type': 'pc',
'language-id': '1',
'origin': 'https://www.qa1.gaia888.com',
'referer': 'https://www.qa1.gaia888.com/',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'source': 'us.qa1.gaia888.com',
'tenant-id': '2'
}
});
console.log('响应状态:', response.status);
console.log('响应头:', Object.fromEntries(response.headers.entries()));
if (response.ok) {
const data = await response.json();
console.log('✅ API 调用成功');
console.log('响应数据:', data);
resultDiv.textContent = `✅ 成功 (HTTP ${response.status}) | 订单 ${orderId}`;
resultDiv.style.color = '#28a745';
alert(`✅ 订单 API 调用成功!\n\n订单 ID: ${orderId}\n状态码: ${response.status}\n\n详细数据请查看控制台`);
} else {
const errorText = await response.text();
console.error('❌ API 调用失败');
console.error('状态码:', response.status);
console.error('响应内容:', errorText);
resultDiv.textContent = `❌ 失败 (HTTP ${response.status})`;
resultDiv.style.color = '#dc3545';
alert(`❌ 订单 API 调用失败\n\n状态码: ${response.status}\n错误: ${errorText}`);
}
} catch (error) {
console.error('❌ 网络错误:', error);
resultDiv.textContent = `❌ 网络错误: ${error.message}`;
resultDiv.style.color = '#dc3545';
alert(`❌ 网络错误\n\n${error.message}`);
}
console.log('======================================');
}
function showConversationInfo() {
console.log('======================================');
console.log('Chatwoot Widget 会话信息');
console.log('======================================');
if (window.$chatwoot) {
try {
// 尝试获取会话信息
const info = window.$chatwoot.getConversationInfo();
console.log('✅ 会话信息:', info);
document.getElementById('conversationId').textContent =
info && info.conversationId ? info.conversationId : '无法获取';
} catch (e) {
console.log('⚠️ 无法获取会话信息:', e.message);
document.getElementById('conversationId').textContent = '无法获取';
}
} else {
console.log('❌ Widget 未初始化');
document.getElementById('conversationId').textContent = 'Widget 未初始化';
}
// 显示本地存储
const storage = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.includes('chatwoot') || key.includes('widget')) {
storage[key] = localStorage.getItem(key);
}
}
console.log('本地存储 (Chatwoot 相关):', storage);
document.getElementById('localStorageData').textContent =
Object.keys(storage).length > 0 ? JSON.stringify(storage, null, 2) : '无';
console.log('======================================');
}
function checkWidgetStatus() {
console.log('======================================');
console.log('Widget 状态检查');
console.log('======================================');
console.log('window.$chatwoot:', window.$chatwoot);
console.log('window.chatwootSDK:', window.chatwootSDK);
if (window.$chatwoot) {
console.log('✅ Widget 已加载');
console.log('可用方法:', Object.getOwnPropertyNames(window.$chatwoot));
document.getElementById('widgetStatus').textContent = '✅ 已加载';
document.getElementById('widgetStatus').style.color = '#28a745';
} else {
console.log('❌ Widget 未加载');
document.getElementById('widgetStatus').textContent = '❌ 未加载';
document.getElementById('widgetStatus').style.color = '#dc3545';
}
console.log('======================================');
}
function clearLocalStorage() {
if (confirm('确定要清除所有本地存储吗?这将重置会话。')) {
// 清除 Chatwoot 相关的本地存储
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.includes('chatwoot') || key.includes('widget')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
console.log(`✅ 已清除 ${keysToRemove.length} 个本地存储项`);
console.log('清除的键:', keysToRemove);
alert(`✅ 已清除 ${keysToRemove.length} 个本地存储项\n\n页面将重新加载以创建新的会话。`);
location.reload();
}
}
// 页面加载时显示本地存储和检查 Token
window.addEventListener('load', function() {
// 显示本地存储
const storage = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.includes('chatwoot') || key.includes('widget')) {
storage[key] = localStorage.getItem(key);
}
}
if (Object.keys(storage).length > 0) {
document.getElementById('localStorageData').textContent = JSON.stringify(storage, null, 2);
}
// 自动检查 Token
setTimeout(checkToken, 500);
});
</script>
<!-- Chatwoot Widget -->
<script>
(function(d,t) {
var BASE_URL="http://localhost:3000";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
// 获取 token 函数
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(";").shift();
return null;
}
const token = getCookie('token');
// 初始化配置
const widgetConfig = {
websiteToken: '39PNCMvbMk3NvB7uaDNucc6o',
baseUrl: BASE_URL,
locale: 'zh_CN',
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('Locale: zh_CN');
console.log('User Identifier:', token || 'web_user_' + Date.now());
document.getElementById('widgetStatus').textContent = '✅ 已加载';
document.getElementById('widgetStatus').style.color = '#28a745';
}
})(document,"script");
</script>
</body>
</html>

View File

@@ -1,261 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>B2B AI 助手 - 简化测试</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
background: white;
border-radius: 10px;
padding: 40px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 10px;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 30px;
}
.info-box {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px 20px;
margin: 20px 0;
border-radius: 5px;
}
.info-box h3 {
margin-top: 0;
color: #667eea;
}
.test-questions {
background: #fff3cd;
border: 1px solid #ffc107;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.test-questions h3 {
margin-top: 0;
color: #856404;
}
.question-list {
list-style: none;
padding: 0;
}
.question-list li {
background: white;
margin: 10px 0;
padding: 12px 15px;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s;
border: 1px solid #ddd;
}
.question-list li:hover {
background: #667eea;
color: white;
transform: translateX(5px);
}
.status {
text-align: center;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
font-weight: bold;
}
.status.online {
background: #d4edda;
color: #155724;
}
.instructions {
background: #e7f3ff;
border-left: 4px solid #2196F3;
padding: 15px 20px;
margin: 20px 0;
border-radius: 5px;
}
.instructions ol {
margin: 10px 0;
padding-left: 20px;
}
.instructions li {
margin: 8px 0;
line-height: 1.6;
}
.feature-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 30px 0;
}
.feature-card {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
text-align: center;
}
.feature-card h4 {
color: #667eea;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>🤖 B2B AI 智能客服助手</h1>
<p class="subtitle">简化测试页面 - Chatwoot 官方集成方式</p>
<div class="status online">
✅ 系统状态:使用官方标准集成
</div>
<div class="instructions">
<h3>📝 使用说明</h3>
<ol>
<li><strong>点击右下角的聊天图标</strong>打开 Chatwoot 对话窗口</li>
<li><strong>输入消息</strong>开始与 AI 对话</li>
<li><strong>或者</strong>点击下面的测试问题,复制后在聊天窗口粘贴发送</li>
<li><strong>查看 AI 如何理解和回答</strong>你的问题</li>
</ol>
</div>
<div class="test-questions">
<h3>💬 推荐测试问题</h3>
<p style="color: #666; margin-bottom: 15px;">点击以下问题复制到剪贴板然后在聊天窗口粘贴Ctrl+V并发送</p>
<ul class="question-list">
<li onclick="copyQuestion(this.textContent)">🕐 你们的营业时间是什么?</li>
<li onclick="copyQuestion(this.textContent)">📦 我的订单 202071324 怎么样了?</li>
<li onclick="copyQuestion(this.textContent)">🔍 查询订单 202071324</li>
<li onclick="copyQuestion(this.textContent)">📞 如何联系客服?</li>
<li onclick="copyQuestion(this.textContent)">🛍️ 我想退换货</li>
<li onclick="copyQuestion(this.textContent)">📦 订单 202071324 的物流信息</li>
</ul>
</div>
<div class="info-box">
<h3>🔧 技术栈</h3>
<ul>
<li><strong>前端:</strong>Chatwoot 客户支持平台(官方 Widget SDK</li>
<li><strong>AI 引擎:</strong>LangGraph + 智谱 AI (GLM-4.5)</li>
<li><strong>知识库:</strong>Strapi CMS + MCP</li>
<li><strong>业务系统:</strong>Hyperf PHP API</li>
<li><strong>缓存:</strong>Redis</li>
<li><strong>容器:</strong>Docker Compose</li>
</ul>
</div>
<div class="feature-list">
<div class="feature-card">
<h4>🎯 智能意图识别</h4>
<p>自动识别客户需求并分类</p>
</div>
<div class="feature-card">
<h4>📚 知识库查询</h4>
<p>快速检索 FAQ 和政策文档</p>
</div>
<div class="feature-card">
<h4>📦 订单管理</h4>
<p>查询订单、售后等服务</p>
</div>
<div class="feature-card">
<h4>🔄 多轮对话</h4>
<p>支持上下文理解的连续对话</p>
</div>
</div>
<div class="info-box">
<h3>📊 系统信息</h3>
<p><strong>Chatwoot 服务:</strong>http://localhost:3000</p>
<p><strong>Website Token</strong>39PNCMvbMk3NvB7uaDNucc6o</p>
<p><strong>集成方式:</strong>Chatwoot 官方 SDK</p>
</div>
</div>
<script>
function copyQuestion(text) {
// 移除表情符号
const cleanText = text.replace(/^[^\s]+\s*/, '');
navigator.clipboard.writeText(cleanText).then(() => {
alert('✅ 问题已复制到剪贴板!\n\n请在聊天窗口中按 Ctrl+V 粘贴并发送。');
}).catch(err => {
console.error('复制失败:', err);
alert('❌ 复制失败,请手动复制问题文本。');
});
}
// ==================== Cookie Token 读取 ====================
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(";").shift();
return null;
}
// 页面加载时检查 Token
window.addEventListener('load', function() {
const token = getCookie('token');
if (token) {
console.log('✅ Token 已从 Cookie 读取');
console.log('Token 长度:', token.length);
} else {
console.warn('⚠️ 未找到 Token订单查询功能可能无法使用');
}
});
</script>
<!-- Chatwoot Widget - 官方集成方式 -->
<script>
(function(d,t) {
var BASE_URL="http://localhost:3000";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: '39PNCMvbMk3NvB7uaDNucc6o',
baseUrl: BASE_URL
});
console.log('✅ Chatwoot Widget 已加载 (官方集成方式)');
console.log('Base URL:', BASE_URL);
console.log('Website Token: 39PNCMvbMk3NvB7uaDNucc6o');
// 设置用户信息(可选)
setTimeout(function() {
const token = getCookie('token');
if (token && window.$chatwoot) {
window.$chatwoot.setUser('user_' + Date.now(), {
email: 'user@example.com',
name: 'Website User',
phone_number: ''
});
console.log('✅ 用户信息已设置');
} else if (!token) {
console.warn('⚠️ 未找到 Token');
}
}, 2000);
}
g.onerror=function(){
console.error('❌ Chatwoot SDK 加载失败');
console.error('请确保 Chatwoot 运行在: ' + BASE_URL);
}
})(document,"script");
</script>
</body>
</html>

View File

@@ -494,7 +494,7 @@ async def get_mall_order_list(
date_added: Optional[str] = None,
date_end: Optional[str] = None,
no: Optional[str] = None,
status: Optional[int] = None,
status: int = 10000,
is_drop_shopping: int = 0
) -> dict:
"""Query order list from Mall API with filters
@@ -517,7 +517,7 @@ async def get_mall_order_list(
date_added: 开始日期,格式 YYYY-MM-DD (default: None)
date_end: 结束日期,格式 YYYY-MM-DD (default: None)
no: 订单号筛选 (default: None)
status: 订单状态筛选 (default: None, None表示全部状态)
status: 订单状态筛选 (default: 10000, 10000表示全部状态)
is_drop_shopping: 是否代发货 (default: 0)
Returns:
@@ -673,9 +673,14 @@ if __name__ == "__main__":
tool_obj = _tools[tool_name]
# Call the tool with arguments
# Filter arguments to only include parameters expected by the tool
# Get parameter names from tool's parameters schema
tool_params = tool_obj.parameters.get('properties', {})
filtered_args = {k: v for k, v in arguments.items() if k in tool_params}
# Call the tool with filtered arguments
# FastMCP FunctionTool.run() takes a dict of arguments
tool_result = await tool_obj.run(arguments)
tool_result = await tool_obj.run(filtered_args)
# Extract content from ToolResult
# ToolResult.content is a list of TextContent objects with a 'text' attribute

View File

@@ -3,15 +3,13 @@ Product MCP Server - Product search, recommendations, and quotes
"""
import sys
import os
from typing import Optional, List
from typing import Optional, List, Dict, Any
# Add shared module to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from fastmcp import FastMCP
from pydantic_settings import BaseSettings
from pydantic import ConfigDict
@@ -19,6 +17,11 @@ class Settings(BaseSettings):
"""Server configuration"""
hyperf_api_url: str
hyperf_api_token: str
mall_api_url: str
mall_tenant_id: str = "2"
mall_currency_code: str = "EUR"
mall_language_id: str = "1"
mall_source: str = "us.qa1.gaia888.com"
log_level: str = "INFO"
model_config = ConfigDict(env_file=".env")
@@ -31,74 +34,24 @@ mcp = FastMCP(
"Product Service"
)
# Tool registry for HTTP access
_tools: Dict[str, Any] = {}
def register_tool(name: str):
"""Decorator to register tool in _tools dict"""
def decorator(func):
_tools[name] = func
return func
return decorator
# Hyperf client for this server
from shared.hyperf_client import HyperfClient
hyperf = HyperfClient(settings.hyperf_api_url, settings.hyperf_api_token)
@mcp.tool()
async def search_products(
query: str,
category: Optional[str] = None,
brand: Optional[str] = None,
price_min: Optional[float] = None,
price_max: Optional[float] = None,
sort: str = "relevance",
page: int = 1,
page_size: int = 20
) -> dict:
"""Search products
Args:
query: Search keywords
category: Category filter
brand: Brand filter
price_min: Minimum price filter
price_max: Maximum price filter
sort: Sort order (relevance, price_asc, price_desc, sales, latest)
page: Page number (default: 1)
page_size: Items per page (default: 20)
Returns:
List of matching products
"""
payload = {
"query": query,
"sort": sort,
"page": page,
"page_size": page_size,
"filters": {}
}
if category:
payload["filters"]["category"] = category
if brand:
payload["filters"]["brand"] = brand
if price_min is not None or price_max is not None:
payload["filters"]["price_range"] = {}
if price_min is not None:
payload["filters"]["price_range"]["min"] = price_min
if price_max is not None:
payload["filters"]["price_range"]["max"] = price_max
try:
result = await hyperf.post("/products/search", json=payload)
return {
"success": True,
"products": result.get("products", []),
"total": result.get("total", 0),
"pagination": result.get("pagination", {})
}
except Exception as e:
return {
"success": False,
"error": str(e),
"products": []
}
@register_tool("get_product_detail")
@mcp.tool()
async def get_product_detail(
product_id: str
@@ -126,54 +79,124 @@ async def get_product_detail(
}
@register_tool("recommend_products")
@mcp.tool()
async def recommend_products(
user_id: str,
account_id: str,
context: Optional[dict] = None,
strategy: str = "hybrid",
limit: int = 10
user_token: str,
page: int = 1,
page_size: int = 6,
warehouse_id: int = 2
) -> dict:
"""Get personalized product recommendations
"""Get recommended products from Mall API Love List
从 Mall API 获取推荐商品列表(心动清单/猜你喜欢)
Args:
user_id: User identifier
account_id: B2B account identifier
context: Optional context for recommendations:
- current_query: Current search query
- recent_views: List of recently viewed product IDs
- cart_items: Items in cart
strategy: Recommendation strategy (collaborative, content_based, hybrid)
limit: Maximum recommendations to return (default: 10)
user_token: User JWT token for authentication
page: Page number (default: 1)
page_size: Number of products per page (default: 6, max 100)
warehouse_id: Warehouse ID (default: 2)
Returns:
List of recommended products with reasons
List of recommended products with product details
"""
payload = {
"user_id": user_id,
"account_id": account_id,
"strategy": strategy,
"limit": limit
}
if context:
payload["context"] = context
try:
result = await hyperf.post("/products/recommend", json=payload)
from shared.mall_client import MallClient
import logging
logger = logging.getLogger(__name__)
logger.info(f"recommend_products called: page={page}, page_size={page_size}, warehouse_id={warehouse_id}")
# 创建 Mall 客户端(使用 user_token
mall = MallClient(
api_url=settings.mall_api_url,
api_token=user_token, # 使用用户的 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 mall.get_love_list(
page=page,
page_size=page_size,
warehouse_id=warehouse_id
)
logger.info(
f"Mall API love list returned: result_type={type(result).__name__}, "
f"result_keys={list(result.keys()) if isinstance(result, dict) else 'not a dict'}, "
f"total={result.get('total', 'N/A') if isinstance(result, dict) else 'N/A'}"
)
# 解析返回结果
# Mall API 实际返回结构: {"total": X, "data": {"data": [...]}}
if isinstance(result, dict) and "data" in result:
data = result.get("data", {})
if isinstance(data, dict) and "data" in data:
products_list = data.get("data", [])
else:
products_list = []
total = result.get("total", 0)
elif isinstance(result, dict) and "list" in result:
# 兼容可能的 list 结构
products_list = result.get("list", [])
total = result.get("total", 0)
else:
products_list = []
total = 0
logger.info(f"Extracted {len(products_list)} products from love list, total={total}")
# 格式化商品数据(与 search_products 格式一致)
formatted_products = []
for product in products_list:
formatted_products.append({
"spu_id": product.get("spuId"),
"spu_sn": product.get("spuSn"),
"product_name": product.get("spuName"),
"product_image": product.get("masterImage"),
"price": product.get("price"),
"special_price": product.get("specialPrice"),
"stock": product.get("stockDescribe"),
"sales_count": product.get("salesCount", 0),
# 额外有用字段
"href": product.get("href"),
"spu_type": product.get("spuType"),
"spu_type_name": product.get("spuTypeName"),
"min_price": product.get("minPrice"),
"max_price": product.get("maxPrice"),
"price_with_currency": product.get("priceWithCurrency"),
"mark_price": product.get("markPrice"),
"skus_count": len(product.get("skus", []))
})
logger.info(f"Formatted {len(formatted_products)} products for recommendation")
return {
"success": True,
"recommendations": result.get("recommendations", [])
"products": formatted_products,
"total": total,
"keyword": "recommendation"
}
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"recommend_products failed: {str(e)}", exc_info=True)
return {
"success": False,
"error": str(e),
"recommendations": []
"products": [],
"total": 0
}
finally:
# 关闭客户端
if 'mall' in dir():
await mall.close()
@register_tool("get_quote")
@mcp.tool()
async def get_quote(
product_id: str,
@@ -233,6 +256,7 @@ async def get_quote(
}
@register_tool("check_inventory")
@mcp.tool()
async def check_inventory(
product_ids: List[str],
@@ -266,6 +290,7 @@ async def check_inventory(
}
@register_tool("get_categories")
@mcp.tool()
async def get_categories() -> dict:
"""Get product category tree
@@ -288,7 +313,234 @@ async def get_categories() -> dict:
}
@register_tool("search_products")
@mcp.tool()
async def search_products(
keyword: str,
page_size: int = 6,
page: int = 1
) -> dict:
"""Search products from Mall API
从 Mall API 搜索商品 SPU根据关键词
Args:
keyword: 搜索关键词(商品名称、编号等)
page_size: 每页数量 (default: 6, max 100)
page: 页码 (default: 1)
Returns:
商品列表,包含 SPU 信息、商品图片、价格等
Product list including SPU ID, name, image, price, etc.
"""
try:
from shared.mall_client import MallClient
import logging
logger = logging.getLogger(__name__)
logger.info(f"search_products called with keyword={keyword}")
print(f"[DEBUG] search_products called: keyword={keyword}")
# 创建 Mall 客户端(无需 token
mall = MallClient(
api_url=settings.mall_api_url,
api_token=None, # 不需要 token
tenant_id=settings.mall_tenant_id,
currency_code=settings.mall_currency_code,
language_id=settings.mall_language_id,
source=settings.mall_source
)
print(f"[DEBUG] Calling Mall API: keyword={keyword}, page_size={page_size}, page={page}")
result = await mall.search_spu_products(
keyword=keyword,
page_size=page_size,
page=page
)
logger.info(
f"Mall API returned: result_type={type(result).__name__}, "
f"result_keys={list(result.keys()) if isinstance(result, dict) else 'not a dict'}, "
f"total={result.get('total', 'N/A') if isinstance(result, dict) else 'N/A'}, "
f"data_length={len(result.get('data', {}).get('data', [])) if isinstance(result, dict) and isinstance(result.get('data'), dict) else 'N/A'}"
)
print(f"[DEBUG] Mall API returned: total={result.get('total', 'N/A')}, data_keys={list(result.get('data', {}).keys()) if isinstance(result.get('data'), dict) else 'N/A'}")
# 详细输出商品数据
if "data" in result and isinstance(result["data"], dict):
products = result["data"].get("data", [])
print(f"[DEBUG] Found {len(products)} products in response")
for i, p in enumerate(products[:3]): # 只打印前3个
print(f"[DEBUG] Product {i+1}: spuId={p.get('spuId')}, spuName={p.get('spuName')}, price={p.get('price')}")
else:
products = result.get("list", [])
print(f"[DEBUG] Found {len(products)} products in list")
total = result.get("total", 0)
print(f"[DEBUG] Total products: {total}")
# 解析返回结果
# Mall API 返回结构: {"total": X, "data": {"data": [...], ...}}
if "data" in result and isinstance(result["data"], dict):
products = result["data"].get("data", [])
else:
products = result.get("list", [])
total = result.get("total", 0)
# 格式化商品数据
formatted_products = []
for product in products:
formatted_products.append({
"spu_id": product.get("spuId"),
"spu_sn": product.get("spuSn"),
"product_name": product.get("spuName"), # 修正字段名
"product_image": product.get("masterImage"), # 修正字段名
"price": product.get("price"),
"special_price": product.get("specialPrice"),
"stock": product.get("stockDescribe"), # 修正字段名
"sales_count": product.get("salesCount", 0),
# 额外有用字段
"href": product.get("href"),
"spu_type": product.get("spuType"),
"spu_type_name": product.get("spuTypeName"),
"min_price": product.get("minPrice"),
"max_price": product.get("maxPrice"),
"price_with_currency": product.get("priceWithCurrency"),
"mark_price": product.get("markPrice"),
"skus_count": len(product.get("skus", []))
})
return {
"success": True,
"products": formatted_products,
"total": total,
"keyword": keyword
}
except Exception as e:
return {
"success": False,
"error": str(e),
"products": [],
"total": 0
}
finally:
# 关闭客户端
if 'client' in dir() and 'mall' in dir():
await mall.close()
@register_tool("search_products_by_image")
@mcp.tool()
async def search_products_by_image(
image_url: str,
page_size: int = 6,
page: int = 1
) -> dict:
"""Search products by image from Mall API
从 Mall API 根据图片搜索商品 SPU以图搜图
Args:
image_url: 图片 URL需要 URL 编码)
page_size: 每页数量 (default: 6, max 100)
page: 页码 (default: 1)
Returns:
商品列表,包含 SPU 信息、商品图片、价格等
Product list including SPU ID, name, image, price, etc.
"""
try:
from shared.mall_client import MallClient
import logging
logger = logging.getLogger(__name__)
logger.info(f"search_products_by_image called: image_url={image_url}")
# 创建 Mall 客户端(不需要 token
mall = MallClient(
api_url=settings.mall_api_url,
api_token=None, # 图片搜索不需要 token
tenant_id=settings.mall_tenant_id,
currency_code=settings.mall_currency_code,
language_id=settings.mall_language_id,
source=settings.mall_source
)
# 调用 Mall API 图片搜索接口
# 注意httpx 会自动对 params 进行 URL 编码,不需要手动编码
result = await mall.get(
"/mall/api/spu",
params={
"pageSize": min(page_size, 100),
"page": page,
"searchImageUrl": image_url
}
)
logger.info(
f"Mall API image search returned: result_type={type(result).__name__}, "
f"result_keys={list(result.keys()) if isinstance(result, dict) else 'not a dict'}, "
f"total={result.get('total', 'N/A') if isinstance(result, dict) else 'N/A'}"
)
# 解析返回结果
# Mall API 返回结构: {"total": X, "data": {"data": [...]}}
if "data" in result and isinstance(result["data"], dict):
products = result["data"].get("data", [])
else:
products = result.get("list", [])
total = result.get("total", 0)
# 格式化商品数据(与 search_products 格式一致)
formatted_products = []
for product in products:
formatted_products.append({
"spu_id": product.get("spuId"),
"spu_sn": product.get("spuSn"),
"product_name": product.get("spuName"),
"product_image": product.get("masterImage"),
"price": product.get("price"),
"special_price": product.get("specialPrice"),
"stock": product.get("stockDescribe"),
"sales_count": product.get("salesCount", 0),
# 额外有用字段
"href": product.get("href"),
"spu_type": product.get("spuType"),
"spu_type_name": product.get("spuTypeName"),
"min_price": product.get("minPrice"),
"max_price": product.get("maxPrice"),
"price_with_currency": product.get("priceWithCurrency"),
"mark_price": product.get("markPrice"),
"skus_count": len(product.get("skus", []))
})
logger.info(f"Formatted {len(formatted_products)} products from image search")
return {
"success": True,
"products": formatted_products,
"total": total,
"keyword": "image_search",
"image_url": image_url
}
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"search_products_by_image failed: {str(e)}", exc_info=True)
return {
"success": False,
"error": str(e),
"products": [],
"total": 0
}
finally:
# 关闭客户端
if 'mall' in dir():
await mall.close()
# Health check endpoint
@register_tool("health_check")
@mcp.tool()
async def health_check() -> dict:
"""Check server health status"""
@@ -301,17 +553,96 @@ async def health_check() -> dict:
if __name__ == "__main__":
import uvicorn
# Create FastAPI app from MCP
app = mcp.http_app()
# Add health endpoint
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.requests import Request
# Custom tool execution endpoint
async def execute_tool(request: Request):
"""Execute an MCP tool via HTTP"""
tool_name = request.path_params["tool_name"]
try:
# Get arguments from request body
arguments = await request.json()
print(f"[DEBUG HTTP] Tool: {tool_name}, Args: {arguments}")
# Get tool function from registry
if tool_name not in _tools:
print(f"[ERROR] Tool '{tool_name}' not found in registry")
return JSONResponse({
"success": False,
"error": f"Tool '{tool_name}' not found"
}, status_code=404)
tool_obj = _tools[tool_name]
print(f"[DEBUG HTTP] Tool object: {tool_obj}, type: {type(tool_obj)}")
# Filter arguments to only include parameters expected by the tool
# Get parameter names from tool's parameters schema
tool_params = tool_obj.parameters.get('properties', {})
filtered_args = {k: v for k, v in arguments.items() if k in tool_params}
if len(filtered_args) < len(arguments):
print(f"[DEBUG HTTP] Filtered arguments: {arguments} -> {filtered_args}")
# Call the tool with filtered arguments
# FastMCP FunctionTool.run() takes a dict of arguments
print(f"[DEBUG HTTP] Calling tool.run()...")
tool_result = await tool_obj.run(filtered_args)
print(f"[DEBUG HTTP] Tool result: {tool_result}")
# Extract content from ToolResult
# ToolResult.content is a list of TextContent objects with a 'text' attribute
if tool_result.content and len(tool_result.content) > 0:
content = tool_result.content[0].text
# Try to parse as JSON if possible
try:
import json
result = json.loads(content)
except:
result = content
else:
result = None
return JSONResponse({
"success": True,
"result": result
})
except TypeError as e:
print(f"[ERROR] TypeError: {e}")
import traceback
traceback.print_exc()
return JSONResponse({
"success": False,
"error": f"Invalid arguments: {str(e)}"
}, status_code=400)
except Exception as e:
print(f"[ERROR] Exception: {e}")
import traceback
traceback.print_exc()
return JSONResponse({
"success": False,
"error": str(e)
}, status_code=500)
# Health check endpoint
async def health_check(request):
return JSONResponse({"status": "healthy"})
# Add the route to the app
from starlette.routing import Route
app.router.routes.append(Route('/health', health_check, methods=['GET']))
# Create routes list
routes = [
Route('/health', health_check, methods=['GET']),
Route('/tools/{tool_name}', execute_tool, methods=['POST'])
]
# Create app from MCP with custom routes
app = mcp.http_app()
# Add our custom routes to the existing app
for route in routes:
app.router.routes.append(route)
uvicorn.run(app, host="0.0.0.0", port=8004)

View File

@@ -55,7 +55,6 @@ class MallClient:
"""
if self._client is None:
default_headers = {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Device-Type": "pc",
@@ -70,6 +69,10 @@ class MallClient:
"DNT": "1",
}
# 只有在有 token 时才添加 Authorization header
if self.api_token:
default_headers["Authorization"] = f"Bearer {self.api_token}"
# 合并额外的 headers用于 Authorization2 等)
if extra_headers:
default_headers.update(extra_headers)
@@ -131,6 +134,14 @@ class MallClient:
json=json,
headers=request_headers
)
# Debug logging for product search
if "/spu" in endpoint:
print(f"[DEBUG MallClient] Request: {method} {endpoint}")
print(f"[DEBUG MallClient] Params: {params}")
print(f"[DEBUG MallClient] Response URL: {response.url}")
print(f"[DEBUG MallClient] Response Status: {response.status_code}")
response.raise_for_status()
data = response.json()
@@ -197,14 +208,14 @@ class MallClient:
async def get_order_list(
self,
page: int = 1,
limit: int = 10,
limit: int = 5,
customer_id: int = 0,
order_types: Optional[list[int]] = None,
shipping_status: int = 10000,
date_added: Optional[str] = None,
date_end: Optional[str] = None,
no: Optional[str] = None,
status: Optional[int] = None,
status: int = 10000,
is_drop_shopping: int = 0
) -> dict[str, Any]:
"""Query order list with filters
@@ -213,14 +224,14 @@ class MallClient:
Args:
page: 页码 (default: 1)
limit: 每页数量 (default: 10)
limit: 每页数量 (default: 5)
customer_id: 客户ID (default: 0)
order_types: 订单类型数组,如 [1, 2] (default: None)
shipping_status: 物流状态 (default: 10000)
shipping_status: 物流状态 (default: 10000, 10000表示全部状态)
date_added: 开始日期,格式 YYYY-MM-DD (default: None)
date_end: 结束日期,格式 YYYY-MM-DD (default: None)
no: 订单号 (default: None)
status: 订单状态 (default: None)
status: 订单状态 (default: 10000, 10000表示全部状态)
is_drop_shopping: 是否代发货 (default: 0)
Returns:
@@ -239,6 +250,7 @@ class MallClient:
"limit": limit,
"customerId": customer_id,
"shippingStatus": shipping_status,
"status": status,
"isDropShopping": is_drop_shopping
}
@@ -253,8 +265,6 @@ class MallClient:
params["dateEnd"] = date_end
if no:
params["no"] = no
if status is not None:
params["status"] = status
result = await self.get(
"/mall/api/order/list",
@@ -265,6 +275,85 @@ class MallClient:
except Exception as e:
raise Exception(f"查询订单列表失败 (Query order list failed): {str(e)}")
# ============ Product APIs ============
async def search_spu_products(
self,
keyword: str,
page_size: int = 5,
page: int = 1
) -> dict[str, Any]:
"""Search SPU products by keyword
根据关键词搜索商品 SPU
Args:
keyword: 搜索关键词(商品名称、编号等)
page_size: 每页数量 (default: 5, max 100)
page: 页码 (default: 1)
Returns:
商品列表,包含 SPU 信息、商品图片、价格等
Product list including SPU info, images, prices, etc.
Example:
>>> client = MallClient()
>>> result = await client.search_spu_products("61607", page_size=60, page=1)
>>> print(f"找到 {len(result.get('list', []))} 个商品")
"""
try:
params = {
"pageSize": min(page_size, 100), # 限制最大 100
"page": page,
"keyword": keyword
}
result = await self.get(
"/mall/api/spu",
params=params
)
return result
except Exception as e:
raise Exception(f"搜索商品失败 (Search SPU products failed): {str(e)}")
async def get_love_list(
self,
page: int = 1,
page_size: int = 6,
warehouse_id: int = 2
) -> dict[str, Any]:
"""Get recommended products (Love List) from Mall API
获取推荐商品列表
Args:
page: Page number (default: 1)
page_size: Number of products per page (default: 6)
warehouse_id: Warehouse ID (default: 2)
Returns:
Dictionary containing product list and metadata
Example:
>>> client = MallClient()
>>> result = await client.get_love_list(page=1, page_size=6, warehouse_id=2)
>>> print(f"找到 {len(result.get('list', []))} 个推荐商品")
"""
try:
params = {
"page": page,
"pageSize": page_size,
"warehouseId": warehouse_id
}
result = await self.get(
"/mall/api/loveList",
params=params
)
return result
except Exception as e:
raise Exception(f"获取推荐商品失败 (Get love list failed): {str(e)}")
# Global Mall client instance
mall_client: Optional[MallClient] = None

95
scripts/backup-production.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/bin/bash
# B2B Shopping AI Assistant - Production Backup Script
# 生产环境备份脚本
set -e
# 配置
BACKUP_DIR="./backups"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=7
# 颜色输出
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
# 创建备份目录
mkdir -p "$BACKUP_DIR"
log_info "========================================"
log_info "生产环境备份 - $DATE"
log_info "========================================"
echo ""
# 1. 备份 Redis 数据
log_info "1. 备份 Redis 数据..."
docker run --rm \
-v ai_redis_data_prod:/data \
-v "$(pwd)/$BACKUP_DIR":/backup \
alpine tar czf /backup/redis-$DATE.tar.gz -C /data .
log_info "✅ Redis 数据备份完成: redis-$DATE.tar.gz"
# 2. 备份 Agent 日志
log_info "2. 备份 Agent 日志..."
docker run --rm \
-v ai_agent_logs_prod:/data \
-v "$(pwd)/$BACKUP_DIR":/backup \
alpine tar czf /backup/agent-logs-$DATE.tar.gz -C /data .
log_info "✅ Agent 日志备份完成: agent-logs-$DATE.tar.gz"
# 3. 备份 Grafana 配置(如果存在)
if docker volume inspect ai_grafana_data &> /dev/null; then
log_info "3. 备份 Grafana 配置..."
docker run --rm \
-v ai_grafana_data:/data \
-v "$(pwd)/$BACKUP_DIR":/backup \
alpine tar czf /backup/grafana-$DATE.tar.gz -C /data .
log_info "✅ Grafana 配置备份完成: grafana-$DATE.tar.gz"
fi
# 4. 备份环境变量文件
log_info "4. 备份环境变量文件..."
cp .env.production "$BACKUP_DIR/env-$DATE.backup"
chmod 600 "$BACKUP_DIR/env-$DATE.backup"
log_info "✅ 环境变量备份完成: env-$DATE.backup"
# 5. 清理旧备份
log_info "5. 清理 $RETENTION_DAYS 天前的旧备份..."
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +$RETENTION_DAYS -delete
find "$BACKUP_DIR" -name "env-*.backup" -mtime +$RETENTION_DAYS -delete
log_info "✅ 旧备份清理完成"
# 6. 生成备份清单
log_info "6. 生成备份清单..."
cat > "$BACKUP_DIR/manifest-$DATE.txt" << EOF
备份时间: $DATE
备份内容:
- Redis 数据: redis-$DATE.tar.gz
- Agent 日志: agent-logs-$DATE.tar.gz
- 环境变量: env-$DATE.backup
EOF
log_info "✅ 备份清单生成完成: manifest-$DATE.txt"
echo ""
log_info "========================================"
log_info "✅ 备份完成!"
log_info "========================================"
echo ""
log_info "备份文件位置: $BACKUP_DIR"
log_info ""
ls -lh "$BACKUP_DIR" | grep "$DATE"

View File

@@ -1,108 +0,0 @@
#!/bin/bash
# Chatwoot 配置诊断工具
echo "======================================"
echo "Chatwoot 配置诊断工具"
echo "======================================"
echo ""
# 检查是否提供了 API Token
if [ -z "$CHATWOOT_API_TOKEN" ]; then
echo "❌ 请先设置 CHATWOOT_API_TOKEN 环境变量"
echo ""
echo "获取方式:"
echo "1. 访问 http://localhost:3000"
echo "2. 登录后进入 Settings → Profile → Access Tokens"
echo "3. 创建一个新的 Access Token"
echo ""
echo "然后运行:"
echo " CHATWOOT_API_TOKEN=your_token $0"
echo ""
exit 1
fi
CHATWOOT_BASE_URL="http://localhost:3000"
ACCOUNT_ID="2"
echo "🔍 正在检查 Chatwoot 配置..."
echo ""
# 1. 检查服务是否运行
echo "1⃣ 检查 Chatwoot 服务状态..."
if curl -s "$CHATWOOT_BASE_URL" > /dev/null; then
echo " ✅ Chatwoot 服务正常运行"
else
echo " ❌ Chatwoot 服务无法访问"
exit 1
fi
echo ""
# 2. 获取所有收件箱
echo "2⃣ 获取所有收件箱..."
INBOXES=$(curl -s \
-H "Authorization: Bearer $CHATWOOT_API_TOKEN" \
"$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/inboxes")
echo "$INBOXES" | grep -o '"id":[0-9]*' | wc -l | xargs echo " 找到收件箱数量:"
echo ""
# 3. 解析并显示每个收件箱的详细信息
echo "3⃣ 收件箱详细信息:"
echo "======================================"
# 提取所有收件箱的 ID
INBOX_IDS=$(echo "$INBOXES" | grep -o '"id":[0-9]*' | grep -o '[0-9]*' | sort -u)
for INBOX_ID in $INBOX_IDS; do
echo ""
echo "📬 收件箱 ID: $INBOX_ID"
echo "--------------------------------------"
# 获取收件箱详情
INBOX_DETAIL=$(curl -s \
-H "Authorization: Bearer $CHATWOOT_API_TOKEN" \
"$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/inboxes/$INBOX_ID")
# 提取收件箱名称
NAME=$(echo "$INBOX_DETAIL" | grep -o '"name":"[^"]*"' | head -1 | cut -d'"' -f4)
echo " 名称: $NAME"
# 提取收件箱类型
TYPE=$(echo "$INBOX_DETAIL" | grep -o '"inbox_type":"[^"]*"' | head -1 | cut -d'"' -f4)
echo " 类型: $TYPE"
# 提取 Website Token如果有
WEBSITE_TOKEN=$(echo "$INBOX_DETAIL" | grep -o '"website_token":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$WEBSITE_TOKEN" ]; then
echo " Website Token: $WEBSITE_TOKEN"
fi
# 提取 Webhook URL
WEBHOOK_URL=$(echo "$INBOX_DETAIL" | grep -o '"webhook_url":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$WEBHOOK_URL" ]; then
echo " Webhook URL: $WEBHOOK_URL"
else
echo " Webhook URL: ❌ 未配置"
fi
# 检查是否是测试页面使用的 token
if [ "$WEBSITE_TOKEN" = "39PNCMvbMk3NvB7uaDNucc6o" ]; then
echo ""
echo " ⭐ 这是测试页面使用的收件箱!"
echo " Webhook 应该配置为: http://agent:8000/webhooks/chatwoot"
fi
done
echo ""
echo "======================================"
echo ""
echo "📋 下一步操作:"
echo ""
echo "1. 找到 Website Token 为 '39PNCMvbMk3NvB7uaDNucc6o' 的收件箱"
echo "2. 记录该收件箱的 ID"
echo "3. 确保该收件箱的 Webhook URL 配置为:"
echo " http://agent:8000/webhooks/chatwoot"
echo ""
echo "💡 提示:可以通过 Chatwoot 界面更新配置:"
echo " Settings → Inboxes → 选择收件箱 → Configuration → Webhook URL"
echo ""

View File

@@ -1,102 +0,0 @@
#!/bin/bash
# 检查 Chatwoot 会话和消息
echo "======================================"
echo "Chatwoot 会话检查工具"
echo "======================================"
echo ""
# 需要设置环境变量
if [ -z "$CHATWOOT_API_TOKEN" ]; then
echo "❌ 请先设置 CHATWOOT_API_TOKEN 环境变量"
echo ""
echo "获取方式:"
echo "1. 访问 http://localhost:3000"
echo "2. 登录后进入 Settings → Profile → Access Tokens"
echo "3. 创建一个新的 Access Token"
echo ""
echo "然后运行:"
echo " CHATWOOT_API_TOKEN=your_token $0"
echo ""
exit 1
fi
CHATWOOT_BASE_URL="http://localhost:3000"
ACCOUNT_ID="2"
echo "🔍 正在检查 Chatwoot 会话..."
echo ""
# 1. 获取所有收件箱
echo "1⃣ 获取所有收件箱..."
INBOXES=$(curl -s \
-H "Authorization: Bearer $CHATWOOT_API_TOKEN" \
"$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/inboxes")
INBOX_IDS=$(echo "$INBOXES" | grep -o '"id":[0-9]*' | grep -o '[0-9]*' | sort -u | head -5)
echo " 找到收件箱: $(echo "$INBOX_IDS" | wc -l)"
echo ""
# 2. 检查每个收件箱的会话
echo "2⃣ 检查最近的会话..."
echo "======================================"
for INBOX_ID in $INBOX_IDS; do
echo ""
echo "📬 收件箱 ID: $INBOX_ID"
echo "--------------------------------------"
# 获取收件箱名称
INBOX_NAME=$(echo "$INBOXES" | grep -o "\"id\":$INBOX_ID" -A 20 | grep '"name":"' | head -1 | cut -d'"' -f4)
echo " 名称: $INBOX_NAME"
# 获取最近5个会话
CONVERSATIONS=$(curl -s \
-H "Authorization: Bearer $CHATWOOT_API_TOKEN" \
"$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/conversations?inbox_id=$INBOX_ID&sort=-created_at" | head -100)
CONV_IDS=$(echo "$CONVERSATIONS" | grep -o '"id":[0-9]*' | grep -o '[0-9]*' | head -5)
if [ -z "$CONV_IDS" ]; then
echo " 没有会话"
continue
fi
echo " 最近的会话:"
echo "$CONV_IDS" | while read CONV_ID; do
# 获取会话详情
CONV_DETAIL=$(curl -s \
-H "Authorization: Bearer $CHATWOOT_API_TOKEN" \
"$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/conversations/$CONV_ID")
# 提取会话信息
STATUS=$(echo "$CONV_DETAIL" | grep -o '"status":"[^"]*"' | head -1 | cut -d'"' -f4)
CREATED_AT=$(echo "$CONV_DETAIL" | grep -o '"created_at":[^,}]*' | head -1 | cut -d'"' -f2)
# 获取消息数量
MESSAGES=$(curl -s \
-H "Authorization: Bearer $CHATWOOT_API_TOKEN" \
"$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/conversations/$CONV_ID/messages")
MSG_COUNT=$(echo "$MESSAGES" | grep -o '"content":' | wc -l)
echo " • 会话 #$CONV_ID - 状态: $Status - 消息数: $MSG_COUNT"
# 获取最后几条消息
echo "$MESSAGES" | grep -o '"content":"[^"]*"' | tail -3 | while read MSG; do
CONTENT=$(echo "$MSG" | cut -d'"' -f4 | sed 's/&quot;/"/g' | head -c 50)
echo " - $CONTENT..."
done
done
done
echo ""
echo "======================================"
echo ""
echo "💡 提示:"
echo "1. 查看上面的会话列表"
echo "2. 记录你正在测试的会话 ID"
echo "3. 在 Agent 日志中查找相同的 conversation_id"
echo "4. 如果会话 ID 不匹配,说明 Widget 连接到了错误的会话"
echo ""

View File

@@ -1,53 +0,0 @@
#!/bin/bash
# 实时监控 Chatwoot 和 Agent 日志
echo "======================================"
echo "Chatwoot 消息流程实时监控"
echo "======================================"
echo ""
echo "📋 使用说明:"
echo "1. 在测试页面 http://localhost:8080/test-chat.html 发送消息"
echo "2. 观察下面的日志输出"
echo "3. 按 Ctrl+C 停止监控"
echo ""
echo "======================================"
echo ""
# 检查 Docker 容器是否运行
if ! docker ps | grep -q "ai_agent"; then
echo "❌ Agent 容器未运行"
exit 1
fi
if ! docker ps | grep -q "ai_chatwoot"; then
echo "❌ Chatwoot 容器未运行"
exit 1
fi
echo "✅ 所有容器运行正常"
echo ""
echo "🔍 开始监控日志..."
echo "======================================"
echo ""
# 使用多 tail 监控多个容器
docker logs ai_agent -f 2>&1 &
AGENT_PID=$!
docker logs ai_chatwoot -f 2>&1 &
CHATWOOT_PID=$!
# 清理函数
cleanup() {
echo ""
echo "======================================"
echo "停止监控..."
kill $AGENT_PID $CHATWOOT_PID 2>/dev/null
exit 0
}
# 捕获 Ctrl+C
trap cleanup INT TERM
# 等待
wait

195
scripts/deploy-production.sh Executable file
View File

@@ -0,0 +1,195 @@
#!/bin/bash
# B2B Shopping AI Assistant - Production Deployment Script
# 生产环境部署脚本
set -e # 遇到错误立即退出
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 日志函数
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 检查必要的命令
check_requirements() {
log_info "检查系统依赖..."
if ! command -v docker &> /dev/null; then
log_error "Docker 未安装,请先安装 Docker"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
log_error "Docker Compose 未安装,请先安装 Docker Compose"
exit 1
fi
log_info "系统依赖检查完成"
}
# 检查环境变量文件
check_env_file() {
log_info "检查环境变量文件..."
if [ ! -f .env.production ]; then
log_error ".env.production 文件不存在"
log_info "请复制 .env.production.example 并填写真实值:"
log_info " cp .env.production.example .env.production"
log_info " vim .env.production"
exit 1
fi
# 检查必要的环境变量
source .env.production
required_vars=(
"ZHIPU_API_KEY"
"CHATWOOT_API_TOKEN"
"HYPERF_API_URL"
"MALL_API_URL"
"REDIS_PASSWORD"
)
missing_vars=()
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ] || [[ "${!var}" == *"your_"*"_here" ]]; then
missing_vars+=("$var")
fi
done
if [ ${#missing_vars[@]} -ne 0 ]; then
log_error "以下环境变量未设置或使用默认值:"
for var in "${missing_vars[@]}"; do
log_error " - $var"
exit 1
fi
fi
log_info "环境变量检查完成"
}
# 构建镜像
build_images() {
log_info "开始构建 Docker 镜像..."
docker-compose -f docker-compose.prod.yml build --no-cache
log_info "Docker 镜像构建完成"
}
# 停止现有服务
stop_services() {
log_info "停止现有服务..."
docker-compose -f docker-compose.prod.yml down
log_info "现有服务已停止"
}
# 启动服务
start_services() {
log_info "启动生产环境服务..."
docker-compose -f docker-compose.prod.yml up -d
log_info "服务启动完成"
}
# 健康检查
health_check() {
log_info "等待服务启动..."
sleep 10
# 检查 Agent 服务
if curl -f http://localhost:8000/health &> /dev/null; then
log_info "✅ Agent 服务健康检查通过"
else
log_error "❌ Agent 服务健康检查失败"
return 1
fi
# 检查 MCP 服务
mcp_ports=(8001 8002 8003 8004)
for port in "${mcp_ports[@]}"; do
if curl -f http://localhost:$port/health &> /dev/null; then
log_info "✅ MCP 服务 (端口 $port) 健康检查通过"
else
log_warn "⚠️ MCP 服务 (端口 $port) 健康检查失败"
fi
done
}
# 查看服务状态
show_status() {
log_info "服务状态:"
docker-compose -f docker-compose.prod.yml ps
}
# 查看日志
show_logs() {
log_info "最近的日志:"
docker-compose -f docker-compose.prod.yml logs --tail=50
}
# 主函数
main() {
log_info "========================================"
log_info "B2B Shopping AI Assistant - 生产环境部署"
log_info "========================================"
echo ""
# 检查参数
if [ "$1" == "--skip-build" ]; then
log_warn "跳过镜像构建步骤"
else
check_requirements
check_env_file
build_images
fi
stop_services
start_services
log_info "等待服务就绪..."
sleep 15
if health_check; then
log_info "========================================"
log_info "✅ 部署成功!"
log_info "========================================"
echo ""
log_info "服务地址:"
log_info " - Agent: http://localhost:8000"
log_info " - Strapi MCP: http://localhost:8001"
log_info " - Order MCP: http://localhost:8002"
log_info " - After MCP: http://localhost:8003"
log_info " - Product MCP: http://localhost:8004"
echo ""
log_info "查看日志:"
log_info " docker-compose -f docker-compose.prod.yml logs -f"
echo ""
log_info "查看状态:"
log_info " docker-compose -f docker-compose.prod.yml ps"
else
log_error "部署失败,请检查日志"
show_logs
exit 1
fi
}
# 执行主函数
main "$@"

41
scripts/set-contact-token.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# 为 Chatwoot Contact 设置 JWT Token
#
# 使用方法:
# ./set-contact-token.sh <contact_id> <jwt_token>
#
# 示例:
# ./set-contact-token.sh 4 "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
CHATWOOT_BASE_URL="http://192.168.15.34:3000"
ACCOUNT_ID="2"
# 从环境变量或参数获取 token
CONTACT_ID=${1:-"4"}
JWT_TOKEN=${2:-"your_jwt_token_here"}
MALL_TOKEN=${3:-"$JWT_TOKEN"} # 默认使用相同的 token
# Chatwoot API Token需要在管理界面创建
CHATWOOT_API_TOKEN="fnWaEeAyC1gw1FYQq6YJMWSj"
echo "📝 为 Contact #$CONTACT_ID 设置 token..."
echo "JWT Token: ${JWT_TOKEN:0:30}..."
echo "Mall Token: ${MALL_TOKEN:0:30}..."
# 更新 contact 的 custom_attributes
curl -X PUT "$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/contacts/$CONTACT_ID" \
-H "Authorization: Bearer $CHATWOOT_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"custom_attributes\": {
\"jwt_token\": \"$JWT_TOKEN\",
\"mall_token\": \"$MALL_TOKEN\"
}
}" | python3 -m json.tool
echo ""
echo "✅ Token 设置完成!"
echo ""
echo "验证:"
echo " curl -H \"Authorization: Bearer $CHATWOOT_API_TOKEN\" \\"
echo " $CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/contacts/$CONTACT_ID"

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# 为远程 Chatwoot 的 Contact 设置 JWT Token
CHATWOOT_BASE_URL="http://192.168.15.28:3000"
ACCOUNT_ID="2"
CONTACT_ID=${1:-"4"} # Contact ID (从日志中看到是 4)
# 从参数或使用测试 token
JWT_TOKEN=${2:-"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"}
echo "📝 为 Contact #$CONTACT_ID 设置 token..."
echo "JWT Token: ${JWT_TOKEN:0:30}..."
echo ""
# 更新 contact 的 custom_attributes
curl -X PUT "$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/contacts/$CONTACT_ID" \
-H "Authorization: Bearer wFc2Dpi3wcf9eT5Cibckd68z" \
-H "Content-Type: application/json" \
-d "{
\"custom_attributes\": {
\"jwt_token\": \"$JWT_TOKEN\",
\"mall_token\": \"$JWT_TOKEN\"
}
}" | python3 -m json.tool
echo ""
echo "✅ Token 设置完成!"
echo ""
echo "验证:"
echo " curl -H \"Authorization: Bearer wFc2Dpi3wcf9eT5Cibckd68z\" \\"
echo " $CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/contacts/$CONTACT_ID"

73
scripts/start.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/bin/bash
# 启动脚本 - B2B AI Assistant
set -e
echo "========================================"
echo "🚀 启动 B2B AI Assistant 服务"
echo "========================================"
echo ""
# 检查 Docker 是否运行
if ! docker info > /dev/null 2>&1; then
echo "❌ Docker 未运行,请先启动 Docker"
exit 1
fi
echo "✅ Docker 运行正常"
echo ""
# 检查远程 Chatwoot 连接
echo "🔍 检查远程 Chatwoot 连接..."
CHATWOOT_URL="http://192.168.15.28:3000"
if curl -s --connect-timeout 5 "$CHATWOOT_URL" > /dev/null 2>&1; then
echo "✅ 远程 Chatwoot 连接正常 ($CHATWOOT_URL)"
else
echo "⚠️ 警告:无法连接到远程 Chatwoot ($CHATWOOT_URL)"
echo " 请确保 Chatwoot 正在运行"
read -p "是否继续启动?(y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
echo ""
# 获取本机 IP
LOCAL_IP=$(hostname -I | awk '{print $1}')
echo "📡 本机 IP: $LOCAL_IP"
echo " Webhook URL: http://$LOCAL_IP:8000/webhooks/chatwoot"
echo ""
# 启动服务
echo "🐳 启动 Docker 服务..."
docker-compose up -d
echo ""
echo "⏳ 等待服务启动..."
sleep 5
# 检查服务状态
echo ""
echo "📊 服务状态:"
docker-compose ps
echo ""
echo "========================================"
echo "✅ 服务启动完成!"
echo "========================================"
echo ""
echo "📝 常用命令:"
echo " 查看日志: docker-compose logs -f agent"
echo " 查看状态: docker-compose ps"
echo " 停止服务: docker-compose down"
echo " 重启服务: docker-compose restart [service_name]"
echo ""
echo "📖 测试页面:"
echo " http://localhost:8080/docs/test-chat.html"
echo " http://localhost:8080/docs/test-conversation-id.html"
echo ""
echo "⚠️ 重要:"
echo " 请确保远程 Chatwoot (192.168.15.28:3000) 已配置 Webhook:"
echo " http://$LOCAL_IP:8000/webhooks/chatwoot"
echo ""

21
scripts/stop.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
# 停止脚本 - B2B AI Assistant
set -e
echo "========================================"
echo "🛑 停止 B2B AI Assistant 服务"
echo "========================================"
echo ""
# 停止服务
echo "🐳 停止 Docker 服务..."
docker-compose down
echo ""
echo "✅ 所有服务已停止"
echo ""
echo "💡 提示:"
echo " 如需删除数据卷,运行: docker-compose down -v"
echo " 如需重新启动,运行: ./scripts/start.sh"
echo ""

View File

@@ -1,62 +0,0 @@
#!/bin/bash
# 更新 Chatwoot Webhook 配置脚本
# 配置
CHATWOOT_BASE_URL="http://localhost:3000"
ACCOUNT_ID="2" # 你的账户 ID
INBOX_ID="" # 需要填入你的收件箱 ID
API_TOKEN="" # 需要填入你的 Chatwoot API Token
NEW_WEBHOOK_URL="http://agent:8000/webhooks/chatwoot"
WEBHOOK_SECRET="b7a12b9c9173718596f02fd912fb59f97891a0e7abb1a5e457b4c8858b2d21b5"
# 使用说明
echo "======================================"
echo "Chatwoot Webhook 配置更新工具"
echo "======================================"
echo ""
echo "请先设置以下变量:"
echo "1. INBOX_ID - 你的收件箱 ID"
echo "2. API_TOKEN - Chatwoot API Token从 Settings → Profile → Access Tokens 获取)"
echo ""
echo "然后运行:"
echo " INBOX_ID=<收件箱ID> API_TOKEN=<API Token> $0"
echo ""
echo "或者直接编辑此脚本设置变量。"
echo ""
# 检查参数
if [ -z "$INBOX_ID" ] || [ -z "$API_TOKEN" ]; then
echo "❌ 缺少必要参数"
exit 1
fi
# 获取当前 webhook 配置
echo "📋 获取当前 webhook 配置..."
CURRENT_CONFIG=$(curl -s \
-H "Authorization: Bearer $API_TOKEN" \
"$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/inboxes/$INBOX_ID")
echo "当前配置:"
echo "$CURRENT_CONFIG" | grep -o '"webhook_url":"[^"]*"' || echo "未找到 webhook_url"
# 更新 webhook
echo ""
echo "🔄 更新 webhook URL 为: $NEW_WEBHOOK_URL"
UPDATE_RESPONSE=$(curl -s -X PUT \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"inbox\": {
\"webhook_url\": \"$NEW_WEBHOOK_URL\"
}
}" \
"$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/inboxes/$INBOX_ID")
echo "更新响应:"
echo "$UPDATE_RESPONSE"
echo ""
echo "✅ 配置更新完成!"
echo ""
echo "现在可以在 Chatwoot 中测试发送消息了。"

17
scripts/verify-contact-token.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# 验证 Contact 的 token 设置
CHATWOOT_BASE_URL="http://192.168.15.34:3000"
ACCOUNT_ID="2"
CONTACT_ID=${1:-"4"}
CHATWOOT_API_TOKEN="fnWaEeAyC1gw1FYQq6YJMWSj"
echo "🔍 查询 Contact #$CONTACT_ID 的信息..."
echo ""
curl -s "$CHATWOOT_BASE_URL/api/v1/accounts/$ACCOUNT_ID/contacts/$CONTACT_ID" \
-H "Authorization: Bearer $CHATWOOT_API_TOKEN" \
-H "Content-Type: application/json" | python3 -m json.tool | grep -A 20 "custom_attributes"
echo ""
echo "✅ 查询完成!"

View File

@@ -1,81 +0,0 @@
#!/bin/bash
# 验证 Chatwoot Webhook 配置
echo "======================================"
echo "Chatwoot Webhook 配置验证工具"
echo "======================================"
echo ""
# 检查 Agent 服务
echo "1⃣ 检查 Agent 服务..."
if curl -s http://localhost:8000/health | grep -q "healthy"; then
echo " ✅ Agent 服务运行正常 (http://localhost:8000)"
else
echo " ❌ Agent 服务未运行"
exit 1
fi
echo ""
# 检查 Chatwoot 服务
echo "2⃣ 检查 Chatwoot 服务..."
if curl -s http://localhost:3000 > /dev/null; then
echo " ✅ Chatwoot 服务运行正常 (http://localhost:3000)"
else
echo " ❌ Chatwoot 服务未运行"
exit 1
fi
echo ""
# 检查网络连通性(从 Chatwoot 容器访问 Agent
echo "3⃣ 检查容器间网络连通性..."
if docker exec ai_chatwoot wget -q -O - http://agent:8000/health | grep -q "healthy"; then
echo " ✅ Chatwoot 可以访问 Agent (http://agent:8000)"
else
echo " ❌ Chatwoot 无法访问 Agent"
echo " 请检查两个容器是否在同一 Docker 网络中"
exit 1
fi
echo ""
# 检查环境变量配置
echo "4⃣ 检查环境变量配置..."
if [ -f .env ]; then
if grep -q "CHATWOOT_WEBHOOK_SECRET" .env; then
echo " ✅ CHATWOOT_WEBHOOK_SECRET 已配置"
else
echo " ⚠️ CHATWOOT_WEBHOOK_SECRET 未配置(可选)"
fi
else
echo " ⚠️ .env 文件不存在"
fi
echo ""
# 显示配置摘要
echo "======================================"
echo "📋 配置摘要"
echo "======================================"
echo ""
echo "Agent 服务:"
echo " • 容器名称: ai_agent"
echo " • 内部地址: http://agent:8000"
echo " • Webhook 端点: http://agent:8000/webhooks/chatwoot"
echo " • 外部访问: http://localhost:8000"
echo ""
echo "Chatwoot 服务:"
echo " • 容器名称: ai_chatwoot"
echo " • 内部地址: http://chatwoot:3000"
echo " • 外部访问: http://localhost:3000"
echo ""
echo "📝 在 Chatwoot 界面中配置:"
echo " 1. 访问: http://localhost:3000"
echo " 2. 进入: Settings → Inboxes → 选择 Website 收件箱"
echo " 3. 点击: Configuration 标签"
echo " 4. 设置 Webhook URL 为: http://agent:8000/webhooks/chatwoot"
echo " 5. 点击 Save 保存"
echo ""
echo "⚠️ 注意事项:"
echo " • 不要在 Chatwoot 中启用内置机器人Bot"
echo " • 只配置 Webhook 即可"
echo " • Webhook URL 使用 'agent' 而不是 'localhost'"
echo ""
echo "======================================"

View File

@@ -1,74 +0,0 @@
#!/bin/bash
# 测试所有 FAQ 分类
echo "=========================================="
echo "🧪 测试所有 FAQ 分类"
echo "=========================================="
echo ""
# 定义测试用例
declare -A TEST_CASES=(
["订单相关"]="How do I place an order?"
["支付相关"]="What payment methods do you accept?"
["运输相关"]="What are the shipping options?"
["退货相关"]="I received a defective item, what should I do?"
["账号相关"]="I forgot my password, now what?"
["营业时间"]="What are your opening hours?"
)
# 测试每个分类
for category in "${!TEST_CASES[@]}"; do
question="${TEST_CASES[$category]}"
conv_id="test_${category}___$(date +%s)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📋 分类: $category"
echo "📝 问题: $question"
echo "⏳ 处理中..."
echo ""
# 调用 API
RESPONSE=$(docker exec ai_agent curl -s -X POST 'http://localhost:8000/api/agent/query' \
-H 'Content-Type: application/json' \
-d "{\"conversation_id\":\"$conv_id\",\"user_id\":\"test_user\",\"account_id\":\"2\",\"message\":\"$question\"}")
# 解析并显示结果
echo "$RESPONSE" | python3 << PYTHON
import json
import sys
try:
data = json.load(sys.stdin)
# 提取响应
response = data.get("response", "")
intent = data.get("intent", "")
if response:
# 清理 HTML 标签(如果有)
import re
clean_response = re.sub(r'<[^<]+?>', '', response)
clean_response = clean_response.strip()
# 截断过长响应
if len(clean_response) > 300:
clean_response = clean_response[:300] + "..."
print(f"🎯 意图: {intent}")
print(f"🤖 回答: {clean_response}")
else:
print("❌ 未获得回答")
print(f"调试信息: {json.dumps(data, indent=2, ensure_ascii=False)}")
except Exception as e:
print(f"❌ 解析错误: {e}")
print(f"原始响应: {sys.stdin.read()}")
PYTHON
echo ""
sleep 2 # 间隔 2 秒
done
echo "=========================================="
echo "✅ 所有测试完成"
echo "=========================================="

View File

@@ -1,103 +0,0 @@
"""
测试商城订单查询接口
Usage:
python test_mall_order_query.py <order_id>
Example:
python test_mall_order_query.py 202071324
"""
import asyncio
import sys
import os
# Add mcp_servers to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "mcp_servers"))
from shared.mall_client import MallClient
async def test_order_query(order_id: str, token: str):
"""测试订单查询
Args:
order_id: 订单号
token: JWT Token
"""
print(f"\n{'='*60}")
print(f"测试商城订单查询接口")
print(f"{'='*60}")
print(f"订单号 (Order ID): {order_id}")
print(f"API URL: https://apicn.qa1.gaia888.com")
print(f"{'='*60}\n")
# 创建客户端
client = MallClient(
api_url="https://apicn.qa1.gaia888.com",
api_token=token,
tenant_id="2",
currency_code="EUR",
language_id="1",
source="us.qa1.gaia888.com"
)
try:
# 调用订单查询接口
result = await client.get_order_by_id(order_id)
# 打印结果
print("✅ 查询成功 (Query Success)!")
print(f"\n返回数据 (Response Data):")
print("-" * 60)
import json
print(json.dumps(result, ensure_ascii=False, indent=2))
print("-" * 60)
# 提取关键信息
if isinstance(result, dict):
print(f"\n关键信息 (Key Information):")
print(f" 订单号 (Order ID): {result.get('order_id') or result.get('orderId') or order_id}")
print(f" 订单状态 (Status): {result.get('status') or result.get('order_status') or 'N/A'}")
print(f" 订单金额 (Amount): {result.get('total_amount') or result.get('amount') or 'N/A'}")
# 商品信息
items = result.get('items') or result.get('order_items') or result.get('products')
if items:
print(f" 商品数量 (Items): {len(items)}")
except Exception as e:
print(f"❌ 查询失败 (Query Failed): {str(e)}")
import traceback
traceback.print_exc()
finally:
await client.close()
def main():
"""主函数"""
if len(sys.argv) < 2:
print("Usage: python test_mall_order_query.py <order_id> [token]")
print("\nExample:")
print(' python test_mall_order_query.py 202071324')
print(' python test_mall_order_query.py 202071324 "your_jwt_token_here"')
sys.exit(1)
order_id = sys.argv[1]
# 从命令行获取 token如果没有提供则使用默认的测试 token
if len(sys.argv) >= 3:
token = sys.argv[2]
else:
# 使用用户提供的示例 token
token = "eyJ0eXAiOiJqd3QifQ.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cDpcL1wvOiIsImV4cCI6MTc3MDUyMDY2MSwiaWF0IjoxNzY3OTI4NjYxLCJuYmYiOjE3Njc5Mjg2NjEsInVzZXJJZCI6MTAxNDMyLCJ0eXBlIjoyLCJ0ZW5hbnRJZCI6MiwidWlkIjoxMDE0MzIsInMiOiJkM0tZMjMiLCJqdGkiOiI3YjcwYTI2MzYwYjJmMzA3YmQ4YTYzNDAxOGVlNjlmZSJ9.dwiqln19-yAQSJd1w5bxZFrRgyohdAkHa1zW3W7Ov2I"
print("⚠️ 使用默认的测试 token可能已过期")
print(" 如需测试,请提供有效的 token:")
print(f' python {sys.argv[0]} {order_id} "your_jwt_token_here"\n')
# 运行异步测试
asyncio.run(test_order_query(order_id, token))
if __name__ == "__main__":
main()

View File

@@ -1,63 +0,0 @@
"""
测试退货相关 FAQ 回答
"""
import asyncio
import sys
import os
# 添加 agent 目录到路径
sys.path.insert(0, '/app')
from agents.customer_service import customer_service_agent
from core.state import AgentState
async def test_return_faq():
"""测试退货相关 FAQ"""
# 测试问题列表
test_questions = [
"I received a defective item, what should I do?",
"How do I return a product?",
"What is your return policy?",
"I want to get a refund for my order",
]
for question in test_questions:
print(f"\n{'='*60}")
print(f"📝 问题: {question}")
print(f"{'='*60}")
# 初始化状态
state = AgentState(
conversation_id="test_return_001",
user_id="test_user",
account_id="2",
message=question,
history=[],
context={}
)
try:
# 调用客服 Agent
final_state = await customer_service_agent(state)
# 获取响应
response = final_state.get("response", "无响应")
tool_calls = final_state.get("tool_calls", [])
intent = final_state.get("intent")
print(f"\n🎯 意图识别: {intent}")
print(f"\n🤖 AI 回答:")
print(response)
print(f"\n📊 调用的工具: {tool_calls}")
except Exception as e:
print(f"\n❌ 错误: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
print("🧪 测试退货相关 FAQ 回答\n")
asyncio.run(test_return_faq())