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:
wangliang
2026-01-16 16:28:47 +08:00
parent 0e59f3067e
commit e093995368
48 changed files with 5263 additions and 395 deletions

View File

@@ -6,109 +6,20 @@ 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
Handles returns, exchanges, complaints and aftersale queries.
Args:
state: Current agent state
Returns:
Updated state with tool calls or response
"""
@@ -117,34 +28,70 @@ async def aftersale_agent(state: AgentState) -> AgentState:
conversation_id=state["conversation_id"],
sub_intent=state.get("sub_intent")
)
state["current_agent"] = "aftersale"
state["agent_history"].append("aftersale")
state["state"] = ConversationState.PROCESSING.value
# Check if we have tool results to process
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
for msg in state["messages"][-8:]: # More history for aftersale context
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"
user_content = f"{context_info}\n用户消息: {state['current_message']}"
context_info += f"Conversation context: {json.dumps(state['context'], ensure_ascii=False)}\n"
user_content = f"{context_info}\nUser message: {state['current_message']}"
messages.append(Message(role="user", content=user_content))
try:
@@ -206,46 +153,46 @@ async def aftersale_agent(state: AgentState) -> AgentState:
async def _generate_aftersale_response(state: AgentState) -> AgentState:
"""Generate response based on aftersale tool results"""
tool_context = []
for result in state["tool_results"]:
if result["success"]:
data = result["data"]
tool_context.append(f"工具 {result['tool_name']} 返回:\n{json.dumps(data, ensure_ascii=False, indent=2)}")
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']}")
prompt = f"""基于以下售后系统返回的信息,生成对用户的回复。
tool_context.append(f"Tool {result['tool_name']} failed: {result['error']}")
用户问题: {state["current_message"]}
prompt = f"""Based on the following aftersale system information, generate a response to the user.
系统返回信息:
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
Return only the response content, do not return JSON."""
只返回回复内容,不要返回 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)
]
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.7)
state = set_response(state, response.content)
return state
except Exception as e:
logger.error("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

View File

@@ -6,76 +6,20 @@ 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
Handles FAQ, company info, and general inquiries using Strapi MCP tools.
Args:
state: Current agent state
Returns:
Updated state with tool calls or response
"""
@@ -83,18 +27,87 @@ async def customer_service_agent(state: AgentState) -> AgentState:
"Customer service agent processing",
conversation_id=state["conversation_id"]
)
state["current_agent"] = "customer_service"
state["agent_history"].append("customer_service")
state["state"] = ConversationState.PROCESSING.value
# Check if we have tool results to process
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
@@ -151,37 +164,37 @@ async def customer_service_agent(state: AgentState) -> AgentState:
async def _generate_response_from_results(state: AgentState) -> AgentState:
"""Generate response based on tool results"""
# Build context from tool results
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']}")
prompt = f"""基于以下工具返回的信息,生成对用户的回复。
tool_context.append(f"Tool {result['tool_name']} failed: {result['error']}")
用户问题: {state["current_message"]}
prompt = f"""Based on the following tool returned information, generate a response to the user.
工具返回信息:
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)
]
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.7)
state = set_response(state, response.content)
return state
except Exception as e:
logger.error("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

View File

@@ -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 工具
- 对于敏感操作(取消、修改),确保有明确的订单号
"""
@@ -131,27 +169,133 @@ async def order_agent(state: AgentState) -> AgentState:
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.5)
# Parse response
content = response.content.strip()
if content.startswith("```"):
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
result = json.loads(content)
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
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"]
state = add_tool_call(
state,
tool_name=result["tool_name"],
@@ -159,6 +303,12 @@ async def order_agent(state: AgentState) -> AgentState:
server="order"
)
state["state"] = ConversationState.TOOL_CALLING.value
logger.info(
"Tool call added",
tool_name=result["tool_name"],
arguments_keys=list(arguments.keys())
)
elif action == "ask_info":
state = set_response(state, result["question"])
@@ -171,13 +321,9 @@ async def order_agent(state: AgentState) -> AgentState:
elif action == "handoff":
state["requires_human"] = True
state["handoff_reason"] = result.get("reason", "Complex order operation")
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)

View File

@@ -4,92 +4,24 @@ 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
This is the first node in the workflow that analyzes the user's message
and determines which agent should handle it.
Args:
state: Current agent state
Returns:
Updated state with intent and entities
"""
@@ -98,24 +30,38 @@ async def classify_intent(state: AgentState) -> AgentState:
conversation_id=state["conversation_id"],
message=state["current_message"][:100]
)
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))

View File

@@ -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
@@ -197,20 +193,36 @@ 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"
@@ -255,13 +267,20 @@ def check_completion(state: AgentState) -> Literal["continue", "end", "error"]:
def create_agent_graph() -> StateGraph:
"""Create the main agent workflow graph
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)
# Add nodes
graph.add_node("receive", receive_message)
graph.add_node("classify", classify_intent)
@@ -347,10 +366,11 @@ 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
Args:
conversation_id: Chatwoot conversation ID
user_id: User identifier
@@ -358,12 +378,13 @@ 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
"""
from .state import create_initial_state
# Create initial state
initial_state = create_initial_state(
conversation_id=conversation_id,
@@ -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

View 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

View File

@@ -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}]
@@ -74,6 +75,10 @@ class AgentState(TypedDict):
intent: Optional[str] # Recognized intent (Intent enum value)
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,10 +116,11 @@ 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
Args:
conversation_id: Chatwoot conversation ID
user_id: User identifier
@@ -122,7 +128,8 @@ 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 [],
@@ -140,6 +148,10 @@ def create_initial_state(
intent=None,
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

View File

@@ -56,10 +56,10 @@ class ChatwootClient:
self,
api_url: Optional[str] = None,
api_token: Optional[str] = None,
account_id: int = 1
account_id: int = 2
):
"""Initialize Chatwoot client
Args:
api_url: Chatwoot API URL, defaults to settings
api_token: API access token, defaults to settings
@@ -69,7 +69,7 @@ class ChatwootClient:
self.api_token = api_token or settings.chatwoot_api_token
self.account_id = account_id
self._client: Optional[httpx.AsyncClient] = None
logger.info("Chatwoot client initialized", api_url=self.api_url)
async def _get_client(self) -> httpx.AsyncClient:

View 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"]

View 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
View 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)

View 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?"

View 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."

View 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."

View 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?"

View File

@@ -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
View 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
}

View 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()

View File

@@ -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,24 +113,25 @@ 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:
logger.warning("No conversation in payload")
return
conversation_id = str(conversation.id)
content = payload.content
if not content:
logger.debug("Empty message content, skipping")
return
# Get user/contact info
contact = payload.contact or payload.sender
user_id = str(contact.id) if contact else "unknown"
@@ -137,21 +140,54 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload) -> None:
# Chatwoot webhook includes account info at the top level
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)
)
# Load conversation context from cache
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
@@ -306,11 +343,16 @@ async def chatwoot_webhook(
background_tasks: BackgroundTasks
):
"""Chatwoot webhook endpoint
Receives events from Chatwoot and processes them asynchronously.
"""
# 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", "")
@@ -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)