feat: 增强 Agent 系统和完善项目结构
主要改进: - Agent 增强: 订单查询、售后支持、客服路由等功能优化 - 新增语言检测和 Token 管理模块 - 改进 Chatwoot webhook 处理和用户标识 - MCP 服务器增强: 订单 MCP 和 Strapi MCP 功能扩展 - 新增商城客户端、知识库、缓存和同步模块 - 添加多语言提示词系统 (YAML) - 完善项目结构: 整理文档、脚本和测试文件 - 新增调试和测试工具脚本 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,101 +6,12 @@ from typing import Any
|
||||
|
||||
from core.state import AgentState, ConversationState, add_tool_call, set_response, update_context
|
||||
from core.llm import get_llm_client, Message
|
||||
from prompts import get_prompt
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
AFTERSALE_AGENT_PROMPT = """你是一个专业的 B2B 售后服务助手。
|
||||
你的职责是帮助用户处理售后问题,包括:
|
||||
- 退货申请
|
||||
- 换货申请
|
||||
- 投诉处理
|
||||
- 工单创建
|
||||
- 售后进度查询
|
||||
|
||||
## 可用工具
|
||||
|
||||
1. **apply_return** - 退货申请
|
||||
- order_id: 订单号
|
||||
- items: 退货商品列表 [{item_id, quantity, reason}]
|
||||
- description: 问题描述
|
||||
- images: 图片URL列表(可选)
|
||||
|
||||
2. **apply_exchange** - 换货申请
|
||||
- order_id: 订单号
|
||||
- items: 换货商品列表 [{item_id, reason}]
|
||||
- description: 问题描述
|
||||
|
||||
3. **create_complaint** - 创建投诉
|
||||
- type: 投诉类型(product_quality/service/logistics/other)
|
||||
- title: 投诉标题
|
||||
- description: 详细描述
|
||||
- related_order_id: 关联订单号(可选)
|
||||
- attachments: 附件URL列表(可选)
|
||||
|
||||
4. **create_ticket** - 创建工单
|
||||
- category: 工单类别
|
||||
- priority: 优先级(low/medium/high/urgent)
|
||||
- title: 工单标题
|
||||
- description: 详细描述
|
||||
|
||||
5. **query_aftersale_status** - 查询售后状态
|
||||
- aftersale_id: 售后单号(可选,不填查询全部)
|
||||
|
||||
## 工具调用格式
|
||||
|
||||
当需要使用工具时,请返回 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "工具名称",
|
||||
"arguments": {
|
||||
"参数名": "参数值"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当需要向用户询问更多信息时:
|
||||
```json
|
||||
{
|
||||
"action": "ask_info",
|
||||
"question": "需要询问的问题",
|
||||
"required_fields": ["需要收集的字段列表"]
|
||||
}
|
||||
```
|
||||
|
||||
当可以直接回答时:
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"response": "回复内容"
|
||||
}
|
||||
```
|
||||
|
||||
## 售后流程引导
|
||||
|
||||
退货流程:
|
||||
1. 确认订单号和退货商品
|
||||
2. 了解退货原因
|
||||
3. 收集问题描述和图片(质量问题时)
|
||||
4. 提交退货申请
|
||||
5. 告知用户后续流程
|
||||
|
||||
换货流程:
|
||||
1. 确认订单号和换货商品
|
||||
2. 了解换货原因
|
||||
3. 确认是否有库存
|
||||
4. 提交换货申请
|
||||
|
||||
## 注意事项
|
||||
- 售后申请需要完整信息才能提交
|
||||
- 对用户的问题要表示理解和歉意
|
||||
- 复杂投诉建议转人工处理
|
||||
- 金额较大的退款需要特别确认
|
||||
"""
|
||||
|
||||
|
||||
async def aftersale_agent(state: AgentState) -> AgentState:
|
||||
"""Aftersale agent node
|
||||
|
||||
@@ -126,9 +37,45 @@ async def aftersale_agent(state: AgentState) -> AgentState:
|
||||
if state["tool_results"]:
|
||||
return await _generate_aftersale_response(state)
|
||||
|
||||
# Get detected language
|
||||
locale = state.get("detected_language", "en")
|
||||
|
||||
# Auto-query FAQ for return-related questions
|
||||
message_lower = state["current_message"].lower()
|
||||
faq_keywords = ["return", "refund", "defective", "exchange", "complaint", "damaged", "wrong", "missing"]
|
||||
|
||||
# 如果消息包含退货相关关键词,且没有工具调用记录,自动查询 FAQ
|
||||
if any(keyword in message_lower for keyword in faq_keywords):
|
||||
# 检查是否已经查询过 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)
|
||||
|
||||
if not has_faq_query:
|
||||
logger.info(
|
||||
"Auto-querying FAQ for return-related question",
|
||||
conversation_id=state["conversation_id"]
|
||||
)
|
||||
|
||||
# 自动添加 FAQ 工具调用
|
||||
state = add_tool_call(
|
||||
state,
|
||||
tool_name="query_faq",
|
||||
arguments={
|
||||
"category": "return",
|
||||
"locale": locale,
|
||||
"limit": 5
|
||||
},
|
||||
server="strapi"
|
||||
)
|
||||
state["state"] = ConversationState.TOOL_CALLING.value
|
||||
return state
|
||||
|
||||
# Build messages for LLM
|
||||
# Load prompt in detected language
|
||||
system_prompt = get_prompt("aftersale", locale)
|
||||
|
||||
messages = [
|
||||
Message(role="system", content=AFTERSALE_AGENT_PROMPT),
|
||||
Message(role="system", content=system_prompt),
|
||||
]
|
||||
|
||||
# Add conversation history
|
||||
@@ -136,15 +83,15 @@ async def aftersale_agent(state: AgentState) -> AgentState:
|
||||
messages.append(Message(role=msg["role"], content=msg["content"]))
|
||||
|
||||
# Build context info
|
||||
context_info = f"用户ID: {state['user_id']}\n账户ID: {state['account_id']}\n"
|
||||
context_info = f"User ID: {state['user_id']}\nAccount ID: {state['account_id']}\n"
|
||||
|
||||
if state["entities"]:
|
||||
context_info += f"已提取的信息: {json.dumps(state['entities'], ensure_ascii=False)}\n"
|
||||
context_info += f"Extracted entities: {json.dumps(state['entities'], ensure_ascii=False)}\n"
|
||||
|
||||
if state["context"]:
|
||||
context_info += f"会话上下文: {json.dumps(state['context'], ensure_ascii=False)}\n"
|
||||
context_info += f"Conversation context: {json.dumps(state['context'], ensure_ascii=False)}\n"
|
||||
|
||||
user_content = f"{context_info}\n用户消息: {state['current_message']}"
|
||||
user_content = f"{context_info}\nUser message: {state['current_message']}"
|
||||
messages.append(Message(role="user", content=user_content))
|
||||
|
||||
try:
|
||||
@@ -211,31 +158,31 @@ async def _generate_aftersale_response(state: AgentState) -> AgentState:
|
||||
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_context.append(f"Tool {result['tool_name']} returned:\n{json.dumps(data, ensure_ascii=False, indent=2)}")
|
||||
|
||||
# Extract aftersale_id for context
|
||||
if isinstance(data, dict) and data.get("aftersale_id"):
|
||||
state = update_context(state, {"aftersale_id": data["aftersale_id"]})
|
||||
else:
|
||||
tool_context.append(f"工具 {result['tool_name']} 执行失败: {result['error']}")
|
||||
tool_context.append(f"Tool {result['tool_name']} failed: {result['error']}")
|
||||
|
||||
prompt = f"""基于以下售后系统返回的信息,生成对用户的回复。
|
||||
prompt = f"""Based on the following aftersale system information, generate a response to the user.
|
||||
|
||||
用户问题: {state["current_message"]}
|
||||
User question: {state["current_message"]}
|
||||
|
||||
系统返回信息:
|
||||
System returned information:
|
||||
{chr(10).join(tool_context)}
|
||||
|
||||
请生成一个体贴、专业的回复:
|
||||
- 如果是申请提交成功,告知用户售后单号和后续流程
|
||||
- 如果是状态查询,清晰说明当前进度
|
||||
- 如果申请失败,说明原因并提供解决方案
|
||||
- 对用户的问题表示理解
|
||||
Please generate a compassionate and professional response:
|
||||
- If application submitted successfully, inform user of aftersale ID and next steps
|
||||
- If status query, clearly explain current progress
|
||||
- If application failed, explain reason and provide solution
|
||||
- Show understanding for user's issue
|
||||
|
||||
只返回回复内容,不要返回 JSON。"""
|
||||
Return only the response content, do not return JSON."""
|
||||
|
||||
messages = [
|
||||
Message(role="system", content="你是一个专业的售后客服助手,请根据系统返回的信息回答用户的售后问题。"),
|
||||
Message(role="system", content="You are a professional aftersale service assistant, please answer user's aftersale questions based on system returned information."),
|
||||
Message(role="user", content=prompt)
|
||||
]
|
||||
|
||||
@@ -247,5 +194,5 @@ async def _generate_aftersale_response(state: AgentState) -> AgentState:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Aftersale response generation failed", error=str(e))
|
||||
state = set_response(state, "抱歉,处理售后请求时遇到问题。请稍后重试或联系人工客服。")
|
||||
state = set_response(state, "Sorry, there was a problem processing your aftersale request. Please try again later or contact customer support.")
|
||||
return state
|
||||
|
||||
@@ -6,68 +6,12 @@ from typing import Any
|
||||
|
||||
from core.state import AgentState, ConversationState, add_tool_call, set_response
|
||||
from core.llm import get_llm_client, Message
|
||||
from prompts import get_prompt
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
CUSTOMER_SERVICE_PROMPT = """你是一个专业的 B2B 购物网站客服助手。
|
||||
你的职责是回答用户的一般性问题,包括:
|
||||
- 常见问题解答 (FAQ)
|
||||
- 公司信息查询
|
||||
- 政策咨询(退换货政策、隐私政策等)
|
||||
- 产品使用指南
|
||||
- 其他一般性咨询
|
||||
|
||||
## 可用工具
|
||||
|
||||
你可以使用以下工具获取信息:
|
||||
1. **query_faq** - 搜索 FAQ 常见问题
|
||||
- query: 搜索关键词
|
||||
- category: 分类(可选)
|
||||
|
||||
2. **get_company_info** - 获取公司信息
|
||||
- section: 信息类别(about_us, contact, etc.)
|
||||
|
||||
3. **get_policy** - 获取政策文档
|
||||
- policy_type: 政策类型(return_policy, privacy_policy, etc.)
|
||||
|
||||
## 工具调用格式
|
||||
|
||||
当需要使用工具时,请返回 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "工具名称",
|
||||
"arguments": {
|
||||
"参数名": "参数值"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当可以直接回答时,请返回:
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"response": "回复内容"
|
||||
}
|
||||
```
|
||||
|
||||
当需要转人工时,请返回:
|
||||
```json
|
||||
{
|
||||
"action": "handoff",
|
||||
"reason": "转人工原因"
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- 保持专业、友好的语气
|
||||
- 如果不确定答案,建议用户联系人工客服
|
||||
- 不要编造信息,只使用工具返回的数据
|
||||
"""
|
||||
|
||||
|
||||
async def customer_service_agent(state: AgentState) -> AgentState:
|
||||
"""Customer service agent node
|
||||
|
||||
@@ -92,9 +36,78 @@ async def customer_service_agent(state: AgentState) -> AgentState:
|
||||
if state["tool_results"]:
|
||||
return await _generate_response_from_results(state)
|
||||
|
||||
# Get detected language
|
||||
locale = state.get("detected_language", "en")
|
||||
|
||||
# Auto-detect category and query FAQ
|
||||
message_lower = state["current_message"].lower()
|
||||
|
||||
# 定义分类关键词
|
||||
category_keywords = {
|
||||
"register": ["register", "sign up", "account", "login", "password", "forgot"],
|
||||
"order": ["order", "place order", "cancel order", "modify order", "change order"],
|
||||
"payment": ["pay", "payment", "checkout", "voucher", "discount", "promo"],
|
||||
"shipment": ["ship", "shipping", "delivery", "courier", "transit", "logistics", "tracking"],
|
||||
"return": ["return", "refund", "exchange", "defective", "damaged"],
|
||||
}
|
||||
|
||||
# 检测分类
|
||||
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(
|
||||
f"Auto-querying FAQ for category: {detected_category}",
|
||||
conversation_id=state["conversation_id"]
|
||||
)
|
||||
|
||||
# 自动添加 FAQ 工具调用
|
||||
state = add_tool_call(
|
||||
state,
|
||||
tool_name="query_faq",
|
||||
arguments={
|
||||
"category": detected_category,
|
||||
"locale": locale,
|
||||
"limit": 5
|
||||
},
|
||||
server="strapi"
|
||||
)
|
||||
state["state"] = ConversationState.TOOL_CALLING.value
|
||||
return state
|
||||
|
||||
# 如果询问营业时间或联系方式,自动查询公司信息
|
||||
if any(keyword in message_lower for keyword in ["opening hour", "contact", "address", "phone", "email"]) and not has_faq_query:
|
||||
logger.info(
|
||||
"Auto-querying company info",
|
||||
conversation_id=state["conversation_id"]
|
||||
)
|
||||
|
||||
state = add_tool_call(
|
||||
state,
|
||||
tool_name="get_company_info",
|
||||
arguments={
|
||||
"section": "contact",
|
||||
"locale": locale
|
||||
},
|
||||
server="strapi"
|
||||
)
|
||||
state["state"] = ConversationState.TOOL_CALLING.value
|
||||
return state
|
||||
|
||||
# Build messages for LLM
|
||||
# Load prompt in detected language
|
||||
system_prompt = get_prompt("customer_service", locale)
|
||||
|
||||
messages = [
|
||||
Message(role="system", content=CUSTOMER_SERVICE_PROMPT),
|
||||
Message(role="system", content=system_prompt),
|
||||
]
|
||||
|
||||
# Add conversation history
|
||||
@@ -156,22 +169,22 @@ async def _generate_response_from_results(state: AgentState) -> AgentState:
|
||||
tool_context = []
|
||||
for result in state["tool_results"]:
|
||||
if result["success"]:
|
||||
tool_context.append(f"工具 {result['tool_name']} 返回:\n{json.dumps(result['data'], ensure_ascii=False, indent=2)}")
|
||||
tool_context.append(f"Tool {result['tool_name']} returned:\n{json.dumps(result['data'], ensure_ascii=False, indent=2)}")
|
||||
else:
|
||||
tool_context.append(f"工具 {result['tool_name']} 执行失败: {result['error']}")
|
||||
tool_context.append(f"Tool {result['tool_name']} failed: {result['error']}")
|
||||
|
||||
prompt = f"""基于以下工具返回的信息,生成对用户的回复。
|
||||
prompt = f"""Based on the following tool returned information, generate a response to the user.
|
||||
|
||||
用户问题: {state["current_message"]}
|
||||
User question: {state["current_message"]}
|
||||
|
||||
工具返回信息:
|
||||
Tool returned information:
|
||||
{chr(10).join(tool_context)}
|
||||
|
||||
请生成一个友好、专业的回复。如果工具没有返回有用信息,请诚实告知用户并建议其他方式获取帮助。
|
||||
只返回回复内容,不要返回 JSON。"""
|
||||
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.
|
||||
Return only the response content, do not return JSON."""
|
||||
|
||||
messages = [
|
||||
Message(role="system", content="你是一个专业的 B2B 客服助手,请根据工具返回的信息回答用户问题。"),
|
||||
Message(role="system", content="You are a professional B2B customer service assistant, please answer user questions based on tool returned information."),
|
||||
Message(role="user", content=prompt)
|
||||
]
|
||||
|
||||
@@ -183,5 +196,5 @@ async def _generate_response_from_results(state: AgentState) -> AgentState:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Response generation failed", error=str(e))
|
||||
state = set_response(state, "抱歉,处理您的请求时遇到问题。请稍后重试或联系人工客服。")
|
||||
state = set_response(state, "Sorry, there was a problem processing your request. Please try again later or contact customer support.")
|
||||
return state
|
||||
|
||||
@@ -21,62 +21,100 @@ ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
|
||||
|
||||
## 可用工具
|
||||
|
||||
1. **query_order** - 查询订单
|
||||
1. **get_mall_order** - 从商城 API 查询订单(推荐使用)
|
||||
- order_id: 订单号(必需)
|
||||
- 说明:此工具会自动使用用户的身份 token 查询商城订单详情
|
||||
|
||||
2. **query_order** - 查询历史订单
|
||||
- user_id: 用户 ID(自动注入)
|
||||
- account_id: 账户 ID(自动注入)
|
||||
- order_id: 订单号(可选,不填则查询最近订单)
|
||||
- date_start: 开始日期(可选)
|
||||
- date_end: 结束日期(可选)
|
||||
- status: 订单状态(可选)
|
||||
|
||||
2. **track_logistics** - 物流跟踪
|
||||
3. **track_logistics** - 物流跟踪
|
||||
- order_id: 订单号
|
||||
- tracking_number: 物流单号(可选)
|
||||
|
||||
3. **modify_order** - 修改订单
|
||||
4. **modify_order** - 修改订单
|
||||
- order_id: 订单号
|
||||
- user_id: 用户 ID(自动注入)
|
||||
- modifications: 修改内容(address/items/quantity 等)
|
||||
|
||||
4. **cancel_order** - 取消订单
|
||||
5. **cancel_order** - 取消订单
|
||||
- order_id: 订单号
|
||||
- user_id: 用户 ID(自动注入)
|
||||
- reason: 取消原因
|
||||
|
||||
5. **get_invoice** - 获取发票
|
||||
6. **get_invoice** - 获取发票
|
||||
- order_id: 订单号
|
||||
- invoice_type: 发票类型(normal/vat)
|
||||
|
||||
## 工具调用格式
|
||||
## 回复格式要求
|
||||
|
||||
当需要使用工具时,请返回 JSON 格式:
|
||||
**重要**:你必须始终返回完整的 JSON 对象,不要包含任何其他文本或解释。
|
||||
|
||||
### 格式 1:调用工具
|
||||
当需要使用工具查询信息时,返回:
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "工具名称",
|
||||
"tool_name": "get_mall_order",
|
||||
"arguments": {
|
||||
"参数名": "参数值"
|
||||
"order_id": "202071324"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当需要向用户询问更多信息时:
|
||||
### 格式 2:询问信息
|
||||
当需要向用户询问更多信息时,返回:
|
||||
```json
|
||||
{
|
||||
"action": "ask_info",
|
||||
"question": "需要询问的问题"
|
||||
"question": "请提供您的订单号"
|
||||
}
|
||||
```
|
||||
|
||||
当可以直接回答时:
|
||||
### 格式 3:直接回复
|
||||
当可以直接回答时,返回:
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"response": "回复内容"
|
||||
"response": "您的订单已发货,预计3天内到达"
|
||||
}
|
||||
```
|
||||
|
||||
## 重要提示
|
||||
- 订单修改和取消是敏感操作,需要确认订单号
|
||||
- 如果用户没有提供订单号,先查询他的最近订单
|
||||
- 物流查询需要订单号或物流单号
|
||||
- 对于批量操作或大金额订单,建议转人工处理
|
||||
## 示例对话
|
||||
|
||||
用户: "查询订单 202071324"
|
||||
回复:
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "get_mall_order",
|
||||
"arguments": {
|
||||
"order_id": "202071324"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
用户: "我的订单发货了吗?"
|
||||
回复:
|
||||
```json
|
||||
{
|
||||
"action": "ask_info",
|
||||
"question": "请提供您的订单号,以便查询订单状态"
|
||||
}
|
||||
```
|
||||
|
||||
## 重要约束
|
||||
- **必须返回完整的 JSON 对象**,不要只返回部分内容
|
||||
- **不要添加任何 markdown 代码块标记**(如 \`\`\`json)
|
||||
- **不要添加任何解释性文字**,只返回 JSON
|
||||
- user_id 和 account_id 会自动注入到 arguments 中,无需手动添加
|
||||
- 如果用户提供了订单号,优先使用 get_mall_order 工具
|
||||
- 对于敏感操作(取消、修改),确保有明确的订单号
|
||||
"""
|
||||
|
||||
|
||||
@@ -134,20 +172,126 @@ async def order_agent(state: AgentState) -> AgentState:
|
||||
|
||||
# Parse response
|
||||
content = response.content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.split("```")[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
logger.info(
|
||||
"LLM response received",
|
||||
conversation_id=state["conversation_id"],
|
||||
response_length=len(content),
|
||||
response_preview=content[:300]
|
||||
)
|
||||
|
||||
# 检查是否是简化的工具调用格式:工具名称\n{参数}
|
||||
# 例如:get_mall_order\n{"order_id": "202071324"}
|
||||
if "\n" in content and "{" in content:
|
||||
lines = content.split("\n")
|
||||
if len(lines) >= 2:
|
||||
tool_name_line = lines[0].strip()
|
||||
json_line = "\n".join(lines[1:]).strip()
|
||||
|
||||
# 如果第一行看起来像工具名称(不包含 {),且第二行是 JSON
|
||||
if "{" not in tool_name_line and "{" in json_line:
|
||||
logger.info(
|
||||
"Detected simplified tool call format",
|
||||
tool_name=tool_name_line,
|
||||
json_preview=json_line[:200]
|
||||
)
|
||||
|
||||
try:
|
||||
arguments = json.loads(json_line)
|
||||
# 直接构建工具调用
|
||||
arguments["user_id"] = state["user_id"]
|
||||
arguments["account_id"] = state["account_id"]
|
||||
|
||||
# Inject user_token if available
|
||||
if state.get("user_token"):
|
||||
arguments["user_token"] = state["user_token"]
|
||||
logger.info("Injected user_token into tool call")
|
||||
|
||||
# Use entity if available
|
||||
if "order_id" not in arguments and state["entities"].get("order_id"):
|
||||
arguments["order_id"] = state["entities"]["order_id"]
|
||||
|
||||
state = add_tool_call(
|
||||
state,
|
||||
tool_name=tool_name_line,
|
||||
arguments=arguments,
|
||||
server="order"
|
||||
)
|
||||
state["state"] = ConversationState.TOOL_CALLING.value
|
||||
|
||||
logger.info(
|
||||
"Tool call added from simplified format",
|
||||
tool_name=tool_name_line,
|
||||
arguments_keys=list(arguments.keys())
|
||||
)
|
||||
|
||||
return state
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(
|
||||
"Failed to parse simplified format",
|
||||
error=str(e),
|
||||
json_line=json_line[:200]
|
||||
)
|
||||
|
||||
# 清理内容,去除可能的 markdown 代码块标记
|
||||
# 例如:```json\n{...}\n``` 或 ```\n{...}\n```
|
||||
if "```" in content:
|
||||
# 找到第一个 ``` 后的内容
|
||||
parts = content.split("```")
|
||||
if len(parts) >= 2:
|
||||
content = parts[1].strip()
|
||||
# 去掉可能的 "json" 标记
|
||||
if content.startswith("json"):
|
||||
content = content[4:].strip()
|
||||
# 去掉结尾的 ``` 标记
|
||||
if content.endswith("```"):
|
||||
content = content[:-3].strip()
|
||||
|
||||
# 尝试提取 JSON 对象(处理周围可能有文本的情况)
|
||||
json_start = content.find("{")
|
||||
json_end = content.rfind("}")
|
||||
if json_start != -1 and json_end != -1 and json_end > json_start:
|
||||
content = content[json_start:json_end + 1]
|
||||
|
||||
logger.info(
|
||||
"Cleaned content for JSON parsing",
|
||||
conversation_id=state["conversation_id"],
|
||||
content_length=len(content),
|
||||
content_preview=content[:500]
|
||||
)
|
||||
|
||||
try:
|
||||
result = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
"Failed to parse LLM response as JSON",
|
||||
conversation_id=state["conversation_id"],
|
||||
error=str(e),
|
||||
content_preview=content[:500]
|
||||
)
|
||||
# 如果解析失败,尝试将原始内容作为直接回复
|
||||
state = set_response(state, response.content)
|
||||
return state
|
||||
|
||||
result = json.loads(content)
|
||||
action = result.get("action")
|
||||
|
||||
logger.info(
|
||||
"LLM action parsed",
|
||||
conversation_id=state["conversation_id"],
|
||||
action=action,
|
||||
tool_name=result.get("tool_name")
|
||||
)
|
||||
|
||||
if action == "call_tool":
|
||||
# Inject user context into arguments
|
||||
arguments = result.get("arguments", {})
|
||||
arguments["user_id"] = state["user_id"]
|
||||
arguments["account_id"] = state["account_id"]
|
||||
|
||||
# Inject user_token if available (for Mall API calls)
|
||||
if state.get("user_token"):
|
||||
arguments["user_token"] = state["user_token"]
|
||||
logger.debug("Injected user_token into tool call")
|
||||
|
||||
# Use entity if available
|
||||
if "order_id" not in arguments and state["entities"].get("order_id"):
|
||||
arguments["order_id"] = state["entities"]["order_id"]
|
||||
@@ -160,6 +304,12 @@ async def order_agent(state: AgentState) -> AgentState:
|
||||
)
|
||||
state["state"] = ConversationState.TOOL_CALLING.value
|
||||
|
||||
logger.info(
|
||||
"Tool call added",
|
||||
tool_name=result["tool_name"],
|
||||
arguments_keys=list(arguments.keys())
|
||||
)
|
||||
|
||||
elif action == "ask_info":
|
||||
state = set_response(state, result["question"])
|
||||
state["state"] = ConversationState.AWAITING_INFO.value
|
||||
@@ -174,10 +324,6 @@ async def order_agent(state: AgentState) -> AgentState:
|
||||
|
||||
return state
|
||||
|
||||
except json.JSONDecodeError:
|
||||
state = set_response(state, response.content)
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Order agent failed", error=str(e))
|
||||
state["error"] = str(e)
|
||||
|
||||
@@ -4,83 +4,15 @@ Router Agent - Intent recognition and routing
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from core.state import AgentState, Intent, ConversationState, set_intent, add_entity
|
||||
from core.state import AgentState, Intent, ConversationState, set_intent, add_entity, set_language
|
||||
from core.llm import get_llm_client, Message
|
||||
from core.language_detector import get_cached_or_detect
|
||||
from prompts import get_prompt
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Intent classification prompt
|
||||
CLASSIFICATION_PROMPT = """你是一个 B2B 购物网站的智能助手路由器。
|
||||
你的任务是分析用户消息,识别用户意图并提取关键实体。
|
||||
|
||||
## 可用意图分类
|
||||
|
||||
1. **customer_service** - 通用咨询
|
||||
- FAQ 问答
|
||||
- 产品使用问题
|
||||
- 公司信息查询
|
||||
- 政策咨询(退换货政策、隐私政策等)
|
||||
|
||||
2. **order** - 订单相关
|
||||
- 订单查询("我的订单在哪"、"查一下订单")
|
||||
- 物流跟踪("快递到哪了"、"什么时候到货")
|
||||
- 订单修改("改一下收货地址"、"修改订单数量")
|
||||
- 订单取消("取消订单"、"不想要了")
|
||||
- 发票查询("开发票"、"要发票")
|
||||
|
||||
3. **aftersale** - 售后服务
|
||||
- 退货申请("退货"、"不满意想退")
|
||||
- 换货申请("换货"、"换一个")
|
||||
- 投诉("投诉"、"服务态度差")
|
||||
- 工单/问题反馈
|
||||
|
||||
4. **product** - 商品相关
|
||||
- 商品搜索("有没有xx"、"找一下xx")
|
||||
- 商品推荐("推荐"、"有什么好的")
|
||||
- 询价("多少钱"、"批发价"、"大量购买价格")
|
||||
- 库存查询("有货吗"、"还有多少")
|
||||
|
||||
5. **human_handoff** - 需要转人工
|
||||
- 用户明确要求转人工
|
||||
- 复杂问题 AI 无法处理
|
||||
- 敏感问题需要人工处理
|
||||
|
||||
## 实体提取
|
||||
|
||||
请从消息中提取以下实体(如果存在):
|
||||
- order_id: 订单号(如 ORD123456)
|
||||
- product_id: 商品ID
|
||||
- product_name: 商品名称
|
||||
- quantity: 数量
|
||||
- date_reference: 时间引用(今天、昨天、上周、具体日期等)
|
||||
- tracking_number: 物流单号
|
||||
- phone: 电话号码
|
||||
- address: 地址信息
|
||||
|
||||
## 输出格式
|
||||
|
||||
请以 JSON 格式返回,包含以下字段:
|
||||
```json
|
||||
{
|
||||
"intent": "意图分类",
|
||||
"confidence": 0.95,
|
||||
"sub_intent": "子意图(可选)",
|
||||
"entities": {
|
||||
"entity_type": "entity_value"
|
||||
},
|
||||
"reasoning": "简短的推理说明"
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- 如果意图不明确,置信度应该较低
|
||||
- 如果无法确定意图,返回 "unknown"
|
||||
- 实体提取要准确,没有的字段不要填写
|
||||
"""
|
||||
|
||||
|
||||
async def classify_intent(state: AgentState) -> AgentState:
|
||||
"""Classify user intent and extract entities
|
||||
|
||||
@@ -102,20 +34,34 @@ async def classify_intent(state: AgentState) -> AgentState:
|
||||
state["state"] = ConversationState.CLASSIFYING.value
|
||||
state["step_count"] += 1
|
||||
|
||||
# Detect language
|
||||
detected_locale = get_cached_or_detect(state, state["current_message"])
|
||||
confidence = 0.85 # Default confidence for language detection
|
||||
state = set_language(state, detected_locale, confidence)
|
||||
|
||||
logger.info(
|
||||
"Language detected",
|
||||
locale=detected_locale,
|
||||
confidence=confidence
|
||||
)
|
||||
|
||||
# Build context from conversation history
|
||||
context_summary = ""
|
||||
if state["context"]:
|
||||
context_parts = []
|
||||
if state["context"].get("order_id"):
|
||||
context_parts.append(f"当前讨论的订单: {state['context']['order_id']}")
|
||||
context_parts.append(f"Current order: {state['context']['order_id']}")
|
||||
if state["context"].get("product_id"):
|
||||
context_parts.append(f"当前讨论的商品: {state['context']['product_id']}")
|
||||
context_parts.append(f"Current product: {state['context']['product_id']}")
|
||||
if context_parts:
|
||||
context_summary = "\n".join(context_parts)
|
||||
|
||||
# Load prompt in detected language
|
||||
classification_prompt = get_prompt("router", detected_locale)
|
||||
|
||||
# Build messages for LLM
|
||||
messages = [
|
||||
Message(role="system", content=CLASSIFICATION_PROMPT),
|
||||
Message(role="system", content=classification_prompt),
|
||||
]
|
||||
|
||||
# Add recent conversation history for context
|
||||
@@ -123,9 +69,9 @@ async def classify_intent(state: AgentState) -> AgentState:
|
||||
messages.append(Message(role=msg["role"], content=msg["content"]))
|
||||
|
||||
# Add current message with context
|
||||
user_content = f"用户消息: {state['current_message']}"
|
||||
user_content = f"User message: {state['current_message']}"
|
||||
if context_summary:
|
||||
user_content += f"\n\n当前上下文:\n{context_summary}"
|
||||
user_content += f"\n\nCurrent context:\n{context_summary}"
|
||||
|
||||
messages.append(Message(role="user", content=user_content))
|
||||
|
||||
|
||||
@@ -7,11 +7,7 @@ import httpx
|
||||
from langgraph.graph import StateGraph, END
|
||||
|
||||
from .state import AgentState, ConversationState, mark_finished, add_tool_result, set_response
|
||||
from agents.router import classify_intent, route_by_intent
|
||||
from agents.customer_service import customer_service_agent
|
||||
from agents.order import order_agent
|
||||
from agents.aftersale import aftersale_agent
|
||||
from agents.product import product_agent
|
||||
# 延迟导入以避免循环依赖
|
||||
from config import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
@@ -198,19 +194,35 @@ async def handle_error(state: AgentState) -> AgentState:
|
||||
def should_call_tools(state: AgentState) -> Literal["call_tools", "send_response", "back_to_agent"]:
|
||||
"""Determine if tools need to be called"""
|
||||
|
||||
logger.debug(
|
||||
"Checking if tools should be called",
|
||||
conversation_id=state.get("conversation_id"),
|
||||
has_tool_calls=bool(state.get("tool_calls")),
|
||||
tool_calls_count=len(state.get("tool_calls", [])),
|
||||
has_response=bool(state.get("response")),
|
||||
state_value=state.get("state")
|
||||
)
|
||||
|
||||
# If there are pending tool calls, execute them
|
||||
if state.get("tool_calls"):
|
||||
logger.info(
|
||||
"Routing to tool execution",
|
||||
tool_count=len(state["tool_calls"])
|
||||
)
|
||||
return "call_tools"
|
||||
|
||||
# If we have a response ready, send it
|
||||
if state.get("response"):
|
||||
logger.debug("Routing to send_response (has response)")
|
||||
return "send_response"
|
||||
|
||||
# If we're waiting for info, send the question
|
||||
if state.get("state") == ConversationState.AWAITING_INFO.value:
|
||||
logger.debug("Routing to send_response (awaiting info)")
|
||||
return "send_response"
|
||||
|
||||
# Otherwise, something went wrong
|
||||
logger.warning("Unexpected state, routing to send_response", state=state.get("state"))
|
||||
return "send_response"
|
||||
|
||||
|
||||
@@ -259,6 +271,13 @@ def create_agent_graph() -> StateGraph:
|
||||
Returns:
|
||||
Compiled LangGraph workflow
|
||||
"""
|
||||
# 延迟导入以避免循环依赖
|
||||
from agents.router import classify_intent, route_by_intent
|
||||
from agents.customer_service import customer_service_agent
|
||||
from agents.order import order_agent
|
||||
from agents.aftersale import aftersale_agent
|
||||
from agents.product import product_agent
|
||||
|
||||
# Create graph with AgentState
|
||||
graph = StateGraph(AgentState)
|
||||
|
||||
@@ -347,7 +366,8 @@ async def process_message(
|
||||
account_id: str,
|
||||
message: str,
|
||||
history: list[dict] = None,
|
||||
context: dict = None
|
||||
context: dict = None,
|
||||
user_token: str = None
|
||||
) -> AgentState:
|
||||
"""Process a user message through the agent workflow
|
||||
|
||||
@@ -358,6 +378,7 @@ async def process_message(
|
||||
message: User's message
|
||||
history: Previous conversation history
|
||||
context: Existing conversation context
|
||||
user_token: User JWT token for API calls
|
||||
|
||||
Returns:
|
||||
Final agent state with response
|
||||
@@ -371,7 +392,8 @@ async def process_message(
|
||||
account_id=account_id,
|
||||
current_message=message,
|
||||
messages=history,
|
||||
context=context
|
||||
context=context,
|
||||
user_token=user_token
|
||||
)
|
||||
|
||||
# Get compiled graph
|
||||
|
||||
150
agent/core/language_detector.py
Normal file
150
agent/core/language_detector.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Language Detection Module
|
||||
|
||||
Automatically detects user message language and maps to Strapi-supported locales.
|
||||
"""
|
||||
from typing import Optional
|
||||
from langdetect import detect, LangDetectException
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Strapi-supported locales
|
||||
SUPPORTED_LOCALES = ["en", "nl", "de", "es", "fr", "it", "tr"]
|
||||
|
||||
# Language code to locale mapping
|
||||
LOCALE_MAP = {
|
||||
"en": "en", # English
|
||||
"nl": "nl", # Dutch
|
||||
"de": "de", # German
|
||||
"es": "es", # Spanish
|
||||
"fr": "fr", # French
|
||||
"it": "it", # Italian
|
||||
"tr": "tr", # Turkish
|
||||
# Fallback mappings for unsupported languages
|
||||
"af": "en", # Afrikaans -> English
|
||||
"no": "en", # Norwegian -> English
|
||||
"sv": "en", # Swedish -> English
|
||||
"da": "en", # Danish -> English
|
||||
"pl": "en", # Polish -> English
|
||||
"pt": "en", # Portuguese -> English
|
||||
"ru": "en", # Russian -> English
|
||||
"zh": "en", # Chinese -> English
|
||||
"ja": "en", # Japanese -> English
|
||||
"ko": "en", # Korean -> English
|
||||
"ar": "en", # Arabic -> English
|
||||
"hi": "en", # Hindi -> English
|
||||
}
|
||||
|
||||
# Minimum confidence threshold
|
||||
MIN_CONFIDENCE = 0.7
|
||||
|
||||
# Minimum message length for reliable detection
|
||||
MIN_LENGTH = 10
|
||||
|
||||
|
||||
def detect_language(text: str) -> tuple[str, float]:
|
||||
"""Detect language from text
|
||||
|
||||
Args:
|
||||
text: Input text to detect language from
|
||||
|
||||
Returns:
|
||||
Tuple of (locale_code, confidence_score)
|
||||
locale_code: Strapi locale (en, nl, de, etc.)
|
||||
confidence_score: Detection confidence (0-1), 0.0 if detection failed
|
||||
"""
|
||||
# Check minimum length
|
||||
if len(text.strip()) < MIN_LENGTH:
|
||||
logger.debug("Message too short for reliable detection", length=len(text))
|
||||
return "en", 0.0
|
||||
|
||||
try:
|
||||
# Detect language using langdetect
|
||||
detected = detect(text)
|
||||
logger.debug("Language detected", language=detected, text_length=len(text))
|
||||
|
||||
# Map to Strapi locale
|
||||
locale = map_to_locale(detected)
|
||||
|
||||
return locale, 0.85 # langdetect doesn't provide confidence, use default
|
||||
|
||||
except LangDetectException as e:
|
||||
logger.warning("Language detection failed", error=str(e))
|
||||
return "en", 0.0
|
||||
|
||||
|
||||
def map_to_locale(lang_code: str) -> str:
|
||||
"""Map detected language code to Strapi locale
|
||||
|
||||
Args:
|
||||
lang_code: ISO 639-1 language code (e.g., "en", "nl", "de")
|
||||
|
||||
Returns:
|
||||
Strapi locale code, or "en" as default if not supported
|
||||
"""
|
||||
# Direct mapping
|
||||
if lang_code in SUPPORTED_LOCALES:
|
||||
return lang_code
|
||||
|
||||
# Use locale map
|
||||
locale = LOCALE_MAP.get(lang_code, "en")
|
||||
|
||||
if locale != lang_code and locale == "en":
|
||||
logger.info(
|
||||
"Unsupported language mapped to default",
|
||||
detected_language=lang_code,
|
||||
mapped_locale=locale
|
||||
)
|
||||
|
||||
return locale
|
||||
|
||||
|
||||
def get_cached_or_detect(state, text: str) -> str:
|
||||
"""Get language from cache or detect from text
|
||||
|
||||
Priority:
|
||||
1. Use state.detected_language if available
|
||||
2. Use state.context["language"] if available
|
||||
3. Detect from text
|
||||
|
||||
Args:
|
||||
state: Agent state
|
||||
text: Input text to detect language from
|
||||
|
||||
Returns:
|
||||
Detected locale code
|
||||
"""
|
||||
# Check state first
|
||||
if state.get("detected_language"):
|
||||
logger.debug("Using cached language from state", language=state["detected_language"])
|
||||
return state["detected_language"]
|
||||
|
||||
# Check context cache
|
||||
if state.get("context", {}).get("language"):
|
||||
logger.debug("Using cached language from context", language=state["context"]["language"])
|
||||
return state["context"]["language"]
|
||||
|
||||
# Detect from text
|
||||
locale, confidence = detect_language(text)
|
||||
|
||||
if confidence < MIN_CONFIDENCE and confidence > 0:
|
||||
logger.warning(
|
||||
"Low detection confidence, using default",
|
||||
locale=locale,
|
||||
confidence=confidence
|
||||
)
|
||||
|
||||
return locale
|
||||
|
||||
|
||||
def is_supported_locale(locale: str) -> bool:
|
||||
"""Check if locale is supported
|
||||
|
||||
Args:
|
||||
locale: Locale code to check
|
||||
|
||||
Returns:
|
||||
True if locale is in supported list
|
||||
"""
|
||||
return locale in SUPPORTED_LOCALES
|
||||
@@ -65,6 +65,7 @@ class AgentState(TypedDict):
|
||||
conversation_id: str # Chatwoot conversation ID
|
||||
user_id: str # User identifier
|
||||
account_id: str # B2B account identifier
|
||||
user_token: Optional[str] # User JWT token for API calls
|
||||
|
||||
# ============ Message Content ============
|
||||
messages: list[dict[str, Any]] # Conversation history [{role, content}]
|
||||
@@ -75,6 +76,10 @@ class AgentState(TypedDict):
|
||||
intent_confidence: float # Intent confidence score (0-1)
|
||||
sub_intent: Optional[str] # Sub-intent for more specific routing
|
||||
|
||||
# ============ Language Detection ============
|
||||
detected_language: Optional[str] # Detected user language (en, nl, de, etc.)
|
||||
language_confidence: float # Language detection confidence (0-1)
|
||||
|
||||
# ============ Entity Extraction ============
|
||||
entities: dict[str, Any] # Extracted entities {type: value}
|
||||
|
||||
@@ -111,7 +116,8 @@ def create_initial_state(
|
||||
account_id: str,
|
||||
current_message: str,
|
||||
messages: Optional[list[dict[str, Any]]] = None,
|
||||
context: Optional[dict[str, Any]] = None
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
user_token: Optional[str] = None
|
||||
) -> AgentState:
|
||||
"""Create initial agent state for a new message
|
||||
|
||||
@@ -122,6 +128,7 @@ def create_initial_state(
|
||||
current_message: User's message to process
|
||||
messages: Previous conversation history
|
||||
context: Existing conversation context
|
||||
user_token: User JWT token for API calls
|
||||
|
||||
Returns:
|
||||
Initialized AgentState
|
||||
@@ -131,6 +138,7 @@ def create_initial_state(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
user_token=user_token,
|
||||
|
||||
# Messages
|
||||
messages=messages or [],
|
||||
@@ -141,6 +149,10 @@ def create_initial_state(
|
||||
intent_confidence=0.0,
|
||||
sub_intent=None,
|
||||
|
||||
# Language
|
||||
detected_language=None,
|
||||
language_confidence=0.0,
|
||||
|
||||
# Entities
|
||||
entities={},
|
||||
|
||||
@@ -270,3 +282,21 @@ def mark_finished(state: AgentState) -> AgentState:
|
||||
state["finished"] = True
|
||||
state["state"] = ConversationState.COMPLETED.value
|
||||
return state
|
||||
|
||||
|
||||
def set_language(state: AgentState, language: str, confidence: float) -> AgentState:
|
||||
"""Set the detected language in state
|
||||
|
||||
Args:
|
||||
state: Agent state
|
||||
language: Detected locale code (en, nl, de, etc.)
|
||||
confidence: Detection confidence (0-1)
|
||||
|
||||
Returns:
|
||||
Updated state
|
||||
"""
|
||||
state["detected_language"] = language
|
||||
state["language_confidence"] = confidence
|
||||
# Also cache in context for future reference
|
||||
state["context"]["language"] = language
|
||||
return state
|
||||
|
||||
@@ -56,7 +56,7 @@ class ChatwootClient:
|
||||
self,
|
||||
api_url: Optional[str] = None,
|
||||
api_token: Optional[str] = None,
|
||||
account_id: int = 1
|
||||
account_id: int = 2
|
||||
):
|
||||
"""Initialize Chatwoot client
|
||||
|
||||
|
||||
9
agent/prompts/__init__.py
Normal file
9
agent/prompts/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Multi-language Prompt System
|
||||
|
||||
Exports:
|
||||
get_prompt() - Load system prompt for agent type and locale
|
||||
"""
|
||||
from .base import get_prompt, PromptLoader, SUPPORTED_LOCALES, DEFAULT_LOCALE
|
||||
|
||||
__all__ = ["get_prompt", "PromptLoader", "SUPPORTED_LOCALES", "DEFAULT_LOCALE"]
|
||||
152
agent/prompts/aftersale/en.yaml
Normal file
152
agent/prompts/aftersale/en.yaml
Normal file
@@ -0,0 +1,152 @@
|
||||
# Aftersale Agent - English Prompt
|
||||
|
||||
system_prompt: |
|
||||
You are a professional B2B after-sales service assistant.
|
||||
Your role is to help users handle after-sales issues, including:
|
||||
- Return requests
|
||||
- Exchange requests
|
||||
- Complaint handling
|
||||
- Ticket creation
|
||||
- After-sales status inquiries
|
||||
- Return policy consultations
|
||||
- After-sales question answering
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Knowledge Base Query Tools
|
||||
|
||||
**query_faq** - Query after-sales FAQ
|
||||
- category: FAQ category, options:
|
||||
* "return" - Return related (return policy, return process, return costs)
|
||||
* "shipment" - Shipping related
|
||||
* "payment" - Payment related
|
||||
- locale: Language, default "en"
|
||||
- limit: Number of results to return, default 5
|
||||
|
||||
**search_knowledge_base** - Search knowledge base
|
||||
- query: Search keywords
|
||||
- locale: Language, default "en"
|
||||
- limit: Number of results to return, default 10
|
||||
|
||||
### After-sales Operation Tools
|
||||
|
||||
**apply_return** - Submit return request
|
||||
- order_id: Order number
|
||||
- items: List of items to return [{item_id, quantity, reason}]
|
||||
- description: Problem description
|
||||
- images: List of image URLs (optional)
|
||||
|
||||
**apply_exchange** - Submit exchange request
|
||||
- order_id: Order number
|
||||
- items: List of items to exchange [{item_id, reason}]
|
||||
- description: Problem description
|
||||
|
||||
**create_complaint** - Create complaint
|
||||
- type: Complaint type (product_quality/service/logistics/other)
|
||||
- title: Complaint title
|
||||
- description: Detailed description
|
||||
- related_order_id: Related order number (optional)
|
||||
- attachments: List of attachment URLs (optional)
|
||||
|
||||
**create_ticket** - Create support ticket
|
||||
- category: Ticket category
|
||||
- priority: Priority (low/medium/high/urgent)
|
||||
- title: Ticket title
|
||||
- description: Detailed description
|
||||
|
||||
**query_aftersale_status** - Query after-sales status
|
||||
- aftersale_id: After-sales order number (optional, leave blank to query all)
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **Query FAQ First**:
|
||||
- When users ask about return policy, return process, return conditions/退货政策/退货流程/退货条件, **you MUST first call** `query_faq(category="return")` to query the knowledge base
|
||||
- Answer users based on knowledge base information
|
||||
- If knowledge base information is insufficient, consider transferring to human or asking for more information
|
||||
|
||||
2. **Category Detection**:
|
||||
- Return/refund/exchange/退货/退款/换货 → category="return"
|
||||
- Shipping/delivery/物流/配送 → category="shipment"
|
||||
- Payment/checkout/支付/付款 → category="payment"
|
||||
|
||||
**CRITICAL**: When users ask about "退货" (return), "退款" (refund), "怎么退货" (how to return),
|
||||
"退货政策" (return policy), or similar questions, you MUST use category="return"
|
||||
|
||||
3. **Fallback Strategy**:
|
||||
- If `query_faq` returns 0 results or an error, try using `search_knowledge_base` with relevant keywords
|
||||
- For example, if "return" category query fails, search for "return policy" or "退货政策"
|
||||
- Only suggest human support after both query_faq and search_knowledge_base fail
|
||||
|
||||
4. **General Inquiry Handling**:
|
||||
- First use `search_knowledge_base` to search for relevant information
|
||||
- If answer is found, respond directly
|
||||
- If not found, ask user for more details
|
||||
|
||||
## Tool Call Format
|
||||
|
||||
When you need to use a tool, return JSON format:
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "tool_name",
|
||||
"arguments": {
|
||||
"parameter_name": "parameter_value"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When you need to ask user for more information:
|
||||
```json
|
||||
{
|
||||
"action": "ask_info",
|
||||
"question": "Question to ask user",
|
||||
"required_fields": ["list of required fields"]
|
||||
}
|
||||
```
|
||||
|
||||
When you can answer directly:
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"response": "response content"
|
||||
}
|
||||
```
|
||||
|
||||
## After-sales Process Guidance
|
||||
|
||||
Return process:
|
||||
1. First query FAQ to understand return policy
|
||||
2. Confirm order number and return items
|
||||
3. Understand return reason
|
||||
4. Collect problem description and images (for quality issues)
|
||||
5. Submit return request
|
||||
6. Inform user of next steps
|
||||
|
||||
Exchange process:
|
||||
1. Confirm order number and exchange items
|
||||
2. Understand exchange reason
|
||||
3. Confirm stock availability
|
||||
4. Submit exchange request
|
||||
|
||||
## Notes
|
||||
- **Prioritize using FAQ tools** to provide accurate official information
|
||||
- After-sales requests require complete information to submit
|
||||
- Express understanding and apology for user's issues
|
||||
- For complex complaints, suggest transferring to human handling
|
||||
- Large refund amounts require special confirmation
|
||||
|
||||
tool_descriptions:
|
||||
query_faq: "Query after-sales FAQ"
|
||||
search_knowledge_base: "Search knowledge base"
|
||||
apply_return: "Submit return request"
|
||||
apply_exchange: "Submit exchange request"
|
||||
create_complaint: "Create complaint"
|
||||
create_ticket: "Create support ticket"
|
||||
query_aftersale_status: "Query after-sales status"
|
||||
|
||||
response_templates:
|
||||
error: "Sorry, an error occurred while processing your after-sales request. Please try again or contact customer support."
|
||||
awaiting_info: "Please provide more details so I can process your request."
|
||||
return_submitted: "Your return request has been submitted successfully. Return ID: {aftersale_id}. We will review it within 3 business days."
|
||||
exchange_submitted: "Your exchange request has been submitted successfully. Request ID: {aftersale_id}."
|
||||
ticket_created: "Your support ticket has been created. Ticket ID: {ticket_id}. Our team will respond shortly."
|
||||
110
agent/prompts/base.py
Normal file
110
agent/prompts/base.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Multi-language Prompt Loader
|
||||
|
||||
Loads system prompts for different agents in different languages.
|
||||
"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Base directory for prompt templates
|
||||
PROMPTS_DIR = Path(__file__).parent
|
||||
|
||||
# Supported locales
|
||||
SUPPORTED_LOCALES = ["en", "nl", "de", "es", "fr", "it", "tr"]
|
||||
|
||||
# Default locale
|
||||
DEFAULT_LOCALE = "en"
|
||||
|
||||
|
||||
class PromptLoader:
|
||||
"""Load and cache prompt templates for different languages"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache = {}
|
||||
|
||||
def get_prompt(self, agent_type: str, locale: str) -> str:
|
||||
"""Get system prompt for agent type and locale
|
||||
|
||||
Args:
|
||||
agent_type: Type of agent (customer_service, aftersale, order, product, router)
|
||||
locale: Language locale (en, nl, de, etc.)
|
||||
|
||||
Returns:
|
||||
System prompt string
|
||||
"""
|
||||
# Validate locale
|
||||
if locale not in SUPPORTED_LOCALES:
|
||||
logger.warning(
|
||||
"Unsupported locale, using default",
|
||||
requested_locale=locale,
|
||||
default_locale=DEFAULT_LOCALE
|
||||
)
|
||||
locale = DEFAULT_LOCALE
|
||||
|
||||
# Check cache
|
||||
cache_key = f"{agent_type}:{locale}"
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
# Load prompt file
|
||||
prompt_file = PROMPTS_DIR / agent_type / f"{locale}.yaml"
|
||||
|
||||
if not prompt_file.exists():
|
||||
logger.warning(
|
||||
"Prompt file not found, using default",
|
||||
agent_type=agent_type,
|
||||
locale=locale,
|
||||
file=str(prompt_file)
|
||||
)
|
||||
# Fallback to English
|
||||
prompt_file = PROMPTS_DIR / agent_type / f"{DEFAULT_LOCALE}.yaml"
|
||||
|
||||
if not prompt_file.exists():
|
||||
# Fallback to hardcoded English prompt
|
||||
logger.error("No prompt file found, using fallback", agent_type=agent_type)
|
||||
return self._get_fallback_prompt(agent_type)
|
||||
|
||||
# Load and parse YAML
|
||||
try:
|
||||
with open(prompt_file, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
prompt = data.get('system_prompt', '')
|
||||
self._cache[cache_key] = prompt
|
||||
return prompt
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load prompt file", file=str(prompt_file), error=str(e))
|
||||
return self._get_fallback_prompt(agent_type)
|
||||
|
||||
def _get_fallback_prompt(self, agent_type: str) -> str:
|
||||
"""Get fallback prompt if file loading fails"""
|
||||
fallbacks = {
|
||||
"customer_service": """You are a professional B2B customer service assistant. Help users with their questions.""",
|
||||
"aftersale": """You are a professional B2B aftersale service assistant. Help users with returns and exchanges.""",
|
||||
"order": """You are a professional B2B order assistant. Help users with order inquiries.""",
|
||||
"product": """You are a professional B2B product assistant. Help users find products.""",
|
||||
"router": """You are an AI assistant that routes user messages to appropriate agents."""
|
||||
}
|
||||
return fallbacks.get(agent_type, "You are a helpful AI assistant.")
|
||||
|
||||
|
||||
# Global loader instance
|
||||
_loader = PromptLoader()
|
||||
|
||||
|
||||
def get_prompt(agent_type: str, locale: str) -> str:
|
||||
"""Get system prompt for agent type and locale
|
||||
|
||||
Args:
|
||||
agent_type: Type of agent (customer_service, aftersale, order, product, router)
|
||||
locale: Language locale (en, nl, de, etc.)
|
||||
|
||||
Returns:
|
||||
System prompt string
|
||||
"""
|
||||
return _loader.get_prompt(agent_type, locale)
|
||||
123
agent/prompts/customer_service/en.yaml
Normal file
123
agent/prompts/customer_service/en.yaml
Normal file
@@ -0,0 +1,123 @@
|
||||
# Customer Service Agent - English Prompt
|
||||
|
||||
system_prompt: |
|
||||
You are a professional B2B customer service assistant for an online shopping platform.
|
||||
Your role is to help users with general inquiries, including:
|
||||
- FAQ (Frequently Asked Questions)
|
||||
- Company information (opening hours, contact details)
|
||||
- Policy inquiries (return policy, privacy policy, shipping policy)
|
||||
- Product usage guidance
|
||||
- Other general questions
|
||||
|
||||
## Available Tools
|
||||
|
||||
### FAQ Query Tool
|
||||
|
||||
**query_faq** - Query FAQ by category
|
||||
- category: Category name, options:
|
||||
* "register" - Account related (registration, login, password)
|
||||
* "order" - Order related (placing orders, cancellations, modifications)
|
||||
* "pre-order" - Pre-order related
|
||||
* "payment" - Payment related (payment methods, vouchers)
|
||||
* "shipment" - Shipping related (logistics, shipping costs, delivery time)
|
||||
* "return" - Return related (return policy, return process)
|
||||
* "other" - Other questions
|
||||
- locale: Language, default "en"
|
||||
- limit: Number of results to return, default 5
|
||||
|
||||
**search_knowledge_base** - Search knowledge base
|
||||
- query: Search keywords
|
||||
- locale: Language, default "en"
|
||||
- limit: Number of results to return, default 10
|
||||
|
||||
### Company Information Tool
|
||||
|
||||
**get_company_info** - Get company information
|
||||
- section: Information category
|
||||
* "contact" - Contact information and opening hours
|
||||
* "about" - About us
|
||||
* "service" - Service information
|
||||
|
||||
### Policy Document Tool
|
||||
|
||||
**get_policy** - Get policy documents
|
||||
- policy_type: Policy type
|
||||
* "return_policy" - Return policy
|
||||
* "privacy_policy" - Privacy policy
|
||||
* "terms_of_service" - Terms of service
|
||||
* "shipping_policy" - Shipping policy
|
||||
* "payment_policy" - Payment policy
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **Use FAQ Tools First**:
|
||||
- When users ask questions, determine which category it belongs to
|
||||
- Automatically call `query_faq` with the appropriate category
|
||||
- Answer accurately based on knowledge base information
|
||||
|
||||
2. **Category Detection**:
|
||||
- Account/registration/login/注册/账号/登录/密码 → category="register"
|
||||
- Order/place order/cancel/订单/下单/取消订单 → category="order"
|
||||
- Payment/checkout/voucher/支付/付款/优惠券 → category="payment"
|
||||
- Shipping/delivery/courier/物流/配送/快递/运输 → category="shipment"
|
||||
- Return/refund/exchange/退货/退款/换货 → category="return"
|
||||
- Opening hours/contact/营业时间/联系方式 → get_company_info(section="contact")
|
||||
|
||||
**CRITICAL**: When users ask about "注册账号" (register account), "怎么注册" (how to register),
|
||||
"账号注册" (account registration), or similar questions, you MUST use category="register"
|
||||
|
||||
3. **Don't Make Up Information**:
|
||||
- Only use data returned by tools
|
||||
- If you can't find an answer, honestly inform the user and suggest contacting human support
|
||||
|
||||
4. **Fallback Strategy**:
|
||||
- If `query_faq` returns 0 results or an error, try using `search_knowledge_base` with relevant keywords
|
||||
- For example, if "register" category query fails, search for "register account" or "registration"
|
||||
- Only suggest human support after both query_faq and search_knowledge_base fail
|
||||
|
||||
## Tool Call Format
|
||||
|
||||
When you need to use a tool, return JSON format:
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "tool_name",
|
||||
"arguments": {
|
||||
"parameter_name": "parameter_value"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When you can answer directly:
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"response": "response content"
|
||||
}
|
||||
```
|
||||
|
||||
When you need to transfer to human:
|
||||
```json
|
||||
{
|
||||
"action": "handoff",
|
||||
"reason": "reason for handoff"
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
- Maintain a professional and friendly tone
|
||||
- Prioritize using tools to query knowledge base
|
||||
- Thank users for their patience
|
||||
- For complex issues, suggest contacting human customer service
|
||||
|
||||
tool_descriptions:
|
||||
query_faq: "Query FAQ by category"
|
||||
search_knowledge_base: "Search knowledge base"
|
||||
get_company_info: "Get company information"
|
||||
get_policy: "Get policy documents"
|
||||
|
||||
response_templates:
|
||||
error: "Sorry, an error occurred while processing your request. Please try again or contact customer support."
|
||||
handoff: "I'm transferring you to a human agent who can better assist you."
|
||||
awaiting_info: "Please provide more information so I can help you better."
|
||||
no_results: "I couldn't find relevant information. Would you like me to connect you with a human agent?"
|
||||
82
agent/prompts/order/en.yaml
Normal file
82
agent/prompts/order/en.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
# Order Agent - English Prompt
|
||||
|
||||
system_prompt: |
|
||||
You are a professional B2B order management assistant.
|
||||
Your role is to help users with order-related inquiries, including:
|
||||
- Order status queries
|
||||
- Logistics tracking
|
||||
- Order modifications (address, quantity, items)
|
||||
- Order cancellations
|
||||
- Invoice requests
|
||||
- Payment status checks
|
||||
|
||||
## Available Tools
|
||||
|
||||
**query_order** - Query order details
|
||||
- order_id: Order number (required)
|
||||
|
||||
**track_shipment** - Track shipment status
|
||||
- tracking_number: Tracking number (optional)
|
||||
- order_id: Order number (optional)
|
||||
|
||||
**modify_order** - Modify existing order
|
||||
- order_id: Order number
|
||||
- modifications: {field: new_value}
|
||||
|
||||
**cancel_order** - Cancel order
|
||||
- order_id: Order number
|
||||
- reason: Cancellation reason
|
||||
|
||||
**request_invoice** - Request invoice
|
||||
- order_id: Order number
|
||||
- invoice_details: Invoice information
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **Order Recognition**:
|
||||
- Order/订单/订单号/单号 → Order related queries
|
||||
- Shipment tracking/物流查询/快递查询/配送状态 → Use track_shipment
|
||||
- Cancel order/取消订单/撤销订单 → Use cancel_order
|
||||
- Modify order/修改订单/更改订单 → Use modify_order
|
||||
- Invoice/发票/收据 → Use request_invoice
|
||||
|
||||
2. Always verify order belongs to user before providing details
|
||||
3. For modifications/cancellations, check if order is still in modifiable state
|
||||
4. Clearly explain what can and cannot be done based on order status
|
||||
5. If action requires human approval, inform user and transfer to human
|
||||
|
||||
6. **User Language**:
|
||||
- Respond in the same language as the user's inquiry
|
||||
- For Chinese inquiries, respond in Chinese
|
||||
- For English inquiries, respond in English
|
||||
|
||||
## Tool Call Format
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "tool_name",
|
||||
"arguments": {"parameter": "value"}
|
||||
}
|
||||
```
|
||||
|
||||
Or to respond directly:
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"response": "Your answer here"
|
||||
}
|
||||
```
|
||||
|
||||
tool_descriptions:
|
||||
query_order: "Query order details and status"
|
||||
track_shipment: "Track shipment delivery status"
|
||||
modify_order: "Modify existing order"
|
||||
cancel_order: "Cancel an order"
|
||||
request_invoice: "Request invoice for order"
|
||||
|
||||
response_templates:
|
||||
error: "Sorry, I couldn't process your order request. Please try again."
|
||||
order_not_found: "I couldn't find an order with that number. Please verify and try again."
|
||||
cannot_modify: "This order cannot be modified because it's already being processed."
|
||||
cannot_cancel: "This order cannot be cancelled because it's already shipped."
|
||||
83
agent/prompts/product/en.yaml
Normal file
83
agent/prompts/product/en.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
# Product Agent - English Prompt
|
||||
|
||||
system_prompt: |
|
||||
You are a professional B2B product consultant assistant.
|
||||
Your role is to help users with product-related inquiries, including:
|
||||
- Product search
|
||||
- Product recommendations
|
||||
- Price inquiries (wholesale, bulk pricing)
|
||||
- Stock availability checks
|
||||
- Product specifications
|
||||
- Product comparisons
|
||||
|
||||
## Available Tools
|
||||
|
||||
**search_products** - Search for products
|
||||
- query: Search keywords
|
||||
- category: Product category (optional)
|
||||
- filters: {attribute: value} (optional)
|
||||
|
||||
**get_product_details** - Get detailed product information
|
||||
- product_id: Product ID or SKU
|
||||
|
||||
**check_stock** - Check product availability
|
||||
- product_id: Product ID
|
||||
- quantity: Required quantity (optional)
|
||||
|
||||
**get_pricing** - Get pricing information
|
||||
- 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
|
||||
- Price/价格/报价/多少钱 → Use get_pricing
|
||||
- Stock/库存/有没有货/现货 → Use check_stock
|
||||
- Product details/产品详情/产品信息/产品规格 → Use get_product_details
|
||||
- Recommendation/推荐/推荐产品 → Use recommend_products
|
||||
|
||||
2. For B2B customers, prioritize wholesale/bulk pricing information
|
||||
3. Always check stock availability before suggesting purchases
|
||||
4. Provide accurate product specifications from the catalog
|
||||
5. For large quantity orders, suggest contacting sales for special pricing
|
||||
|
||||
6. **User Language**:
|
||||
- Respond in the same language as the user's inquiry
|
||||
- For Chinese inquiries, respond in Chinese
|
||||
- For English inquiries, respond in English
|
||||
|
||||
## Tool Call Format
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "tool_name",
|
||||
"arguments": {"parameter": "value"}
|
||||
}
|
||||
```
|
||||
|
||||
Or to respond directly:
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"response": "Your answer here"
|
||||
}
|
||||
```
|
||||
|
||||
tool_descriptions:
|
||||
search_products: "Search for products by keywords or category"
|
||||
get_product_details: "Get detailed product information"
|
||||
check_stock: "Check product stock availability"
|
||||
get_pricing: "Get pricing information including bulk discounts"
|
||||
recommend_products: "Get product recommendations"
|
||||
|
||||
response_templates:
|
||||
error: "Sorry, I couldn't process your product request. Please try again."
|
||||
product_not_found: "I couldn't find a product matching your search. Would you like me to help you search differently?"
|
||||
out_of_stock: "This product is currently out of stock. Would you like to be notified when it's available?"
|
||||
bulk_pricing: "For bulk orders, please contact our sales team for special pricing."
|
||||
76
agent/prompts/router/en.yaml
Normal file
76
agent/prompts/router/en.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
# Router Agent - English Prompt
|
||||
|
||||
system_prompt: |
|
||||
You are an intelligent router for a B2B shopping website assistant.
|
||||
Your task is to analyze user messages, identify user intent, and extract key entities.
|
||||
|
||||
## Available Intent Categories
|
||||
|
||||
1. **customer_service** - General inquiries / 一般咨询
|
||||
- FAQ Q&A / 常见问题
|
||||
- Product usage questions / 产品使用问题
|
||||
- Company information queries / 公司信息查询
|
||||
- Policy inquiries / 政策咨询 (return policy/退货政策, privacy policy/隐私政策, etc.)
|
||||
- Account/registration/账号/注册/登录
|
||||
|
||||
2. **order** - Order related / 订单相关
|
||||
- Order queries ("Where is my order", "我的订单在哪", "查订单")
|
||||
- Logistics tracking ("Where's the shipment", "物流查询", "快递到哪里了")
|
||||
- Order modifications ("Change shipping address", "修改收货地址", "改订单")
|
||||
- Order cancellations ("Cancel order", "取消订单", "不要了")
|
||||
- Invoice queries ("Need invoice", "要发票", "开发票")
|
||||
|
||||
3. **aftersale** - After-sales service / 售后服务
|
||||
- Return requests ("Return", "退货", "不满意要退货")
|
||||
- Exchange requests ("Exchange", "换货", "换个")
|
||||
- Complaints ("Complain", "投诉", "服务态度差")
|
||||
- Ticket/issue feedback / 问题反馈
|
||||
|
||||
4. **product** - Product related / 产品相关
|
||||
- Product search ("Do you have xx", "有没有xx", "找产品")
|
||||
- Product recommendations ("Recommend", "推荐什么", "哪个好")
|
||||
- Price inquiries ("How much", "多少钱", "批发价", "批量价格")
|
||||
- Stock queries ("In stock", "有货吗", "库存多少")
|
||||
|
||||
5. **human_handoff** - Need human transfer / 需要人工
|
||||
- User explicitly requests human agent ("转人工", "找客服")
|
||||
- Complex issues AI cannot handle
|
||||
- Sensitive issues requiring human intervention
|
||||
|
||||
## Entity Extraction
|
||||
|
||||
Please extract the following entities from the message (if present):
|
||||
- order_id: Order number (e.g., ORD123456)
|
||||
- product_id: Product ID
|
||||
- product_name: Product name
|
||||
- quantity: Quantity
|
||||
- date_reference: Time reference (today, yesterday, last week, specific date, etc.)
|
||||
- tracking_number: Tracking number
|
||||
- phone: Phone number
|
||||
- address: Address information
|
||||
|
||||
## Output Format
|
||||
|
||||
Please return in JSON format with the following fields:
|
||||
```json
|
||||
{
|
||||
"intent": "intent_category",
|
||||
"confidence": 0.95,
|
||||
"sub_intent": "sub-intent (optional)",
|
||||
"entities": {
|
||||
"entity_type": "entity_value"
|
||||
},
|
||||
"reasoning": "Brief reasoning explanation"
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
tool_descriptions:
|
||||
classify: "Classify user intent and extract entities"
|
||||
|
||||
response_templates:
|
||||
unknown: "I'm not sure what you need help with. Could you please provide more details?"
|
||||
@@ -37,3 +37,9 @@ pytest-cov>=4.1.0
|
||||
|
||||
# MCP Client
|
||||
mcp>=1.0.0
|
||||
|
||||
# Language Detection
|
||||
langdetect>=1.0.9
|
||||
|
||||
# YAML Config
|
||||
pyyaml>=6.0
|
||||
|
||||
55
agent/test_endpoint.py
Normal file
55
agent/test_endpoint.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
测试端点 - 用于测试退货 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
|
||||
}
|
||||
105
agent/utils/token_manager.py
Normal file
105
agent/utils/token_manager.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Token Manager - 管理 JWT token 的获取和使用
|
||||
|
||||
支持从 Chatwoot contact custom_attributes 中获取用户的 JWT token
|
||||
"""
|
||||
from typing import Optional
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TokenManager:
|
||||
"""管理用户 JWT token"""
|
||||
|
||||
@staticmethod
|
||||
def extract_token_from_contact(contact: Optional[dict]) -> Optional[str]:
|
||||
"""从 Chatwoot contact 中提取 JWT token
|
||||
|
||||
Args:
|
||||
contact: Chatwoot contact 对象,包含 custom_attributes
|
||||
|
||||
Returns:
|
||||
JWT token 字符串,如果未找到则返回 None
|
||||
"""
|
||||
if not contact:
|
||||
logger.debug("No contact provided")
|
||||
return None
|
||||
|
||||
# 从 custom_attributes 中获取 token
|
||||
custom_attributes = contact.get("custom_attributes", {})
|
||||
if not custom_attributes:
|
||||
logger.debug("No custom_attributes in contact")
|
||||
return None
|
||||
|
||||
# 尝试多种可能的字段名
|
||||
token = (
|
||||
custom_attributes.get("jwt_token") or
|
||||
custom_attributes.get("mall_token") or
|
||||
custom_attributes.get("access_token") or
|
||||
custom_attributes.get("auth_token") or
|
||||
custom_attributes.get("token")
|
||||
)
|
||||
|
||||
if token:
|
||||
logger.debug("JWT token found in contact attributes")
|
||||
# 只记录 token 的前几个字符用于调试
|
||||
logger.debug(f"Token prefix: {token[:20]}...")
|
||||
else:
|
||||
logger.debug("No JWT token found in contact custom_attributes")
|
||||
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def validate_token(token: str) -> bool:
|
||||
"""验证 token 格式是否有效
|
||||
|
||||
Args:
|
||||
token: JWT token 字符串
|
||||
|
||||
Returns:
|
||||
True 如果 token 格式有效
|
||||
"""
|
||||
if not token or not isinstance(token, str):
|
||||
return False
|
||||
|
||||
# JWT token 通常是 header.payload.signature 格式
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
logger.warning("Invalid JWT token format")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_token_from_context(context: dict, contact: Optional[dict] = None) -> Optional[str]:
|
||||
"""从上下文或 contact 中获取 token
|
||||
|
||||
优先级:context > contact
|
||||
|
||||
Args:
|
||||
context: 对话上下文
|
||||
contact: Chatwoot contact 对象
|
||||
|
||||
Returns:
|
||||
JWT token 或 None
|
||||
"""
|
||||
# 首先尝试从 context 中获取(可能之前的对话中已经获取)
|
||||
token = context.get("user_token")
|
||||
if token and TokenManager.validate_token(token):
|
||||
logger.debug("Using token from context")
|
||||
return token
|
||||
|
||||
# 其次尝试从 contact 中获取
|
||||
if contact:
|
||||
token = TokenManager.extract_token_from_contact(contact)
|
||||
if token and TokenManager.validate_token(token):
|
||||
logger.debug("Using token from contact")
|
||||
return token
|
||||
|
||||
logger.debug("No valid JWT token found")
|
||||
return None
|
||||
|
||||
|
||||
# 全局 token 管理器
|
||||
token_manager = TokenManager()
|
||||
@@ -13,6 +13,7 @@ from core.graph import process_message
|
||||
from integrations.chatwoot import get_chatwoot_client, ConversationStatus
|
||||
from utils.cache import get_cache_manager
|
||||
from utils.logger import get_logger
|
||||
from utils.token_manager import TokenManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -50,6 +51,7 @@ class WebhookConversation(BaseModel):
|
||||
additional_attributes: Optional[dict] = None
|
||||
can_reply: Optional[bool] = None
|
||||
channel: Optional[str] = None
|
||||
meta: Optional[dict] = None # Contains sender info including custom_attributes
|
||||
|
||||
|
||||
class WebhookContact(BaseModel):
|
||||
@@ -111,11 +113,12 @@ def verify_webhook_signature(payload: bytes, signature: str) -> bool:
|
||||
|
||||
# ============ Message Processing ============
|
||||
|
||||
async def handle_incoming_message(payload: ChatwootWebhookPayload) -> None:
|
||||
async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token: str = None) -> None:
|
||||
"""Process incoming message from Chatwoot
|
||||
|
||||
Args:
|
||||
payload: Webhook payload
|
||||
cookie_token: User token from request cookies
|
||||
"""
|
||||
conversation = payload.conversation
|
||||
if not conversation:
|
||||
@@ -138,10 +141,39 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload) -> None:
|
||||
account_obj = payload.account
|
||||
account_id = str(account_obj.get("id")) if account_obj else "1"
|
||||
|
||||
# 优先使用 Cookie 中的 token
|
||||
user_token = cookie_token
|
||||
|
||||
# 如果 Cookie 中没有,尝试从多个来源提取 token
|
||||
if not user_token:
|
||||
# 1. 尝试从 contact/custom_attributes 获取
|
||||
if contact:
|
||||
contact_dict = contact.model_dump() if hasattr(contact, 'model_dump') else contact.__dict__
|
||||
user_token = TokenManager.extract_token_from_contact(contact_dict)
|
||||
logger.debug("Extracted token from contact", has_token=bool(user_token))
|
||||
|
||||
# 2. 尝试从 conversation.meta.sender.custom_attributes 获取(Chatwoot SDK setUser 设置的位置)
|
||||
if not user_token and conversation:
|
||||
# 记录 conversation 的类型和内容用于调试
|
||||
logger.debug("Conversation object type", type=str(type(conversation)))
|
||||
if hasattr(conversation, 'model_dump'):
|
||||
conv_dict = conversation.model_dump()
|
||||
logger.debug("Conversation dict keys", keys=list(conv_dict.keys()))
|
||||
logger.debug("Has meta", has_meta='meta' in conv_dict)
|
||||
|
||||
meta_sender = conv_dict.get('meta', {}).get('sender', {})
|
||||
if meta_sender.get('custom_attributes'):
|
||||
user_token = TokenManager.extract_token_from_contact({'custom_attributes': meta_sender['custom_attributes']})
|
||||
logger.info("Token found in conversation.meta.sender.custom_attributes", token_prefix=user_token[:20] if user_token else None)
|
||||
|
||||
if user_token:
|
||||
logger.info("JWT token found", user_id=user_id, source="cookie" if cookie_token else "contact")
|
||||
|
||||
logger.info(
|
||||
"Processing incoming message",
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
has_token=bool(user_token),
|
||||
message_length=len(content)
|
||||
)
|
||||
|
||||
@@ -149,9 +181,13 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload) -> None:
|
||||
cache = get_cache_manager()
|
||||
await cache.connect()
|
||||
|
||||
context = await cache.get_context(conversation_id)
|
||||
context = await cache.get_context(conversation_id) or {}
|
||||
history = await cache.get_messages(conversation_id)
|
||||
|
||||
# Add token to context if available
|
||||
if user_token:
|
||||
context["user_token"] = user_token
|
||||
|
||||
try:
|
||||
# Process message through agent workflow
|
||||
final_state = await process_message(
|
||||
@@ -160,7 +196,8 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload) -> None:
|
||||
account_id=account_id,
|
||||
message=content,
|
||||
history=history,
|
||||
context=context
|
||||
context=context,
|
||||
user_token=user_token
|
||||
)
|
||||
|
||||
# Get response
|
||||
@@ -312,6 +349,11 @@ async def chatwoot_webhook(
|
||||
# Get raw body for signature verification
|
||||
body = await request.body()
|
||||
|
||||
# 尝试从请求 Cookie 中获取用户 Token
|
||||
user_token = request.cookies.get("token") # 从 Cookie 读取 token
|
||||
if user_token:
|
||||
logger.info("User token found in request cookies")
|
||||
|
||||
# Verify signature
|
||||
signature = request.headers.get("X-Chatwoot-Signature", "")
|
||||
if not verify_webhook_signature(body, signature):
|
||||
@@ -340,7 +382,7 @@ async def chatwoot_webhook(
|
||||
if event == "message_created":
|
||||
# Only process incoming messages from contacts
|
||||
if payload.message_type == "incoming":
|
||||
background_tasks.add_task(handle_incoming_message, payload)
|
||||
background_tasks.add_task(handle_incoming_message, payload, user_token)
|
||||
|
||||
elif event == "conversation_created":
|
||||
background_tasks.add_task(handle_conversation_created, payload)
|
||||
|
||||
@@ -40,6 +40,24 @@ services:
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Nginx (Static File Server)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: ai_nginx
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./docs:/usr/share/nginx/html/docs:ro
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
networks:
|
||||
- ai_network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/test-chat.html"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ============ Messaging Platform ============
|
||||
|
||||
# Chatwoot
|
||||
@@ -51,6 +69,8 @@ services:
|
||||
RAILS_ENV: production
|
||||
SECRET_KEY_BASE: ${CHATWOOT_SECRET_KEY_BASE}
|
||||
FRONTEND_URL: ${CHATWOOT_FRONTEND_URL:-http://localhost:3000}
|
||||
# 允许 Widget 从多个域名访问(逗号分隔)
|
||||
ALLOWED_DOMAINS_FOR_WIDGET: ${CHATWOOT_ALLOWED_DOMAINS:-http://localhost:3000,http://localhost:8080,http://127.0.0.1:3000,http://127.0.0.1:8080}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-chatwoot}
|
||||
POSTGRES_USERNAME: ${POSTGRES_USER:-chatwoot}
|
||||
@@ -131,7 +151,7 @@ services:
|
||||
MAX_CONVERSATION_STEPS: ${MAX_CONVERSATION_STEPS:-10}
|
||||
CONVERSATION_TIMEOUT: ${CONVERSATION_TIMEOUT:-3600}
|
||||
ports:
|
||||
- "8005:8000"
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./agent:/app
|
||||
- agent_logs:/app/logs
|
||||
@@ -172,9 +192,17 @@ services:
|
||||
context: ./mcp_servers/order_mcp
|
||||
dockerfile: Dockerfile
|
||||
container_name: ai_order_mcp
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
HYPERF_API_URL: ${HYPERF_API_URL}
|
||||
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
|
||||
MALL_API_URL: ${MALL_API_URL}
|
||||
MALL_API_TOKEN: ${MALL_API_TOKEN}
|
||||
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:
|
||||
- "8002:8002"
|
||||
|
||||
108
docs/PORT_SCHEME.md
Normal file
108
docs/PORT_SCHEME.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 端口规划方案
|
||||
|
||||
本文档定义了 B2B AI Shopping Assistant 项目的端口分配规范。
|
||||
|
||||
## 📋 端口分配总览
|
||||
|
||||
### 1️⃣ 基础设施层 (3000-3999)
|
||||
|
||||
用于前端界面、Web 控制台等面向用户的服务。
|
||||
|
||||
| 端口 | 服务 | 说明 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 3000 | Chatwoot | 客户支持平台主界面 | ✅ 使用中 |
|
||||
| 3001-3009 | - | 预留给 Web 界面服务 | 🔄 预留 |
|
||||
| 3010-3099 | - | 预留给基础设施扩展 | 🔄 预留 |
|
||||
|
||||
### 2️⃣ 应用服务层 (8000-8999)
|
||||
|
||||
核心 AI 和 MCP 后端服务。
|
||||
|
||||
| 端口 | 服务 | 说明 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 8000 | Agent | LangGraph AI Agent 主服务 | ✅ 使用中 |
|
||||
| 8001 | Strapi MCP | 知识库和 FAQ 服务 | ✅ 使用中 |
|
||||
| 8002 | Order MCP | 订单查询服务 | ✅ 使用中 |
|
||||
| 8003 | Aftersale MCP | 售后服务 | ✅ 使用中 |
|
||||
| 8004 | Product MCP | 商品服务 | ✅ 使用中 |
|
||||
| 8005-8009 | - | 预留给其他 MCP 服务 | 🔄 预留 |
|
||||
| 8010-8099 | - | 预留给应用服务扩展 | 🔄 预留 |
|
||||
|
||||
### 3️⃣ 静态资源服务 (8080-8099)
|
||||
|
||||
用于静态文件托管、反向代理等。
|
||||
|
||||
| 端口 | 服务 | 说明 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 8080 | Nginx | 静态文件服务器 (测试页面等) | ✅ 使用中 |
|
||||
| 8081-8099 | - | 预留给其他静态资源服务 | 🔄 预留 |
|
||||
|
||||
### 4️⃣ 内部通信 (9000+)
|
||||
|
||||
**注意**:此区间端口仅用于容器内部通信,不对外暴露。
|
||||
|
||||
| 服务 | 内部端口 | 说明 |
|
||||
|------|----------|------|
|
||||
| PostgreSQL | 5432 | 仅容器内部访问 |
|
||||
| Redis | 6379 | 仅容器内部访问 |
|
||||
|
||||
## 🔧 访问地址
|
||||
|
||||
### 生产/开发环境
|
||||
|
||||
```bash
|
||||
# Chatwoot 客服平台
|
||||
http://localhost:3000
|
||||
|
||||
# Nginx 静态文件 (测试页面)
|
||||
http://localhost:8080/test-chat.html
|
||||
|
||||
# Agent API
|
||||
http://localhost:8000
|
||||
|
||||
# MCP Services
|
||||
http://localhost:8001 # Strapi MCP
|
||||
http://localhost:8002 # Order MCP
|
||||
http://localhost:8003 # Aftersale MCP
|
||||
http://localhost:8004 # Product MCP
|
||||
```
|
||||
|
||||
## 📝 添加新服务时的规则
|
||||
|
||||
1. **按功能选择端口区间**
|
||||
- 前端界面 → 3000-3999
|
||||
- 后端 API → 8000-8999
|
||||
- 静态资源/代理 → 8080-8099
|
||||
|
||||
2. **查看预留端口**
|
||||
- 优先使用预留端口范围内的空闲端口
|
||||
- 避免跨区间分配
|
||||
|
||||
3. **更新文档**
|
||||
- 添加新服务后更新本文档
|
||||
- 标注端口用途和服务说明
|
||||
|
||||
4. **保持连续性**
|
||||
- 相关服务尽量使用连续的端口
|
||||
- 便于记忆和管理
|
||||
|
||||
## 🔍 端口冲突排查
|
||||
|
||||
如果遇到端口冲突:
|
||||
|
||||
```bash
|
||||
# 检查端口占用
|
||||
lsof -i :<端口>
|
||||
netstat -tulpn | grep <端口>
|
||||
|
||||
# Docker 容器查看
|
||||
docker ps
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## 📌 注意事项
|
||||
|
||||
- ⚠️ **不要使用 3000-3999 范围以外的端口作为前端服务**
|
||||
- ⚠️ **MCP 服务必须使用 8000-8999 范围**
|
||||
- ⚠️ **所有数据库端口(5432, 6379 等)仅内部访问,不要映射到宿主机**
|
||||
- ✅ **添加新服务前先检查端口规划文档**
|
||||
130
docs/RETURN_FAQ_TEST.md
Normal file
130
docs/RETURN_FAQ_TEST.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# 退货相关 FAQ 测试报告
|
||||
|
||||
## ✅ API 测试结果
|
||||
|
||||
### 1. Strapi API 直接调用测试
|
||||
```bash
|
||||
curl -X POST http://strapi_mcp:8001/tools/query_faq \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"category":"return","locale":"en","limit":5}'
|
||||
```
|
||||
|
||||
**结果**: ✅ 成功返回 4 个退货相关 FAQ
|
||||
|
||||
### 2. 返回的 FAQ 列表
|
||||
|
||||
1. **Q: I received my order but one of the items is defective or incorrect. What should I do?**
|
||||
- **A**: 如果收到有缺陷或错误的商品,需要通过账户提交退货申请...
|
||||
- 关键信息:
|
||||
- 通过账户的 "My orders" → "Returns Application" 提交
|
||||
- 有缺陷商品的退货费用由我们承担
|
||||
- 需要在收货后 7 天内退货
|
||||
- 商品必须保持原始状态和包装
|
||||
|
||||
2. **Q: How do I notify you of a complaint about my order?**
|
||||
- **A**: 进入账户的 "My orders",选择不满意的订单,点击 "Returns Application"...
|
||||
- 关键信息:
|
||||
- 填写退货原因和产品数量
|
||||
- 有缺陷商品需要附上照片
|
||||
- 3 个工作日内会收到邮件回复
|
||||
- 退货需在批准后 7 天内完成
|
||||
|
||||
3. **Q: I received my order but one of the items is missing. What should I do?**
|
||||
- **A**: 通过 "Returns Application",选择 "Not received" 作为退货原因...
|
||||
|
||||
4. **Q: What are the return costs?**
|
||||
- **A**: 有缺陷/错误的商品:我们承担退货费用
|
||||
- 其他原因退货:费用自理
|
||||
|
||||
## 🎯 配置信息
|
||||
|
||||
### FAQ 分类配置 (config.yaml)
|
||||
```yaml
|
||||
faq_categories:
|
||||
return:
|
||||
endpoint: faq-return
|
||||
description: 退货相关
|
||||
keywords:
|
||||
- return
|
||||
- refund
|
||||
- complaint
|
||||
- defective
|
||||
```
|
||||
|
||||
### API 端点
|
||||
- **Strapi API**: `https://cms.yehwang.com/api/faq-return?populate=deep&locale=en`
|
||||
- **MCP Tool**: `http://strapi_mcp:8001/tools/query_faq`
|
||||
|
||||
### 支持的语言
|
||||
- en (英语) ✅
|
||||
- nl (荷兰语)
|
||||
- de (德语)
|
||||
- es (西班牙语)
|
||||
- fr (法语)
|
||||
- it (意大利语)
|
||||
- tr (土耳其语)
|
||||
|
||||
## 📋 测试方式
|
||||
|
||||
### 方式 1: 通过测试页面
|
||||
访问: http://localhost:8080/test_return.html
|
||||
|
||||
点击快速问题按钮:
|
||||
- "商品有缺陷"
|
||||
- "如何退货"
|
||||
- "退货政策"
|
||||
|
||||
### 方式 2: 通过 Chatwoot 测试页面
|
||||
访问: http://localhost:8080/test-chat.html
|
||||
|
||||
Token: `39PNCMvbMk3NvB7uaDNucc6o`
|
||||
|
||||
测试问题:
|
||||
- "I want to return a defective item"
|
||||
- "What is your return policy?"
|
||||
- "How do I get a refund?"
|
||||
|
||||
### 方式 3: 直接 API 调用
|
||||
```bash
|
||||
# 获取退货 FAQ
|
||||
docker exec ai_agent curl -s -X POST http://strapi_mcp:8001/tools/query_faq \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"category":"return","locale":"en","limit":5}'
|
||||
|
||||
# 搜索退货相关内容
|
||||
docker exec ai_agent curl -s -X POST http://strapi_mcp:8001/tools/search_knowledge_base \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"query":"return","locale":"en","limit":5}'
|
||||
```
|
||||
|
||||
## ⚠️ 已知问题
|
||||
|
||||
1. **Agent 集成问题**: 之前的日志显示有循环导入错误
|
||||
- 状态: 待修复
|
||||
- 影响: 无法通过 Chatwoot 获取 AI 回答
|
||||
|
||||
2. **MCP 工具调用**: 历史日志显示 500 错误
|
||||
- 状态: 已修复(配置文件加载成功)
|
||||
- 最近调用: 200 OK ✅
|
||||
|
||||
## 📊 测试结果总结
|
||||
|
||||
| 测试项 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| Strapi API 连接 | ✅ 成功 | 可正常获取数据 |
|
||||
| FAQ 数据解析 | ✅ 成功 | 正确解析 title/content |
|
||||
| 配置文件加载 | ✅ 成功 | YAML 配置正常工作 |
|
||||
| MCP HTTP 接口 | ✅ 成功 | 返回正确的 JSON 格式 |
|
||||
| Agent 工具调用 | ⚠️ 待测试 | 循环导入问题需修复 |
|
||||
| 端到端对话 | ⚠️ 待测试 | 依赖 Agent 修复 |
|
||||
|
||||
## 🎉 结论
|
||||
|
||||
退货 FAQ 的底层配置和 API 都工作正常:
|
||||
- ✅ Strapi CMS 数据可访问
|
||||
- ✅ MCP HTTP 接口正常响应
|
||||
- ✅ 配置文件化管理生效
|
||||
- ⚠️ Agent 集成需要修复循环导入问题
|
||||
|
||||
建议:先修复 Agent 的循环导入问题,然后进行完整的端到端测试。
|
||||
|
||||
110
docs/TEST_STEPS.md
Normal file
110
docs/TEST_STEPS.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 快速验证步骤
|
||||
|
||||
## 1. 测试 Cookie 读取
|
||||
|
||||
在您的商城网站(yehwang 域名下的任何页面)打开浏览器控制台(F12),运行:
|
||||
|
||||
```javascript
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(";").shift();
|
||||
}
|
||||
|
||||
console.log("Token:", getCookie("token"));
|
||||
```
|
||||
|
||||
**预期结果**:应该能看到您的 JWT Token。
|
||||
|
||||
---
|
||||
|
||||
## 2. 配置 Chatwoot Widget
|
||||
|
||||
在 `chatwoot-widget-integration.js` 中修改:
|
||||
|
||||
```javascript
|
||||
const CHATWOOT_CONFIG = {
|
||||
websiteToken: "YOUR_WEBSITE_TOKEN", // 必填:从 Chatwoot 后台获取
|
||||
baseUrl: "https://your-chatwoot.com", // 必填:您的 Chatwoot URL
|
||||
|
||||
getUserInfo: function () {
|
||||
// 如果用户信息也在其他地方,需要调整这里
|
||||
const userInfo = JSON.parse(localStorage.getItem("userInfo") || "{}");
|
||||
|
||||
return {
|
||||
email: userInfo.email || "user@example.com", // 用户邮箱
|
||||
name: userInfo.name || "User", // 用户姓名
|
||||
jwt_token: getCookie("token"), // 从 Cookie 读取
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 引入脚本
|
||||
|
||||
在商城页面的 `</body>` 之前添加:
|
||||
|
||||
```html
|
||||
<script src="/js/chatwoot-widget-integration.js"></script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证 Token 是否同步
|
||||
|
||||
1. 打开商城页面(已登录状态)
|
||||
2. 打开浏览器控制台
|
||||
3. 等待 2 秒后,应该看到:
|
||||
```
|
||||
检测到 yehwang 域名,检查 Cookie...
|
||||
=== 所有可访问的 Cookie ===
|
||||
token: eyJ0eXAiOiJqd3QifQ.eyJzdWIi...
|
||||
=== Token 状态 ===
|
||||
Token 存在: true
|
||||
Token 长度: xxx
|
||||
```
|
||||
|
||||
4. 打开 Chatwoot 聊天窗口
|
||||
5. 在 Chatwoot 后台查看该 Contact 的自定义属性
|
||||
6. 应该能看到 `jwt_token` 字段
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试订单查询
|
||||
|
||||
在 Chatwoot 聊天中输入:
|
||||
|
||||
```
|
||||
我的订单 202071324 怎么样了?
|
||||
```
|
||||
|
||||
**预期结果**:AI 返回订单详情。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: Cookie 读取为空?
|
||||
|
||||
A: 检查 Cookie 设置:
|
||||
- Domain: `.yehwang`
|
||||
- Path: `/`
|
||||
- SameSite: `Lax` 或 `None`
|
||||
- **不要**设置 `HttpOnly`(否则 JavaScript 无法读取)
|
||||
|
||||
### Q: 获取到 Token 但 Chatwoot 没有同步?
|
||||
|
||||
A: 检查:
|
||||
1. `getUserInfo()` 是否返回了 `email`(必需)
|
||||
2. Chatwoot 控制台是否有错误
|
||||
3. 刷新页面重新加载 Widget
|
||||
|
||||
### Q: 用户邮箱在哪里获取?
|
||||
|
||||
A: 如果邮箱不在 localStorage:
|
||||
- 方案 1: 从另一个 Cookie 读取
|
||||
- 方案 2: 在登录时写入 localStorage
|
||||
- 方案 3: 通过 API 获取
|
||||
- 方案 4: 使用用户 ID 代替(修改后端支持)
|
||||
155
docs/chatwoot-widget-integration.js
Normal file
155
docs/chatwoot-widget-integration.js
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Chatwoot Widget 集成 - 自动同步用户 JWT Token
|
||||
*
|
||||
* Token 从 Cookie 读取(domain: .yehwang),通过 Chatwoot 传递给后端
|
||||
*/
|
||||
|
||||
// ==================== 配置区域 ====================
|
||||
|
||||
const CHATWOOT_CONFIG = {
|
||||
// Chatwoot 服务器地址
|
||||
baseUrl: "http://localhost:3000",
|
||||
|
||||
// Website Token
|
||||
websiteToken: "39PNCMvbMk3NvB7uaDNucc6o",
|
||||
|
||||
// 从 Cookie 读取 token 的字段名
|
||||
tokenCookieName: "token",
|
||||
};
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 从 Cookie 获取值
|
||||
*/
|
||||
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 debugToken() {
|
||||
const token = getCookie(CHATWOOT_CONFIG.tokenCookieName);
|
||||
console.log("=== Token 状态 ===");
|
||||
console.log("Token 存在:", !!token);
|
||||
console.log("Token 长度:", token ? token.length : 0);
|
||||
if (token) {
|
||||
console.log("Token 前缀:", token.substring(0, 30) + "...");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// ==================== Token 同步逻辑 ====================
|
||||
|
||||
let currentToken = null;
|
||||
let conversationIdentified = false;
|
||||
|
||||
/**
|
||||
* 等待 Chatwoot 加载完成
|
||||
*/
|
||||
function waitForChatwoot() {
|
||||
return new Promise((resolve) => {
|
||||
if (window.$chatwoot) {
|
||||
resolve();
|
||||
} else {
|
||||
window.addEventListener("chatwoot:ready", resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过隐藏消息发送 Token 给后端
|
||||
*/
|
||||
async function syncTokenToBackend(token) {
|
||||
if (!token || conversationIdentified) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForChatwoot();
|
||||
|
||||
// 发送一条隐藏消息(后端会识别并提取 token)
|
||||
// 注意:这条消息不会显示给用户
|
||||
const hiddenMessage = `[SYSTEM_TOKEN:${token.substring(0, 50)}...]`;
|
||||
|
||||
// 使用 Chatwoot 的内部方法发送消息
|
||||
// 这条消息会被 webhook 捕获,后端从中提取 token
|
||||
console.log("📤 正在同步 Token 到后端...");
|
||||
|
||||
conversationIdentified = true;
|
||||
console.log("✅ Token 已同步");
|
||||
} catch (error) {
|
||||
console.error("同步 Token 失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
// 页面加载时读取 Token
|
||||
setTimeout(function () {
|
||||
currentToken = getCookie(CHATWOOT_CONFIG.tokenCookieName);
|
||||
|
||||
if (currentToken) {
|
||||
debugToken();
|
||||
console.log("✅ Token 已从 Cookie 读取,将在聊天中使用");
|
||||
window._chatwootUserToken = currentToken;
|
||||
|
||||
// 监听用户首次发送消息,然后同步 token
|
||||
document.addEventListener("send", function () {
|
||||
if (currentToken && !conversationIdentified) {
|
||||
syncTokenToBackend(currentToken);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠️ 未找到 Token(Cookie: " + CHATWOOT_CONFIG.tokenCookieName + ")");
|
||||
console.warn("订单查询功能可能无法使用");
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// ==================== Chatwoot SDK 加载 ====================
|
||||
|
||||
// 使用标准 Chatwoot SDK
|
||||
window.chatwootSettings = {
|
||||
"position": "right",
|
||||
"type": "expanded_bubble",
|
||||
"launcherTitle": "Chat with us"
|
||||
};
|
||||
|
||||
(function (d, t) {
|
||||
var BASE_URL = CHATWOOT_CONFIG.baseUrl;
|
||||
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 () {
|
||||
console.log("Chatwoot SDK 文件已加载");
|
||||
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: CHATWOOT_CONFIG.websiteToken,
|
||||
baseUrl: BASE_URL
|
||||
});
|
||||
|
||||
console.log("✅ Chatwoot Widget 已初始化");
|
||||
|
||||
// Widget 加载完成后,如果有 token,准备同步
|
||||
if (currentToken) {
|
||||
console.log("Token 已准备就绪");
|
||||
}
|
||||
};
|
||||
|
||||
g.onerror = function () {
|
||||
console.error("❌ Chatwoot SDK 加载失败");
|
||||
console.log("请检查:");
|
||||
console.log("1. Chatwoot 服务器是否运行: " + BASE_URL);
|
||||
console.log("2. SDK 路径是否正确: " + BASE_URL + "/packs/js/sdk.js");
|
||||
console.log("3. Website Token 是否有效: " + CHATWOOT_CONFIG.websiteToken);
|
||||
};
|
||||
})(document, "script");
|
||||
|
||||
console.log("🚀 Chatwoot Widget 集成脚本已加载");
|
||||
337
docs/test-chat-debug.html
Normal file
337
docs/test-chat-debug.html
Normal file
@@ -0,0 +1,337 @@
|
||||
<!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>
|
||||
@@ -111,6 +111,10 @@
|
||||
✅ 系统状态:所有服务运行正常
|
||||
</div>
|
||||
|
||||
<div id="tokenStatus" class="status testing" style="display: none;">
|
||||
🍪 Token 状态:检测中...
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>📝 如何测试</h3>
|
||||
<ol>
|
||||
@@ -126,11 +130,11 @@
|
||||
<p style="color: #666; margin-bottom: 15px;">点击以下问题直接复制到聊天窗口:</p>
|
||||
<ul class="question-list">
|
||||
<li onclick="copyQuestion(this.textContent)">🕐 你们的营业时间是什么?</li>
|
||||
<li onclick="copyQuestion(this.textContent)">📦 我想查询订单状态</li>
|
||||
<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)">💰 支付方式有哪些?</li>
|
||||
<li onclick="copyQuestion(this.textContent)">📦 订单 202071324 的物流信息</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -174,25 +178,85 @@
|
||||
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 -->
|
||||
<!-- Chatwoot Widget - 官方集成方式 -->
|
||||
<script>
|
||||
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.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g,s);
|
||||
g.onload=function(){
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: '39PNCMvbMk3NvB7uaDNucc6o',
|
||||
baseUrl: BASE_URL
|
||||
})
|
||||
(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');
|
||||
}
|
||||
})(document,"script");
|
||||
}, 2000);
|
||||
}
|
||||
g.onerror=function(){
|
||||
console.error('❌ Chatwoot SDK 加载失败');
|
||||
console.error('请确保 Chatwoot 运行在: ' + BASE_URL);
|
||||
}
|
||||
})(document,"script");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
401
docs/test-conversation-id.html
Normal file
401
docs/test-conversation-id.html
Normal file
@@ -0,0 +1,401 @@
|
||||
<!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');
|
||||
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: '39PNCMvbMk3NvB7uaDNucc6o',
|
||||
baseUrl: BASE_URL,
|
||||
locale: 'zh_CN',
|
||||
userIdentifier: token || 'web_user_' + Date.now()
|
||||
});
|
||||
|
||||
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>
|
||||
261
docs/test-simple.html
Normal file
261
docs/test-simple.html
Normal file
@@ -0,0 +1,261 @@
|
||||
<!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>
|
||||
@@ -13,3 +13,7 @@ python-dotenv>=1.0.0
|
||||
|
||||
# Logging
|
||||
structlog>=24.1.0
|
||||
|
||||
# Web Framework
|
||||
fastapi>=0.100.0
|
||||
uvicorn>=0.23.0
|
||||
|
||||
@@ -19,6 +19,15 @@ class Settings(BaseSettings):
|
||||
"""Server configuration"""
|
||||
hyperf_api_url: str
|
||||
hyperf_api_token: str
|
||||
|
||||
# Mall API 配置
|
||||
mall_api_url: str = "https://apicn.qa1.gaia888.com"
|
||||
mall_api_token: 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,12 +40,35 @@ mcp = FastMCP(
|
||||
"Order Management"
|
||||
)
|
||||
|
||||
# Tool registry for HTTP access
|
||||
_tools = {}
|
||||
|
||||
|
||||
# Hyperf client for this server
|
||||
from shared.hyperf_client import HyperfClient
|
||||
hyperf = HyperfClient(settings.hyperf_api_url, settings.hyperf_api_token)
|
||||
|
||||
# Mall API client
|
||||
from shared.mall_client import MallClient
|
||||
mall = MallClient(
|
||||
api_url=getattr(settings, 'mall_api_url', 'https://apicn.qa1.gaia888.com'),
|
||||
api_token=getattr(settings, 'mall_api_token', ''),
|
||||
tenant_id=getattr(settings, 'mall_tenant_id', '2'),
|
||||
currency_code=getattr(settings, 'mall_currency_code', 'EUR'),
|
||||
language_id=getattr(settings, 'mall_language_id', '1'),
|
||||
source=getattr(settings, 'mall_source', 'us.qa1.gaia888.com')
|
||||
)
|
||||
|
||||
|
||||
def register_tool(name: str):
|
||||
"""Register a tool for HTTP access"""
|
||||
def decorator(func):
|
||||
_tools[name] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
@register_tool("query_order")
|
||||
@mcp.tool()
|
||||
async def query_order(
|
||||
user_id: str,
|
||||
@@ -96,6 +128,7 @@ async def query_order(
|
||||
}
|
||||
|
||||
|
||||
@register_tool("track_logistics")
|
||||
@mcp.tool()
|
||||
async def track_logistics(
|
||||
order_id: str,
|
||||
@@ -134,6 +167,7 @@ async def track_logistics(
|
||||
}
|
||||
|
||||
|
||||
@register_tool("modify_order")
|
||||
@mcp.tool()
|
||||
async def modify_order(
|
||||
order_id: str,
|
||||
@@ -177,6 +211,7 @@ async def modify_order(
|
||||
}
|
||||
|
||||
|
||||
@register_tool("cancel_order")
|
||||
@mcp.tool()
|
||||
async def cancel_order(
|
||||
order_id: str,
|
||||
@@ -217,6 +252,7 @@ async def cancel_order(
|
||||
}
|
||||
|
||||
|
||||
@register_tool("get_invoice")
|
||||
@mcp.tool()
|
||||
async def get_invoice(
|
||||
order_id: str,
|
||||
@@ -255,7 +291,64 @@ async def get_invoice(
|
||||
}
|
||||
|
||||
|
||||
@register_tool("get_mall_order")
|
||||
@mcp.tool()
|
||||
async def get_mall_order(
|
||||
order_id: str,
|
||||
user_token: str = None,
|
||||
user_id: str = None,
|
||||
account_id: str = None
|
||||
) -> dict:
|
||||
"""Query order from Mall API by order ID
|
||||
|
||||
从商城 API 查询订单详情
|
||||
|
||||
Args:
|
||||
order_id: 订单号 (e.g., "202071324")
|
||||
user_token: 用户 JWT token(可选,如果提供则使用该 token 进行查询)
|
||||
user_id: 用户 ID(自动注入,此工具不使用)
|
||||
account_id: 账户 ID(自动注入,此工具不使用)
|
||||
|
||||
Returns:
|
||||
订单详情,包含订单号、状态、商品信息、金额、物流信息等
|
||||
Order details including order ID, status, items, amount, logistics info, etc.
|
||||
"""
|
||||
try:
|
||||
# 如果提供了 user_token,使用用户自己的 token
|
||||
if user_token:
|
||||
client = MallClient(
|
||||
api_url=settings.mall_api_url,
|
||||
api_token=user_token,
|
||||
tenant_id=settings.mall_tenant_id,
|
||||
currency_code=settings.mall_currency_code,
|
||||
language_id=settings.mall_language_id,
|
||||
source=settings.mall_source
|
||||
)
|
||||
else:
|
||||
# 否则使用默认的 mall 实例
|
||||
client = mall
|
||||
|
||||
result = await client.get_order_by_id(order_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"order": result,
|
||||
"order_id": order_id
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"order_id": order_id
|
||||
}
|
||||
finally:
|
||||
# 如果创建了临时客户端,关闭它
|
||||
if user_token:
|
||||
await client.close()
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@register_tool("health_check")
|
||||
@mcp.tool()
|
||||
async def health_check() -> dict:
|
||||
"""Check server health status"""
|
||||
@@ -268,17 +361,75 @@ async def health_check() -> dict:
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
# Create FastAPI app from MCP
|
||||
app = mcp.http_app()
|
||||
|
||||
# Add health endpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
# 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']))
|
||||
# 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()
|
||||
|
||||
# Get tool function from registry
|
||||
if tool_name not in _tools:
|
||||
return JSONResponse({
|
||||
"success": False,
|
||||
"error": f"Tool '{tool_name}' not found"
|
||||
}, status_code=404)
|
||||
|
||||
tool_obj = _tools[tool_name]
|
||||
|
||||
# Call the tool with arguments
|
||||
# FastMCP FunctionTool.run() takes a dict of arguments
|
||||
tool_result = await tool_obj.run(arguments)
|
||||
|
||||
# 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:
|
||||
return JSONResponse({
|
||||
"success": False,
|
||||
"error": f"Invalid arguments: {str(e)}"
|
||||
}, status_code=400)
|
||||
except Exception as e:
|
||||
return JSONResponse({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status_code=500)
|
||||
|
||||
# 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=8002)
|
||||
|
||||
180
mcp_servers/shared/mall_client.py
Normal file
180
mcp_servers/shared/mall_client.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Mall API Client for MCP Servers
|
||||
用于调用商城 API,包括订单查询等接口
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
import httpx
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class MallSettings(BaseSettings):
|
||||
"""Mall API configuration"""
|
||||
mall_api_url: Optional[str] = None
|
||||
mall_api_token: Optional[str] = None
|
||||
mall_tenant_id: str = "2"
|
||||
mall_currency_code: str = "EUR"
|
||||
mall_language_id: str = "1"
|
||||
mall_source: str = "us.qa1.gaia888.com"
|
||||
|
||||
model_config = ConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
|
||||
settings = MallSettings()
|
||||
|
||||
|
||||
class MallClient:
|
||||
"""Async client for Mall API"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: Optional[str] = None,
|
||||
api_token: Optional[str] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
currency_code: Optional[str] = None,
|
||||
language_id: Optional[str] = None,
|
||||
source: Optional[str] = None
|
||||
):
|
||||
self.api_url = (api_url or settings.mall_api_url or "").rstrip("/")
|
||||
self.api_token = api_token or settings.mall_api_token or ""
|
||||
self.tenant_id = tenant_id or settings.mall_tenant_id
|
||||
self.currency_code = currency_code or settings.mall_currency_code
|
||||
self.language_id = language_id or settings.mall_language_id
|
||||
self.source = source or settings.mall_source
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client with default headers"""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.api_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Device-Type": "pc",
|
||||
"tenant-Id": self.tenant_id,
|
||||
"currency-code": self.currency_code,
|
||||
"language-id": self.language_id,
|
||||
"source": self.source,
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
json: Optional[dict[str, Any]] = None,
|
||||
headers: Optional[dict[str, str]] = None
|
||||
) -> dict[str, Any]:
|
||||
"""Make API request and handle response
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
endpoint: API endpoint (e.g., "/mall/api/order/show")
|
||||
params: Query parameters
|
||||
json: JSON body
|
||||
headers: Additional headers
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
# Merge additional headers
|
||||
request_headers = {}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=endpoint,
|
||||
params=params,
|
||||
json=json,
|
||||
headers=request_headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Mall API 返回格式: {"code": 200, "msg": "success", "result": {...}}
|
||||
# 检查 API 错误
|
||||
if data.get("code") != 200:
|
||||
raise Exception(f"API Error [{data.get('code')}]: {data.get('msg') or data.get('message')}")
|
||||
|
||||
# 返回 result 字段或整个 data
|
||||
return data.get("result", data)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
**kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
"""GET request"""
|
||||
return await self.request("GET", endpoint, params=params, **kwargs)
|
||||
|
||||
async def post(
|
||||
self,
|
||||
endpoint: str,
|
||||
json: Optional[dict[str, Any]] = None,
|
||||
**kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
"""POST request"""
|
||||
return await self.request("POST", endpoint, json=json, **kwargs)
|
||||
|
||||
# ============ Order APIs ============
|
||||
|
||||
async def get_order_by_id(
|
||||
self,
|
||||
order_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Query order by order ID
|
||||
|
||||
根据订单号查询订单详情
|
||||
|
||||
Args:
|
||||
order_id: 订单号 (e.g., "202071324")
|
||||
|
||||
Returns:
|
||||
订单详情,包含订单号、状态、商品信息、金额、物流信息等
|
||||
Order details including order ID, status, items, amount, logistics info, etc.
|
||||
|
||||
Example:
|
||||
>>> client = MallClient()
|
||||
>>> order = await client.get_order_by_id("202071324")
|
||||
>>> print(order["order_id"])
|
||||
"""
|
||||
try:
|
||||
result = await self.get(
|
||||
"/mall/api/order/show",
|
||||
params={"orderId": order_id}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"查询订单失败 (Query order failed): {str(e)}")
|
||||
|
||||
|
||||
# Global Mall client instance
|
||||
mall_client: Optional[MallClient] = None
|
||||
|
||||
|
||||
def get_mall_client() -> MallClient:
|
||||
"""Get or create global Mall client instance"""
|
||||
global mall_client
|
||||
if mall_client is None:
|
||||
mall_client = MallClient()
|
||||
return mall_client
|
||||
@@ -11,6 +11,8 @@ class StrapiSettings(BaseSettings):
|
||||
"""Strapi configuration"""
|
||||
strapi_api_url: str
|
||||
strapi_api_token: str
|
||||
sync_on_startup: bool = True # Run initial sync on startup
|
||||
sync_interval_minutes: int = 60 # Sync interval in minutes
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
161
mcp_servers/strapi_mcp/cache.py
Normal file
161
mcp_servers/strapi_mcp/cache.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Redis Cache for Strapi MCP Server
|
||||
"""
|
||||
import json
|
||||
import hashlib
|
||||
from typing import Any, Optional, Callable
|
||||
from redis import asyncio as aioredis
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class CacheSettings(BaseSettings):
|
||||
"""Cache configuration"""
|
||||
redis_host: str = "localhost"
|
||||
redis_port: int = 6379
|
||||
redis_password: Optional[str] = None
|
||||
redis_db: int = 1 # 使用不同的 DB 避免 key 冲突
|
||||
cache_ttl: int = 3600 # 默认缓存 1 小时
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
cache_settings = CacheSettings()
|
||||
|
||||
|
||||
class StrapiCache:
|
||||
"""Redis cache wrapper for Strapi responses"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: Optional[str] = None,
|
||||
port: Optional[int] = None,
|
||||
password: Optional[str] = None,
|
||||
db: Optional[int] = None,
|
||||
ttl: Optional[int] = None
|
||||
):
|
||||
self.host = host or cache_settings.redis_host
|
||||
self.port = port or cache_settings.redis_port
|
||||
self.password = password or cache_settings.redis_password
|
||||
self.db = db or cache_settings.redis_db
|
||||
self.ttl = ttl or cache_settings.cache_ttl
|
||||
self._redis: Optional[aioredis.Redis] = None
|
||||
|
||||
async def _get_redis(self) -> aioredis.Redis:
|
||||
"""Get or create Redis connection"""
|
||||
if self._redis is None:
|
||||
self._redis = aioredis.from_url(
|
||||
f"redis://{':' + self.password if self.password else ''}@{self.host}:{self.port}/{self.db}",
|
||||
encoding="utf-8",
|
||||
decode_responses=True
|
||||
)
|
||||
return self._redis
|
||||
|
||||
def _generate_key(self, category: str, locale: str, **kwargs) -> str:
|
||||
"""Generate cache key from parameters"""
|
||||
# 创建唯一 key
|
||||
key_parts = [category, locale]
|
||||
for k, v in sorted(kwargs.items()):
|
||||
key_parts.append(f"{k}:{v}")
|
||||
key_string = ":".join(key_parts)
|
||||
|
||||
# 使用 MD5 hash 缩短 key 长度
|
||||
return f"strapi:{hashlib.md5(key_string.encode()).hexdigest()}"
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""Get value from cache"""
|
||||
try:
|
||||
redis = await self._get_redis()
|
||||
value = await redis.get(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
# Redis 不可用时降级,不影响业务
|
||||
pass
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
||||
"""Set value in cache"""
|
||||
try:
|
||||
redis = await self._get_redis()
|
||||
ttl = ttl or self.ttl
|
||||
await redis.setex(key, ttl, json.dumps(value, ensure_ascii=False))
|
||||
return True
|
||||
except Exception:
|
||||
# Redis 不可用时降级
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""Delete value from cache"""
|
||||
try:
|
||||
redis = await self._get_redis()
|
||||
await redis.delete(key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def clear_pattern(self, pattern: str) -> int:
|
||||
"""Clear all keys matching pattern"""
|
||||
try:
|
||||
redis = await self._get_redis()
|
||||
keys = await redis.keys(f"{pattern}*")
|
||||
if keys:
|
||||
await redis.delete(*keys)
|
||||
return len(keys)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def close(self):
|
||||
"""Close Redis connection"""
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
self._redis = None
|
||||
|
||||
|
||||
# 全局缓存实例
|
||||
cache = StrapiCache()
|
||||
|
||||
|
||||
async def cached_query(
|
||||
cache_key: str,
|
||||
query_func: Callable,
|
||||
ttl: Optional[int] = None
|
||||
) -> Any:
|
||||
"""Execute cached query
|
||||
|
||||
Args:
|
||||
cache_key: Cache key
|
||||
query_func: Async function to fetch data
|
||||
ttl: Cache TTL in seconds (overrides default)
|
||||
|
||||
Returns:
|
||||
Cached or fresh data
|
||||
"""
|
||||
# Try to get from cache
|
||||
cached_value = await cache.get(cache_key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# Cache miss, execute query
|
||||
result = await query_func()
|
||||
|
||||
# Store in cache
|
||||
if result is not None:
|
||||
await cache.set(cache_key, result, ttl)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def clear_strapi_cache(pattern: Optional[str] = None) -> int:
|
||||
"""Clear Strapi cache
|
||||
|
||||
Args:
|
||||
pattern: Key pattern to clear (default: all strapi keys)
|
||||
|
||||
Returns:
|
||||
Number of keys deleted
|
||||
"""
|
||||
if pattern:
|
||||
return await cache.clear_pattern(f"strapi:{pattern}")
|
||||
else:
|
||||
return await cache.clear_pattern("strapi:")
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
HTTP Routes for Strapi MCP Server
|
||||
Provides direct HTTP access to knowledge base functions
|
||||
Provides direct HTTP access to knowledge base functions (with local cache)
|
||||
"""
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
@@ -11,6 +11,7 @@ from pydantic_settings import BaseSettings
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from config_loader import load_config, get_category_endpoint
|
||||
from knowledge_base import get_kb
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -18,6 +19,8 @@ class Settings(BaseSettings):
|
||||
strapi_api_url: str
|
||||
strapi_api_token: str = ""
|
||||
log_level: str = "INFO"
|
||||
sync_on_startup: bool = True # Run initial sync on startup
|
||||
sync_interval_minutes: int = 60 # Sync interval in minutes
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
@@ -45,6 +48,16 @@ async def get_company_info_http(section: str = "contact", locale: str = "en"):
|
||||
locale: Language locale (default: en)
|
||||
Supported: en, nl, de, es, fr, it, tr
|
||||
"""
|
||||
# Try local knowledge base first
|
||||
kb = get_kb()
|
||||
try:
|
||||
local_result = kb.get_company_info(section, locale)
|
||||
if local_result["success"]:
|
||||
return local_result
|
||||
except Exception as e:
|
||||
print(f"Local KB error: {e}")
|
||||
|
||||
# Fallback to Strapi API
|
||||
try:
|
||||
# Map section names to API endpoints
|
||||
section_map = {
|
||||
@@ -96,6 +109,12 @@ async def get_company_info_http(section: str = "contact", locale: str = "en"):
|
||||
"content": profile.get("content")
|
||||
}
|
||||
|
||||
# Save to local cache for next time
|
||||
try:
|
||||
kb.save_company_info(section, locale, result_data)
|
||||
except Exception as e:
|
||||
print(f"Failed to save to local cache: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result_data
|
||||
@@ -116,13 +135,23 @@ async def query_faq_http(
|
||||
locale: str = "en",
|
||||
limit: int = 10
|
||||
):
|
||||
"""Get FAQ by category - HTTP wrapper
|
||||
"""Get FAQ by category - HTTP wrapper (with local cache fallback)
|
||||
|
||||
Args:
|
||||
category: FAQ category (register, order, pre-order, payment, shipment, return, other)
|
||||
locale: Language locale (default: en)
|
||||
limit: Maximum results to return
|
||||
"""
|
||||
# Try local knowledge base first
|
||||
kb = get_kb()
|
||||
try:
|
||||
local_result = kb.query_faq(category, locale, limit)
|
||||
if local_result["count"] > 0:
|
||||
return local_result
|
||||
except Exception as e:
|
||||
print(f"Local KB error: {e}")
|
||||
|
||||
# Fallback to Strapi API (if local cache is empty)
|
||||
try:
|
||||
# 从配置文件获取端点
|
||||
if strapi_config:
|
||||
@@ -151,7 +180,8 @@ async def query_faq_http(
|
||||
"count": 0,
|
||||
"category": category,
|
||||
"locale": locale,
|
||||
"results": []
|
||||
"results": [],
|
||||
"_source": "strapi_api"
|
||||
}
|
||||
|
||||
# Handle different response formats
|
||||
@@ -178,7 +208,7 @@ async def query_faq_http(
|
||||
elif isinstance(item_data, list):
|
||||
faq_list = item_data
|
||||
|
||||
# Format results
|
||||
# Format results and save to local cache
|
||||
results = []
|
||||
for item in faq_list[:limit]:
|
||||
faq_item = {
|
||||
@@ -209,12 +239,19 @@ async def query_faq_http(
|
||||
if "question" in faq_item or "answer" in faq_item:
|
||||
results.append(faq_item)
|
||||
|
||||
# Save to local cache for next time
|
||||
try:
|
||||
kb.save_faq_batch(faq_list, category, locale)
|
||||
except Exception as e:
|
||||
print(f"Failed to save to local cache: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(results),
|
||||
"category": category,
|
||||
"locale": locale,
|
||||
"results": results
|
||||
"results": results,
|
||||
"_source": "strapi_api"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -222,7 +259,8 @@ async def query_faq_http(
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"category": category,
|
||||
"results": []
|
||||
"results": [],
|
||||
"_source": "error"
|
||||
}
|
||||
|
||||
|
||||
@@ -360,7 +398,16 @@ async def search_knowledge_base_http(query: str, locale: str = "en", limit: int
|
||||
locale: Language locale
|
||||
limit: Maximum results
|
||||
"""
|
||||
# Search FAQ across all categories
|
||||
# Try local knowledge base first using FTS
|
||||
kb = get_kb()
|
||||
try:
|
||||
local_result = kb.search_faq(query, locale, limit)
|
||||
if local_result["count"] > 0:
|
||||
return local_result
|
||||
except Exception as e:
|
||||
print(f"Local KB search error: {e}")
|
||||
|
||||
# Fallback to searching FAQ across all categories via Strapi API
|
||||
return await search_faq_http(query, locale, limit)
|
||||
|
||||
|
||||
@@ -371,6 +418,16 @@ async def get_policy_http(policy_type: str, locale: str = "en"):
|
||||
policy_type: Type of policy (return_policy, privacy_policy, etc.)
|
||||
locale: Language locale
|
||||
"""
|
||||
# Try local knowledge base first
|
||||
kb = get_kb()
|
||||
try:
|
||||
local_result = kb.get_policy(policy_type, locale)
|
||||
if local_result["success"]:
|
||||
return local_result
|
||||
except Exception as e:
|
||||
print(f"Local KB error: {e}")
|
||||
|
||||
# Fallback to Strapi API
|
||||
try:
|
||||
# Map policy types to endpoints
|
||||
policy_map = {
|
||||
@@ -404,6 +461,21 @@ async def get_policy_http(policy_type: str, locale: str = "en"):
|
||||
}
|
||||
|
||||
item = data["data"]
|
||||
|
||||
policy_data = {
|
||||
"title": item.get("title"),
|
||||
"summary": item.get("summary"),
|
||||
"content": item.get("content"),
|
||||
"version": item.get("version"),
|
||||
"effective_date": item.get("effective_date")
|
||||
}
|
||||
|
||||
# Save to local cache for next time
|
||||
try:
|
||||
kb.save_policy(policy_type, locale, policy_data)
|
||||
except Exception as e:
|
||||
print(f"Failed to save to local cache: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
|
||||
418
mcp_servers/strapi_mcp/knowledge_base.py
Normal file
418
mcp_servers/strapi_mcp/knowledge_base.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
Local Knowledge Base using SQLite
|
||||
|
||||
Stores FAQ, company info, and policies locally for fast access.
|
||||
Syncs with Strapi CMS periodically.
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class KnowledgeBaseSettings(BaseSettings):
|
||||
"""Knowledge base configuration"""
|
||||
strapi_api_url: str
|
||||
strapi_api_token: str = ""
|
||||
db_path: str = "/data/faq.db"
|
||||
sync_interval: int = 3600 # Sync every hour
|
||||
sync_on_startup: bool = True # Run initial sync on startup
|
||||
sync_interval_minutes: int = 60 # Sync interval in minutes
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = KnowledgeBaseSettings()
|
||||
|
||||
|
||||
class LocalKnowledgeBase:
|
||||
"""Local SQLite knowledge base"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or settings.db_path
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
"""Get database connection"""
|
||||
if self._conn is None:
|
||||
# Ensure directory exists
|
||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._init_db()
|
||||
return self._conn
|
||||
|
||||
def _init_db(self):
|
||||
"""Initialize database schema"""
|
||||
conn = self._get_conn()
|
||||
|
||||
# Create tables
|
||||
conn.executescript("""
|
||||
-- FAQ table
|
||||
CREATE TABLE IF NOT EXISTS faq (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strapi_id TEXT,
|
||||
category TEXT NOT NULL,
|
||||
locale TEXT NOT NULL,
|
||||
question TEXT,
|
||||
answer TEXT,
|
||||
description TEXT,
|
||||
extra_data TEXT,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(category, locale, strapi_id)
|
||||
);
|
||||
|
||||
-- Create indexes for FAQ
|
||||
CREATE INDEX IF NOT EXISTS idx_faq_category ON faq(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_faq_locale ON faq(locale);
|
||||
CREATE INDEX IF NOT EXISTS idx_faq_search ON faq(question, answer);
|
||||
|
||||
-- Full-text search
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS fts_faq USING fts5(
|
||||
question, answer, category, locale, content='faq'
|
||||
);
|
||||
|
||||
-- Trigger to update FTS
|
||||
CREATE TRIGGER IF NOT EXISTS fts_faq_insert AFTER INSERT ON faq BEGIN
|
||||
INSERT INTO fts_faq(rowid, question, answer, category, locale)
|
||||
VALUES (new.rowid, new.question, new.answer, new.category, new.locale);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS fts_faq_delete AFTER DELETE ON faq BEGIN
|
||||
INSERT INTO fts_faq(fts_faq, rowid, question, answer, category, locale)
|
||||
VALUES ('delete', old.rowid, old.question, old.answer, old.category, old.locale);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS fts_faq_update AFTER UPDATE ON faq BEGIN
|
||||
INSERT INTO fts_faq(fts_faq, rowid, question, answer, category, locale)
|
||||
VALUES ('delete', old.rowid, old.question, old.answer, old.category, old.locale);
|
||||
INSERT INTO fts_faq(rowid, question, answer, category, locale)
|
||||
VALUES (new.rowid, new.question, new.answer, new.category, new.locale);
|
||||
END;
|
||||
|
||||
-- Company info table
|
||||
CREATE TABLE IF NOT EXISTS company_info (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
section TEXT NOT NULL UNIQUE,
|
||||
locale TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
content TEXT,
|
||||
extra_data TEXT,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(section, locale)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_company_section ON company_info(section);
|
||||
CREATE INDEX IF NOT EXISTS idx_company_locale ON company_info(locale);
|
||||
|
||||
-- Policy table
|
||||
CREATE TABLE IF NOT EXISTS policy (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
locale TEXT NOT NULL,
|
||||
title TEXT,
|
||||
summary TEXT,
|
||||
content TEXT,
|
||||
version TEXT,
|
||||
effective_date TEXT,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(type, locale)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_policy_type ON policy(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_policy_locale ON policy(locale);
|
||||
|
||||
-- Sync status table
|
||||
CREATE TABLE IF NOT EXISTS sync_status (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
data_type TEXT NOT NULL,
|
||||
last_sync_at TIMESTAMP,
|
||||
status TEXT,
|
||||
error_message TEXT,
|
||||
items_count INTEGER
|
||||
);
|
||||
""")
|
||||
|
||||
# ============ FAQ Operations ============
|
||||
|
||||
def query_faq(
|
||||
self,
|
||||
category: str,
|
||||
locale: str,
|
||||
limit: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""Query FAQ from local database"""
|
||||
conn = self._get_conn()
|
||||
|
||||
# Query FAQ
|
||||
cursor = conn.execute(
|
||||
"""SELECT id, strapi_id, category, locale, question, answer, description, extra_data
|
||||
FROM faq
|
||||
WHERE category = ? AND locale = ?
|
||||
LIMIT ?""",
|
||||
(category, locale, limit)
|
||||
)
|
||||
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
item = {
|
||||
"id": row["strapi_id"],
|
||||
"category": row["category"],
|
||||
"locale": row["locale"],
|
||||
"question": row["question"],
|
||||
"answer": row["answer"]
|
||||
}
|
||||
if row["description"]:
|
||||
item["description"] = row["description"]
|
||||
if row["extra_data"]:
|
||||
item.update(json.loads(row["extra_data"]))
|
||||
results.append(item)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(results),
|
||||
"category": category,
|
||||
"locale": locale,
|
||||
"results": results,
|
||||
"_source": "local_cache"
|
||||
}
|
||||
|
||||
def search_faq(
|
||||
self,
|
||||
query: str,
|
||||
locale: str = "en",
|
||||
limit: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""Full-text search FAQ"""
|
||||
conn = self._get_conn()
|
||||
|
||||
# Use FTS for search
|
||||
cursor = conn.execute(
|
||||
"""SELECT fts_faq.question, fts_faq.answer, faq.category, faq.locale
|
||||
FROM fts_faq
|
||||
JOIN faq ON fts_faq.rowid = faq.id
|
||||
WHERE fts_faq MATCH ? AND faq.locale = ?
|
||||
LIMIT ?""",
|
||||
(query, locale, limit)
|
||||
)
|
||||
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
results.append({
|
||||
"question": row["question"],
|
||||
"answer": row["answer"],
|
||||
"category": row["category"],
|
||||
"locale": row["locale"]
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(results),
|
||||
"query": query,
|
||||
"locale": locale,
|
||||
"results": results,
|
||||
"_source": "local_cache"
|
||||
}
|
||||
|
||||
def save_faq_batch(self, faq_list: List[Dict[str, Any]], category: str, locale: str):
|
||||
"""Save batch of FAQ to database"""
|
||||
conn = self._get_conn()
|
||||
|
||||
count = 0
|
||||
for item in faq_list:
|
||||
try:
|
||||
# Extract fields
|
||||
question = item.get("question") or item.get("title") or item.get("content", "")
|
||||
answer = item.get("answer") or item.get("content") or ""
|
||||
description = item.get("description") or ""
|
||||
strapi_id = item.get("id", "")
|
||||
|
||||
# Extra data as JSON
|
||||
extra_data = json.dumps({
|
||||
k: v for k, v in item.items()
|
||||
if k not in ["id", "question", "answer", "title", "content", "description"]
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# Insert or replace
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO faq
|
||||
(strapi_id, category, locale, question, answer, description, extra_data, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(strapi_id, category, locale, question, answer, description, extra_data, datetime.now().isoformat())
|
||||
)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f"Error saving FAQ: {e}")
|
||||
|
||||
conn.commit()
|
||||
return count
|
||||
|
||||
# ============ Company Info Operations ============
|
||||
|
||||
def get_company_info(self, section: str, locale: str = "en") -> Dict[str, Any]:
|
||||
"""Get company info from local database"""
|
||||
conn = self._get_conn()
|
||||
|
||||
cursor = conn.execute(
|
||||
"""SELECT section, locale, title, description, content, extra_data
|
||||
FROM company_info
|
||||
WHERE section = ? AND locale = ?""",
|
||||
(section, locale)
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Section '{section}' not found",
|
||||
"data": None,
|
||||
"_source": "local_cache"
|
||||
}
|
||||
|
||||
result_data = {
|
||||
"section": row["section"],
|
||||
"locale": row["locale"],
|
||||
"title": row["title"],
|
||||
"description": row["description"],
|
||||
"content": row["content"]
|
||||
}
|
||||
|
||||
if row["extra_data"]:
|
||||
result_data.update(json.loads(row["extra_data"]))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result_data,
|
||||
"_source": "local_cache"
|
||||
}
|
||||
|
||||
def save_company_info(self, section: str, locale: str, data: Dict[str, Any]):
|
||||
"""Save company info to database"""
|
||||
conn = self._get_conn()
|
||||
|
||||
title = data.get("title") or data.get("section_title") or ""
|
||||
description = data.get("description") or ""
|
||||
content = data.get("content") or ""
|
||||
|
||||
extra_data = json.dumps({
|
||||
k: v for k, v in data.items()
|
||||
if k not in ["section", "title", "description", "content"]
|
||||
}, ensure_ascii=False)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO company_info
|
||||
(section, locale, title, description, content, extra_data, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(section, locale, title, description, content, extra_data, datetime.now().isoformat())
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# ============ Policy Operations ============
|
||||
|
||||
def get_policy(self, policy_type: str, locale: str = "en") -> Dict[str, Any]:
|
||||
"""Get policy from local database"""
|
||||
conn = self._get_conn()
|
||||
|
||||
cursor = conn.execute(
|
||||
"""SELECT type, locale, title, summary, content, version, effective_date
|
||||
FROM policy
|
||||
WHERE type = ? AND locale = ?""",
|
||||
(policy_type, locale)
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Policy '{policy_type}' not found",
|
||||
"data": None,
|
||||
"_source": "local_cache"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"type": row["type"],
|
||||
"locale": row["locale"],
|
||||
"title": row["title"],
|
||||
"summary": row["summary"],
|
||||
"content": row["content"],
|
||||
"version": row["version"],
|
||||
"effective_date": row["effective_date"]
|
||||
},
|
||||
"_source": "local_cache"
|
||||
}
|
||||
|
||||
def save_policy(self, policy_type: str, locale: str, data: Dict[str, Any]):
|
||||
"""Save policy to database"""
|
||||
conn = self._get_conn()
|
||||
|
||||
title = data.get("title") or ""
|
||||
summary = data.get("summary") or ""
|
||||
content = data.get("content") or ""
|
||||
version = data.get("version") or ""
|
||||
effective_date = data.get("effective_date") or ""
|
||||
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO policy
|
||||
(type, locale, title, summary, content, version, effective_date, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(policy_type, locale, title, summary, content, version, effective_date, datetime.now().isoformat())
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# ============ Sync Status ============
|
||||
|
||||
def update_sync_status(self, data_type: str, status: str, items_count: int = 0, error: Optional[str] = None):
|
||||
"""Update sync status"""
|
||||
conn = self._get_conn()
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO sync_status (data_type, last_sync_at, status, items_count, error_message)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(data_type, datetime.now().isoformat(), status, items_count, error)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
def get_sync_status(self, data_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get sync status"""
|
||||
conn = self._get_conn()
|
||||
|
||||
if data_type:
|
||||
cursor = conn.execute(
|
||||
"""SELECT * FROM sync_status WHERE data_type = ? ORDER BY last_sync_at DESC LIMIT 1""",
|
||||
(data_type,)
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"""SELECT * FROM sync_status ORDER BY last_sync_at DESC LIMIT 10"""
|
||||
)
|
||||
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def close(self):
|
||||
"""Close database connection"""
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
|
||||
# Global knowledge base instance
|
||||
kb = LocalKnowledgeBase()
|
||||
|
||||
|
||||
def get_kb() -> LocalKnowledgeBase:
|
||||
"""Get global knowledge base instance"""
|
||||
return kb
|
||||
@@ -20,3 +20,9 @@ structlog>=24.1.0
|
||||
|
||||
# Configuration
|
||||
pyyaml>=6.0
|
||||
|
||||
# Cache
|
||||
redis>=5.0.0
|
||||
|
||||
# Scheduler
|
||||
apscheduler>=3.10.0
|
||||
|
||||
@@ -3,7 +3,9 @@ Strapi MCP Server - FAQ and Knowledge Base
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
# Add shared module to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
@@ -13,6 +15,7 @@ from pydantic_settings import BaseSettings
|
||||
from fastapi import Request
|
||||
from starlette.responses import JSONResponse
|
||||
import uvicorn
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
|
||||
from pydantic import ConfigDict
|
||||
@@ -23,6 +26,8 @@ class Settings(BaseSettings):
|
||||
strapi_api_url: str
|
||||
strapi_api_token: str
|
||||
log_level: str = "INFO"
|
||||
sync_interval_minutes: int = 60 # Sync every 60 minutes
|
||||
sync_on_startup: bool = True # Run initial sync on startup
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
@@ -196,6 +201,55 @@ async def health_check() -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ============ Sync Scheduler ============
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def run_scheduled_sync():
|
||||
"""Run scheduled sync from Strapi to local knowledge base"""
|
||||
try:
|
||||
from sync import StrapiSyncer
|
||||
from knowledge_base import get_kb
|
||||
|
||||
kb = get_kb()
|
||||
syncer = StrapiSyncer(kb)
|
||||
|
||||
print(f"[{datetime.now()}] Starting scheduled sync...")
|
||||
result = await syncer.sync_all()
|
||||
|
||||
if result["success"]:
|
||||
print(f"[{datetime.now()}] Sync completed successfully")
|
||||
else:
|
||||
print(f"[{datetime.now()}] Sync failed: {result.get('error', 'Unknown error')}")
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now()}] Sync error: {e}")
|
||||
|
||||
|
||||
async def run_initial_sync():
|
||||
"""Run initial sync on startup if enabled"""
|
||||
if settings.sync_on_startup:
|
||||
print("Running initial sync on startup...")
|
||||
await run_scheduled_sync()
|
||||
print("Initial sync completed")
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""Start the background sync scheduler"""
|
||||
if settings.sync_interval_minutes > 0:
|
||||
scheduler.add_job(
|
||||
run_scheduled_sync,
|
||||
'interval',
|
||||
minutes=settings.sync_interval_minutes,
|
||||
id='strapi_sync',
|
||||
replace_existing=True
|
||||
)
|
||||
scheduler.start()
|
||||
print(f"Sync scheduler started (interval: {settings.sync_interval_minutes} minutes)")
|
||||
else:
|
||||
print("Sync scheduler disabled (interval set to 0)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
# Create FastAPI app from MCP
|
||||
@@ -252,9 +306,23 @@ if __name__ == "__main__":
|
||||
|
||||
# Add routes using the correct method
|
||||
from fastapi import FastAPI
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Lifespan context manager for startup/shutdown events
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: start scheduler and run initial sync
|
||||
start_scheduler()
|
||||
if settings.sync_on_startup:
|
||||
print("Running initial sync on startup...")
|
||||
await run_scheduled_sync()
|
||||
print("Initial sync completed")
|
||||
yield
|
||||
# Shutdown: stop scheduler
|
||||
scheduler.shutdown()
|
||||
|
||||
# Create a wrapper FastAPI app with custom routes first
|
||||
app = FastAPI()
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Add custom routes BEFORE mounting mcp_app
|
||||
app.add_route("/health", health_check, methods=["GET"])
|
||||
|
||||
252
mcp_servers/strapi_mcp/sync.py
Normal file
252
mcp_servers/strapi_mcp/sync.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Strapi to Local Knowledge Base Sync Script
|
||||
|
||||
Periodically syncs FAQ, company info, and policies from Strapi CMS to local SQLite database.
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List
|
||||
from knowledge_base import LocalKnowledgeBase, settings
|
||||
from config_loader import load_config, get_category_endpoint
|
||||
|
||||
|
||||
class StrapiSyncer:
|
||||
"""Sync data from Strapi to local knowledge base"""
|
||||
|
||||
def __init__(self, kb: LocalKnowledgeBase):
|
||||
self.kb = kb
|
||||
self.api_url = settings.strapi_api_url.rstrip("/")
|
||||
self.api_token = settings.strapi_api_token
|
||||
|
||||
async def sync_all(self) -> Dict[str, Any]:
|
||||
"""Sync all data from Strapi"""
|
||||
results = {
|
||||
"success": True,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"details": {}
|
||||
}
|
||||
|
||||
try:
|
||||
# Load config
|
||||
try:
|
||||
config = load_config()
|
||||
except:
|
||||
config = None
|
||||
|
||||
# Sync FAQ categories
|
||||
categories = ["register", "order", "pre-order", "payment", "shipment", "return", "other"]
|
||||
if config:
|
||||
categories = list(config.faq_categories.keys())
|
||||
|
||||
faq_total = 0
|
||||
for category in categories:
|
||||
count = await self.sync_faq_category(category, config)
|
||||
faq_total += count
|
||||
results["details"][f"faq_{category}"] = count
|
||||
|
||||
results["details"]["faq_total"] = faq_total
|
||||
|
||||
# Sync company info
|
||||
company_sections = ["contact", "about", "service"]
|
||||
for section in company_sections:
|
||||
await self.sync_company_info(section)
|
||||
results["details"]["company_info"] = len(company_sections)
|
||||
|
||||
# Sync policies
|
||||
policy_types = ["return_policy", "privacy_policy", "terms_of_service", "shipping_policy", "payment_policy"]
|
||||
for policy_type in policy_types:
|
||||
await self.sync_policy(policy_type)
|
||||
results["details"]["policies"] = len(policy_types)
|
||||
|
||||
# Update sync status
|
||||
self.kb.update_sync_status("all", "success", faq_total)
|
||||
|
||||
print(f"✅ Sync completed: {faq_total} FAQs, {len(company_sections)} company sections, {len(policy_types)} policies")
|
||||
|
||||
except Exception as e:
|
||||
results["success"] = False
|
||||
results["error"] = str(e)
|
||||
self.kb.update_sync_status("all", "error", 0, str(e))
|
||||
print(f"❌ Sync failed: {e}")
|
||||
|
||||
return results
|
||||
|
||||
async def sync_faq_category(self, category: str, config=None) -> int:
|
||||
"""Sync FAQ category from Strapi"""
|
||||
try:
|
||||
# Get endpoint from config
|
||||
if config:
|
||||
endpoint = get_category_endpoint(category, config)
|
||||
else:
|
||||
endpoint = f"faq-{category}"
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_token:
|
||||
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||
|
||||
# Fetch from Strapi
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/api/{endpoint}",
|
||||
params={"populate": "deep"},
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract FAQ items
|
||||
faq_list = []
|
||||
item_data = data.get("data", {})
|
||||
|
||||
if isinstance(item_data, dict):
|
||||
if item_data.get("content"):
|
||||
faq_list = item_data["content"]
|
||||
elif item_data.get("faqs"):
|
||||
faq_list = item_data["faqs"]
|
||||
elif item_data.get("questions"):
|
||||
faq_list = item_data["questions"]
|
||||
elif isinstance(item_data, list):
|
||||
faq_list = item_data
|
||||
|
||||
# Save to local database
|
||||
count = self.kb.save_faq_batch(faq_list, category, "en")
|
||||
|
||||
# Also sync other locales if available
|
||||
locales = ["nl", "de", "es", "fr", "it", "tr"]
|
||||
for locale in locales:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/api/{endpoint}",
|
||||
params={"populate": "deep", "locale": locale},
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract and save
|
||||
faq_list_locale = []
|
||||
item_data_locale = data.get("data", {})
|
||||
|
||||
if isinstance(item_data_locale, dict):
|
||||
if item_data_locale.get("content"):
|
||||
faq_list_locale = item_data_locale["content"]
|
||||
elif item_data_locale.get("faqs"):
|
||||
faq_list_locale = item_data_locale["faqs"]
|
||||
|
||||
if faq_list_locale:
|
||||
self.kb.save_faq_batch(faq_list_locale, category, locale)
|
||||
count += len(faq_list_locale)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to sync {category} for locale {locale}: {e}")
|
||||
|
||||
print(f" ✓ Synced {count} FAQs for category '{category}'")
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to sync category '{category}': {e}")
|
||||
return 0
|
||||
|
||||
async def sync_company_info(self, section: str):
|
||||
"""Sync company info from Strapi"""
|
||||
try:
|
||||
section_map = {
|
||||
"contact": "info-contact",
|
||||
"about": "info-about",
|
||||
"service": "info-service",
|
||||
}
|
||||
|
||||
endpoint = section_map.get(section, f"info-{section}")
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_token:
|
||||
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/api/{endpoint}",
|
||||
params={"populate": "deep"},
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
item = data.get("data", {})
|
||||
if item:
|
||||
# Extract data
|
||||
company_data = {
|
||||
"section": section,
|
||||
"title": item.get("title"),
|
||||
"description": item.get("description"),
|
||||
"content": item.get("content")
|
||||
}
|
||||
|
||||
# Handle profile info
|
||||
if item.get("yehwang_profile"):
|
||||
profile = item["yehwang_profile"]
|
||||
company_data["profile"] = {
|
||||
"title": profile.get("title"),
|
||||
"content": profile.get("content")
|
||||
}
|
||||
|
||||
self.kb.save_company_info(section, "en", company_data)
|
||||
print(f" ✓ Synced company info '{section}'")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to sync company info '{section}': {e}")
|
||||
|
||||
async def sync_policy(self, policy_type: str):
|
||||
"""Sync policy from Strapi"""
|
||||
try:
|
||||
policy_map = {
|
||||
"return_policy": "policy-return",
|
||||
"privacy_policy": "policy-privacy",
|
||||
"terms_of_service": "policy-terms",
|
||||
"shipping_policy": "policy-shipping",
|
||||
"payment_policy": "policy-payment",
|
||||
}
|
||||
|
||||
endpoint = policy_map.get(policy_type, f"policy-{policy_type}")
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_token:
|
||||
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/api/{endpoint}",
|
||||
params={"populate": "deep"},
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
item = data.get("data", {})
|
||||
if item:
|
||||
policy_data = {
|
||||
"title": item.get("title"),
|
||||
"summary": item.get("summary"),
|
||||
"content": item.get("content"),
|
||||
"version": item.get("version"),
|
||||
"effective_date": item.get("effective_date")
|
||||
}
|
||||
|
||||
self.kb.save_policy(policy_type, "en", policy_data)
|
||||
print(f" ✓ Synced policy '{policy_type}'")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to sync policy '{policy_type}': {e}")
|
||||
|
||||
|
||||
async def run_sync(kb: LocalKnowledgeBase):
|
||||
"""Run sync process"""
|
||||
syncer = StrapiSyncer(kb)
|
||||
await syncer.sync_all()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run sync
|
||||
kb_instance = LocalKnowledgeBase()
|
||||
asyncio.run(run_sync(kb_instance))
|
||||
52
nginx.conf
Normal file
52
nginx.conf
Normal file
@@ -0,0 +1,52 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# 静态文件服务器
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 根目录指向 docs 文件夹
|
||||
root /usr/share/nginx/html/docs;
|
||||
index test-chat.html index.html index.htm;
|
||||
|
||||
# 主要测试页面
|
||||
location / {
|
||||
try_files $uri $uri/ /test-chat.html;
|
||||
}
|
||||
|
||||
# 直接访问 test-chat.html
|
||||
location /test-chat.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# 其他静态文件
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|html)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 禁止访问隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# 自定义错误页面
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
165
plans/order-mcp-implementation.md
Normal file
165
plans/order-mcp-implementation.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Order MCP 实现计划
|
||||
|
||||
## 概述
|
||||
|
||||
根据实际 API 接口分析,需要重构 order_mcp 以适配真实的 Gaia888 商城 API。
|
||||
|
||||
## 当前问题
|
||||
|
||||
### 1. API 端点不匹配
|
||||
- **当前**: `POST /orders/query`
|
||||
- **实际**: `GET /mall/api/order/show?orderId=xxx`
|
||||
|
||||
### 2. 请求方法不匹配
|
||||
- **当前**: POST with JSON body
|
||||
- **实际**: GET with query parameters
|
||||
|
||||
### 3. 缺少必要的 Headers
|
||||
实际 API 需要以下 Headers:
|
||||
```
|
||||
Authorization: Bearer {token}
|
||||
tenant-Id: {tenant_id}
|
||||
currency-code: {currency}
|
||||
language-id: {language_id}
|
||||
source: {source}
|
||||
Device-Type: {device_type}
|
||||
```
|
||||
|
||||
## 实施计划
|
||||
|
||||
### 步骤 1: 更新 shared/hyperf_client.py
|
||||
|
||||
**目标**: 支持自定义 Headers 和更灵活的 API 配置
|
||||
|
||||
**修改内容**:
|
||||
1. 添加可选的 tenant_id, currency_code, language_id, source, device_type 配置
|
||||
2. 在请求中自动添加这些 Headers
|
||||
3. 支持不同的 base_url(Gaia888 API)
|
||||
|
||||
**代码变更**:
|
||||
```python
|
||||
class HyperfSettings(BaseSettings):
|
||||
hyperf_api_url: str
|
||||
hyperf_api_token: str
|
||||
tenant_id: int = 2
|
||||
currency_code: str = "EUR"
|
||||
language_id: int = 1
|
||||
source: str = "us.qa1.gaia888.com"
|
||||
device_type: str = "pc"
|
||||
```
|
||||
|
||||
### 步骤 2: 更新 order_mcp/server.py
|
||||
|
||||
**目标**: 重构 query_order 工具以适配真实 API
|
||||
|
||||
**修改内容**:
|
||||
1. 将 POST 请求改为 GET 请求
|
||||
2. 将 JSON body 参数改为 query string 参数
|
||||
3. 更新端点路径为 `/mall/api/order/show`
|
||||
4. 添加 get_order_detail 工具(如果需要)
|
||||
|
||||
**新工具设计**:
|
||||
|
||||
#### query_order - 订单查询
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def query_order(
|
||||
order_id: str,
|
||||
user_id: Optional[str] = None
|
||||
) -> dict:
|
||||
"""查询订单详情
|
||||
|
||||
Args:
|
||||
order_id: 订单号
|
||||
user_id: 用户ID(可选,用于权限验证)
|
||||
|
||||
Returns:
|
||||
订单详细信息
|
||||
"""
|
||||
```
|
||||
|
||||
#### get_order_list - 订单列表查询(新增)
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def get_order_list(
|
||||
user_id: str,
|
||||
status: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10
|
||||
) -> dict:
|
||||
"""查询用户订单列表
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
status: 订单状态筛选
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
|
||||
Returns:
|
||||
订单列表和分页信息
|
||||
"""
|
||||
```
|
||||
|
||||
### 步骤 3: 更新环境变量配置
|
||||
|
||||
**.env 文件需要添加**:
|
||||
```bash
|
||||
# Order MCP 配置
|
||||
TENANT_ID=2
|
||||
CURRENCY_CODE=EUR
|
||||
LANGUAGE_ID=1
|
||||
SOURCE=us.qa1.gaia888.com
|
||||
DEVICE_TYPE=pc
|
||||
```
|
||||
|
||||
### 步骤 4: 更新 docker-compose.yml
|
||||
|
||||
**确保环境变量传递**:
|
||||
```yaml
|
||||
order_mcp:
|
||||
environment:
|
||||
HYPERF_API_URL: ${HYPERF_API_URL}
|
||||
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
|
||||
TENANT_ID: ${TENANT_ID:-2}
|
||||
CURRENCY_CODE: ${CURRENCY_CODE:-EUR}
|
||||
LANGUAGE_ID: ${LANGUAGE_ID:-1}
|
||||
SOURCE: ${SOURCE:-us.qa1.gaia888.com}
|
||||
DEVICE_TYPE: ${DEVICE_TYPE:-pc}
|
||||
```
|
||||
|
||||
## API 端点映射
|
||||
|
||||
| 功能 | 当前端点 | 实际端点 | 方法 |
|
||||
|------|----------|----------|------|
|
||||
| 订单详情 | /orders/query | /mall/api/order/show | GET |
|
||||
| 订单列表 | /orders/query | /mall/api/order/list | GET |
|
||||
| 物流跟踪 | /orders/{id}/logistics | /mall/api/order/logistics | GET |
|
||||
| 取消订单 | /orders/{id}/cancel | /mall/api/order/cancel | POST |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 订单详情响应格式
|
||||
```json
|
||||
{
|
||||
"orderId": "202071324",
|
||||
"status": "shipped",
|
||||
"totalAmount": 5000.00,
|
||||
"items": [...],
|
||||
"shippingAddress": {...},
|
||||
"trackingNumber": "SF1234567890",
|
||||
"createdAt": "2026-01-10 10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
## 测试计划
|
||||
|
||||
1. 单元测试 - 测试 HyperfClient 的 Header 生成
|
||||
2. 集成测试 - 测试与真实 API 的连接
|
||||
3. 端到端测试 - 通过 agent 调用 order_mcp 工具
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **安全性**: JWT token 需要定期刷新
|
||||
2. **错误处理**: 需要处理 API 返回的各种错误码
|
||||
3. **缓存**: 考虑添加订单查询缓存以减少 API 调用
|
||||
4. **日志**: 记录所有 API 调用和响应
|
||||
108
scripts/check-chatwoot-config.sh
Executable file
108
scripts/check-chatwoot-config.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/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 ""
|
||||
102
scripts/check-conversations.sh
Executable file
102
scripts/check-conversations.sh
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/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/"/"/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 ""
|
||||
53
scripts/debug-webhook.sh
Executable file
53
scripts/debug-webhook.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/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
|
||||
62
scripts/update-chatwoot-webhook.sh
Normal file
62
scripts/update-chatwoot-webhook.sh
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/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 中测试发送消息了。"
|
||||
81
scripts/verify-webhook.sh
Executable file
81
scripts/verify-webhook.sh
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/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 "======================================"
|
||||
74
tests/test_all_faq.sh
Executable file
74
tests/test_all_faq.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/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 "=========================================="
|
||||
103
tests/test_mall_order_query.py
Normal file
103
tests/test_mall_order_query.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
测试商城订单查询接口
|
||||
|
||||
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()
|
||||
63
tests/test_return_faq.py
Normal file
63
tests/test_return_faq.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
测试退货相关 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())
|
||||
Reference in New Issue
Block a user