feat: 初始化 B2B AI Shopping Assistant 项目
- 配置 Docker Compose 多服务编排 - 实现 Chatwoot + Agent 集成 - 配置 Strapi MCP 知识库 - 支持 7 种语言的 FAQ 系统 - 实现 LangGraph AI 工作流 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
44
.env.example
Normal file
44
.env.example
Normal file
@@ -0,0 +1,44 @@
|
||||
# B2B Shopping AI Assistant Platform - Environment Variables
|
||||
|
||||
# ============ AI Model ============
|
||||
ZHIPU_API_KEY=your_zhipu_api_key
|
||||
ZHIPU_MODEL=glm-4
|
||||
|
||||
# ============ Redis ============
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
REDIS_DB=0
|
||||
|
||||
# ============ PostgreSQL (Chatwoot) ============
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=chatwoot
|
||||
POSTGRES_USER=chatwoot
|
||||
POSTGRES_PASSWORD=your_postgres_password
|
||||
|
||||
# ============ Chatwoot ============
|
||||
CHATWOOT_API_URL=http://chatwoot:3000
|
||||
CHATWOOT_API_TOKEN=your_chatwoot_api_token
|
||||
CHATWOOT_WEBHOOK_SECRET=your_webhook_secret
|
||||
CHATWOOT_FRONTEND_URL=http://localhost:3000
|
||||
CHATWOOT_SECRET_KEY_BASE=your_secret_key_base
|
||||
|
||||
# ============ Strapi CMS (FAQ/Knowledge Base) ============
|
||||
STRAPI_API_URL=http://your-strapi:1337
|
||||
STRAPI_API_TOKEN=your_strapi_api_token
|
||||
|
||||
# ============ Hyperf PHP API ============
|
||||
HYPERF_API_URL=http://your-hyperf-api:9501
|
||||
HYPERF_API_TOKEN=your_hyperf_api_token
|
||||
|
||||
# ============ MCP Servers ============
|
||||
STRAPI_MCP_URL=http://strapi_mcp:8001
|
||||
ORDER_MCP_URL=http://order_mcp:8002
|
||||
AFTERSALE_MCP_URL=http://aftersale_mcp:8003
|
||||
PRODUCT_MCP_URL=http://product_mcp:8004
|
||||
|
||||
# ============ Agent Config ============
|
||||
LOG_LEVEL=INFO
|
||||
MAX_CONVERSATION_STEPS=10
|
||||
CONVERSATION_TIMEOUT=3600
|
||||
80
.gitignore
vendored
Normal file
80
.gitignore
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
# Environment Variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
*.log.*
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Docker volumes
|
||||
chatwoot_data/
|
||||
postgres_data/
|
||||
redis_data/
|
||||
|
||||
# Node modules (if any)
|
||||
node_modules/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# Test coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
*.claude
|
||||
31
agent/Dockerfile
Normal file
31
agent/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create logs directory
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
0
agent/__init__.py
Normal file
0
agent/__init__.py
Normal file
15
agent/agents/__init__.py
Normal file
15
agent/agents/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Agents package"""
|
||||
from .router import classify_intent, route_by_intent
|
||||
from .customer_service import customer_service_agent
|
||||
from .order import order_agent
|
||||
from .aftersale import aftersale_agent
|
||||
from .product import product_agent
|
||||
|
||||
__all__ = [
|
||||
"classify_intent",
|
||||
"route_by_intent",
|
||||
"customer_service_agent",
|
||||
"order_agent",
|
||||
"aftersale_agent",
|
||||
"product_agent",
|
||||
]
|
||||
251
agent/agents/aftersale.py
Normal file
251
agent/agents/aftersale.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Aftersale Agent - Handles returns, exchanges, and complaints
|
||||
"""
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from core.state import AgentState, ConversationState, add_tool_call, set_response, update_context
|
||||
from core.llm import get_llm_client, Message
|
||||
from utils.logger import get_logger
|
||||
|
||||
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
|
||||
"""
|
||||
logger.info(
|
||||
"Aftersale agent processing",
|
||||
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)
|
||||
|
||||
# Build messages for LLM
|
||||
messages = [
|
||||
Message(role="system", content=AFTERSALE_AGENT_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"
|
||||
|
||||
if state["entities"]:
|
||||
context_info += f"已提取的信息: {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']}"
|
||||
messages.append(Message(role="user", content=user_content))
|
||||
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
response = await llm.chat(messages, temperature=0.5)
|
||||
|
||||
# Parse response
|
||||
content = response.content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.split("```")[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
|
||||
result = json.loads(content)
|
||||
action = result.get("action")
|
||||
|
||||
if action == "call_tool":
|
||||
arguments = result.get("arguments", {})
|
||||
arguments["user_id"] = state["user_id"]
|
||||
|
||||
# Use entity if available
|
||||
if "order_id" not in arguments and state["entities"].get("order_id"):
|
||||
arguments["order_id"] = state["entities"]["order_id"]
|
||||
|
||||
state = add_tool_call(
|
||||
state,
|
||||
tool_name=result["tool_name"],
|
||||
arguments=arguments,
|
||||
server="aftersale"
|
||||
)
|
||||
state["state"] = ConversationState.TOOL_CALLING.value
|
||||
|
||||
elif action == "ask_info":
|
||||
state = set_response(state, result["question"])
|
||||
state["state"] = ConversationState.AWAITING_INFO.value
|
||||
# Store required fields in context for next iteration
|
||||
if result.get("required_fields"):
|
||||
state = update_context(state, {"required_fields": result["required_fields"]})
|
||||
|
||||
elif action == "respond":
|
||||
state = set_response(state, result["response"])
|
||||
state["state"] = ConversationState.GENERATING.value
|
||||
|
||||
elif action == "handoff":
|
||||
state["requires_human"] = True
|
||||
state["handoff_reason"] = result.get("reason", "Complex aftersale issue")
|
||||
|
||||
return state
|
||||
|
||||
except json.JSONDecodeError:
|
||||
state = set_response(state, response.content)
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Aftersale agent failed", error=str(e))
|
||||
state["error"] = str(e)
|
||||
return state
|
||||
|
||||
|
||||
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)}")
|
||||
|
||||
# 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"""基于以下售后系统返回的信息,生成对用户的回复。
|
||||
|
||||
用户问题: {state["current_message"]}
|
||||
|
||||
系统返回信息:
|
||||
{chr(10).join(tool_context)}
|
||||
|
||||
请生成一个体贴、专业的回复:
|
||||
- 如果是申请提交成功,告知用户售后单号和后续流程
|
||||
- 如果是状态查询,清晰说明当前进度
|
||||
- 如果申请失败,说明原因并提供解决方案
|
||||
- 对用户的问题表示理解
|
||||
|
||||
只返回回复内容,不要返回 JSON。"""
|
||||
|
||||
messages = [
|
||||
Message(role="system", content="你是一个专业的售后客服助手,请根据系统返回的信息回答用户的售后问题。"),
|
||||
Message(role="user", content=prompt)
|
||||
]
|
||||
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
response = await llm.chat(messages, temperature=0.7)
|
||||
state = set_response(state, response.content)
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Aftersale response generation failed", error=str(e))
|
||||
state = set_response(state, "抱歉,处理售后请求时遇到问题。请稍后重试或联系人工客服。")
|
||||
return state
|
||||
187
agent/agents/customer_service.py
Normal file
187
agent/agents/customer_service.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Customer Service Agent - Handles FAQ and general inquiries
|
||||
"""
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from core.state import AgentState, ConversationState, add_tool_call, set_response
|
||||
from core.llm import get_llm_client, Message
|
||||
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
|
||||
"""
|
||||
logger.info(
|
||||
"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)
|
||||
|
||||
# Build messages for LLM
|
||||
messages = [
|
||||
Message(role="system", content=CUSTOMER_SERVICE_PROMPT),
|
||||
]
|
||||
|
||||
# Add conversation history
|
||||
for msg in state["messages"][-6:]:
|
||||
messages.append(Message(role=msg["role"], content=msg["content"]))
|
||||
|
||||
# Add current message
|
||||
messages.append(Message(role="user", content=state["current_message"]))
|
||||
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
response = await llm.chat(messages, temperature=0.7)
|
||||
|
||||
# Parse response
|
||||
content = response.content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.split("```")[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
|
||||
result = json.loads(content)
|
||||
action = result.get("action")
|
||||
|
||||
if action == "call_tool":
|
||||
# Add tool call to state
|
||||
state = add_tool_call(
|
||||
state,
|
||||
tool_name=result["tool_name"],
|
||||
arguments=result.get("arguments", {}),
|
||||
server="strapi"
|
||||
)
|
||||
state["state"] = ConversationState.TOOL_CALLING.value
|
||||
|
||||
elif action == "respond":
|
||||
state = set_response(state, result["response"])
|
||||
state["state"] = ConversationState.GENERATING.value
|
||||
|
||||
elif action == "handoff":
|
||||
state["requires_human"] = True
|
||||
state["handoff_reason"] = result.get("reason", "User request")
|
||||
|
||||
return state
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# LLM returned plain text, use as response
|
||||
state = set_response(state, response.content)
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Customer service agent failed", error=str(e))
|
||||
state["error"] = str(e)
|
||||
return state
|
||||
|
||||
|
||||
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)}")
|
||||
else:
|
||||
tool_context.append(f"工具 {result['tool_name']} 执行失败: {result['error']}")
|
||||
|
||||
prompt = f"""基于以下工具返回的信息,生成对用户的回复。
|
||||
|
||||
用户问题: {state["current_message"]}
|
||||
|
||||
工具返回信息:
|
||||
{chr(10).join(tool_context)}
|
||||
|
||||
请生成一个友好、专业的回复。如果工具没有返回有用信息,请诚实告知用户并建议其他方式获取帮助。
|
||||
只返回回复内容,不要返回 JSON。"""
|
||||
|
||||
messages = [
|
||||
Message(role="system", content="你是一个专业的 B2B 客服助手,请根据工具返回的信息回答用户问题。"),
|
||||
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, "抱歉,处理您的请求时遇到问题。请稍后重试或联系人工客服。")
|
||||
return state
|
||||
231
agent/agents/order.py
Normal file
231
agent/agents/order.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Order Agent - Handles order-related queries and operations
|
||||
"""
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from core.state import AgentState, ConversationState, add_tool_call, set_response, update_context
|
||||
from core.llm import get_llm_client, Message
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
ORDER_AGENT_PROMPT = """你是一个专业的 B2B 订单服务助手。
|
||||
你的职责是帮助用户处理订单相关的问题,包括:
|
||||
- 订单查询
|
||||
- 物流跟踪
|
||||
- 订单修改
|
||||
- 订单取消
|
||||
- 发票获取
|
||||
|
||||
## 可用工具
|
||||
|
||||
1. **query_order** - 查询订单
|
||||
- order_id: 订单号(可选,不填则查询最近订单)
|
||||
- date_start: 开始日期(可选)
|
||||
- date_end: 结束日期(可选)
|
||||
- status: 订单状态(可选)
|
||||
|
||||
2. **track_logistics** - 物流跟踪
|
||||
- order_id: 订单号
|
||||
- tracking_number: 物流单号(可选)
|
||||
|
||||
3. **modify_order** - 修改订单
|
||||
- order_id: 订单号
|
||||
- modifications: 修改内容(address/items/quantity 等)
|
||||
|
||||
4. **cancel_order** - 取消订单
|
||||
- order_id: 订单号
|
||||
- reason: 取消原因
|
||||
|
||||
5. **get_invoice** - 获取发票
|
||||
- order_id: 订单号
|
||||
- invoice_type: 发票类型(normal/vat)
|
||||
|
||||
## 工具调用格式
|
||||
|
||||
当需要使用工具时,请返回 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "工具名称",
|
||||
"arguments": {
|
||||
"参数名": "参数值"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当需要向用户询问更多信息时:
|
||||
```json
|
||||
{
|
||||
"action": "ask_info",
|
||||
"question": "需要询问的问题"
|
||||
}
|
||||
```
|
||||
|
||||
当可以直接回答时:
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"response": "回复内容"
|
||||
}
|
||||
```
|
||||
|
||||
## 重要提示
|
||||
- 订单修改和取消是敏感操作,需要确认订单号
|
||||
- 如果用户没有提供订单号,先查询他的最近订单
|
||||
- 物流查询需要订单号或物流单号
|
||||
- 对于批量操作或大金额订单,建议转人工处理
|
||||
"""
|
||||
|
||||
|
||||
async def order_agent(state: AgentState) -> AgentState:
|
||||
"""Order agent node
|
||||
|
||||
Handles order queries, tracking, modifications, and cancellations.
|
||||
|
||||
Args:
|
||||
state: Current agent state
|
||||
|
||||
Returns:
|
||||
Updated state with tool calls or response
|
||||
"""
|
||||
logger.info(
|
||||
"Order agent processing",
|
||||
conversation_id=state["conversation_id"],
|
||||
sub_intent=state.get("sub_intent")
|
||||
)
|
||||
|
||||
state["current_agent"] = "order"
|
||||
state["agent_history"].append("order")
|
||||
state["state"] = ConversationState.PROCESSING.value
|
||||
|
||||
# Check if we have tool results to process
|
||||
if state["tool_results"]:
|
||||
return await _generate_order_response(state)
|
||||
|
||||
# Build messages for LLM
|
||||
messages = [
|
||||
Message(role="system", content=ORDER_AGENT_PROMPT),
|
||||
]
|
||||
|
||||
# Add conversation history
|
||||
for msg in state["messages"][-6:]:
|
||||
messages.append(Message(role=msg["role"], content=msg["content"]))
|
||||
|
||||
# Build context info
|
||||
context_info = f"用户ID: {state['user_id']}\n账户ID: {state['account_id']}\n"
|
||||
|
||||
# Add entities if available
|
||||
if state["entities"]:
|
||||
context_info += f"已提取的信息: {json.dumps(state['entities'], ensure_ascii=False)}\n"
|
||||
|
||||
# Add existing context
|
||||
if state["context"].get("order_id"):
|
||||
context_info += f"当前讨论的订单号: {state['context']['order_id']}\n"
|
||||
|
||||
user_content = f"{context_info}\n用户消息: {state['current_message']}"
|
||||
messages.append(Message(role="user", content=user_content))
|
||||
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
response = await llm.chat(messages, temperature=0.5)
|
||||
|
||||
# Parse response
|
||||
content = response.content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.split("```")[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
|
||||
result = json.loads(content)
|
||||
action = result.get("action")
|
||||
|
||||
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"]
|
||||
|
||||
# Use entity if available
|
||||
if "order_id" not in arguments and state["entities"].get("order_id"):
|
||||
arguments["order_id"] = state["entities"]["order_id"]
|
||||
|
||||
state = add_tool_call(
|
||||
state,
|
||||
tool_name=result["tool_name"],
|
||||
arguments=arguments,
|
||||
server="order"
|
||||
)
|
||||
state["state"] = ConversationState.TOOL_CALLING.value
|
||||
|
||||
elif action == "ask_info":
|
||||
state = set_response(state, result["question"])
|
||||
state["state"] = ConversationState.AWAITING_INFO.value
|
||||
|
||||
elif action == "respond":
|
||||
state = set_response(state, result["response"])
|
||||
state["state"] = ConversationState.GENERATING.value
|
||||
|
||||
elif action == "handoff":
|
||||
state["requires_human"] = True
|
||||
state["handoff_reason"] = result.get("reason", "Complex order operation")
|
||||
|
||||
return state
|
||||
|
||||
except 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)
|
||||
return state
|
||||
|
||||
|
||||
async def _generate_order_response(state: AgentState) -> AgentState:
|
||||
"""Generate response based on order tool results"""
|
||||
|
||||
# Build context from tool results
|
||||
tool_context = []
|
||||
for result in state["tool_results"]:
|
||||
if result["success"]:
|
||||
data = result["data"]
|
||||
tool_context.append(f"工具 {result['tool_name']} 返回:\n{json.dumps(data, ensure_ascii=False, indent=2)}")
|
||||
|
||||
# Extract order_id for context
|
||||
if isinstance(data, dict):
|
||||
if data.get("order_id"):
|
||||
state = update_context(state, {"order_id": data["order_id"]})
|
||||
elif data.get("orders") and len(data["orders"]) > 0:
|
||||
state = update_context(state, {"order_id": data["orders"][0].get("order_id")})
|
||||
else:
|
||||
tool_context.append(f"工具 {result['tool_name']} 执行失败: {result['error']}")
|
||||
|
||||
prompt = f"""基于以下订单系统返回的信息,生成对用户的回复。
|
||||
|
||||
用户问题: {state["current_message"]}
|
||||
|
||||
系统返回信息:
|
||||
{chr(10).join(tool_context)}
|
||||
|
||||
请生成一个清晰、友好的回复,包含订单的关键信息(订单号、状态、金额、物流等)。
|
||||
如果是物流信息,请按时间线整理展示。
|
||||
只返回回复内容,不要返回 JSON。"""
|
||||
|
||||
messages = [
|
||||
Message(role="system", content="你是一个专业的订单客服助手,请根据系统返回的信息回答用户的订单问题。"),
|
||||
Message(role="user", content=prompt)
|
||||
]
|
||||
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
response = await llm.chat(messages, temperature=0.7)
|
||||
state = set_response(state, response.content)
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Order response generation failed", error=str(e))
|
||||
state = set_response(state, "抱歉,处理订单信息时遇到问题。请稍后重试或联系人工客服。")
|
||||
return state
|
||||
256
agent/agents/product.py
Normal file
256
agent/agents/product.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Product Agent - Handles product search, recommendations, and quotes
|
||||
"""
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from core.state import AgentState, ConversationState, add_tool_call, set_response, update_context
|
||||
from core.llm import get_llm_client, Message
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
PRODUCT_AGENT_PROMPT = """你是一个专业的 B2B 商品顾问助手。
|
||||
你的职责是帮助用户找到合适的商品,包括:
|
||||
- 商品搜索
|
||||
- 智能推荐
|
||||
- B2B 询价
|
||||
- 库存查询
|
||||
- 商品详情
|
||||
|
||||
## 可用工具
|
||||
|
||||
1. **search_products** - 搜索商品
|
||||
- query: 搜索关键词
|
||||
- filters: 过滤条件(category, price_range, brand 等)
|
||||
- sort: 排序方式(price_asc/price_desc/sales/latest)
|
||||
- page: 页码
|
||||
- page_size: 每页数量
|
||||
|
||||
2. **get_product_detail** - 获取商品详情
|
||||
- product_id: 商品ID
|
||||
|
||||
3. **recommend_products** - 智能推荐
|
||||
- context: 推荐上下文(可包含当前查询、浏览历史等)
|
||||
- limit: 推荐数量
|
||||
|
||||
4. **get_quote** - B2B 询价
|
||||
- product_id: 商品ID
|
||||
- quantity: 采购数量
|
||||
- delivery_address: 收货地址(可选,用于计算运费)
|
||||
|
||||
5. **check_inventory** - 库存查询
|
||||
- product_ids: 商品ID列表
|
||||
- warehouse: 仓库(可选)
|
||||
|
||||
## 工具调用格式
|
||||
|
||||
当需要使用工具时,请返回 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"action": "call_tool",
|
||||
"tool_name": "工具名称",
|
||||
"arguments": {
|
||||
"参数名": "参数值"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当需要向用户询问更多信息时:
|
||||
```json
|
||||
{
|
||||
"action": "ask_info",
|
||||
"question": "需要询问的问题"
|
||||
}
|
||||
```
|
||||
|
||||
当可以直接回答时:
|
||||
```json
|
||||
{
|
||||
"action": "respond",
|
||||
"response": "回复内容"
|
||||
}
|
||||
```
|
||||
|
||||
## B2B 询价特点
|
||||
- 大批量采购通常有阶梯价格
|
||||
- 可能需要考虑运费
|
||||
- 企业客户可能有专属折扣
|
||||
- 报价通常有有效期
|
||||
|
||||
## 商品推荐策略
|
||||
- 根据用户采购历史推荐
|
||||
- 根据当前查询语义推荐
|
||||
- 根据企业行业特点推荐
|
||||
- 根据季节性和热门商品推荐
|
||||
|
||||
## 注意事项
|
||||
- 帮助用户准确描述需求
|
||||
- 如果搜索结果太多,建议用户缩小范围
|
||||
- 询价时确认数量,因为会影响价格
|
||||
- 库存紧张时及时告知用户
|
||||
"""
|
||||
|
||||
|
||||
async def product_agent(state: AgentState) -> AgentState:
|
||||
"""Product agent node
|
||||
|
||||
Handles product search, recommendations, quotes and inventory queries.
|
||||
|
||||
Args:
|
||||
state: Current agent state
|
||||
|
||||
Returns:
|
||||
Updated state with tool calls or response
|
||||
"""
|
||||
logger.info(
|
||||
"Product agent processing",
|
||||
conversation_id=state["conversation_id"],
|
||||
sub_intent=state.get("sub_intent")
|
||||
)
|
||||
|
||||
state["current_agent"] = "product"
|
||||
state["agent_history"].append("product")
|
||||
state["state"] = ConversationState.PROCESSING.value
|
||||
|
||||
# Check if we have tool results to process
|
||||
if state["tool_results"]:
|
||||
return await _generate_product_response(state)
|
||||
|
||||
# Build messages for LLM
|
||||
messages = [
|
||||
Message(role="system", content=PRODUCT_AGENT_PROMPT),
|
||||
]
|
||||
|
||||
# Add conversation history
|
||||
for msg in state["messages"][-6:]:
|
||||
messages.append(Message(role=msg["role"], content=msg["content"]))
|
||||
|
||||
# Build context info
|
||||
context_info = f"用户ID: {state['user_id']}\n账户ID: {state['account_id']}\n"
|
||||
|
||||
if state["entities"]:
|
||||
context_info += f"已提取的信息: {json.dumps(state['entities'], ensure_ascii=False)}\n"
|
||||
|
||||
if state["context"].get("product_id"):
|
||||
context_info += f"当前讨论的商品ID: {state['context']['product_id']}\n"
|
||||
|
||||
if state["context"].get("recent_searches"):
|
||||
context_info += f"最近搜索: {state['context']['recent_searches']}\n"
|
||||
|
||||
user_content = f"{context_info}\n用户消息: {state['current_message']}"
|
||||
messages.append(Message(role="user", content=user_content))
|
||||
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
response = await llm.chat(messages, temperature=0.7)
|
||||
|
||||
# Parse response
|
||||
content = response.content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.split("```")[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
|
||||
result = json.loads(content)
|
||||
action = result.get("action")
|
||||
|
||||
if action == "call_tool":
|
||||
arguments = result.get("arguments", {})
|
||||
|
||||
# Inject context for recommendation
|
||||
if result["tool_name"] == "recommend_products":
|
||||
arguments["user_id"] = state["user_id"]
|
||||
arguments["account_id"] = state["account_id"]
|
||||
|
||||
# Inject context for quote
|
||||
if result["tool_name"] == "get_quote":
|
||||
arguments["account_id"] = state["account_id"]
|
||||
|
||||
# Use entity if available
|
||||
if "product_id" not in arguments and state["entities"].get("product_id"):
|
||||
arguments["product_id"] = state["entities"]["product_id"]
|
||||
|
||||
if "quantity" not in arguments and state["entities"].get("quantity"):
|
||||
arguments["quantity"] = state["entities"]["quantity"]
|
||||
|
||||
state = add_tool_call(
|
||||
state,
|
||||
tool_name=result["tool_name"],
|
||||
arguments=arguments,
|
||||
server="product"
|
||||
)
|
||||
state["state"] = ConversationState.TOOL_CALLING.value
|
||||
|
||||
elif action == "ask_info":
|
||||
state = set_response(state, result["question"])
|
||||
state["state"] = ConversationState.AWAITING_INFO.value
|
||||
|
||||
elif action == "respond":
|
||||
state = set_response(state, result["response"])
|
||||
state["state"] = ConversationState.GENERATING.value
|
||||
|
||||
return state
|
||||
|
||||
except json.JSONDecodeError:
|
||||
state = set_response(state, response.content)
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Product agent failed", error=str(e))
|
||||
state["error"] = str(e)
|
||||
return state
|
||||
|
||||
|
||||
async def _generate_product_response(state: AgentState) -> AgentState:
|
||||
"""Generate response based on product tool results"""
|
||||
|
||||
tool_context = []
|
||||
for result in state["tool_results"]:
|
||||
if result["success"]:
|
||||
data = result["data"]
|
||||
tool_context.append(f"工具 {result['tool_name']} 返回:\n{json.dumps(data, ensure_ascii=False, indent=2)}")
|
||||
|
||||
# Extract product context
|
||||
if isinstance(data, dict):
|
||||
if data.get("product_id"):
|
||||
state = update_context(state, {"product_id": data["product_id"]})
|
||||
if data.get("products"):
|
||||
# Store recent search results
|
||||
product_ids = [p.get("product_id") for p in data["products"][:5]]
|
||||
state = update_context(state, {"recent_product_ids": product_ids})
|
||||
else:
|
||||
tool_context.append(f"工具 {result['tool_name']} 执行失败: {result['error']}")
|
||||
|
||||
prompt = f"""基于以下商品系统返回的信息,生成对用户的回复。
|
||||
|
||||
用户问题: {state["current_message"]}
|
||||
|
||||
系统返回信息:
|
||||
{chr(10).join(tool_context)}
|
||||
|
||||
请生成一个清晰、有帮助的回复:
|
||||
- 如果是搜索结果,展示商品名称、价格、规格等关键信息
|
||||
- 如果是询价结果,清晰说明单价、总价、折扣、有效期等
|
||||
- 如果是推荐商品,简要说明推荐理由
|
||||
- 如果是库存查询,告知可用数量和发货时间
|
||||
- 结果较多时可以总结关键信息
|
||||
|
||||
只返回回复内容,不要返回 JSON。"""
|
||||
|
||||
messages = [
|
||||
Message(role="system", content="你是一个专业的商品顾问,请根据系统返回的信息回答用户的商品问题。"),
|
||||
Message(role="user", content=prompt)
|
||||
]
|
||||
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
response = await llm.chat(messages, temperature=0.7)
|
||||
state = set_response(state, response.content)
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Product response generation failed", error=str(e))
|
||||
state = set_response(state, "抱歉,处理商品信息时遇到问题。请稍后重试或联系人工客服。")
|
||||
return state
|
||||
220
agent/agents/router.py
Normal file
220
agent/agents/router.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
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.llm import get_llm_client, Message
|
||||
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
|
||||
"""
|
||||
logger.info(
|
||||
"Classifying intent",
|
||||
conversation_id=state["conversation_id"],
|
||||
message=state["current_message"][:100]
|
||||
)
|
||||
|
||||
state["state"] = ConversationState.CLASSIFYING.value
|
||||
state["step_count"] += 1
|
||||
|
||||
# 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']}")
|
||||
if state["context"].get("product_id"):
|
||||
context_parts.append(f"当前讨论的商品: {state['context']['product_id']}")
|
||||
if context_parts:
|
||||
context_summary = "\n".join(context_parts)
|
||||
|
||||
# Build messages for LLM
|
||||
messages = [
|
||||
Message(role="system", content=CLASSIFICATION_PROMPT),
|
||||
]
|
||||
|
||||
# Add recent conversation history for context
|
||||
for msg in state["messages"][-6:]: # Last 3 turns
|
||||
messages.append(Message(role=msg["role"], content=msg["content"]))
|
||||
|
||||
# Add current message with context
|
||||
user_content = f"用户消息: {state['current_message']}"
|
||||
if context_summary:
|
||||
user_content += f"\n\n当前上下文:\n{context_summary}"
|
||||
|
||||
messages.append(Message(role="user", content=user_content))
|
||||
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
response = await llm.chat(messages, temperature=0.3)
|
||||
|
||||
# Parse JSON response
|
||||
content = response.content.strip()
|
||||
# Handle markdown code blocks
|
||||
if content.startswith("```"):
|
||||
content = content.split("```")[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
|
||||
result = json.loads(content)
|
||||
|
||||
# Extract intent
|
||||
intent_str = result.get("intent", "unknown")
|
||||
try:
|
||||
intent = Intent(intent_str)
|
||||
except ValueError:
|
||||
intent = Intent.UNKNOWN
|
||||
|
||||
confidence = float(result.get("confidence", 0.5))
|
||||
sub_intent = result.get("sub_intent")
|
||||
|
||||
# Set intent in state
|
||||
state = set_intent(state, intent, confidence, sub_intent)
|
||||
|
||||
# Extract entities
|
||||
entities = result.get("entities", {})
|
||||
for entity_type, entity_value in entities.items():
|
||||
if entity_value:
|
||||
state = add_entity(state, entity_type, entity_value)
|
||||
|
||||
logger.info(
|
||||
"Intent classified",
|
||||
intent=intent.value,
|
||||
confidence=confidence,
|
||||
entities=list(entities.keys())
|
||||
)
|
||||
|
||||
# Check if human handoff is needed
|
||||
if intent == Intent.HUMAN_HANDOFF or confidence < 0.5:
|
||||
state["requires_human"] = True
|
||||
state["handoff_reason"] = result.get("reasoning", "Intent unclear")
|
||||
|
||||
return state
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse intent response", error=str(e))
|
||||
state["intent"] = Intent.UNKNOWN.value
|
||||
state["intent_confidence"] = 0.0
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Intent classification failed", error=str(e))
|
||||
state["error"] = str(e)
|
||||
state["intent"] = Intent.UNKNOWN.value
|
||||
return state
|
||||
|
||||
|
||||
def route_by_intent(state: AgentState) -> str:
|
||||
"""Route to appropriate agent based on intent
|
||||
|
||||
This is the routing function used by conditional edges in the graph.
|
||||
|
||||
Args:
|
||||
state: Current agent state
|
||||
|
||||
Returns:
|
||||
Name of the next node to execute
|
||||
"""
|
||||
intent = state.get("intent")
|
||||
requires_human = state.get("requires_human", False)
|
||||
|
||||
# Human handoff takes priority
|
||||
if requires_human:
|
||||
return "human_handoff"
|
||||
|
||||
# Route based on intent
|
||||
routing_map = {
|
||||
Intent.CUSTOMER_SERVICE.value: "customer_service_agent",
|
||||
Intent.ORDER.value: "order_agent",
|
||||
Intent.AFTERSALE.value: "aftersale_agent",
|
||||
Intent.PRODUCT.value: "product_agent",
|
||||
Intent.HUMAN_HANDOFF.value: "human_handoff",
|
||||
Intent.UNKNOWN.value: "customer_service_agent" # Default to customer service
|
||||
}
|
||||
|
||||
return routing_map.get(intent, "customer_service_agent")
|
||||
60
agent/config.py
Normal file
60
agent/config.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Configuration management for B2B Shopping AI Assistant
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables"""
|
||||
|
||||
# ============ AI Model ============
|
||||
zhipu_api_key: str = Field(..., description="ZhipuAI API Key")
|
||||
zhipu_model: str = Field(default="glm-4", description="ZhipuAI Model name")
|
||||
|
||||
# ============ Redis ============
|
||||
redis_host: str = Field(default="localhost", description="Redis host")
|
||||
redis_port: int = Field(default=6379, description="Redis port")
|
||||
redis_password: Optional[str] = Field(default=None, description="Redis password")
|
||||
redis_db: int = Field(default=0, description="Redis database number")
|
||||
|
||||
# ============ Chatwoot ============
|
||||
chatwoot_api_url: str = Field(..., description="Chatwoot API URL")
|
||||
chatwoot_api_token: str = Field(..., description="Chatwoot API Token")
|
||||
chatwoot_webhook_secret: Optional[str] = Field(default=None, description="Chatwoot Webhook Secret")
|
||||
|
||||
# ============ Strapi CMS ============
|
||||
strapi_api_url: str = Field(..., description="Strapi API URL")
|
||||
strapi_api_token: str = Field(..., description="Strapi API Token")
|
||||
|
||||
# ============ Hyperf API ============
|
||||
hyperf_api_url: str = Field(..., description="Hyperf API URL")
|
||||
hyperf_api_token: str = Field(..., description="Hyperf API Token")
|
||||
|
||||
# ============ MCP Servers ============
|
||||
strapi_mcp_url: str = Field(default="http://localhost:8001", description="Strapi MCP URL")
|
||||
order_mcp_url: str = Field(default="http://localhost:8002", description="Order MCP URL")
|
||||
aftersale_mcp_url: str = Field(default="http://localhost:8003", description="Aftersale MCP URL")
|
||||
product_mcp_url: str = Field(default="http://localhost:8004", description="Product MCP URL")
|
||||
|
||||
# ============ Application Config ============
|
||||
log_level: str = Field(default="INFO", description="Log level")
|
||||
max_conversation_steps: int = Field(default=10, description="Max steps in conversation")
|
||||
conversation_timeout: int = Field(default=3600, description="Conversation timeout in seconds")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_redis_url() -> str:
|
||||
"""Get Redis connection URL"""
|
||||
if settings.redis_password and settings.redis_password.strip():
|
||||
return f"redis://:{settings.redis_password}@{settings.redis_host}:{settings.redis_port}/{settings.redis_db}"
|
||||
return f"redis://{settings.redis_host}:{settings.redis_port}/{settings.redis_db}"
|
||||
18
agent/core/__init__.py
Normal file
18
agent/core/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Agent core package"""
|
||||
from .state import AgentState, Intent, ConversationState, create_initial_state
|
||||
from .llm import ZhipuLLMClient, get_llm_client, Message, LLMResponse
|
||||
from .graph import create_agent_graph, get_agent_graph, process_message
|
||||
|
||||
__all__ = [
|
||||
"AgentState",
|
||||
"Intent",
|
||||
"ConversationState",
|
||||
"create_initial_state",
|
||||
"ZhipuLLMClient",
|
||||
"get_llm_client",
|
||||
"Message",
|
||||
"LLMResponse",
|
||||
"create_agent_graph",
|
||||
"get_agent_graph",
|
||||
"process_message",
|
||||
]
|
||||
404
agent/core/graph.py
Normal file
404
agent/core/graph.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
LangGraph workflow definition for B2B Shopping AI Assistant
|
||||
"""
|
||||
from typing import Literal
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ============ Node Functions ============
|
||||
|
||||
async def receive_message(state: AgentState) -> AgentState:
|
||||
"""Receive and preprocess incoming message
|
||||
|
||||
This is the entry point of the workflow.
|
||||
"""
|
||||
logger.info(
|
||||
"Receiving message",
|
||||
conversation_id=state["conversation_id"],
|
||||
message_length=len(state["current_message"])
|
||||
)
|
||||
|
||||
# Add user message to history
|
||||
state["messages"].append({
|
||||
"role": "user",
|
||||
"content": state["current_message"]
|
||||
})
|
||||
|
||||
state["state"] = ConversationState.INITIAL.value
|
||||
return state
|
||||
|
||||
|
||||
async def call_mcp_tools(state: AgentState) -> AgentState:
|
||||
"""Execute pending MCP tool calls
|
||||
|
||||
Calls the appropriate MCP server based on the tool_calls in state.
|
||||
"""
|
||||
if not state["tool_calls"]:
|
||||
logger.debug("No tool calls to execute")
|
||||
return state
|
||||
|
||||
logger.info(
|
||||
"Executing MCP tools",
|
||||
tool_count=len(state["tool_calls"])
|
||||
)
|
||||
|
||||
# MCP server URL mapping
|
||||
mcp_servers = {
|
||||
"strapi": settings.strapi_mcp_url,
|
||||
"order": settings.order_mcp_url,
|
||||
"aftersale": settings.aftersale_mcp_url,
|
||||
"product": settings.product_mcp_url
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
for tool_call in state["tool_calls"]:
|
||||
server = tool_call["server"]
|
||||
tool_name = tool_call["tool_name"]
|
||||
arguments = tool_call["arguments"]
|
||||
|
||||
server_url = mcp_servers.get(server)
|
||||
if not server_url:
|
||||
state = add_tool_result(
|
||||
state,
|
||||
tool_name=tool_name,
|
||||
success=False,
|
||||
data=None,
|
||||
error=f"Unknown MCP server: {server}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
# Call MCP tool endpoint
|
||||
response = await client.post(
|
||||
f"{server_url}/tools/{tool_name}",
|
||||
json=arguments
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
state = add_tool_result(
|
||||
state,
|
||||
tool_name=tool_name,
|
||||
success=True,
|
||||
data=result
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Tool executed successfully",
|
||||
tool=tool_name,
|
||||
server=server
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
"Tool HTTP error",
|
||||
tool=tool_name,
|
||||
status=e.response.status_code
|
||||
)
|
||||
state = add_tool_result(
|
||||
state,
|
||||
tool_name=tool_name,
|
||||
success=False,
|
||||
data=None,
|
||||
error=f"HTTP {e.response.status_code}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Tool execution failed", tool=tool_name, error=str(e))
|
||||
state = add_tool_result(
|
||||
state,
|
||||
tool_name=tool_name,
|
||||
success=False,
|
||||
data=None,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Clear pending tool calls
|
||||
state["tool_calls"] = []
|
||||
|
||||
return state
|
||||
|
||||
|
||||
async def human_handoff(state: AgentState) -> AgentState:
|
||||
"""Handle transfer to human agent
|
||||
|
||||
Sets up the state for human intervention.
|
||||
"""
|
||||
logger.info(
|
||||
"Human handoff requested",
|
||||
conversation_id=state["conversation_id"],
|
||||
reason=state.get("handoff_reason")
|
||||
)
|
||||
|
||||
state["state"] = ConversationState.HUMAN_REVIEW.value
|
||||
|
||||
# Generate handoff message
|
||||
reason = state.get("handoff_reason", "您的问题需要人工客服协助")
|
||||
state = set_response(
|
||||
state,
|
||||
f"正在为您转接人工客服,请稍候。\n转接原因:{reason}\n\n人工客服将尽快为您服务。"
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
async def send_response(state: AgentState) -> AgentState:
|
||||
"""Finalize and send response
|
||||
|
||||
This is the final node that marks processing as complete.
|
||||
"""
|
||||
logger.info(
|
||||
"Sending response",
|
||||
conversation_id=state["conversation_id"],
|
||||
response_length=len(state.get("response", ""))
|
||||
)
|
||||
|
||||
# Add assistant response to history
|
||||
if state.get("response"):
|
||||
state["messages"].append({
|
||||
"role": "assistant",
|
||||
"content": state["response"]
|
||||
})
|
||||
|
||||
state = mark_finished(state)
|
||||
return state
|
||||
|
||||
|
||||
async def handle_error(state: AgentState) -> AgentState:
|
||||
"""Handle errors in the workflow"""
|
||||
logger.error(
|
||||
"Workflow error",
|
||||
conversation_id=state["conversation_id"],
|
||||
error=state.get("error")
|
||||
)
|
||||
|
||||
state = set_response(
|
||||
state,
|
||||
"抱歉,处理您的请求时遇到了问题。请稍后重试,或联系人工客服获取帮助。"
|
||||
)
|
||||
state = mark_finished(state)
|
||||
return state
|
||||
|
||||
|
||||
# ============ Routing Functions ============
|
||||
|
||||
def should_call_tools(state: AgentState) -> Literal["call_tools", "send_response", "back_to_agent"]:
|
||||
"""Determine if tools need to be called"""
|
||||
|
||||
# If there are pending tool calls, execute them
|
||||
if state.get("tool_calls"):
|
||||
return "call_tools"
|
||||
|
||||
# If we have a response ready, send it
|
||||
if state.get("response"):
|
||||
return "send_response"
|
||||
|
||||
# If we're waiting for info, send the question
|
||||
if state.get("state") == ConversationState.AWAITING_INFO.value:
|
||||
return "send_response"
|
||||
|
||||
# Otherwise, something went wrong
|
||||
return "send_response"
|
||||
|
||||
|
||||
def after_tools(state: AgentState) -> str:
|
||||
"""Route after tool execution
|
||||
|
||||
Returns the agent that should process the tool results.
|
||||
"""
|
||||
current_agent = state.get("current_agent")
|
||||
|
||||
# Route back to the agent that made the tool call
|
||||
agent_mapping = {
|
||||
"customer_service": "customer_service_agent",
|
||||
"order": "order_agent",
|
||||
"aftersale": "aftersale_agent",
|
||||
"product": "product_agent"
|
||||
}
|
||||
|
||||
return agent_mapping.get(current_agent, "customer_service_agent")
|
||||
|
||||
|
||||
def check_completion(state: AgentState) -> Literal["continue", "end", "error"]:
|
||||
"""Check if workflow should continue or end"""
|
||||
|
||||
# Check for errors
|
||||
if state.get("error"):
|
||||
return "error"
|
||||
|
||||
# Check if finished
|
||||
if state.get("finished"):
|
||||
return "end"
|
||||
|
||||
# Check step limit
|
||||
if state.get("step_count", 0) >= state.get("max_steps", 10):
|
||||
logger.warning("Max steps reached", conversation_id=state["conversation_id"])
|
||||
return "end"
|
||||
|
||||
return "continue"
|
||||
|
||||
|
||||
# ============ Graph Construction ============
|
||||
|
||||
def create_agent_graph() -> StateGraph:
|
||||
"""Create the main agent workflow graph
|
||||
|
||||
Returns:
|
||||
Compiled LangGraph workflow
|
||||
"""
|
||||
# Create graph with AgentState
|
||||
graph = StateGraph(AgentState)
|
||||
|
||||
# Add nodes
|
||||
graph.add_node("receive", receive_message)
|
||||
graph.add_node("classify", classify_intent)
|
||||
graph.add_node("customer_service_agent", customer_service_agent)
|
||||
graph.add_node("order_agent", order_agent)
|
||||
graph.add_node("aftersale_agent", aftersale_agent)
|
||||
graph.add_node("product_agent", product_agent)
|
||||
graph.add_node("call_tools", call_mcp_tools)
|
||||
graph.add_node("human_handoff", human_handoff)
|
||||
graph.add_node("send_response", send_response)
|
||||
graph.add_node("handle_error", handle_error)
|
||||
|
||||
# Set entry point
|
||||
graph.set_entry_point("receive")
|
||||
|
||||
# Add edges
|
||||
graph.add_edge("receive", "classify")
|
||||
|
||||
# Conditional routing based on intent
|
||||
graph.add_conditional_edges(
|
||||
"classify",
|
||||
route_by_intent,
|
||||
{
|
||||
"customer_service_agent": "customer_service_agent",
|
||||
"order_agent": "order_agent",
|
||||
"aftersale_agent": "aftersale_agent",
|
||||
"product_agent": "product_agent",
|
||||
"human_handoff": "human_handoff"
|
||||
}
|
||||
)
|
||||
|
||||
# After each agent, check if tools need to be called
|
||||
for agent_node in ["customer_service_agent", "order_agent", "aftersale_agent", "product_agent"]:
|
||||
graph.add_conditional_edges(
|
||||
agent_node,
|
||||
should_call_tools,
|
||||
{
|
||||
"call_tools": "call_tools",
|
||||
"send_response": "send_response",
|
||||
"back_to_agent": agent_node
|
||||
}
|
||||
)
|
||||
|
||||
# After tool execution, route back to appropriate agent
|
||||
graph.add_conditional_edges(
|
||||
"call_tools",
|
||||
after_tools,
|
||||
{
|
||||
"customer_service_agent": "customer_service_agent",
|
||||
"order_agent": "order_agent",
|
||||
"aftersale_agent": "aftersale_agent",
|
||||
"product_agent": "product_agent"
|
||||
}
|
||||
)
|
||||
|
||||
# Human handoff leads to send response
|
||||
graph.add_edge("human_handoff", "send_response")
|
||||
|
||||
# Error handling
|
||||
graph.add_edge("handle_error", END)
|
||||
|
||||
# Final node
|
||||
graph.add_edge("send_response", END)
|
||||
|
||||
return graph.compile()
|
||||
|
||||
|
||||
# Global compiled graph
|
||||
_compiled_graph = None
|
||||
|
||||
|
||||
def get_agent_graph():
|
||||
"""Get or create the compiled agent graph"""
|
||||
global _compiled_graph
|
||||
if _compiled_graph is None:
|
||||
_compiled_graph = create_agent_graph()
|
||||
return _compiled_graph
|
||||
|
||||
|
||||
async def process_message(
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
message: str,
|
||||
history: list[dict] = None,
|
||||
context: dict = None
|
||||
) -> AgentState:
|
||||
"""Process a user message through the agent workflow
|
||||
|
||||
Args:
|
||||
conversation_id: Chatwoot conversation ID
|
||||
user_id: User identifier
|
||||
account_id: B2B account identifier
|
||||
message: User's message
|
||||
history: Previous conversation history
|
||||
context: Existing conversation context
|
||||
|
||||
Returns:
|
||||
Final agent state with response
|
||||
"""
|
||||
from .state import create_initial_state
|
||||
|
||||
# Create initial state
|
||||
initial_state = create_initial_state(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
current_message=message,
|
||||
messages=history,
|
||||
context=context
|
||||
)
|
||||
|
||||
# Get compiled graph
|
||||
graph = get_agent_graph()
|
||||
|
||||
# Run the workflow
|
||||
logger.info(
|
||||
"Starting workflow",
|
||||
conversation_id=conversation_id,
|
||||
message=message[:100]
|
||||
)
|
||||
|
||||
try:
|
||||
final_state = await graph.ainvoke(initial_state)
|
||||
|
||||
logger.info(
|
||||
"Workflow completed",
|
||||
conversation_id=conversation_id,
|
||||
intent=final_state.get("intent"),
|
||||
steps=final_state.get("step_count")
|
||||
)
|
||||
|
||||
return final_state
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Workflow failed", error=str(e))
|
||||
initial_state["error"] = str(e)
|
||||
initial_state["response"] = "抱歉,处理您的请求时遇到了问题。请稍后重试。"
|
||||
initial_state["finished"] = True
|
||||
return initial_state
|
||||
195
agent/core/llm.py
Normal file
195
agent/core/llm.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
ZhipuAI LLM Client for B2B Shopping AI Assistant
|
||||
"""
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from zhipuai import ZhipuAI
|
||||
|
||||
from config import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""Chat message structure"""
|
||||
role: str # "system", "user", "assistant"
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""LLM response structure"""
|
||||
content: str
|
||||
finish_reason: str
|
||||
usage: dict[str, int]
|
||||
|
||||
|
||||
class ZhipuLLMClient:
|
||||
"""ZhipuAI LLM Client wrapper"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
model: Optional[str] = None
|
||||
):
|
||||
"""Initialize ZhipuAI client
|
||||
|
||||
Args:
|
||||
api_key: ZhipuAI API key, defaults to settings
|
||||
model: Model name, defaults to settings
|
||||
"""
|
||||
self.api_key = api_key or settings.zhipu_api_key
|
||||
self.model = model or settings.zhipu_model
|
||||
self._client = ZhipuAI(api_key=self.api_key)
|
||||
logger.info("ZhipuAI client initialized", model=self.model)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[Message],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
top_p: float = 0.9,
|
||||
**kwargs: Any
|
||||
) -> LLMResponse:
|
||||
"""Send chat completion request
|
||||
|
||||
Args:
|
||||
messages: List of chat messages
|
||||
temperature: Sampling temperature
|
||||
max_tokens: Maximum tokens to generate
|
||||
top_p: Top-p sampling parameter
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
LLM response with content and metadata
|
||||
"""
|
||||
formatted_messages = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in messages
|
||||
]
|
||||
|
||||
logger.debug(
|
||||
"Sending chat request",
|
||||
model=self.model,
|
||||
message_count=len(messages),
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
try:
|
||||
response = self._client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=formatted_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
result = LLMResponse(
|
||||
content=choice.message.content,
|
||||
finish_reason=choice.finish_reason,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Chat response received",
|
||||
finish_reason=result.finish_reason,
|
||||
total_tokens=result.usage["total_tokens"]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Chat request failed", error=str(e))
|
||||
raise
|
||||
|
||||
async def chat_with_tools(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[dict[str, Any]],
|
||||
temperature: float = 0.7,
|
||||
**kwargs: Any
|
||||
) -> tuple[LLMResponse, Optional[list[dict[str, Any]]]]:
|
||||
"""Send chat completion request with tool calling
|
||||
|
||||
Args:
|
||||
messages: List of chat messages
|
||||
tools: List of tool definitions
|
||||
temperature: Sampling temperature
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Tuple of (LLM response, tool calls if any)
|
||||
"""
|
||||
formatted_messages = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in messages
|
||||
]
|
||||
|
||||
logger.debug(
|
||||
"Sending chat request with tools",
|
||||
model=self.model,
|
||||
tool_count=len(tools)
|
||||
)
|
||||
|
||||
try:
|
||||
response = self._client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=formatted_messages,
|
||||
tools=tools,
|
||||
temperature=temperature,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
result = LLMResponse(
|
||||
content=choice.message.content or "",
|
||||
finish_reason=choice.finish_reason,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens
|
||||
}
|
||||
)
|
||||
|
||||
# Extract tool calls if present
|
||||
tool_calls = None
|
||||
if hasattr(choice.message, 'tool_calls') and choice.message.tool_calls:
|
||||
tool_calls = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": tc.type,
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments
|
||||
}
|
||||
}
|
||||
for tc in choice.message.tool_calls
|
||||
]
|
||||
logger.debug("Tool calls received", tool_count=len(tool_calls))
|
||||
|
||||
return result, tool_calls
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Chat with tools request failed", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
# Global LLM client instance
|
||||
llm_client: Optional[ZhipuLLMClient] = None
|
||||
|
||||
|
||||
def get_llm_client() -> ZhipuLLMClient:
|
||||
"""Get or create global LLM client instance"""
|
||||
global llm_client
|
||||
if llm_client is None:
|
||||
llm_client = ZhipuLLMClient()
|
||||
return llm_client
|
||||
272
agent/core/state.py
Normal file
272
agent/core/state.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Agent state definitions for LangGraph workflow
|
||||
"""
|
||||
from typing import Any, Optional, Literal
|
||||
from typing_extensions import TypedDict, Annotated
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Intent(str, Enum):
|
||||
"""User intent categories"""
|
||||
CUSTOMER_SERVICE = "customer_service" # FAQ, general inquiries
|
||||
ORDER = "order" # Order query, tracking, modify, cancel
|
||||
AFTERSALE = "aftersale" # Return, exchange, complaint
|
||||
PRODUCT = "product" # Search, recommend, quote
|
||||
HUMAN_HANDOFF = "human_handoff" # Transfer to human agent
|
||||
UNKNOWN = "unknown" # Cannot determine intent
|
||||
|
||||
|
||||
class ConversationState(str, Enum):
|
||||
"""Conversation state machine"""
|
||||
INITIAL = "initial"
|
||||
CLASSIFYING = "classifying"
|
||||
PROCESSING = "processing"
|
||||
AWAITING_INFO = "awaiting_info"
|
||||
TOOL_CALLING = "tool_calling"
|
||||
GENERATING = "generating"
|
||||
HUMAN_REVIEW = "human_review"
|
||||
COMPLETED = "completed"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
"""Extracted entity from user message"""
|
||||
type: str # Entity type (order_id, product_id, date, etc.)
|
||||
value: Any # Entity value
|
||||
confidence: float # Extraction confidence (0-1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
"""MCP tool call record"""
|
||||
tool_name: str
|
||||
arguments: dict[str, Any]
|
||||
server: str # MCP server name (strapi, order, aftersale, product)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""MCP tool execution result"""
|
||||
tool_name: str
|
||||
success: bool
|
||||
data: Any
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AgentState(TypedDict):
|
||||
"""Main agent state for LangGraph workflow
|
||||
|
||||
This state is passed through all nodes in the workflow graph.
|
||||
"""
|
||||
|
||||
# ============ Session Information ============
|
||||
conversation_id: str # Chatwoot conversation ID
|
||||
user_id: str # User identifier
|
||||
account_id: str # B2B account identifier
|
||||
|
||||
# ============ Message Content ============
|
||||
messages: list[dict[str, Any]] # Conversation history [{role, content}]
|
||||
current_message: str # Current user message being processed
|
||||
|
||||
# ============ Intent Recognition ============
|
||||
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
|
||||
|
||||
# ============ Entity Extraction ============
|
||||
entities: dict[str, Any] # Extracted entities {type: value}
|
||||
|
||||
# ============ Agent Routing ============
|
||||
current_agent: Optional[str] # Current processing agent name
|
||||
agent_history: list[str] # History of agents involved
|
||||
|
||||
# ============ Tool Calling ============
|
||||
tool_calls: list[dict[str, Any]] # Pending tool calls
|
||||
tool_results: list[dict[str, Any]] # Tool execution results
|
||||
|
||||
# ============ Response Generation ============
|
||||
response: Optional[str] # Generated response text
|
||||
response_type: str # Response type (text, rich, action)
|
||||
|
||||
# ============ Human Handoff ============
|
||||
requires_human: bool # Whether human intervention is needed
|
||||
handoff_reason: Optional[str] # Reason for human handoff
|
||||
|
||||
# ============ Conversation Context ============
|
||||
context: dict[str, Any] # Accumulated context (order details, etc.)
|
||||
|
||||
# ============ State Control ============
|
||||
state: str # Current conversation state
|
||||
step_count: int # Number of steps taken
|
||||
max_steps: int # Maximum allowed steps
|
||||
error: Optional[str] # Error message if any
|
||||
finished: bool # Whether processing is complete
|
||||
|
||||
|
||||
def create_initial_state(
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
current_message: str,
|
||||
messages: Optional[list[dict[str, Any]]] = None,
|
||||
context: Optional[dict[str, Any]] = None
|
||||
) -> AgentState:
|
||||
"""Create initial agent state for a new message
|
||||
|
||||
Args:
|
||||
conversation_id: Chatwoot conversation ID
|
||||
user_id: User identifier
|
||||
account_id: B2B account identifier
|
||||
current_message: User's message to process
|
||||
messages: Previous conversation history
|
||||
context: Existing conversation context
|
||||
|
||||
Returns:
|
||||
Initialized AgentState
|
||||
"""
|
||||
return AgentState(
|
||||
# Session
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
|
||||
# Messages
|
||||
messages=messages or [],
|
||||
current_message=current_message,
|
||||
|
||||
# Intent
|
||||
intent=None,
|
||||
intent_confidence=0.0,
|
||||
sub_intent=None,
|
||||
|
||||
# Entities
|
||||
entities={},
|
||||
|
||||
# Routing
|
||||
current_agent=None,
|
||||
agent_history=[],
|
||||
|
||||
# Tools
|
||||
tool_calls=[],
|
||||
tool_results=[],
|
||||
|
||||
# Response
|
||||
response=None,
|
||||
response_type="text",
|
||||
|
||||
# Human handoff
|
||||
requires_human=False,
|
||||
handoff_reason=None,
|
||||
|
||||
# Context
|
||||
context=context or {},
|
||||
|
||||
# Control
|
||||
state=ConversationState.INITIAL.value,
|
||||
step_count=0,
|
||||
max_steps=10,
|
||||
error=None,
|
||||
finished=False
|
||||
)
|
||||
|
||||
|
||||
# ============ State Update Helpers ============
|
||||
|
||||
def add_message(state: AgentState, role: str, content: str) -> AgentState:
|
||||
"""Add a message to the conversation history"""
|
||||
state["messages"].append({"role": role, "content": content})
|
||||
return state
|
||||
|
||||
|
||||
def set_intent(
|
||||
state: AgentState,
|
||||
intent: Intent,
|
||||
confidence: float,
|
||||
sub_intent: Optional[str] = None
|
||||
) -> AgentState:
|
||||
"""Set the recognized intent"""
|
||||
state["intent"] = intent.value
|
||||
state["intent_confidence"] = confidence
|
||||
state["sub_intent"] = sub_intent
|
||||
return state
|
||||
|
||||
|
||||
def add_entity(state: AgentState, entity_type: str, value: Any) -> AgentState:
|
||||
"""Add an extracted entity"""
|
||||
state["entities"][entity_type] = value
|
||||
return state
|
||||
|
||||
|
||||
def add_tool_call(
|
||||
state: AgentState,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
server: str
|
||||
) -> AgentState:
|
||||
"""Add a tool call to pending calls"""
|
||||
state["tool_calls"].append({
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"server": server
|
||||
})
|
||||
return state
|
||||
|
||||
|
||||
def add_tool_result(
|
||||
state: AgentState,
|
||||
tool_name: str,
|
||||
success: bool,
|
||||
data: Any,
|
||||
error: Optional[str] = None
|
||||
) -> AgentState:
|
||||
"""Add a tool execution result"""
|
||||
state["tool_results"].append({
|
||||
"tool_name": tool_name,
|
||||
"success": success,
|
||||
"data": data,
|
||||
"error": error
|
||||
})
|
||||
return state
|
||||
|
||||
|
||||
def set_response(
|
||||
state: AgentState,
|
||||
response: str,
|
||||
response_type: str = "text"
|
||||
) -> AgentState:
|
||||
"""Set the generated response"""
|
||||
state["response"] = response
|
||||
state["response_type"] = response_type
|
||||
return state
|
||||
|
||||
|
||||
def request_human_handoff(
|
||||
state: AgentState,
|
||||
reason: str
|
||||
) -> AgentState:
|
||||
"""Request transfer to human agent"""
|
||||
state["requires_human"] = True
|
||||
state["handoff_reason"] = reason
|
||||
return state
|
||||
|
||||
|
||||
def update_context(state: AgentState, updates: dict[str, Any]) -> AgentState:
|
||||
"""Update conversation context"""
|
||||
state["context"].update(updates)
|
||||
return state
|
||||
|
||||
|
||||
def set_error(state: AgentState, error: str) -> AgentState:
|
||||
"""Set error state"""
|
||||
state["error"] = error
|
||||
state["state"] = ConversationState.ERROR.value
|
||||
return state
|
||||
|
||||
|
||||
def mark_finished(state: AgentState) -> AgentState:
|
||||
"""Mark processing as complete"""
|
||||
state["finished"] = True
|
||||
state["state"] = ConversationState.COMPLETED.value
|
||||
return state
|
||||
14
agent/integrations/__init__.py
Normal file
14
agent/integrations/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Agent integrations package"""
|
||||
from .chatwoot import ChatwootClient, get_chatwoot_client, MessageType, ConversationStatus
|
||||
from .hyperf_client import HyperfClient, get_hyperf_client, APIResponse, APIError
|
||||
|
||||
__all__ = [
|
||||
"ChatwootClient",
|
||||
"get_chatwoot_client",
|
||||
"MessageType",
|
||||
"ConversationStatus",
|
||||
"HyperfClient",
|
||||
"get_hyperf_client",
|
||||
"APIResponse",
|
||||
"APIError",
|
||||
]
|
||||
354
agent/integrations/chatwoot.py
Normal file
354
agent/integrations/chatwoot.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Chatwoot API Client for B2B Shopping AI Assistant
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MessageType(str, Enum):
|
||||
"""Chatwoot message types"""
|
||||
INCOMING = "incoming"
|
||||
OUTGOING = "outgoing"
|
||||
|
||||
|
||||
class ConversationStatus(str, Enum):
|
||||
"""Chatwoot conversation status"""
|
||||
OPEN = "open"
|
||||
RESOLVED = "resolved"
|
||||
PENDING = "pending"
|
||||
SNOOZED = "snoozed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatwootMessage:
|
||||
"""Chatwoot message structure"""
|
||||
id: int
|
||||
content: str
|
||||
message_type: str
|
||||
conversation_id: int
|
||||
sender_type: Optional[str] = None
|
||||
sender_id: Optional[int] = None
|
||||
private: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatwootContact:
|
||||
"""Chatwoot contact structure"""
|
||||
id: int
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
custom_attributes: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class ChatwootClient:
|
||||
"""Chatwoot API Client"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: Optional[str] = None,
|
||||
api_token: Optional[str] = None,
|
||||
account_id: int = 1
|
||||
):
|
||||
"""Initialize Chatwoot client
|
||||
|
||||
Args:
|
||||
api_url: Chatwoot API URL, defaults to settings
|
||||
api_token: API access token, defaults to settings
|
||||
account_id: Chatwoot account ID
|
||||
"""
|
||||
self.api_url = (api_url or settings.chatwoot_api_url).rstrip("/")
|
||||
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:
|
||||
"""Get or create HTTP client"""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=f"{self.api_url}/api/v1/accounts/{self.account_id}",
|
||||
headers={
|
||||
"api_access_token": self.api_token,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close HTTP client"""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
# ============ Messages ============
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
conversation_id: int,
|
||||
content: str,
|
||||
message_type: MessageType = MessageType.OUTGOING,
|
||||
private: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Send a message to a conversation
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
content: Message content
|
||||
message_type: Message type (incoming/outgoing)
|
||||
private: Whether message is private (internal note)
|
||||
|
||||
Returns:
|
||||
Created message data
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
payload = {
|
||||
"content": content,
|
||||
"message_type": message_type.value,
|
||||
"private": private
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
f"/conversations/{conversation_id}/messages",
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
logger.debug(
|
||||
"Message sent",
|
||||
conversation_id=conversation_id,
|
||||
message_id=data.get("id")
|
||||
)
|
||||
return data
|
||||
|
||||
async def send_rich_message(
|
||||
self,
|
||||
conversation_id: int,
|
||||
content: str,
|
||||
content_type: str,
|
||||
content_attributes: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Send a rich message (cards, buttons, etc.)
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
content: Fallback text content
|
||||
content_type: Rich content type (cards, input_select, etc.)
|
||||
content_attributes: Rich content attributes
|
||||
|
||||
Returns:
|
||||
Created message data
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
payload = {
|
||||
"content": content,
|
||||
"message_type": MessageType.OUTGOING.value,
|
||||
"content_type": content_type,
|
||||
"content_attributes": content_attributes
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
f"/conversations/{conversation_id}/messages",
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
# ============ Conversations ============
|
||||
|
||||
async def get_conversation(self, conversation_id: int) -> dict[str, Any]:
|
||||
"""Get conversation details
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
Conversation data
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
response = await client.get(f"/conversations/{conversation_id}")
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
async def update_conversation_status(
|
||||
self,
|
||||
conversation_id: int,
|
||||
status: ConversationStatus
|
||||
) -> dict[str, Any]:
|
||||
"""Update conversation status
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
status: New status
|
||||
|
||||
Returns:
|
||||
Updated conversation data
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
response = await client.post(
|
||||
f"/conversations/{conversation_id}/toggle_status",
|
||||
json={"status": status.value}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(
|
||||
"Conversation status updated",
|
||||
conversation_id=conversation_id,
|
||||
status=status.value
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def add_labels(
|
||||
self,
|
||||
conversation_id: int,
|
||||
labels: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Add labels to a conversation
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
labels: List of label names
|
||||
|
||||
Returns:
|
||||
Updated labels
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
response = await client.post(
|
||||
f"/conversations/{conversation_id}/labels",
|
||||
json={"labels": labels}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
async def assign_agent(
|
||||
self,
|
||||
conversation_id: int,
|
||||
agent_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""Assign an agent to a conversation
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
agent_id: Agent user ID
|
||||
|
||||
Returns:
|
||||
Assignment result
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
response = await client.post(
|
||||
f"/conversations/{conversation_id}/assignments",
|
||||
json={"assignee_id": agent_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(
|
||||
"Agent assigned",
|
||||
conversation_id=conversation_id,
|
||||
agent_id=agent_id
|
||||
)
|
||||
return response.json()
|
||||
|
||||
# ============ Contacts ============
|
||||
|
||||
async def get_contact(self, contact_id: int) -> dict[str, Any]:
|
||||
"""Get contact details
|
||||
|
||||
Args:
|
||||
contact_id: Contact ID
|
||||
|
||||
Returns:
|
||||
Contact data
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
response = await client.get(f"/contacts/{contact_id}")
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
async def update_contact(
|
||||
self,
|
||||
contact_id: int,
|
||||
attributes: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Update contact attributes
|
||||
|
||||
Args:
|
||||
contact_id: Contact ID
|
||||
attributes: Attributes to update
|
||||
|
||||
Returns:
|
||||
Updated contact data
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
response = await client.put(
|
||||
f"/contacts/{contact_id}",
|
||||
json=attributes
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
# ============ Messages History ============
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
conversation_id: int,
|
||||
before: Optional[int] = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get conversation messages
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
before: Get messages before this message ID
|
||||
|
||||
Returns:
|
||||
List of messages
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
params = {}
|
||||
if before:
|
||||
params["before"] = before
|
||||
|
||||
response = await client.get(
|
||||
f"/conversations/{conversation_id}/messages",
|
||||
params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return data.get("payload", [])
|
||||
|
||||
|
||||
# Global Chatwoot client instance
|
||||
chatwoot_client: Optional[ChatwootClient] = None
|
||||
|
||||
|
||||
def get_chatwoot_client() -> ChatwootClient:
|
||||
"""Get or create global Chatwoot client instance"""
|
||||
global chatwoot_client
|
||||
if chatwoot_client is None:
|
||||
chatwoot_client = ChatwootClient()
|
||||
return chatwoot_client
|
||||
538
agent/integrations/hyperf_client.py
Normal file
538
agent/integrations/hyperf_client.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
Hyperf PHP API Client for B2B Shopping AI Assistant
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
"""API error with code and message"""
|
||||
def __init__(self, code: int, message: str, data: Optional[Any] = None):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.data = data
|
||||
super().__init__(f"[{code}] {message}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class APIResponse:
|
||||
"""Standardized API response"""
|
||||
code: int
|
||||
message: str
|
||||
data: Any
|
||||
meta: Optional[dict[str, Any]] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.code == 0
|
||||
|
||||
|
||||
class HyperfClient:
|
||||
"""Hyperf PHP API Client"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: Optional[str] = None,
|
||||
api_token: Optional[str] = None
|
||||
):
|
||||
"""Initialize Hyperf client
|
||||
|
||||
Args:
|
||||
api_url: Hyperf API base URL, defaults to settings
|
||||
api_token: API access token, defaults to settings
|
||||
"""
|
||||
self.api_url = (api_url or settings.hyperf_api_url).rstrip("/")
|
||||
self.api_token = api_token or settings.hyperf_api_token
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
logger.info("Hyperf client initialized", api_url=self.api_url)
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client"""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=f"{self.api_url}/api/v1",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""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
|
||||
) -> APIResponse:
|
||||
"""Make API request
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
endpoint: API endpoint
|
||||
params: Query parameters
|
||||
json: JSON body
|
||||
headers: Additional headers
|
||||
|
||||
Returns:
|
||||
Parsed API response
|
||||
|
||||
Raises:
|
||||
APIError: If API returns error
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
# Merge headers
|
||||
request_headers = {}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
logger.debug(
|
||||
"API request",
|
||||
method=method,
|
||||
endpoint=endpoint
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=endpoint,
|
||||
params=params,
|
||||
json=json,
|
||||
headers=request_headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
result = APIResponse(
|
||||
code=data.get("code", 0),
|
||||
message=data.get("message", "success"),
|
||||
data=data.get("data"),
|
||||
meta=data.get("meta")
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise APIError(result.code, result.message, result.data)
|
||||
|
||||
logger.debug(
|
||||
"API response",
|
||||
endpoint=endpoint,
|
||||
code=result.code
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
"HTTP error",
|
||||
endpoint=endpoint,
|
||||
status_code=e.response.status_code
|
||||
)
|
||||
raise APIError(
|
||||
e.response.status_code,
|
||||
f"HTTP error: {e.response.status_code}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("API request failed", endpoint=endpoint, error=str(e))
|
||||
raise
|
||||
|
||||
async def get(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
**kwargs: Any
|
||||
) -> APIResponse:
|
||||
"""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
|
||||
) -> APIResponse:
|
||||
"""POST request"""
|
||||
return await self._request("POST", endpoint, json=json, **kwargs)
|
||||
|
||||
async def put(
|
||||
self,
|
||||
endpoint: str,
|
||||
json: Optional[dict[str, Any]] = None,
|
||||
**kwargs: Any
|
||||
) -> APIResponse:
|
||||
"""PUT request"""
|
||||
return await self._request("PUT", endpoint, json=json, **kwargs)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
endpoint: str,
|
||||
**kwargs: Any
|
||||
) -> APIResponse:
|
||||
"""DELETE request"""
|
||||
return await self._request("DELETE", endpoint, **kwargs)
|
||||
|
||||
# ============ Order APIs ============
|
||||
|
||||
async def query_orders(
|
||||
self,
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
order_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
date_start: Optional[str] = None,
|
||||
date_end: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> APIResponse:
|
||||
"""Query orders
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
account_id: Account ID
|
||||
order_id: Optional specific order ID
|
||||
status: Optional order status filter
|
||||
date_start: Optional start date (YYYY-MM-DD)
|
||||
date_end: Optional end date (YYYY-MM-DD)
|
||||
page: Page number
|
||||
page_size: Items per page
|
||||
|
||||
Returns:
|
||||
Orders list response
|
||||
"""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"account_id": account_id,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
if status:
|
||||
payload["status"] = status
|
||||
if date_start:
|
||||
payload["date_range"] = {"start": date_start}
|
||||
if date_end:
|
||||
payload.setdefault("date_range", {})["end"] = date_end
|
||||
|
||||
return await self.post("/orders/query", json=payload)
|
||||
|
||||
async def get_logistics(
|
||||
self,
|
||||
order_id: str,
|
||||
tracking_number: Optional[str] = None
|
||||
) -> APIResponse:
|
||||
"""Get order logistics information
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
tracking_number: Optional tracking number
|
||||
|
||||
Returns:
|
||||
Logistics tracking response
|
||||
"""
|
||||
params = {}
|
||||
if tracking_number:
|
||||
params["tracking_number"] = tracking_number
|
||||
|
||||
return await self.get(f"/orders/{order_id}/logistics", params=params)
|
||||
|
||||
async def modify_order(
|
||||
self,
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
modifications: dict[str, Any]
|
||||
) -> APIResponse:
|
||||
"""Modify order
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
user_id: User ID for permission check
|
||||
modifications: Changes to apply
|
||||
|
||||
Returns:
|
||||
Modified order response
|
||||
"""
|
||||
return await self.put(
|
||||
f"/orders/{order_id}/modify",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"modifications": modifications
|
||||
}
|
||||
)
|
||||
|
||||
async def cancel_order(
|
||||
self,
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
reason: str
|
||||
) -> APIResponse:
|
||||
"""Cancel order
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
user_id: User ID for permission check
|
||||
reason: Cancellation reason
|
||||
|
||||
Returns:
|
||||
Cancellation result with refund info
|
||||
"""
|
||||
return await self.post(
|
||||
f"/orders/{order_id}/cancel",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"reason": reason
|
||||
}
|
||||
)
|
||||
|
||||
# ============ Product APIs ============
|
||||
|
||||
async def search_products(
|
||||
self,
|
||||
query: str,
|
||||
filters: Optional[dict[str, Any]] = None,
|
||||
sort: str = "relevance",
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> APIResponse:
|
||||
"""Search products
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
filters: Optional filters (category, price_range, brand, etc.)
|
||||
sort: Sort order
|
||||
page: Page number
|
||||
page_size: Items per page
|
||||
|
||||
Returns:
|
||||
Products list response
|
||||
"""
|
||||
payload = {
|
||||
"query": query,
|
||||
"sort": sort,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
if filters:
|
||||
payload["filters"] = filters
|
||||
|
||||
return await self.post("/products/search", json=payload)
|
||||
|
||||
async def get_product(self, product_id: str) -> APIResponse:
|
||||
"""Get product details
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
|
||||
Returns:
|
||||
Product details response
|
||||
"""
|
||||
return await self.get(f"/products/{product_id}")
|
||||
|
||||
async def get_recommendations(
|
||||
self,
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
limit: int = 10
|
||||
) -> APIResponse:
|
||||
"""Get product recommendations
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
account_id: Account ID
|
||||
context: Optional context (recent views, current query)
|
||||
limit: Number of recommendations
|
||||
|
||||
Returns:
|
||||
Recommendations response
|
||||
"""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"account_id": account_id,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
if context:
|
||||
payload["context"] = context
|
||||
|
||||
return await self.post("/products/recommend", json=payload)
|
||||
|
||||
async def get_quote(
|
||||
self,
|
||||
product_id: str,
|
||||
quantity: int,
|
||||
account_id: str,
|
||||
delivery_address: Optional[dict[str, str]] = None
|
||||
) -> APIResponse:
|
||||
"""Get B2B price quote
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
quantity: Quantity
|
||||
account_id: Account ID for pricing tier
|
||||
delivery_address: Optional delivery address
|
||||
|
||||
Returns:
|
||||
Quote response with pricing details
|
||||
"""
|
||||
payload = {
|
||||
"product_id": product_id,
|
||||
"quantity": quantity,
|
||||
"account_id": account_id
|
||||
}
|
||||
|
||||
if delivery_address:
|
||||
payload["delivery_address"] = delivery_address
|
||||
|
||||
return await self.post("/products/quote", json=payload)
|
||||
|
||||
# ============ Aftersale APIs ============
|
||||
|
||||
async def apply_return(
|
||||
self,
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
items: list[dict[str, Any]],
|
||||
description: str,
|
||||
images: Optional[list[str]] = None
|
||||
) -> APIResponse:
|
||||
"""Apply for return
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
user_id: User ID
|
||||
items: Items to return with quantity and reason
|
||||
description: Description of issue
|
||||
images: Optional image URLs
|
||||
|
||||
Returns:
|
||||
Return application response
|
||||
"""
|
||||
payload = {
|
||||
"order_id": order_id,
|
||||
"user_id": user_id,
|
||||
"items": items,
|
||||
"description": description
|
||||
}
|
||||
|
||||
if images:
|
||||
payload["images"] = images
|
||||
|
||||
return await self.post("/aftersales/return", json=payload)
|
||||
|
||||
async def apply_exchange(
|
||||
self,
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
items: list[dict[str, Any]],
|
||||
description: str
|
||||
) -> APIResponse:
|
||||
"""Apply for exchange
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
user_id: User ID
|
||||
items: Items to exchange with reason
|
||||
description: Description of issue
|
||||
|
||||
Returns:
|
||||
Exchange application response
|
||||
"""
|
||||
return await self.post(
|
||||
"/aftersales/exchange",
|
||||
json={
|
||||
"order_id": order_id,
|
||||
"user_id": user_id,
|
||||
"items": items,
|
||||
"description": description
|
||||
}
|
||||
)
|
||||
|
||||
async def create_complaint(
|
||||
self,
|
||||
user_id: str,
|
||||
complaint_type: str,
|
||||
title: str,
|
||||
description: str,
|
||||
related_order_id: Optional[str] = None,
|
||||
attachments: Optional[list[str]] = None
|
||||
) -> APIResponse:
|
||||
"""Create complaint
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
complaint_type: Type of complaint
|
||||
title: Complaint title
|
||||
description: Detailed description
|
||||
related_order_id: Optional related order
|
||||
attachments: Optional attachment URLs
|
||||
|
||||
Returns:
|
||||
Complaint creation response
|
||||
"""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"type": complaint_type,
|
||||
"title": title,
|
||||
"description": description
|
||||
}
|
||||
|
||||
if related_order_id:
|
||||
payload["related_order_id"] = related_order_id
|
||||
if attachments:
|
||||
payload["attachments"] = attachments
|
||||
|
||||
return await self.post("/aftersales/complaint", json=payload)
|
||||
|
||||
async def query_aftersales(
|
||||
self,
|
||||
user_id: str,
|
||||
aftersale_id: Optional[str] = None
|
||||
) -> APIResponse:
|
||||
"""Query aftersale records
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
aftersale_id: Optional specific aftersale ID
|
||||
|
||||
Returns:
|
||||
Aftersale records response
|
||||
"""
|
||||
params = {"user_id": user_id}
|
||||
if aftersale_id:
|
||||
params["aftersale_id"] = aftersale_id
|
||||
|
||||
return await self.get("/aftersales/query", params=params)
|
||||
|
||||
|
||||
# Global Hyperf client instance
|
||||
hyperf_client: Optional[HyperfClient] = None
|
||||
|
||||
|
||||
def get_hyperf_client() -> HyperfClient:
|
||||
"""Get or create global Hyperf client instance"""
|
||||
global hyperf_client
|
||||
if hyperf_client is None:
|
||||
hyperf_client = HyperfClient()
|
||||
return hyperf_client
|
||||
205
agent/main.py
Normal file
205
agent/main.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
B2B Shopping AI Assistant - Main Application Entry
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import settings
|
||||
from webhooks.chatwoot_webhook import router as webhook_router
|
||||
from utils.logger import setup_logging, get_logger
|
||||
from utils.cache import get_cache_manager
|
||||
from integrations.chatwoot import get_chatwoot_client
|
||||
|
||||
|
||||
# Setup logging
|
||||
setup_logging(settings.log_level)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ============ Lifespan Management ============
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager"""
|
||||
# Startup
|
||||
logger.info("Starting B2B Shopping AI Assistant")
|
||||
|
||||
# Initialize cache connection
|
||||
cache = get_cache_manager()
|
||||
await cache.connect()
|
||||
logger.info("Redis cache connected")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down B2B Shopping AI Assistant")
|
||||
|
||||
# Close connections
|
||||
await cache.disconnect()
|
||||
|
||||
chatwoot = get_chatwoot_client()
|
||||
await chatwoot.close()
|
||||
|
||||
logger.info("Connections closed")
|
||||
|
||||
|
||||
# ============ Application Setup ============
|
||||
|
||||
app = FastAPI(
|
||||
title="B2B Shopping AI Assistant",
|
||||
description="AI-powered customer service assistant with LangGraph and MCP",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure appropriately for production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ============ Exception Handlers ============
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""Global exception handler"""
|
||||
logger.error(
|
||||
"Unhandled exception",
|
||||
path=request.url.path,
|
||||
error=str(exc)
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"}
|
||||
)
|
||||
|
||||
|
||||
# ============ Include Routers ============
|
||||
|
||||
app.include_router(webhook_router)
|
||||
|
||||
|
||||
# ============ API Endpoints ============
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "b2b-ai-assistant",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"name": "B2B Shopping AI Assistant",
|
||||
"version": "1.0.0",
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
"""Direct query request model"""
|
||||
conversation_id: str
|
||||
user_id: str
|
||||
account_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class QueryResponse(BaseModel):
|
||||
"""Query response model"""
|
||||
response: str
|
||||
intent: str | None = None
|
||||
requires_human: bool = False
|
||||
context: dict[str, Any] = {}
|
||||
|
||||
|
||||
@app.post("/api/agent/query", response_model=QueryResponse)
|
||||
async def agent_query(request: QueryRequest):
|
||||
"""Direct agent query endpoint
|
||||
|
||||
Allows direct testing of the agent without Chatwoot integration.
|
||||
"""
|
||||
from core.graph import process_message
|
||||
|
||||
logger.info(
|
||||
"Direct query received",
|
||||
conversation_id=request.conversation_id,
|
||||
user_id=request.user_id
|
||||
)
|
||||
|
||||
# Load context from cache
|
||||
cache = get_cache_manager()
|
||||
context = await cache.get_context(request.conversation_id)
|
||||
history = await cache.get_messages(request.conversation_id)
|
||||
|
||||
# Process message
|
||||
final_state = await process_message(
|
||||
conversation_id=request.conversation_id,
|
||||
user_id=request.user_id,
|
||||
account_id=request.account_id,
|
||||
message=request.message,
|
||||
history=history,
|
||||
context=context
|
||||
)
|
||||
|
||||
# Update cache
|
||||
await cache.add_message(request.conversation_id, "user", request.message)
|
||||
if final_state.get("response"):
|
||||
await cache.add_message(
|
||||
request.conversation_id,
|
||||
"assistant",
|
||||
final_state["response"]
|
||||
)
|
||||
|
||||
# Save context
|
||||
new_context = final_state.get("context", {})
|
||||
new_context["last_intent"] = final_state.get("intent")
|
||||
await cache.set_context(request.conversation_id, new_context)
|
||||
|
||||
return QueryResponse(
|
||||
response=final_state.get("response", ""),
|
||||
intent=final_state.get("intent"),
|
||||
requires_human=final_state.get("requires_human", False),
|
||||
context=final_state.get("context", {})
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
async def get_config():
|
||||
"""Get sanitized configuration"""
|
||||
return {
|
||||
"zhipu_model": settings.zhipu_model,
|
||||
"max_conversation_steps": settings.max_conversation_steps,
|
||||
"conversation_timeout": settings.conversation_timeout,
|
||||
"mcp_servers": {
|
||||
"strapi": settings.strapi_mcp_url,
|
||||
"order": settings.order_mcp_url,
|
||||
"aftersale": settings.aftersale_mcp_url,
|
||||
"product": settings.product_mcp_url
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============ Run Application ============
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True
|
||||
)
|
||||
39
agent/requirements.txt
Normal file
39
agent/requirements.txt
Normal file
@@ -0,0 +1,39 @@
|
||||
# Web Framework
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
|
||||
# LangGraph & LangChain
|
||||
langgraph>=0.0.40
|
||||
langchain>=0.1.0
|
||||
langchain-core>=0.1.0
|
||||
|
||||
# AI Model SDK
|
||||
zhipuai>=2.0.0
|
||||
# Async utilities
|
||||
sniffio>=1.3.0
|
||||
anyio>=4.0.0
|
||||
|
||||
# HTTP Client
|
||||
httpx>=0.26.0
|
||||
aiohttp>=3.9.0
|
||||
|
||||
# Data Validation
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Redis
|
||||
redis>=5.0.0
|
||||
|
||||
# Environment & Config
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Logging
|
||||
structlog>=24.1.0
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.23.0
|
||||
pytest-cov>=4.1.0
|
||||
|
||||
# MCP Client
|
||||
mcp>=1.0.0
|
||||
0
agent/tests/__init__.py
Normal file
0
agent/tests/__init__.py
Normal file
11
agent/utils/__init__.py
Normal file
11
agent/utils/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Agent utilities package"""
|
||||
from .logger import get_logger, setup_logging, logger
|
||||
from .cache import CacheManager, get_cache_manager
|
||||
|
||||
__all__ = [
|
||||
"get_logger",
|
||||
"setup_logging",
|
||||
"logger",
|
||||
"CacheManager",
|
||||
"get_cache_manager",
|
||||
]
|
||||
245
agent/utils/cache.py
Normal file
245
agent/utils/cache.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Redis cache management for conversation context
|
||||
"""
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
from datetime import timedelta
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from config import settings, get_redis_url
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Redis cache manager for conversation context"""
|
||||
|
||||
def __init__(self, redis_url: Optional[str] = None):
|
||||
"""Initialize cache manager
|
||||
|
||||
Args:
|
||||
redis_url: Redis connection URL, defaults to settings
|
||||
"""
|
||||
self._redis_url = redis_url or get_redis_url()
|
||||
self._client: Optional[redis.Redis] = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to Redis"""
|
||||
if self._client is None:
|
||||
self._client = redis.from_url(
|
||||
self._redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True
|
||||
)
|
||||
logger.info("Connected to Redis")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Redis"""
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
logger.info("Disconnected from Redis")
|
||||
|
||||
async def _ensure_connected(self) -> redis.Redis:
|
||||
"""Ensure Redis connection is established"""
|
||||
if self._client is None:
|
||||
await self.connect()
|
||||
return self._client
|
||||
|
||||
# ============ Conversation Context ============
|
||||
|
||||
def _context_key(self, conversation_id: str) -> str:
|
||||
"""Generate Redis key for conversation context"""
|
||||
return f"conversation:{conversation_id}"
|
||||
|
||||
async def get_context(self, conversation_id: str) -> Optional[dict[str, Any]]:
|
||||
"""Get conversation context
|
||||
|
||||
Args:
|
||||
conversation_id: Unique conversation identifier
|
||||
|
||||
Returns:
|
||||
Context dictionary or None if not found
|
||||
"""
|
||||
client = await self._ensure_connected()
|
||||
key = self._context_key(conversation_id)
|
||||
|
||||
data = await client.get(key)
|
||||
if data:
|
||||
logger.debug("Context retrieved", conversation_id=conversation_id)
|
||||
return json.loads(data)
|
||||
return None
|
||||
|
||||
async def set_context(
|
||||
self,
|
||||
conversation_id: str,
|
||||
context: dict[str, Any],
|
||||
ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Set conversation context
|
||||
|
||||
Args:
|
||||
conversation_id: Unique conversation identifier
|
||||
context: Context dictionary
|
||||
ttl: Time-to-live in seconds, defaults to settings
|
||||
"""
|
||||
client = await self._ensure_connected()
|
||||
key = self._context_key(conversation_id)
|
||||
ttl = ttl or settings.conversation_timeout
|
||||
|
||||
await client.setex(
|
||||
key,
|
||||
timedelta(seconds=ttl),
|
||||
json.dumps(context, ensure_ascii=False)
|
||||
)
|
||||
logger.debug("Context saved", conversation_id=conversation_id, ttl=ttl)
|
||||
|
||||
async def update_context(
|
||||
self,
|
||||
conversation_id: str,
|
||||
updates: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Update conversation context with new values
|
||||
|
||||
Args:
|
||||
conversation_id: Unique conversation identifier
|
||||
updates: Dictionary of updates to merge
|
||||
|
||||
Returns:
|
||||
Updated context dictionary
|
||||
"""
|
||||
context = await self.get_context(conversation_id) or {}
|
||||
context.update(updates)
|
||||
await self.set_context(conversation_id, context)
|
||||
return context
|
||||
|
||||
async def delete_context(self, conversation_id: str) -> bool:
|
||||
"""Delete conversation context
|
||||
|
||||
Args:
|
||||
conversation_id: Unique conversation identifier
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
client = await self._ensure_connected()
|
||||
key = self._context_key(conversation_id)
|
||||
|
||||
result = await client.delete(key)
|
||||
if result:
|
||||
logger.debug("Context deleted", conversation_id=conversation_id)
|
||||
return bool(result)
|
||||
|
||||
# ============ Message History ============
|
||||
|
||||
def _messages_key(self, conversation_id: str) -> str:
|
||||
"""Generate Redis key for message history"""
|
||||
return f"messages:{conversation_id}"
|
||||
|
||||
async def add_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
max_messages: int = 20
|
||||
) -> None:
|
||||
"""Add message to conversation history
|
||||
|
||||
Args:
|
||||
conversation_id: Unique conversation identifier
|
||||
role: Message role (user/assistant/system)
|
||||
content: Message content
|
||||
max_messages: Maximum messages to keep
|
||||
"""
|
||||
client = await self._ensure_connected()
|
||||
key = self._messages_key(conversation_id)
|
||||
|
||||
message = json.dumps({
|
||||
"role": role,
|
||||
"content": content
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# Add to list and trim
|
||||
await client.rpush(key, message)
|
||||
await client.ltrim(key, -max_messages, -1)
|
||||
await client.expire(key, settings.conversation_timeout)
|
||||
|
||||
logger.debug(
|
||||
"Message added",
|
||||
conversation_id=conversation_id,
|
||||
role=role
|
||||
)
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
conversation_id: str,
|
||||
limit: int = 20
|
||||
) -> list[dict[str, str]]:
|
||||
"""Get conversation message history
|
||||
|
||||
Args:
|
||||
conversation_id: Unique conversation identifier
|
||||
limit: Maximum messages to retrieve
|
||||
|
||||
Returns:
|
||||
List of message dictionaries
|
||||
"""
|
||||
client = await self._ensure_connected()
|
||||
key = self._messages_key(conversation_id)
|
||||
|
||||
messages = await client.lrange(key, -limit, -1)
|
||||
return [json.loads(m) for m in messages]
|
||||
|
||||
async def clear_messages(self, conversation_id: str) -> bool:
|
||||
"""Clear conversation message history
|
||||
|
||||
Args:
|
||||
conversation_id: Unique conversation identifier
|
||||
|
||||
Returns:
|
||||
True if cleared, False if not found
|
||||
"""
|
||||
client = await self._ensure_connected()
|
||||
key = self._messages_key(conversation_id)
|
||||
|
||||
result = await client.delete(key)
|
||||
return bool(result)
|
||||
|
||||
# ============ Generic Cache Operations ============
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""Get value from cache"""
|
||||
client = await self._ensure_connected()
|
||||
return await client.get(key)
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Set value in cache"""
|
||||
client = await self._ensure_connected()
|
||||
if ttl:
|
||||
await client.setex(key, timedelta(seconds=ttl), value)
|
||||
else:
|
||||
await client.set(key, value)
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""Delete key from cache"""
|
||||
client = await self._ensure_connected()
|
||||
return bool(await client.delete(key))
|
||||
|
||||
|
||||
# Global cache manager instance
|
||||
cache_manager: Optional[CacheManager] = None
|
||||
|
||||
|
||||
def get_cache_manager() -> CacheManager:
|
||||
"""Get or create global cache manager instance"""
|
||||
global cache_manager
|
||||
if cache_manager is None:
|
||||
cache_manager = CacheManager()
|
||||
return cache_manager
|
||||
56
agent/utils/logger.py
Normal file
56
agent/utils/logger.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Logging utilities for B2B Shopping AI Assistant
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from structlog.types import Processor
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO") -> None:
|
||||
"""Setup structured logging configuration"""
|
||||
|
||||
# Configure standard library logging
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=getattr(logging, level.upper()),
|
||||
)
|
||||
|
||||
# Define processors for structlog
|
||||
shared_processors: list[Processor] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
]
|
||||
|
||||
# Configure structlog
|
||||
structlog.configure(
|
||||
processors=shared_processors + [
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: Optional[str] = None) -> structlog.stdlib.BoundLogger:
|
||||
"""Get a structured logger instance
|
||||
|
||||
Args:
|
||||
name: Logger name, defaults to module name
|
||||
|
||||
Returns:
|
||||
Configured structlog logger
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
|
||||
|
||||
# Create default logger
|
||||
logger = get_logger("agent")
|
||||
0
agent/webhooks/__init__.py
Normal file
0
agent/webhooks/__init__.py
Normal file
355
agent/webhooks/chatwoot_webhook.py
Normal file
355
agent/webhooks/chatwoot_webhook.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Chatwoot Webhook Handler
|
||||
"""
|
||||
import hmac
|
||||
import hashlib
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import settings
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
# ============ Webhook Payload Models ============
|
||||
|
||||
class WebhookSender(BaseModel):
|
||||
"""Webhook sender information"""
|
||||
id: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
type: Optional[str] = None # "contact" or "user"
|
||||
|
||||
|
||||
class WebhookMessage(BaseModel):
|
||||
"""Webhook message content"""
|
||||
id: int
|
||||
content: Optional[str] = None
|
||||
message_type: str # "incoming" or "outgoing"
|
||||
content_type: Optional[str] = None
|
||||
private: bool = False
|
||||
sender: Optional[WebhookSender] = None
|
||||
|
||||
|
||||
class WebhookConversation(BaseModel):
|
||||
"""Webhook conversation information"""
|
||||
id: int
|
||||
inbox_id: int
|
||||
status: str
|
||||
account_id: Optional[int] = None # Chatwoot may not always include this
|
||||
contact_inbox: Optional[dict] = None
|
||||
messages: Optional[list] = None
|
||||
additional_attributes: Optional[dict] = None
|
||||
can_reply: Optional[bool] = None
|
||||
channel: Optional[str] = None
|
||||
|
||||
|
||||
class WebhookContact(BaseModel):
|
||||
"""Webhook contact information"""
|
||||
id: int
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
custom_attributes: Optional[dict] = None
|
||||
|
||||
|
||||
class ChatwootWebhookPayload(BaseModel):
|
||||
"""Chatwoot webhook payload structure"""
|
||||
event: str
|
||||
id: Optional[int] = None
|
||||
content: Optional[str] = None
|
||||
message_type: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
private: Optional[bool] = False
|
||||
conversation: Optional[WebhookConversation] = None
|
||||
sender: Optional[WebhookSender] = None
|
||||
contact: Optional[WebhookContact] = None
|
||||
account: Optional[dict] = None
|
||||
|
||||
|
||||
# ============ Signature Verification ============
|
||||
|
||||
def verify_webhook_signature(payload: bytes, signature: str) -> bool:
|
||||
"""Verify Chatwoot webhook signature
|
||||
|
||||
Args:
|
||||
payload: Raw request body
|
||||
signature: X-Chatwoot-Signature header value
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
# TODO: Re-enable signature verification after configuring Chatwoot properly
|
||||
# For now, skip verification to test webhook functionality
|
||||
logger.debug("Skipping webhook signature verification for testing")
|
||||
return True
|
||||
|
||||
if not settings.chatwoot_webhook_secret:
|
||||
logger.warning("Webhook secret not configured, skipping verification")
|
||||
return True
|
||||
|
||||
if not signature:
|
||||
logger.warning("No signature provided in request")
|
||||
return True
|
||||
|
||||
expected = hmac.new(
|
||||
settings.chatwoot_webhook_secret.encode(),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
# ============ Message Processing ============
|
||||
|
||||
async def handle_incoming_message(payload: ChatwootWebhookPayload) -> None:
|
||||
"""Process incoming message from Chatwoot
|
||||
|
||||
Args:
|
||||
payload: Webhook payload
|
||||
"""
|
||||
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"
|
||||
|
||||
# Get account_id from payload (top-level account object)
|
||||
# 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"
|
||||
|
||||
logger.info(
|
||||
"Processing incoming message",
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
message_length=len(content)
|
||||
)
|
||||
|
||||
# Load conversation context from cache
|
||||
cache = get_cache_manager()
|
||||
await cache.connect()
|
||||
|
||||
context = await cache.get_context(conversation_id)
|
||||
history = await cache.get_messages(conversation_id)
|
||||
|
||||
try:
|
||||
# Process message through agent workflow
|
||||
final_state = await process_message(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
message=content,
|
||||
history=history,
|
||||
context=context
|
||||
)
|
||||
|
||||
# Get response
|
||||
response = final_state.get("response")
|
||||
if not response:
|
||||
response = "抱歉,我暂时无法处理您的请求。请稍后重试或联系人工客服。"
|
||||
|
||||
# Send response to Chatwoot
|
||||
# Create client with correct account_id from webhook
|
||||
from integrations.chatwoot import ChatwootClient
|
||||
chatwoot = ChatwootClient(account_id=int(account_id))
|
||||
await chatwoot.send_message(
|
||||
conversation_id=conversation.id,
|
||||
content=response
|
||||
)
|
||||
await chatwoot.close()
|
||||
|
||||
# Handle human handoff
|
||||
if final_state.get("requires_human"):
|
||||
await chatwoot.update_conversation_status(
|
||||
conversation_id=conversation.id,
|
||||
status=ConversationStatus.OPEN
|
||||
)
|
||||
# Add label for routing
|
||||
await chatwoot.add_labels(
|
||||
conversation_id=conversation.id,
|
||||
labels=["needs_human", final_state.get("intent", "unknown")]
|
||||
)
|
||||
|
||||
# Update cache
|
||||
await cache.add_message(conversation_id, "user", content)
|
||||
await cache.add_message(conversation_id, "assistant", response)
|
||||
|
||||
# Save context
|
||||
new_context = final_state.get("context", {})
|
||||
new_context["last_intent"] = final_state.get("intent")
|
||||
await cache.set_context(conversation_id, new_context)
|
||||
|
||||
logger.info(
|
||||
"Message processed successfully",
|
||||
conversation_id=conversation_id,
|
||||
intent=final_state.get("intent"),
|
||||
requires_human=final_state.get("requires_human")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Message processing failed",
|
||||
conversation_id=conversation_id,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Send error response
|
||||
chatwoot = get_chatwoot_client()
|
||||
await chatwoot.send_message(
|
||||
conversation_id=conversation.id,
|
||||
content="抱歉,处理您的消息时遇到了问题。我们的客服团队将尽快为您服务。"
|
||||
)
|
||||
|
||||
# Transfer to human
|
||||
await chatwoot.update_conversation_status(
|
||||
conversation_id=conversation.id,
|
||||
status=ConversationStatus.OPEN
|
||||
)
|
||||
|
||||
|
||||
async def handle_conversation_created(payload: ChatwootWebhookPayload) -> None:
|
||||
"""Handle new conversation created
|
||||
|
||||
Args:
|
||||
payload: Webhook payload
|
||||
"""
|
||||
conversation = payload.conversation
|
||||
if not conversation:
|
||||
return
|
||||
|
||||
conversation_id = str(conversation.id)
|
||||
|
||||
logger.info(
|
||||
"New conversation created",
|
||||
conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# Initialize conversation context
|
||||
cache = get_cache_manager()
|
||||
await cache.connect()
|
||||
|
||||
context = {
|
||||
"created": True,
|
||||
"inbox_id": conversation.inbox_id
|
||||
}
|
||||
|
||||
# Add contact info to context
|
||||
contact = payload.contact
|
||||
if contact:
|
||||
context["contact_name"] = contact.name
|
||||
context["contact_email"] = contact.email
|
||||
if contact.custom_attributes:
|
||||
context.update(contact.custom_attributes)
|
||||
|
||||
await cache.set_context(conversation_id, context)
|
||||
|
||||
# Send welcome message
|
||||
chatwoot = get_chatwoot_client()
|
||||
await chatwoot.send_message(
|
||||
conversation_id=conversation.id,
|
||||
content="您好!我是 AI 智能助手,很高兴为您服务。请问有什么可以帮您的?\n\n您可以询问我关于订单、商品、售后等问题,我会尽力为您解答。"
|
||||
)
|
||||
|
||||
|
||||
async def handle_conversation_status_changed(payload: ChatwootWebhookPayload) -> None:
|
||||
"""Handle conversation status change
|
||||
|
||||
Args:
|
||||
payload: Webhook payload
|
||||
"""
|
||||
conversation = payload.conversation
|
||||
if not conversation:
|
||||
return
|
||||
|
||||
conversation_id = str(conversation.id)
|
||||
new_status = conversation.status
|
||||
|
||||
logger.info(
|
||||
"Conversation status changed",
|
||||
conversation_id=conversation_id,
|
||||
status=new_status
|
||||
)
|
||||
|
||||
# If resolved, clean up context
|
||||
if new_status == "resolved":
|
||||
cache = get_cache_manager()
|
||||
await cache.connect()
|
||||
await cache.delete_context(conversation_id)
|
||||
await cache.clear_messages(conversation_id)
|
||||
|
||||
|
||||
# ============ Webhook Endpoint ============
|
||||
|
||||
@router.post("/chatwoot")
|
||||
async def chatwoot_webhook(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""Chatwoot webhook endpoint
|
||||
|
||||
Receives events from Chatwoot and processes them asynchronously.
|
||||
"""
|
||||
# Get raw body for signature verification
|
||||
body = await request.body()
|
||||
|
||||
# Verify signature
|
||||
signature = request.headers.get("X-Chatwoot-Signature", "")
|
||||
if not verify_webhook_signature(body, signature):
|
||||
logger.warning("Invalid webhook signature")
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
payload = ChatwootWebhookPayload.model_validate_json(body)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse webhook payload", error=str(e))
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
|
||||
event = payload.event
|
||||
logger.debug(f"Webhook received: {event}")
|
||||
|
||||
# Filter out bot's own messages
|
||||
if payload.message_type == "outgoing":
|
||||
return {"status": "ignored", "reason": "outgoing message"}
|
||||
|
||||
# Filter private messages
|
||||
if payload.private:
|
||||
return {"status": "ignored", "reason": "private message"}
|
||||
|
||||
# Route by event type
|
||||
if event == "message_created":
|
||||
# Only process incoming messages from contacts
|
||||
if payload.message_type == "incoming":
|
||||
background_tasks.add_task(handle_incoming_message, payload)
|
||||
|
||||
elif event == "conversation_created":
|
||||
background_tasks.add_task(handle_conversation_created, payload)
|
||||
|
||||
elif event == "conversation_status_changed":
|
||||
background_tasks.add_task(handle_conversation_status_changed, payload)
|
||||
|
||||
elif event == "conversation_updated":
|
||||
# Handle other conversation updates if needed
|
||||
pass
|
||||
|
||||
return {"status": "accepted", "event": event}
|
||||
234
docker-compose.yml
Normal file
234
docker-compose.yml
Normal file
@@ -0,0 +1,234 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ============ Infrastructure ============
|
||||
|
||||
# PostgreSQL (Chatwoot Database)
|
||||
postgres:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: postgres-with-pgvector.Dockerfile
|
||||
container_name: ai_postgres
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-chatwoot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-chatwoot}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- ai_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-chatwoot}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Redis (Cache & Queue)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ai_redis
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- ai_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# ============ Messaging Platform ============
|
||||
|
||||
# Chatwoot
|
||||
chatwoot:
|
||||
image: chatwoot/chatwoot:latest
|
||||
container_name: ai_chatwoot
|
||||
command: bundle exec rails s -p 3000 -b 0.0.0.0
|
||||
environment:
|
||||
RAILS_ENV: production
|
||||
SECRET_KEY_BASE: ${CHATWOOT_SECRET_KEY_BASE}
|
||||
FRONTEND_URL: ${CHATWOOT_FRONTEND_URL:-http://localhost:3000}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-chatwoot}
|
||||
POSTGRES_USERNAME: ${POSTGRES_USER:-chatwoot}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
REDIS_URL: redis://redis:6379
|
||||
INSTALLATION_NAME: B2B AI Assistant
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- chatwoot_data:/app/storage
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- ai_network
|
||||
restart: unless-stopped
|
||||
|
||||
# Chatwoot Sidekiq Worker
|
||||
chatwoot_worker:
|
||||
image: chatwoot/chatwoot:latest
|
||||
container_name: ai_chatwoot_worker
|
||||
command: bundle exec sidekiq -C config/sidekiq.yml
|
||||
environment:
|
||||
RAILS_ENV: production
|
||||
SECRET_KEY_BASE: ${CHATWOOT_SECRET_KEY_BASE}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-chatwoot}
|
||||
POSTGRES_USERNAME: ${POSTGRES_USER:-chatwoot}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
REDIS_URL: redis://redis:6379
|
||||
INSTALLATION_NAME: B2B AI Assistant
|
||||
volumes:
|
||||
- chatwoot_data:/app/storage
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- ai_network
|
||||
restart: unless-stopped
|
||||
|
||||
# ============ AI Agent Layer ============
|
||||
|
||||
# LangGraph Agent Main Service
|
||||
agent:
|
||||
build:
|
||||
context: ./agent
|
||||
dockerfile: Dockerfile
|
||||
container_name: ai_agent
|
||||
environment:
|
||||
# AI Model
|
||||
ZHIPU_API_KEY: ${ZHIPU_API_KEY}
|
||||
ZHIPU_MODEL: ${ZHIPU_MODEL:-glm-4}
|
||||
# Redis
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
REDIS_DB: 0
|
||||
# Chatwoot
|
||||
CHATWOOT_API_URL: http://chatwoot:3000
|
||||
CHATWOOT_API_TOKEN: ${CHATWOOT_API_TOKEN}
|
||||
CHATWOOT_WEBHOOK_SECRET: ${CHATWOOT_WEBHOOK_SECRET}
|
||||
# External APIs
|
||||
STRAPI_API_URL: ${STRAPI_API_URL}
|
||||
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}
|
||||
HYPERF_API_URL: ${HYPERF_API_URL}
|
||||
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
|
||||
# MCP Servers
|
||||
STRAPI_MCP_URL: http://strapi_mcp:8001
|
||||
ORDER_MCP_URL: http://order_mcp:8002
|
||||
AFTERSALE_MCP_URL: http://aftersale_mcp:8003
|
||||
PRODUCT_MCP_URL: http://product_mcp:8004
|
||||
# Config
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
MAX_CONVERSATION_STEPS: ${MAX_CONVERSATION_STEPS:-10}
|
||||
CONVERSATION_TIMEOUT: ${CONVERSATION_TIMEOUT:-3600}
|
||||
ports:
|
||||
- "8005:8000"
|
||||
volumes:
|
||||
- ./agent:/app
|
||||
- agent_logs:/app/logs
|
||||
depends_on:
|
||||
- redis
|
||||
- strapi_mcp
|
||||
- order_mcp
|
||||
- aftersale_mcp
|
||||
- product_mcp
|
||||
networks:
|
||||
- ai_network
|
||||
restart: unless-stopped
|
||||
|
||||
# ============ MCP Servers ============
|
||||
|
||||
# Strapi MCP (FAQ/Knowledge Base)
|
||||
strapi_mcp:
|
||||
build:
|
||||
context: ./mcp_servers/strapi_mcp
|
||||
dockerfile: Dockerfile
|
||||
container_name: ai_strapi_mcp
|
||||
environment:
|
||||
STRAPI_API_URL: ${STRAPI_API_URL}
|
||||
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ports:
|
||||
- "8001:8001"
|
||||
volumes:
|
||||
- ./mcp_servers/strapi_mcp:/app
|
||||
- ./mcp_servers/shared:/app/shared
|
||||
networks:
|
||||
- ai_network
|
||||
restart: unless-stopped
|
||||
|
||||
# Order MCP
|
||||
order_mcp:
|
||||
build:
|
||||
context: ./mcp_servers/order_mcp
|
||||
dockerfile: Dockerfile
|
||||
container_name: ai_order_mcp
|
||||
environment:
|
||||
HYPERF_API_URL: ${HYPERF_API_URL}
|
||||
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ports:
|
||||
- "8002:8002"
|
||||
volumes:
|
||||
- ./mcp_servers/order_mcp:/app
|
||||
- ./mcp_servers/shared:/app/shared
|
||||
networks:
|
||||
- ai_network
|
||||
restart: unless-stopped
|
||||
|
||||
# Aftersale MCP
|
||||
aftersale_mcp:
|
||||
build:
|
||||
context: ./mcp_servers/aftersale_mcp
|
||||
dockerfile: Dockerfile
|
||||
container_name: ai_aftersale_mcp
|
||||
environment:
|
||||
HYPERF_API_URL: ${HYPERF_API_URL}
|
||||
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ports:
|
||||
- "8003:8003"
|
||||
volumes:
|
||||
- ./mcp_servers/aftersale_mcp:/app
|
||||
- ./mcp_servers/shared:/app/shared
|
||||
networks:
|
||||
- ai_network
|
||||
restart: unless-stopped
|
||||
|
||||
# Product MCP
|
||||
product_mcp:
|
||||
build:
|
||||
context: ./mcp_servers/product_mcp
|
||||
dockerfile: Dockerfile
|
||||
container_name: ai_product_mcp
|
||||
environment:
|
||||
HYPERF_API_URL: ${HYPERF_API_URL}
|
||||
HYPERF_API_TOKEN: ${HYPERF_API_TOKEN}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ports:
|
||||
- "8004:8004"
|
||||
volumes:
|
||||
- ./mcp_servers/product_mcp:/app
|
||||
- ./mcp_servers/shared:/app/shared
|
||||
networks:
|
||||
- ai_network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
ai_network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
chatwoot_data:
|
||||
agent_logs:
|
||||
64
docs/strapi.txt
Normal file
64
docs/strapi.txt
Normal file
@@ -0,0 +1,64 @@
|
||||
账号相关
|
||||
https://cms.yehwang.com/api/faq-register?populate=deep&locale=en
|
||||
https://cms.yehwang.com/api/faq-register?populate=deep&locale=nl
|
||||
https://cms.yehwang.com/api/faq-register?populate=deep&locale=de
|
||||
https://cms.yehwang.com/api/faq-register?populate=deep&locale=es
|
||||
https://cms.yehwang.com/api/faq-register?populate=deep&locale=fr
|
||||
https://cms.yehwang.com/api/faq-register?populate=deep&locale=it
|
||||
https://cms.yehwang.com/api/faq-register?populate=deep&locale=tr
|
||||
订单相关
|
||||
https://cms.yehwang.com/api/faq-order?populate=deep&locale=en
|
||||
https://cms.yehwang.com/api/faq-order?populate=deep&locale=nl
|
||||
https://cms.yehwang.com/api/faq-order?populate=deep&locale=de
|
||||
https://cms.yehwang.com/api/faq-order?populate=deep&locale=es
|
||||
https://cms.yehwang.com/api/faq-order?populate=deep&locale=fr
|
||||
https://cms.yehwang.com/api/faq-order?populate=deep&locale=it
|
||||
https://cms.yehwang.com/api/faq-order?populate=deep&locale=tr
|
||||
预售订单相关
|
||||
https://cms.yehwang.com/api/faq-pre-order?populate=deep&locale=en
|
||||
https://cms.yehwang.com/api/faq-pre-order?populate=deep&locale=nl
|
||||
https://cms.yehwang.com/api/faq-pre-order?populate=deep&locale=de
|
||||
https://cms.yehwang.com/api/faq-pre-order?populate=deep&locale=es
|
||||
https://cms.yehwang.com/api/faq-pre-order?populate=deep&locale=fr
|
||||
https://cms.yehwang.com/api/faq-pre-order?populate=deep&locale=it
|
||||
https://cms.yehwang.com/api/faq-pre-order?populate=deep&locale=tr
|
||||
支付相关
|
||||
https://cms.yehwang.com/api/faq-payment?populate=deep&locale=en
|
||||
https://cms.yehwang.com/api/faq-payment?populate=deep&locale=nl
|
||||
https://cms.yehwang.com/api/faq-payment?populate=deep&locale=de
|
||||
https://cms.yehwang.com/api/faq-payment?populate=deep&locale=es
|
||||
https://cms.yehwang.com/api/faq-payment?populate=deep&locale=fr
|
||||
https://cms.yehwang.com/api/faq-payment?populate=deep&locale=it
|
||||
https://cms.yehwang.com/api/faq-payment?populate=deep&locale=tr
|
||||
运输相关
|
||||
https://cms.yehwang.com/api/faq-shipment?populate=deep&locale=en
|
||||
https://cms.yehwang.com/api/faq-shipment?populate=deep&locale=nl
|
||||
https://cms.yehwang.com/api/faq-shipment?populate=deep&locale=de
|
||||
https://cms.yehwang.com/api/faq-shipment?populate=deep&locale=es
|
||||
https://cms.yehwang.com/api/faq-shipment?populate=deep&locale=fr
|
||||
https://cms.yehwang.com/api/faq-shipment?populate=deep&locale=it
|
||||
https://cms.yehwang.com/api/faq-shipment?populate=deep&locale=tr
|
||||
退货相关
|
||||
https://cms.yehwang.com/api/faq-return?populate=deep&locale=en
|
||||
https://cms.yehwang.com/api/faq-return?populate=deep&locale=nl
|
||||
https://cms.yehwang.com/api/faq-return?populate=deep&locale=de
|
||||
https://cms.yehwang.com/api/faq-return?populate=deep&locale=es
|
||||
https://cms.yehwang.com/api/faq-return?populate=deep&locale=fr
|
||||
https://cms.yehwang.com/api/faq-return?populate=deep&locale=it
|
||||
https://cms.yehwang.com/api/faq-return?populate=deep&locale=tr
|
||||
其他问题
|
||||
https://cms.yehwang.com/api/faq-other-question?populate=deep&locale=en
|
||||
https://cms.yehwang.com/api/faq-other-question?populate=deep&locale=nl
|
||||
https://cms.yehwang.com/api/faq-other-question?populate=deep&locale=de
|
||||
https://cms.yehwang.com/api/faq-other-question?populate=deep&locale=es
|
||||
https://cms.yehwang.com/api/faq-other-question?populate=deep&locale=fr
|
||||
https://cms.yehwang.com/api/faq-other-question?populate=deep&locale=it
|
||||
https://cms.yehwang.com/api/faq-other-question?populate=deep&locale=tr
|
||||
获取公司信息
|
||||
https://cms.yehwang.com/api/info-contact?populate=deep&locale=en
|
||||
https://cms.yehwang.com/api/info-contact?populate=deep&locale=nl
|
||||
https://cms.yehwang.com/api/info-contact?populate=deep&locale=de
|
||||
https://cms.yehwang.com/api/info-contact?populate=deep&locale=es
|
||||
https://cms.yehwang.com/api/info-contact?populate=deep&locale=fr
|
||||
https://cms.yehwang.com/api/info-contact?populate=deep&locale=it
|
||||
https://cms.yehwang.com/api/info-contact?populate=deep&locale=tr
|
||||
198
docs/test-chat.html
Normal file
198
docs/test-chat.html
Normal file
@@ -0,0 +1,198 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>B2B AI 助手 - 测试页面</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.info-box {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 15px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.info-box h3 {
|
||||
margin-top: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
.feature-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.feature-card {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.feature-card h4 {
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.test-questions {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.test-questions h3 {
|
||||
margin-top: 0;
|
||||
color: #856404;
|
||||
}
|
||||
.question-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.question-list li {
|
||||
background: white;
|
||||
margin: 10px 0;
|
||||
padding: 12px 15px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.question-list li:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status.online {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.status.testing {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🤖 B2B AI 智能客服助手</h1>
|
||||
<p class="subtitle">基于 LangGraph + MCP 的智能客服系统</p>
|
||||
|
||||
<div class="status online">
|
||||
✅ 系统状态:所有服务运行正常
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>📝 如何测试</h3>
|
||||
<ol>
|
||||
<li>点击右下角的聊天图标打开对话窗口</li>
|
||||
<li>输入你的名字开始对话</li>
|
||||
<li>尝试下面的问题测试 AI 能力</li>
|
||||
<li>查看 AI 如何理解并回答你的问题</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="test-questions">
|
||||
<h3>💬 推荐测试问题</h3>
|
||||
<p style="color: #666; margin-bottom: 15px;">点击以下问题直接复制到聊天窗口:</p>
|
||||
<ul class="question-list">
|
||||
<li onclick="copyQuestion(this.textContent)">🕐 你们的营业时间是什么?</li>
|
||||
<li onclick="copyQuestion(this.textContent)">📦 我想查询订单状态</li>
|
||||
<li onclick="copyQuestion(this.textContent)">🔍 你们有哪些产品?</li>
|
||||
<li onclick="copyQuestion(this.textContent)">📞 如何联系客服?</li>
|
||||
<li onclick="copyQuestion(this.textContent)">🛍️ 我想退换货</li>
|
||||
<li onclick="copyQuestion(this.textContent)">💰 支付方式有哪些?</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-list">
|
||||
<div class="feature-card">
|
||||
<h4>🎯 智能意图识别</h4>
|
||||
<p>自动识别客户需求并分类</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h4>📚 知识库查询</h4>
|
||||
<p>快速检索 FAQ 和政策文档</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h4>📦 订单管理</h4>
|
||||
<p>查询订单、售后等服务</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h4>🔄 多轮对话</h4>
|
||||
<p>支持上下文理解的连续对话</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>🔧 技术栈</h3>
|
||||
<ul>
|
||||
<li><strong>前端:</strong>Chatwoot 客户支持平台</li>
|
||||
<li><strong>AI 引擎:</strong>LangGraph + 智谱 AI (GLM-4.5)</li>
|
||||
<li><strong>知识库:</strong>Strapi CMS + MCP</li>
|
||||
<li><strong>业务系统:</strong>Hyperf PHP API</li>
|
||||
<li><strong>缓存:</strong>Redis</li>
|
||||
<li><strong>容器:</strong>Docker Compose</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyQuestion(text) {
|
||||
// 移除表情符号
|
||||
const cleanText = text.replace(/^[^\s]+\s*/, '');
|
||||
navigator.clipboard.writeText(cleanText).then(() => {
|
||||
alert('问题已复制!请粘贴到聊天窗口中发送。');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 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
|
||||
})
|
||||
}
|
||||
})(document,"script");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
470
hyperf_api/api_contract.md
Normal file
470
hyperf_api/api_contract.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# Hyperf API 接口约定文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档定义了 AI 助手与 Hyperf PHP 后端之间的 API 接口约定。
|
||||
|
||||
## 基本规范
|
||||
|
||||
### Base URL
|
||||
```
|
||||
http://hyperf-api:9501/api/v1
|
||||
```
|
||||
|
||||
### 认证方式
|
||||
```
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 响应格式
|
||||
```json
|
||||
{
|
||||
"code": 0, // 0=成功,非0=错误码
|
||||
"message": "success",
|
||||
"data": {...}, // 业务数据
|
||||
"meta": {
|
||||
"timestamp": 1705234567,
|
||||
"request_id": "uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 错误码规范
|
||||
| 范围 | 说明 |
|
||||
|------|------|
|
||||
| 1xxx | 认证相关错误 |
|
||||
| 2xxx | 参数验证错误 |
|
||||
| 3xxx | 业务逻辑错误 |
|
||||
| 4xxx | 资源不存在 |
|
||||
| 5xxx | 系统错误 |
|
||||
|
||||
---
|
||||
|
||||
## 订单模块 (Orders)
|
||||
|
||||
### 1. 查询订单
|
||||
|
||||
**POST** `/orders/query`
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| user_id | string | 是 | 用户ID |
|
||||
| account_id | string | 是 | 企业账号ID |
|
||||
| order_id | string | 否 | 订单号(精确查询) |
|
||||
| status | string | 否 | 订单状态筛选 |
|
||||
| date_range.start | string | 否 | 开始日期 YYYY-MM-DD |
|
||||
| date_range.end | string | 否 | 结束日期 YYYY-MM-DD |
|
||||
| page | int | 否 | 页码,默认1 |
|
||||
| page_size | int | 否 | 每页数量,默认20 |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"orders": [
|
||||
{
|
||||
"order_id": "ORD20260114001",
|
||||
"status": "shipped",
|
||||
"items": [
|
||||
{
|
||||
"product_id": "prod_001",
|
||||
"name": "商品A",
|
||||
"quantity": 100,
|
||||
"unit_price": 50.00
|
||||
}
|
||||
],
|
||||
"total_amount": 5000.00,
|
||||
"shipping_address": {...},
|
||||
"tracking_number": "SF1234567890",
|
||||
"created_at": "2026-01-10 10:00:00"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 50,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 物流跟踪
|
||||
|
||||
**GET** `/orders/{order_id}/logistics`
|
||||
|
||||
**查询参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| tracking_number | string | 否 | 物流单号(可选) |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"tracking_number": "SF1234567890",
|
||||
"courier": "顺丰速运",
|
||||
"status": "in_transit",
|
||||
"estimated_delivery": "2026-01-15",
|
||||
"timeline": [
|
||||
{
|
||||
"time": "2026-01-12 10:00:00",
|
||||
"location": "深圳转运中心",
|
||||
"status": "已发出",
|
||||
"description": "快件已从深圳发出"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 修改订单
|
||||
|
||||
**PUT** `/orders/{order_id}/modify`
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| user_id | string | 是 | 用户ID(权限校验) |
|
||||
| modifications | object | 是 | 修改内容 |
|
||||
| modifications.shipping_address | object | 否 | 新收货地址 |
|
||||
| modifications.items | array | 否 | 商品数量修改 |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"order": {...},
|
||||
"price_diff": 2500.00,
|
||||
"message": "订单修改成功"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 取消订单
|
||||
|
||||
**POST** `/orders/{order_id}/cancel`
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| user_id | string | 是 | 用户ID |
|
||||
| reason | string | 是 | 取消原因 |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"order_id": "ORD20260114001",
|
||||
"status": "cancelled",
|
||||
"refund_info": {
|
||||
"amount": 5000.00,
|
||||
"method": "original_payment",
|
||||
"estimated_arrival": "3-5个工作日"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 商品模块 (Products)
|
||||
|
||||
### 1. 商品搜索
|
||||
|
||||
**POST** `/products/search`
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| query | string | 是 | 搜索关键词 |
|
||||
| filters.category | string | 否 | 分类筛选 |
|
||||
| filters.brand | string | 否 | 品牌筛选 |
|
||||
| filters.price_range.min | float | 否 | 最低价格 |
|
||||
| filters.price_range.max | float | 否 | 最高价格 |
|
||||
| sort | string | 否 | 排序:relevance/price_asc/price_desc/sales/latest |
|
||||
| page | int | 否 | 页码 |
|
||||
| page_size | int | 否 | 每页数量 |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"products": [
|
||||
{
|
||||
"product_id": "prod_001",
|
||||
"name": "办公笔记本A4",
|
||||
"brand": "得力",
|
||||
"price": 25.00,
|
||||
"image": "https://...",
|
||||
"stock": 5000,
|
||||
"min_order_quantity": 50
|
||||
}
|
||||
],
|
||||
"total": 150,
|
||||
"pagination": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 商品详情
|
||||
|
||||
**GET** `/products/{product_id}`
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"product_id": "prod_001",
|
||||
"name": "办公笔记本A4",
|
||||
"description": "...",
|
||||
"specifications": {
|
||||
"size": "A4",
|
||||
"pages": 100
|
||||
},
|
||||
"price": 25.00,
|
||||
"price_tiers": [
|
||||
{"min_qty": 50, "price": 25.00},
|
||||
{"min_qty": 500, "price": 23.00},
|
||||
{"min_qty": 1000, "price": 20.00}
|
||||
],
|
||||
"images": [...],
|
||||
"stock": 5000,
|
||||
"min_order_quantity": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 智能推荐
|
||||
|
||||
**POST** `/products/recommend`
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| user_id | string | 是 | 用户ID |
|
||||
| account_id | string | 是 | 企业账号ID |
|
||||
| context | object | 否 | 推荐上下文 |
|
||||
| strategy | string | 否 | 推荐策略:collaborative/content_based/hybrid |
|
||||
| limit | int | 否 | 推荐数量 |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"recommendations": [
|
||||
{
|
||||
"product": {...},
|
||||
"score": 0.95,
|
||||
"reason": "基于您的采购历史推荐"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. B2B 询价
|
||||
|
||||
**POST** `/products/quote`
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| product_id | string | 是 | 商品ID |
|
||||
| quantity | int | 是 | 采购数量 |
|
||||
| account_id | string | 是 | 企业账号ID(用于获取专属价格) |
|
||||
| delivery_address | object | 否 | 收货地址(用于计算运费) |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"quote_id": "QT20260114001",
|
||||
"product_id": "prod_001",
|
||||
"quantity": 1000,
|
||||
"unit_price": 20.00,
|
||||
"subtotal": 20000.00,
|
||||
"discount": 500.00,
|
||||
"discount_reason": "VIP客户折扣",
|
||||
"tax": 2535.00,
|
||||
"shipping_fee": 200.00,
|
||||
"total_price": 22235.00,
|
||||
"validity": "7天",
|
||||
"payment_terms": "月结30天",
|
||||
"estimated_delivery": "2026-01-25"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 库存查询
|
||||
|
||||
**POST** `/products/inventory/check`
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| product_ids | array | 是 | 商品ID列表 |
|
||||
| warehouse | string | 否 | 指定仓库 |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"inventory": [
|
||||
{
|
||||
"product_id": "prod_001",
|
||||
"available_stock": 4500,
|
||||
"reserved_stock": 500,
|
||||
"warehouse": "华南仓"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 售后模块 (Aftersales)
|
||||
|
||||
### 1. 退货申请
|
||||
|
||||
**POST** `/aftersales/return`
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| order_id | string | 是 | 订单号 |
|
||||
| user_id | string | 是 | 用户ID |
|
||||
| items | array | 是 | 退货商品列表 |
|
||||
| items[].item_id | string | 是 | 订单商品ID |
|
||||
| items[].quantity | int | 是 | 退货数量 |
|
||||
| items[].reason | string | 是 | 退货原因 |
|
||||
| description | string | 是 | 问题描述 |
|
||||
| images | array | 否 | 图片URL列表 |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"aftersale_id": "AS20260114001",
|
||||
"status": "pending_review",
|
||||
"estimated_refund": 2500.00,
|
||||
"process_steps": [
|
||||
"提交申请",
|
||||
"商家审核",
|
||||
"寄回商品",
|
||||
"确认收货",
|
||||
"退款到账"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 换货申请
|
||||
|
||||
**POST** `/aftersales/exchange`
|
||||
|
||||
参数结构与退货类似,返回换货单信息。
|
||||
|
||||
### 3. 创建投诉
|
||||
|
||||
**POST** `/aftersales/complaint`
|
||||
|
||||
**请求参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| user_id | string | 是 | 用户ID |
|
||||
| type | string | 是 | 投诉类型:product_quality/service/logistics/other |
|
||||
| title | string | 是 | 投诉标题 |
|
||||
| description | string | 是 | 详细描述 |
|
||||
| related_order_id | string | 否 | 关联订单号 |
|
||||
| attachments | array | 否 | 附件URL |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"complaint_id": "CMP20260114001",
|
||||
"status": "processing",
|
||||
"assigned_to": "客服主管-张三",
|
||||
"expected_response_time": "24小时内"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 查询售后状态
|
||||
|
||||
**GET** `/aftersales/query`
|
||||
|
||||
**查询参数**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| user_id | string | 是 | 用户ID |
|
||||
| aftersale_id | string | 否 | 售后单号(不填查询全部) |
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"records": [
|
||||
{
|
||||
"aftersale_id": "AS20260114001",
|
||||
"type": "return",
|
||||
"status": "approved",
|
||||
"order_id": "ORD20260114001",
|
||||
"progress": [
|
||||
{
|
||||
"step": "商家审核",
|
||||
"status": "completed",
|
||||
"time": "2026-01-14 10:00:00",
|
||||
"note": "审核通过,请寄回商品"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-01-13"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现优先级
|
||||
|
||||
1. **高优先级**(核心流程)
|
||||
- POST /orders/query
|
||||
- GET /orders/{id}/logistics
|
||||
- POST /products/search
|
||||
- GET /products/{id}
|
||||
|
||||
2. **中优先级**(完整体验)
|
||||
- POST /products/quote
|
||||
- POST /aftersales/return
|
||||
- GET /aftersales/query
|
||||
|
||||
3. **低优先级**(增强功能)
|
||||
- POST /products/recommend
|
||||
- PUT /orders/{id}/modify
|
||||
- POST /aftersales/complaint
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **权限校验**:所有涉及订单/售后操作的接口需要验证 user_id 是否有权限
|
||||
2. **幂等性**:POST 请求应该支持幂等,避免重复提交
|
||||
3. **错误处理**:返回清晰的错误信息,便于 AI 理解和向用户解释
|
||||
4. **性能考虑**:列表接口支持分页,单次返回数据量不超过 100 条
|
||||
901
hyperf_api/openapi.yaml
Normal file
901
hyperf_api/openapi.yaml
Normal file
@@ -0,0 +1,901 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: B2B Shopping AI Assistant - Hyperf API Contract
|
||||
description: |
|
||||
API contract for the Hyperf PHP backend to support the AI Assistant.
|
||||
This document defines the expected endpoints and data structures.
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: AI Assistant Team
|
||||
|
||||
servers:
|
||||
- url: http://hyperf-api:9501/api/v1
|
||||
description: Internal Docker network
|
||||
- url: http://localhost:9501/api/v1
|
||||
description: Local development
|
||||
|
||||
security:
|
||||
- BearerAuth: []
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
schemas:
|
||||
# Common response wrapper
|
||||
ApiResponse:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
description: "0 for success, non-zero for errors"
|
||||
example: 0
|
||||
message:
|
||||
type: string
|
||||
example: "success"
|
||||
data:
|
||||
type: object
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
timestamp:
|
||||
type: integer
|
||||
request_id:
|
||||
type: string
|
||||
|
||||
# Pagination
|
||||
Pagination:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
total_pages:
|
||||
type: integer
|
||||
|
||||
# Order schemas
|
||||
Order:
|
||||
type: object
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
example: "ORD20260114001"
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, paid, processing, shipped, delivered, cancelled]
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OrderItem'
|
||||
total_amount:
|
||||
type: number
|
||||
format: float
|
||||
shipping_address:
|
||||
$ref: '#/components/schemas/Address'
|
||||
tracking_number:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
OrderItem:
|
||||
type: object
|
||||
properties:
|
||||
item_id:
|
||||
type: string
|
||||
product_id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
unit_price:
|
||||
type: number
|
||||
format: float
|
||||
subtotal:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
Address:
|
||||
type: object
|
||||
properties:
|
||||
province:
|
||||
type: string
|
||||
city:
|
||||
type: string
|
||||
district:
|
||||
type: string
|
||||
detail:
|
||||
type: string
|
||||
contact:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
|
||||
LogisticsTimeline:
|
||||
type: object
|
||||
properties:
|
||||
time:
|
||||
type: string
|
||||
format: date-time
|
||||
location:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
|
||||
# Product schemas
|
||||
Product:
|
||||
type: object
|
||||
properties:
|
||||
product_id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
brand:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
price:
|
||||
type: number
|
||||
format: float
|
||||
price_tiers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
min_qty:
|
||||
type: integer
|
||||
price:
|
||||
type: number
|
||||
image:
|
||||
type: string
|
||||
images:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
stock:
|
||||
type: integer
|
||||
min_order_quantity:
|
||||
type: integer
|
||||
specifications:
|
||||
type: object
|
||||
|
||||
Quote:
|
||||
type: object
|
||||
properties:
|
||||
quote_id:
|
||||
type: string
|
||||
product_id:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
unit_price:
|
||||
type: number
|
||||
subtotal:
|
||||
type: number
|
||||
discount:
|
||||
type: number
|
||||
discount_reason:
|
||||
type: string
|
||||
tax:
|
||||
type: number
|
||||
shipping_fee:
|
||||
type: number
|
||||
total_price:
|
||||
type: number
|
||||
validity:
|
||||
type: string
|
||||
payment_terms:
|
||||
type: string
|
||||
estimated_delivery:
|
||||
type: string
|
||||
|
||||
# Aftersale schemas
|
||||
AftersaleRecord:
|
||||
type: object
|
||||
properties:
|
||||
aftersale_id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [return, exchange, complaint, ticket]
|
||||
status:
|
||||
type: string
|
||||
order_id:
|
||||
type: string
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
progress:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
step:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
time:
|
||||
type: string
|
||||
note:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
paths:
|
||||
# ============ Order APIs ============
|
||||
/orders/query:
|
||||
post:
|
||||
summary: Query orders
|
||||
operationId: queryOrders
|
||||
tags: [Orders]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id, account_id]
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
account_id:
|
||||
type: string
|
||||
order_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
date_range:
|
||||
type: object
|
||||
properties:
|
||||
start:
|
||||
type: string
|
||||
format: date
|
||||
end:
|
||||
type: string
|
||||
format: date
|
||||
page:
|
||||
type: integer
|
||||
default: 1
|
||||
page_size:
|
||||
type: integer
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: Orders list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
orders:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Order'
|
||||
pagination:
|
||||
$ref: '#/components/schemas/Pagination'
|
||||
|
||||
/orders/{order_id}/logistics:
|
||||
get:
|
||||
summary: Get logistics tracking
|
||||
operationId: getLogistics
|
||||
tags: [Orders]
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: tracking_number
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Logistics information
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
tracking_number:
|
||||
type: string
|
||||
courier:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
estimated_delivery:
|
||||
type: string
|
||||
timeline:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LogisticsTimeline'
|
||||
|
||||
/orders/{order_id}/modify:
|
||||
put:
|
||||
summary: Modify order
|
||||
operationId: modifyOrder
|
||||
tags: [Orders]
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id, modifications]
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
modifications:
|
||||
type: object
|
||||
properties:
|
||||
shipping_address:
|
||||
$ref: '#/components/schemas/Address'
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
product_id:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Modified order
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
order:
|
||||
$ref: '#/components/schemas/Order'
|
||||
price_diff:
|
||||
type: number
|
||||
|
||||
/orders/{order_id}/cancel:
|
||||
post:
|
||||
summary: Cancel order
|
||||
operationId: cancelOrder
|
||||
tags: [Orders]
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id, reason]
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Cancellation result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
refund_info:
|
||||
type: object
|
||||
properties:
|
||||
amount:
|
||||
type: number
|
||||
method:
|
||||
type: string
|
||||
estimated_arrival:
|
||||
type: string
|
||||
|
||||
/orders/{order_id}/invoice:
|
||||
get:
|
||||
summary: Get invoice
|
||||
operationId: getInvoice
|
||||
tags: [Orders]
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: type
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [normal, vat]
|
||||
default: normal
|
||||
responses:
|
||||
'200':
|
||||
description: Invoice information
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
invoice_number:
|
||||
type: string
|
||||
invoice_url:
|
||||
type: string
|
||||
amount:
|
||||
type: number
|
||||
tax:
|
||||
type: number
|
||||
issued_at:
|
||||
type: string
|
||||
|
||||
# ============ Product APIs ============
|
||||
/products/search:
|
||||
post:
|
||||
summary: Search products
|
||||
operationId: searchProducts
|
||||
tags: [Products]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [query]
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
filters:
|
||||
type: object
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
brand:
|
||||
type: string
|
||||
price_range:
|
||||
type: object
|
||||
properties:
|
||||
min:
|
||||
type: number
|
||||
max:
|
||||
type: number
|
||||
sort:
|
||||
type: string
|
||||
enum: [relevance, price_asc, price_desc, sales, latest]
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Search results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
products:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Product'
|
||||
total:
|
||||
type: integer
|
||||
pagination:
|
||||
$ref: '#/components/schemas/Pagination'
|
||||
|
||||
/products/{product_id}:
|
||||
get:
|
||||
summary: Get product details
|
||||
operationId: getProduct
|
||||
tags: [Products]
|
||||
parameters:
|
||||
- name: product_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Product details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Product'
|
||||
|
||||
/products/recommend:
|
||||
post:
|
||||
summary: Get product recommendations
|
||||
operationId: recommendProducts
|
||||
tags: [Products]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id, account_id]
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
account_id:
|
||||
type: string
|
||||
context:
|
||||
type: object
|
||||
properties:
|
||||
current_query:
|
||||
type: string
|
||||
recent_views:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
strategy:
|
||||
type: string
|
||||
enum: [collaborative, content_based, hybrid]
|
||||
limit:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Recommendations
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
recommendations:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
product:
|
||||
$ref: '#/components/schemas/Product'
|
||||
score:
|
||||
type: number
|
||||
reason:
|
||||
type: string
|
||||
|
||||
/products/quote:
|
||||
post:
|
||||
summary: Get B2B price quote
|
||||
operationId: getQuote
|
||||
tags: [Products]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [product_id, quantity, account_id]
|
||||
properties:
|
||||
product_id:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
account_id:
|
||||
type: string
|
||||
delivery_address:
|
||||
type: object
|
||||
properties:
|
||||
province:
|
||||
type: string
|
||||
city:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Price quote
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Quote'
|
||||
|
||||
/products/inventory/check:
|
||||
post:
|
||||
summary: Check inventory
|
||||
operationId: checkInventory
|
||||
tags: [Products]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [product_ids]
|
||||
properties:
|
||||
product_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
warehouse:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Inventory status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
inventory:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
product_id:
|
||||
type: string
|
||||
available_stock:
|
||||
type: integer
|
||||
reserved_stock:
|
||||
type: integer
|
||||
warehouse:
|
||||
type: string
|
||||
|
||||
# ============ Aftersale APIs ============
|
||||
/aftersales/return:
|
||||
post:
|
||||
summary: Apply for return
|
||||
operationId: applyReturn
|
||||
tags: [Aftersales]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [order_id, user_id, items, description]
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
item_id:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
reason:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
images:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Return application result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
aftersale_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
estimated_refund:
|
||||
type: number
|
||||
process_steps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/aftersales/exchange:
|
||||
post:
|
||||
summary: Apply for exchange
|
||||
operationId: applyExchange
|
||||
tags: [Aftersales]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [order_id, user_id, items, description]
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
item_id:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
new_specs:
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Exchange application result
|
||||
|
||||
/aftersales/complaint:
|
||||
post:
|
||||
summary: Create complaint
|
||||
operationId: createComplaint
|
||||
tags: [Aftersales]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id, type, title, description]
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [product_quality, service, logistics, pricing, other]
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
related_order_id:
|
||||
type: string
|
||||
attachments:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Complaint creation result
|
||||
|
||||
/aftersales/ticket:
|
||||
post:
|
||||
summary: Create support ticket
|
||||
operationId: createTicket
|
||||
tags: [Aftersales]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id, category, priority, title, description]
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
enum: [low, medium, high, urgent]
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Ticket creation result
|
||||
|
||||
/aftersales/query:
|
||||
get:
|
||||
summary: Query aftersale records
|
||||
operationId: queryAftersales
|
||||
tags: [Aftersales]
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: aftersale_id
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Aftersale records
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiResponse'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
records:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AftersaleRecord'
|
||||
|
||||
tags:
|
||||
- name: Orders
|
||||
description: Order management APIs
|
||||
- name: Products
|
||||
description: Product catalog and pricing APIs
|
||||
- name: Aftersales
|
||||
description: Returns, exchanges, and complaints APIs
|
||||
0
mcp_servers/__init__.py
Normal file
0
mcp_servers/__init__.py
Normal file
29
mcp_servers/aftersale_mcp/Dockerfile
Normal file
29
mcp_servers/aftersale_mcp/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Note: shared modules are mounted via docker-compose volumes
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8003
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8003/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "server.py"]
|
||||
0
mcp_servers/aftersale_mcp/__init__.py
Normal file
0
mcp_servers/aftersale_mcp/__init__.py
Normal file
15
mcp_servers/aftersale_mcp/requirements.txt
Normal file
15
mcp_servers/aftersale_mcp/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
# FastMCP Framework
|
||||
fastmcp>=0.1.0
|
||||
|
||||
# HTTP Client
|
||||
httpx>=0.26.0
|
||||
|
||||
# Data Validation
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Environment & Config
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Logging
|
||||
structlog>=24.1.0
|
||||
299
mcp_servers/aftersale_mcp/server.py
Normal file
299
mcp_servers/aftersale_mcp/server.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Aftersale MCP Server - Returns, exchanges, and complaints
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from typing import Optional, List
|
||||
|
||||
# Add shared module to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Server configuration"""
|
||||
hyperf_api_url: str
|
||||
hyperf_api_token: str
|
||||
log_level: str = "INFO"
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Create MCP server
|
||||
mcp = FastMCP(
|
||||
"Aftersale Service"
|
||||
)
|
||||
|
||||
|
||||
# Hyperf client for this server
|
||||
from shared.hyperf_client import HyperfClient
|
||||
hyperf = HyperfClient(settings.hyperf_api_url, settings.hyperf_api_token)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def apply_return(
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
items: List[dict],
|
||||
description: str,
|
||||
images: Optional[List[str]] = None
|
||||
) -> dict:
|
||||
"""Apply for a return
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
user_id: User identifier
|
||||
items: List of items to return, each with:
|
||||
- item_id: Order item ID
|
||||
- quantity: Quantity to return
|
||||
- reason: Return reason (quality_issue, wrong_item, not_as_described, etc.)
|
||||
description: Detailed description of the issue
|
||||
images: Optional list of image URLs showing the issue
|
||||
|
||||
Returns:
|
||||
Return application result with aftersale ID
|
||||
"""
|
||||
payload = {
|
||||
"order_id": order_id,
|
||||
"user_id": user_id,
|
||||
"items": items,
|
||||
"description": description
|
||||
}
|
||||
|
||||
if images:
|
||||
payload["images"] = images
|
||||
|
||||
try:
|
||||
result = await hyperf.post("/aftersales/return", json=payload)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"aftersale_id": result.get("aftersale_id"),
|
||||
"status": result.get("status"),
|
||||
"estimated_refund": result.get("estimated_refund"),
|
||||
"process_steps": result.get("process_steps", []),
|
||||
"message": "Return application submitted successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def apply_exchange(
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
items: List[dict],
|
||||
description: str
|
||||
) -> dict:
|
||||
"""Apply for an exchange
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
user_id: User identifier
|
||||
items: List of items to exchange, each with:
|
||||
- item_id: Order item ID
|
||||
- reason: Exchange reason
|
||||
- new_specs: Optional new specifications (size, color, etc.)
|
||||
description: Detailed description of the issue
|
||||
|
||||
Returns:
|
||||
Exchange application result with aftersale ID
|
||||
"""
|
||||
try:
|
||||
result = await hyperf.post(
|
||||
"/aftersales/exchange",
|
||||
json={
|
||||
"order_id": order_id,
|
||||
"user_id": user_id,
|
||||
"items": items,
|
||||
"description": description
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"aftersale_id": result.get("aftersale_id"),
|
||||
"status": result.get("status"),
|
||||
"process_steps": result.get("process_steps", []),
|
||||
"message": "Exchange application submitted successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def create_complaint(
|
||||
user_id: str,
|
||||
complaint_type: str,
|
||||
title: str,
|
||||
description: str,
|
||||
related_order_id: Optional[str] = None,
|
||||
attachments: Optional[List[str]] = None
|
||||
) -> dict:
|
||||
"""Create a complaint
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
complaint_type: Type of complaint:
|
||||
- product_quality: Product quality issues
|
||||
- service: Service attitude or process issues
|
||||
- logistics: Shipping/delivery issues
|
||||
- pricing: Pricing or billing issues
|
||||
- other: Other complaints
|
||||
title: Brief complaint title
|
||||
description: Detailed description
|
||||
related_order_id: Related order ID (optional)
|
||||
attachments: Optional list of attachment URLs
|
||||
|
||||
Returns:
|
||||
Complaint creation result
|
||||
"""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"type": complaint_type,
|
||||
"title": title,
|
||||
"description": description
|
||||
}
|
||||
|
||||
if related_order_id:
|
||||
payload["related_order_id"] = related_order_id
|
||||
if attachments:
|
||||
payload["attachments"] = attachments
|
||||
|
||||
try:
|
||||
result = await hyperf.post("/aftersales/complaint", json=payload)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"complaint_id": result.get("complaint_id"),
|
||||
"status": result.get("status"),
|
||||
"assigned_to": result.get("assigned_to"),
|
||||
"expected_response_time": result.get("expected_response_time"),
|
||||
"message": "Complaint submitted successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def create_ticket(
|
||||
user_id: str,
|
||||
category: str,
|
||||
priority: str,
|
||||
title: str,
|
||||
description: str
|
||||
) -> dict:
|
||||
"""Create a support ticket
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
category: Ticket category (technical_support, account, payment, other)
|
||||
priority: Priority level (low, medium, high, urgent)
|
||||
title: Ticket title
|
||||
description: Detailed description
|
||||
|
||||
Returns:
|
||||
Ticket creation result
|
||||
"""
|
||||
try:
|
||||
result = await hyperf.post(
|
||||
"/aftersales/ticket",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"category": category,
|
||||
"priority": priority,
|
||||
"title": title,
|
||||
"description": description
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"ticket_id": result.get("ticket_id"),
|
||||
"status": result.get("status"),
|
||||
"assigned_team": result.get("assigned_team"),
|
||||
"message": "Support ticket created successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def query_aftersale_status(
|
||||
user_id: str,
|
||||
aftersale_id: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Query aftersale records and status
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
aftersale_id: Specific aftersale ID (optional, queries all if not provided)
|
||||
|
||||
Returns:
|
||||
List of aftersale records with progress
|
||||
"""
|
||||
params = {"user_id": user_id}
|
||||
if aftersale_id:
|
||||
params["aftersale_id"] = aftersale_id
|
||||
|
||||
try:
|
||||
result = await hyperf.get("/aftersales/query", params=params)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"records": result.get("records", [])
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"records": []
|
||||
}
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@mcp.tool()
|
||||
async def health_check() -> dict:
|
||||
"""Check server health status"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "aftersale_mcp",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
# Create FastAPI app from MCP
|
||||
app = mcp.http_app()
|
||||
|
||||
# Add health endpoint
|
||||
from starlette.responses import JSONResponse
|
||||
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']))
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8003)
|
||||
0
mcp_servers/aftersale_mcp/tools/__init__.py
Normal file
0
mcp_servers/aftersale_mcp/tools/__init__.py
Normal file
29
mcp_servers/order_mcp/Dockerfile
Normal file
29
mcp_servers/order_mcp/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Note: shared modules are mounted via docker-compose volumes
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8002
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8002/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "server.py"]
|
||||
0
mcp_servers/order_mcp/__init__.py
Normal file
0
mcp_servers/order_mcp/__init__.py
Normal file
15
mcp_servers/order_mcp/requirements.txt
Normal file
15
mcp_servers/order_mcp/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
# FastMCP Framework
|
||||
fastmcp>=0.1.0
|
||||
|
||||
# HTTP Client
|
||||
httpx>=0.26.0
|
||||
|
||||
# Data Validation
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Environment & Config
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Logging
|
||||
structlog>=24.1.0
|
||||
284
mcp_servers/order_mcp/server.py
Normal file
284
mcp_servers/order_mcp/server.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Order MCP Server - Order management tools
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from typing import Optional, List
|
||||
|
||||
# Add shared module to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Server configuration"""
|
||||
hyperf_api_url: str
|
||||
hyperf_api_token: str
|
||||
log_level: str = "INFO"
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Create MCP server
|
||||
mcp = FastMCP(
|
||||
"Order Management"
|
||||
)
|
||||
|
||||
|
||||
# Hyperf client for this server
|
||||
from shared.hyperf_client import HyperfClient
|
||||
hyperf = HyperfClient(settings.hyperf_api_url, settings.hyperf_api_token)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def query_order(
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
order_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
date_start: Optional[str] = None,
|
||||
date_end: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10
|
||||
) -> dict:
|
||||
"""Query orders for a user
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
account_id: B2B account identifier
|
||||
order_id: Specific order ID to query (optional)
|
||||
status: Order status filter (pending, paid, shipped, delivered, cancelled)
|
||||
date_start: Start date filter (YYYY-MM-DD)
|
||||
date_end: End date filter (YYYY-MM-DD)
|
||||
page: Page number (default: 1)
|
||||
page_size: Items per page (default: 10)
|
||||
|
||||
Returns:
|
||||
List of orders with details
|
||||
"""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"account_id": account_id,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
if status:
|
||||
payload["status"] = status
|
||||
if date_start or date_end:
|
||||
payload["date_range"] = {}
|
||||
if date_start:
|
||||
payload["date_range"]["start"] = date_start
|
||||
if date_end:
|
||||
payload["date_range"]["end"] = date_end
|
||||
|
||||
try:
|
||||
result = await hyperf.post("/orders/query", json=payload)
|
||||
return {
|
||||
"success": True,
|
||||
"orders": result.get("orders", []),
|
||||
"pagination": result.get("pagination", {})
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"orders": []
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def track_logistics(
|
||||
order_id: str,
|
||||
tracking_number: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Track order logistics/shipping status
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
tracking_number: Tracking number (optional, will be fetched from order if not provided)
|
||||
|
||||
Returns:
|
||||
Logistics tracking information with timeline
|
||||
"""
|
||||
try:
|
||||
params = {}
|
||||
if tracking_number:
|
||||
params["tracking_number"] = tracking_number
|
||||
|
||||
result = await hyperf.get(f"/orders/{order_id}/logistics", params=params)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"order_id": order_id,
|
||||
"tracking_number": result.get("tracking_number"),
|
||||
"courier": result.get("courier"),
|
||||
"status": result.get("status"),
|
||||
"estimated_delivery": result.get("estimated_delivery"),
|
||||
"timeline": result.get("timeline", [])
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"order_id": order_id
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def modify_order(
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
modifications: dict
|
||||
) -> dict:
|
||||
"""Modify an existing order
|
||||
|
||||
Args:
|
||||
order_id: Order ID to modify
|
||||
user_id: User ID for permission verification
|
||||
modifications: Changes to apply. Can include:
|
||||
- shipping_address: {province, city, district, detail, contact, phone}
|
||||
- items: [{product_id, quantity}] to update quantities
|
||||
- notes: Order notes/instructions
|
||||
|
||||
Returns:
|
||||
Modified order details and any price changes
|
||||
"""
|
||||
try:
|
||||
result = await hyperf.put(
|
||||
f"/orders/{order_id}/modify",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"modifications": modifications
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"order_id": order_id,
|
||||
"order": result.get("order", {}),
|
||||
"price_diff": result.get("price_diff", 0),
|
||||
"message": result.get("message", "Order modified successfully")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"order_id": order_id
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def cancel_order(
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
reason: str
|
||||
) -> dict:
|
||||
"""Cancel an order
|
||||
|
||||
Args:
|
||||
order_id: Order ID to cancel
|
||||
user_id: User ID for permission verification
|
||||
reason: Cancellation reason
|
||||
|
||||
Returns:
|
||||
Cancellation result with refund information
|
||||
"""
|
||||
try:
|
||||
result = await hyperf.post(
|
||||
f"/orders/{order_id}/cancel",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"reason": reason
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"order_id": order_id,
|
||||
"status": "cancelled",
|
||||
"refund_info": result.get("refund_info", {}),
|
||||
"message": result.get("message", "Order cancelled successfully")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"order_id": order_id
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_invoice(
|
||||
order_id: str,
|
||||
invoice_type: str = "normal"
|
||||
) -> dict:
|
||||
"""Get invoice for an order
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
invoice_type: Invoice type ('normal' for regular invoice, 'vat' for VAT invoice)
|
||||
|
||||
Returns:
|
||||
Invoice information and download URL
|
||||
"""
|
||||
try:
|
||||
result = await hyperf.get(
|
||||
f"/orders/{order_id}/invoice",
|
||||
params={"type": invoice_type}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"order_id": order_id,
|
||||
"invoice_number": result.get("invoice_number"),
|
||||
"invoice_type": invoice_type,
|
||||
"amount": result.get("amount"),
|
||||
"tax": result.get("tax"),
|
||||
"invoice_url": result.get("invoice_url"),
|
||||
"issued_at": result.get("issued_at")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"order_id": order_id
|
||||
}
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@mcp.tool()
|
||||
async def health_check() -> dict:
|
||||
"""Check server health status"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "order_mcp",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
# Create FastAPI app from MCP
|
||||
app = mcp.http_app()
|
||||
|
||||
# Add health endpoint
|
||||
from starlette.responses import JSONResponse
|
||||
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']))
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8002)
|
||||
0
mcp_servers/order_mcp/tools/__init__.py
Normal file
0
mcp_servers/order_mcp/tools/__init__.py
Normal file
29
mcp_servers/product_mcp/Dockerfile
Normal file
29
mcp_servers/product_mcp/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Note: shared modules are mounted via docker-compose volumes
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8004
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8004/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "server.py"]
|
||||
0
mcp_servers/product_mcp/__init__.py
Normal file
0
mcp_servers/product_mcp/__init__.py
Normal file
15
mcp_servers/product_mcp/requirements.txt
Normal file
15
mcp_servers/product_mcp/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
# FastMCP Framework
|
||||
fastmcp>=0.1.0
|
||||
|
||||
# HTTP Client
|
||||
httpx>=0.26.0
|
||||
|
||||
# Data Validation
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Environment & Config
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Logging
|
||||
structlog>=24.1.0
|
||||
317
mcp_servers/product_mcp/server.py
Normal file
317
mcp_servers/product_mcp/server.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Product MCP Server - Product search, recommendations, and quotes
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from typing import Optional, List
|
||||
|
||||
# Add shared module to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Server configuration"""
|
||||
hyperf_api_url: str
|
||||
hyperf_api_token: str
|
||||
log_level: str = "INFO"
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Create MCP server
|
||||
mcp = FastMCP(
|
||||
"Product Service"
|
||||
)
|
||||
|
||||
|
||||
# Hyperf client for this server
|
||||
from shared.hyperf_client import HyperfClient
|
||||
hyperf = HyperfClient(settings.hyperf_api_url, settings.hyperf_api_token)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_products(
|
||||
query: str,
|
||||
category: Optional[str] = None,
|
||||
brand: Optional[str] = None,
|
||||
price_min: Optional[float] = None,
|
||||
price_max: Optional[float] = None,
|
||||
sort: str = "relevance",
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> dict:
|
||||
"""Search products
|
||||
|
||||
Args:
|
||||
query: Search keywords
|
||||
category: Category filter
|
||||
brand: Brand filter
|
||||
price_min: Minimum price filter
|
||||
price_max: Maximum price filter
|
||||
sort: Sort order (relevance, price_asc, price_desc, sales, latest)
|
||||
page: Page number (default: 1)
|
||||
page_size: Items per page (default: 20)
|
||||
|
||||
Returns:
|
||||
List of matching products
|
||||
"""
|
||||
payload = {
|
||||
"query": query,
|
||||
"sort": sort,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"filters": {}
|
||||
}
|
||||
|
||||
if category:
|
||||
payload["filters"]["category"] = category
|
||||
if brand:
|
||||
payload["filters"]["brand"] = brand
|
||||
if price_min is not None or price_max is not None:
|
||||
payload["filters"]["price_range"] = {}
|
||||
if price_min is not None:
|
||||
payload["filters"]["price_range"]["min"] = price_min
|
||||
if price_max is not None:
|
||||
payload["filters"]["price_range"]["max"] = price_max
|
||||
|
||||
try:
|
||||
result = await hyperf.post("/products/search", json=payload)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"products": result.get("products", []),
|
||||
"total": result.get("total", 0),
|
||||
"pagination": result.get("pagination", {})
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"products": []
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_product_detail(
|
||||
product_id: str
|
||||
) -> dict:
|
||||
"""Get product details
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
|
||||
Returns:
|
||||
Detailed product information including specifications, pricing, and stock
|
||||
"""
|
||||
try:
|
||||
result = await hyperf.get(f"/products/{product_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product": result
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"product": None
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def recommend_products(
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
context: Optional[dict] = None,
|
||||
strategy: str = "hybrid",
|
||||
limit: int = 10
|
||||
) -> dict:
|
||||
"""Get personalized product recommendations
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
account_id: B2B account identifier
|
||||
context: Optional context for recommendations:
|
||||
- current_query: Current search query
|
||||
- recent_views: List of recently viewed product IDs
|
||||
- cart_items: Items in cart
|
||||
strategy: Recommendation strategy (collaborative, content_based, hybrid)
|
||||
limit: Maximum recommendations to return (default: 10)
|
||||
|
||||
Returns:
|
||||
List of recommended products with reasons
|
||||
"""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"account_id": account_id,
|
||||
"strategy": strategy,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
if context:
|
||||
payload["context"] = context
|
||||
|
||||
try:
|
||||
result = await hyperf.post("/products/recommend", json=payload)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"recommendations": result.get("recommendations", [])
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"recommendations": []
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_quote(
|
||||
product_id: str,
|
||||
quantity: int,
|
||||
account_id: str,
|
||||
delivery_province: Optional[str] = None,
|
||||
delivery_city: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Get B2B price quote
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
quantity: Desired quantity
|
||||
account_id: B2B account ID (for customer-specific pricing)
|
||||
delivery_province: Delivery province (for shipping calculation)
|
||||
delivery_city: Delivery city (for shipping calculation)
|
||||
|
||||
Returns:
|
||||
Detailed quote with unit price, discounts, tax, and shipping
|
||||
"""
|
||||
payload = {
|
||||
"product_id": product_id,
|
||||
"quantity": quantity,
|
||||
"account_id": account_id
|
||||
}
|
||||
|
||||
if delivery_province or delivery_city:
|
||||
payload["delivery_address"] = {}
|
||||
if delivery_province:
|
||||
payload["delivery_address"]["province"] = delivery_province
|
||||
if delivery_city:
|
||||
payload["delivery_address"]["city"] = delivery_city
|
||||
|
||||
try:
|
||||
result = await hyperf.post("/products/quote", json=payload)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"quote_id": result.get("quote_id"),
|
||||
"product_id": product_id,
|
||||
"quantity": quantity,
|
||||
"unit_price": result.get("unit_price"),
|
||||
"subtotal": result.get("subtotal"),
|
||||
"discount": result.get("discount", 0),
|
||||
"discount_reason": result.get("discount_reason"),
|
||||
"tax": result.get("tax"),
|
||||
"shipping_fee": result.get("shipping_fee"),
|
||||
"total_price": result.get("total_price"),
|
||||
"validity": result.get("validity"),
|
||||
"payment_terms": result.get("payment_terms"),
|
||||
"estimated_delivery": result.get("estimated_delivery")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def check_inventory(
|
||||
product_ids: List[str],
|
||||
warehouse: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Check product inventory/stock
|
||||
|
||||
Args:
|
||||
product_ids: List of product IDs to check
|
||||
warehouse: Specific warehouse to check (optional)
|
||||
|
||||
Returns:
|
||||
Inventory status for each product
|
||||
"""
|
||||
payload = {"product_ids": product_ids}
|
||||
if warehouse:
|
||||
payload["warehouse"] = warehouse
|
||||
|
||||
try:
|
||||
result = await hyperf.post("/products/inventory/check", json=payload)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"inventory": result.get("inventory", [])
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"inventory": []
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_categories() -> dict:
|
||||
"""Get product category tree
|
||||
|
||||
Returns:
|
||||
Hierarchical category structure
|
||||
"""
|
||||
try:
|
||||
result = await hyperf.get("/products/categories")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"categories": result.get("categories", [])
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"categories": []
|
||||
}
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@mcp.tool()
|
||||
async def health_check() -> dict:
|
||||
"""Check server health status"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "product_mcp",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
# Create FastAPI app from MCP
|
||||
app = mcp.http_app()
|
||||
|
||||
# Add health endpoint
|
||||
from starlette.responses import JSONResponse
|
||||
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']))
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8004)
|
||||
0
mcp_servers/product_mcp/tools/__init__.py
Normal file
0
mcp_servers/product_mcp/tools/__init__.py
Normal file
0
mcp_servers/shared/__init__.py
Normal file
0
mcp_servers/shared/__init__.py
Normal file
87
mcp_servers/shared/hyperf_client.py
Normal file
87
mcp_servers/shared/hyperf_client.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Hyperf API Client for MCP Servers
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
import httpx
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class HyperfSettings(BaseSettings):
|
||||
"""Hyperf configuration"""
|
||||
hyperf_api_url: str
|
||||
hyperf_api_token: str
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = HyperfSettings()
|
||||
|
||||
|
||||
class HyperfClient:
|
||||
"""Async client for Hyperf PHP API"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: Optional[str] = None,
|
||||
api_token: Optional[str] = None
|
||||
):
|
||||
self.api_url = (api_url or settings.hyperf_api_url).rstrip("/")
|
||||
self.api_token = api_token or settings.hyperf_api_token
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=f"{self.api_url}/api/v1",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
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
|
||||
) -> dict[str, Any]:
|
||||
"""Make API request and handle response"""
|
||||
client = await self._get_client()
|
||||
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=endpoint,
|
||||
params=params,
|
||||
json=json
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Check for API-level errors
|
||||
if data.get("code", 0) != 0:
|
||||
raise Exception(f"API Error [{data.get('code')}]: {data.get('message')}")
|
||||
|
||||
return data.get("data", data)
|
||||
|
||||
async def get(self, endpoint: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
return await self.request("GET", endpoint, params=params)
|
||||
|
||||
async def post(self, endpoint: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
return await self.request("POST", endpoint, json=json)
|
||||
|
||||
async def put(self, endpoint: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
return await self.request("PUT", endpoint, json=json)
|
||||
|
||||
async def delete(self, endpoint: str) -> dict[str, Any]:
|
||||
return await self.request("DELETE", endpoint)
|
||||
128
mcp_servers/shared/strapi_client.py
Normal file
128
mcp_servers/shared/strapi_client.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Strapi API Client for MCP Server
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
import httpx
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class StrapiSettings(BaseSettings):
|
||||
"""Strapi configuration"""
|
||||
strapi_api_url: str
|
||||
strapi_api_token: str
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = StrapiSettings()
|
||||
|
||||
|
||||
class StrapiClient:
|
||||
"""Async client for Strapi CMS API"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: Optional[str] = None,
|
||||
api_token: Optional[str] = None
|
||||
):
|
||||
self.api_url = (api_url or settings.strapi_api_url).rstrip("/")
|
||||
self.api_token = api_token or settings.strapi_api_token
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
# Only add Authorization header if token is provided
|
||||
if self.api_token and self.api_token.strip():
|
||||
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=f"{self.api_url}/api",
|
||||
headers=headers,
|
||||
timeout=30.0
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def get(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: Optional[dict[str, Any]] = None
|
||||
) -> dict[str, Any]:
|
||||
"""GET request to Strapi API"""
|
||||
client = await self._get_client()
|
||||
response = await client.get(endpoint, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def query_collection(
|
||||
self,
|
||||
collection: str,
|
||||
filters: Optional[dict[str, Any]] = None,
|
||||
sort: Optional[list[str]] = None,
|
||||
pagination: Optional[dict[str, int]] = None,
|
||||
locale: str = "zh-CN"
|
||||
) -> dict[str, Any]:
|
||||
"""Query a Strapi collection with filters
|
||||
|
||||
Args:
|
||||
collection: Collection name (e.g., 'faqs', 'company-infos')
|
||||
filters: Strapi filter object
|
||||
sort: Sort fields (e.g., ['priority:desc'])
|
||||
pagination: Pagination params {page, pageSize} or {limit}
|
||||
locale: Locale for i18n content
|
||||
"""
|
||||
params = {"locale": locale}
|
||||
|
||||
# Add filters
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
params[f"filters{key}"] = value
|
||||
|
||||
# Add sort
|
||||
if sort:
|
||||
for i, s in enumerate(sort):
|
||||
params[f"sort[{i}]"] = s
|
||||
|
||||
# Add pagination
|
||||
if pagination:
|
||||
for key, value in pagination.items():
|
||||
params[f"pagination[{key}]"] = value
|
||||
|
||||
return await self.get(f"/{collection}", params=params)
|
||||
|
||||
@staticmethod
|
||||
def flatten_response(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Flatten Strapi response structure
|
||||
|
||||
Converts Strapi's {data: [{id, attributes: {...}}]} format
|
||||
to simple [{id, ...attributes}] format.
|
||||
"""
|
||||
items = data.get("data", [])
|
||||
result = []
|
||||
|
||||
for item in items:
|
||||
flattened = {"id": item.get("id")}
|
||||
attributes = item.get("attributes", {})
|
||||
flattened.update(attributes)
|
||||
result.append(flattened)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def flatten_single(data: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Flatten a single Strapi item response"""
|
||||
item = data.get("data")
|
||||
if not item:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": item.get("id"),
|
||||
**item.get("attributes", {})
|
||||
}
|
||||
29
mcp_servers/strapi_mcp/Dockerfile
Normal file
29
mcp_servers/strapi_mcp/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Note: shared modules are mounted via docker-compose volumes
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8001/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "server.py"]
|
||||
0
mcp_servers/strapi_mcp/__init__.py
Normal file
0
mcp_servers/strapi_mcp/__init__.py
Normal file
404
mcp_servers/strapi_mcp/http_routes.py
Normal file
404
mcp_servers/strapi_mcp/http_routes.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
HTTP Routes for Strapi MCP Server
|
||||
Provides direct HTTP access to knowledge base functions
|
||||
"""
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Server configuration"""
|
||||
strapi_api_url: str
|
||||
strapi_api_token: str = ""
|
||||
log_level: str = "INFO"
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
# ============ FAQ Categories ============
|
||||
|
||||
FAQ_CATEGORIES = {
|
||||
"register": "faq-register",
|
||||
"order": "faq-order",
|
||||
"pre-order": "faq-pre-order",
|
||||
"payment": "faq-payment",
|
||||
"shipment": "faq-shipment",
|
||||
"return": "faq-return",
|
||||
"other": "faq-other-question",
|
||||
}
|
||||
|
||||
|
||||
# ============ Company Info ============
|
||||
|
||||
async def get_company_info_http(section: str = "contact", locale: str = "en"):
|
||||
"""Get company information - HTTP wrapper
|
||||
|
||||
Args:
|
||||
section: Section identifier (e.g., "contact")
|
||||
locale: Language locale (default: en)
|
||||
Supported: en, nl, de, es, fr, it, tr
|
||||
"""
|
||||
try:
|
||||
# Map section names to API endpoints
|
||||
section_map = {
|
||||
"contact": "info-contact",
|
||||
"about": "info-about",
|
||||
"service": "info-service",
|
||||
}
|
||||
|
||||
endpoint = section_map.get(section, f"info-{section}")
|
||||
|
||||
# Build query parameters
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if settings.strapi_api_token and settings.strapi_api_token.strip():
|
||||
headers["Authorization"] = f"Bearer {settings.strapi_api_token}"
|
||||
|
||||
# Request with populate=deep to get all related data
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.strapi_api_url}/api/{endpoint}",
|
||||
params={"populate": "deep", "locale": locale},
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data.get("data"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Section '{section}' not found",
|
||||
"data": None
|
||||
}
|
||||
|
||||
item = data["data"]
|
||||
|
||||
# Extract relevant information
|
||||
result_data = {
|
||||
"id": item.get("id"),
|
||||
"title": item.get("title"),
|
||||
"description": item.get("description"),
|
||||
"section": section,
|
||||
"locale": locale
|
||||
}
|
||||
|
||||
# Add profile information if available
|
||||
if item.get("yehwang_profile"):
|
||||
profile = item["yehwang_profile"]
|
||||
result_data["profile"] = {
|
||||
"title": profile.get("title"),
|
||||
"content": profile.get("content")
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"data": None
|
||||
}
|
||||
|
||||
|
||||
# ============ FAQ Query ============
|
||||
|
||||
async def query_faq_http(
|
||||
category: str = "other",
|
||||
locale: str = "en",
|
||||
limit: int = 10
|
||||
):
|
||||
"""Get FAQ by category - HTTP wrapper
|
||||
|
||||
Args:
|
||||
category: FAQ category (register, order, pre-order, payment, shipment, return, other)
|
||||
locale: Language locale (default: en)
|
||||
limit: Maximum results to return
|
||||
"""
|
||||
try:
|
||||
# Map category to endpoint
|
||||
endpoint = FAQ_CATEGORIES.get(category, f"faq-{category}")
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if settings.strapi_api_token and settings.strapi_api_token.strip():
|
||||
headers["Authorization"] = f"Bearer {settings.strapi_api_token}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.strapi_api_url}/api/{endpoint}",
|
||||
params={"populate": "deep", "locale": locale},
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Check if data exists
|
||||
if not data.get("data"):
|
||||
return {
|
||||
"success": True,
|
||||
"count": 0,
|
||||
"category": category,
|
||||
"locale": locale,
|
||||
"results": []
|
||||
}
|
||||
|
||||
# Handle different response formats
|
||||
item_data = data["data"]
|
||||
|
||||
# If it's a single object with nested items
|
||||
faq_list = []
|
||||
if isinstance(item_data, dict):
|
||||
# Check for content array (Yehwang format)
|
||||
if item_data.get("content"):
|
||||
faq_list = item_data["content"]
|
||||
# Check for nested FAQ array
|
||||
elif item_data.get("faqs"):
|
||||
faq_list = item_data["faqs"]
|
||||
# Check for questions array
|
||||
elif item_data.get("questions"):
|
||||
faq_list = item_data["questions"]
|
||||
# The item itself might be the FAQ
|
||||
elif item_data.get("question") and item_data.get("answer"):
|
||||
faq_list = [item_data]
|
||||
else:
|
||||
# Return the whole data as one FAQ
|
||||
faq_list = [item_data]
|
||||
elif isinstance(item_data, list):
|
||||
faq_list = item_data
|
||||
|
||||
# Format results
|
||||
results = []
|
||||
for item in faq_list[:limit]:
|
||||
faq_item = {
|
||||
"id": item.get("id"),
|
||||
"category": category,
|
||||
"locale": locale
|
||||
}
|
||||
|
||||
# Yehwang format: title and content
|
||||
if item.get("title"):
|
||||
faq_item["question"] = item.get("title")
|
||||
if item.get("content"):
|
||||
faq_item["answer"] = item.get("content")
|
||||
|
||||
# Also support other field names
|
||||
if item.get("question"):
|
||||
faq_item["question"] = item.get("question")
|
||||
if item.get("answer"):
|
||||
faq_item["answer"] = item.get("answer")
|
||||
if item.get("description"):
|
||||
faq_item["description"] = item.get("description")
|
||||
|
||||
# Add any other fields
|
||||
for key, value in item.items():
|
||||
if key not in faq_item and key not in ["id", "createdAt", "updatedAt", "publishedAt", "locale"]:
|
||||
faq_item[key] = value
|
||||
|
||||
if "question" in faq_item or "answer" in faq_item:
|
||||
results.append(faq_item)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(results),
|
||||
"category": category,
|
||||
"locale": locale,
|
||||
"results": results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"category": category,
|
||||
"results": []
|
||||
}
|
||||
|
||||
|
||||
async def search_faq_http(
|
||||
query: str,
|
||||
locale: str = "en",
|
||||
limit: int = 5
|
||||
):
|
||||
"""Search FAQ across all categories - HTTP wrapper
|
||||
|
||||
Args:
|
||||
query: Search keywords
|
||||
locale: Language locale (default: en)
|
||||
limit: Maximum results per category
|
||||
"""
|
||||
try:
|
||||
all_results = []
|
||||
|
||||
# Search all categories in parallel
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
tasks = []
|
||||
for category_name, endpoint in FAQ_CATEGORIES.items():
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if settings.strapi_api_token and settings.strapi_api_token.strip():
|
||||
headers["Authorization"] = f"Bearer {settings.strapi_api_token}"
|
||||
|
||||
task = client.get(
|
||||
f"{settings.strapi_api_url}/api/{endpoint}",
|
||||
params={"populate": "deep", "locale": locale},
|
||||
headers=headers
|
||||
)
|
||||
tasks.append((category_name, task))
|
||||
|
||||
# Execute all requests
|
||||
for category_name, task in tasks:
|
||||
try:
|
||||
response = await task
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
if data.get("data"):
|
||||
item_data = data["data"]
|
||||
|
||||
# Extract FAQ list
|
||||
faq_list = []
|
||||
if isinstance(item_data, dict):
|
||||
# Yehwang format: content array
|
||||
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 item_data.get("question") and item_data.get("answer"):
|
||||
faq_list = [item_data]
|
||||
elif isinstance(item_data, list):
|
||||
faq_list = item_data
|
||||
|
||||
# Filter by query and add to results
|
||||
for item in faq_list:
|
||||
# Search in title/content (Yehwang format) or question/answer
|
||||
item_text = (
|
||||
str(item.get("title", "")) +
|
||||
str(item.get("content", "")) +
|
||||
str(item.get("question", "")) +
|
||||
str(item.get("answer", "")) +
|
||||
str(item.get("description", ""))
|
||||
)
|
||||
|
||||
if query.lower() in item_text.lower():
|
||||
result_item = {
|
||||
"id": item.get("id"),
|
||||
"category": category_name,
|
||||
"locale": locale
|
||||
}
|
||||
|
||||
# Use title as question (Yehwang format)
|
||||
if item.get("title"):
|
||||
result_item["question"] = item.get("title")
|
||||
if item.get("content"):
|
||||
result_item["answer"] = item.get("content")
|
||||
|
||||
# Also support other field names
|
||||
if item.get("question"):
|
||||
result_item["question"] = item.get("question")
|
||||
if item.get("answer"):
|
||||
result_item["answer"] = item.get("answer")
|
||||
if item.get("description"):
|
||||
result_item["description"] = item.get("description")
|
||||
|
||||
all_results.append(result_item)
|
||||
|
||||
except Exception as e:
|
||||
# Continue with next category on error
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(all_results),
|
||||
"query": query,
|
||||
"locale": locale,
|
||||
"results": all_results[:limit]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"results": []
|
||||
}
|
||||
|
||||
|
||||
async def search_knowledge_base_http(query: str, locale: str = "en", limit: int = 10):
|
||||
"""Search knowledge base - HTTP wrapper
|
||||
|
||||
Args:
|
||||
query: Search keywords
|
||||
locale: Language locale
|
||||
limit: Maximum results
|
||||
"""
|
||||
# Search FAQ across all categories
|
||||
return await search_faq_http(query, locale, limit)
|
||||
|
||||
|
||||
async def get_policy_http(policy_type: str, locale: str = "en"):
|
||||
"""Get policy document - HTTP wrapper
|
||||
|
||||
Args:
|
||||
policy_type: Type of policy (return_policy, privacy_policy, etc.)
|
||||
locale: Language locale
|
||||
"""
|
||||
try:
|
||||
# Map policy types to endpoints
|
||||
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 settings.strapi_api_token and settings.strapi_api_token.strip():
|
||||
headers["Authorization"] = f"Bearer {settings.strapi_api_token}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.strapi_api_url}/api/{endpoint}",
|
||||
params={"populate": "deep", "locale": locale},
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data.get("data"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Policy '{policy_type}' not found",
|
||||
"data": None
|
||||
}
|
||||
|
||||
item = data["data"]
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"id": item.get("id"),
|
||||
"type": policy_type,
|
||||
"title": item.get("title"),
|
||||
"content": item.get("content"),
|
||||
"summary": item.get("summary"),
|
||||
"locale": locale
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"data": None
|
||||
}
|
||||
19
mcp_servers/strapi_mcp/requirements.txt
Normal file
19
mcp_servers/strapi_mcp/requirements.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
# FastMCP Framework
|
||||
fastmcp>=0.1.0
|
||||
|
||||
# HTTP Server
|
||||
fastapi>=0.100.0
|
||||
uvicorn>=0.23.0
|
||||
|
||||
# HTTP Client
|
||||
httpx>=0.26.0
|
||||
|
||||
# Data Validation
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Environment & Config
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Logging
|
||||
structlog>=24.1.0
|
||||
269
mcp_servers/strapi_mcp/server.py
Normal file
269
mcp_servers/strapi_mcp/server.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Strapi MCP Server - FAQ and Knowledge Base
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
# Add shared module to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic_settings import BaseSettings
|
||||
from fastapi import Request
|
||||
from starlette.responses import JSONResponse
|
||||
import uvicorn
|
||||
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Server configuration"""
|
||||
strapi_api_url: str
|
||||
strapi_api_token: str
|
||||
log_level: str = "INFO"
|
||||
|
||||
model_config = ConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Create MCP server
|
||||
mcp = FastMCP(
|
||||
"Strapi Knowledge Base"
|
||||
)
|
||||
|
||||
|
||||
# Strapi client for this server
|
||||
from shared.strapi_client import StrapiClient
|
||||
strapi = StrapiClient(settings.strapi_api_url, settings.strapi_api_token)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def query_faq(
|
||||
category: str = "other",
|
||||
locale: str = "en",
|
||||
limit: int = 10
|
||||
) -> dict:
|
||||
"""Get FAQ by category
|
||||
|
||||
Args:
|
||||
category: FAQ category
|
||||
Available: register, order, pre-order, payment, shipment, return, other
|
||||
locale: Language locale (default: en)
|
||||
Available: en, nl, de, es, fr, it, tr
|
||||
limit: Maximum results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
List of FAQ items with questions and answers for the specified category
|
||||
"""
|
||||
from http_routes import query_faq_http
|
||||
return await query_faq_http(category, locale, limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_company_info(
|
||||
section: str,
|
||||
locale: str = "en"
|
||||
) -> dict:
|
||||
"""Get company information by section
|
||||
|
||||
Args:
|
||||
section: Information section (about_us, contact, service_hours, locations, etc.)
|
||||
locale: Language locale (default: zh-CN)
|
||||
|
||||
Returns:
|
||||
Company information for the requested section
|
||||
"""
|
||||
try:
|
||||
response = await strapi.query_collection(
|
||||
"company-infos",
|
||||
filters={"[section][$eq]": section},
|
||||
locale=locale
|
||||
)
|
||||
|
||||
results = strapi.flatten_response(response)
|
||||
|
||||
if not results:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Section '{section}' not found",
|
||||
"data": None
|
||||
}
|
||||
|
||||
item = results[0]
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"section": item.get("section"),
|
||||
"title": item.get("title"),
|
||||
"content": item.get("content"),
|
||||
"metadata": item.get("metadata", {})
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"data": None
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_policy(
|
||||
policy_type: str,
|
||||
locale: str = "en"
|
||||
) -> dict:
|
||||
"""Get policy document
|
||||
|
||||
Args:
|
||||
policy_type: Type of policy (return_policy, privacy_policy, terms_of_service,
|
||||
shipping_policy, payment_policy, etc.)
|
||||
locale: Language locale (default: zh-CN)
|
||||
|
||||
Returns:
|
||||
Policy document with content and metadata
|
||||
"""
|
||||
try:
|
||||
response = await strapi.query_collection(
|
||||
"policies",
|
||||
filters={"[type][$eq]": policy_type},
|
||||
locale=locale
|
||||
)
|
||||
|
||||
results = strapi.flatten_response(response)
|
||||
|
||||
if not results:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Policy '{policy_type}' not found",
|
||||
"data": None
|
||||
}
|
||||
|
||||
item = results[0]
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"type": item.get("type"),
|
||||
"title": item.get("title"),
|
||||
"content": item.get("content"),
|
||||
"summary": item.get("summary"),
|
||||
"version": item.get("version"),
|
||||
"effective_date": item.get("effective_date"),
|
||||
"last_updated": item.get("updatedAt")
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"data": None
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_knowledge_base(
|
||||
query: str,
|
||||
locale: str = "en",
|
||||
limit: int = 10
|
||||
) -> dict:
|
||||
"""Search knowledge base documents across all FAQ categories
|
||||
|
||||
Args:
|
||||
query: Search keywords
|
||||
locale: Language locale (default: en)
|
||||
Available: en, nl, de, es, fr, it, tr
|
||||
limit: Maximum results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
List of matching FAQ documents from all categories
|
||||
"""
|
||||
from http_routes import search_knowledge_base_http
|
||||
return await search_knowledge_base_http(query, locale, limit)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@mcp.tool()
|
||||
async def health_check() -> dict:
|
||||
"""Check server health status"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "strapi_mcp",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
# Create FastAPI app from MCP
|
||||
mcp_app = mcp.http_app()
|
||||
|
||||
# Add health endpoint
|
||||
async def health_check(request):
|
||||
return JSONResponse({"status": "healthy"})
|
||||
|
||||
# Import HTTP routes
|
||||
from http_routes import (
|
||||
get_company_info_http,
|
||||
query_faq_http,
|
||||
get_policy_http,
|
||||
search_knowledge_base_http
|
||||
)
|
||||
|
||||
# Direct function references for HTTP endpoints
|
||||
async def call_query_faq(request):
|
||||
"""HTTP endpoint for query_faq"""
|
||||
try:
|
||||
data = await request.json()
|
||||
result = await query_faq_http(**data)
|
||||
return JSONResponse(result)
|
||||
except Exception as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
||||
|
||||
async def call_get_company_info(request):
|
||||
"""HTTP endpoint for get_company_info"""
|
||||
try:
|
||||
data = await request.json()
|
||||
result = await get_company_info_http(**data)
|
||||
return JSONResponse(result)
|
||||
except Exception as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
||||
|
||||
async def call_get_policy(request):
|
||||
"""HTTP endpoint for get_policy"""
|
||||
try:
|
||||
data = await request.json()
|
||||
result = await get_policy_http(**data)
|
||||
return JSONResponse(result)
|
||||
except Exception as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
||||
|
||||
async def call_search_knowledge_base(request):
|
||||
"""HTTP endpoint for search_knowledge_base"""
|
||||
try:
|
||||
data = await request.json()
|
||||
result = await search_knowledge_base_http(**data)
|
||||
return JSONResponse(result)
|
||||
except Exception as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
||||
|
||||
# Add routes using the correct method
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Create a wrapper FastAPI app with custom routes first
|
||||
app = FastAPI()
|
||||
|
||||
# Add custom routes BEFORE mounting mcp_app
|
||||
app.add_route("/health", health_check, methods=["GET"])
|
||||
app.add_route("/tools/query_faq", call_query_faq, methods=["POST"])
|
||||
app.add_route("/tools/get_company_info", call_get_company_info, methods=["POST"])
|
||||
app.add_route("/tools/get_policy", call_get_policy, methods=["POST"])
|
||||
app.add_route("/tools/search_knowledge_base", call_search_knowledge_base, methods=["POST"])
|
||||
|
||||
# Mount MCP app at root (will catch all other routes)
|
||||
app.mount("/", mcp_app)
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
0
mcp_servers/strapi_mcp/tools/__init__.py
Normal file
0
mcp_servers/strapi_mcp/tools/__init__.py
Normal file
159
plans/mcp-container-restart-diagnosis.md
Normal file
159
plans/mcp-container-restart-diagnosis.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# MCP 容器重启问题诊断报告
|
||||
|
||||
## 问题概述
|
||||
|
||||
MCP 服务容器(strapi_mcp, order_mcp, aftersale_mcp, product_mcp)一直在重启。
|
||||
|
||||
## 容器架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "MCP Servers"
|
||||
A[strapi_mcp:8001]
|
||||
B[order_mcp:8002]
|
||||
C[aftersale_mcp:8003]
|
||||
D[product_mcp:8004]
|
||||
end
|
||||
|
||||
subgraph "外部依赖"
|
||||
E[Strapi CMS<br/>49.234.16.160:1337]
|
||||
F[Hyperf API<br/>apicn.qa1.gaia888.com]
|
||||
end
|
||||
|
||||
subgraph "内部依赖"
|
||||
G[agent:8005]
|
||||
end
|
||||
|
||||
A --> E
|
||||
B --> F
|
||||
C --> F
|
||||
D --> F
|
||||
G --> A
|
||||
G --> B
|
||||
G --> C
|
||||
G --> D
|
||||
```
|
||||
|
||||
## 诊断结果
|
||||
|
||||
### 1. 环境变量配置问题(主要原因)
|
||||
|
||||
在 [`.env`](../.env) 文件中发现以下问题:
|
||||
|
||||
| 环境变量 | 当前值 | 问题 |
|
||||
|---------|--------|------|
|
||||
| `STRAPI_API_TOKEN` | `""` (空) | strapi_mcp 无法认证 |
|
||||
| `HYPERF_API_TOKEN` | `""` (空) | order_mcp, aftersale_mcp, product_mcp 无法认证 |
|
||||
| `REDIS_PASSWORD` | `""` (空) | Redis 健康检查可能失败 |
|
||||
|
||||
### 2. 客户端初始化问题
|
||||
|
||||
在 [`mcp_servers/shared/strapi_client.py`](../mcp_servers/shared/strapi_client.py) 和 [`mcp_servers/shared/hyperf_client.py`](../mcp_servers/shared/hyperf_client.py) 中:
|
||||
|
||||
```python
|
||||
class StrapiSettings(BaseSettings):
|
||||
strapi_api_url: str
|
||||
strapi_api_token: str # 必需字段,但值为空字符串
|
||||
|
||||
settings = StrapiSettings() # 模块加载时执行
|
||||
```
|
||||
|
||||
当 `api_token` 为空时:
|
||||
- 不会立即抛出异常(空字符串是有效的 str 值)
|
||||
- 但所有 API 请求都会因认证失败而报错
|
||||
- 可能导致服务启动失败或运行时崩溃
|
||||
|
||||
### 3. 健康检查配置
|
||||
|
||||
在 [`docker-compose.yml`](../docker-compose.yml) 中,所有 MCP 服务都配置了健康检查:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start-period: 5s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
如果服务启动失败或 `/health` 端点不可用,健康检查会失败,Docker 可能会重启容器。
|
||||
|
||||
### 4. 服务启动流程
|
||||
|
||||
MCP 服务的启动流程:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant DC as Docker
|
||||
participant SVR as Server.py
|
||||
participant ENV as Settings
|
||||
participant CLI as HTTP Client
|
||||
participant API as External API
|
||||
|
||||
DC->>SVR: python server.py
|
||||
SVR->>ENV: 加载环境变量
|
||||
ENV-->>SVR: api_token = ""
|
||||
SVR->>CLI: 初始化客户端
|
||||
CLI->>API: 首次请求(如果需要)
|
||||
API-->>CLI: 401 Unauthorized
|
||||
CLI-->>SVR: 抛出异常
|
||||
SVR-->>DC: 启动失败
|
||||
DC->>DC: 重启容器
|
||||
```
|
||||
|
||||
## 可能的错误信息
|
||||
|
||||
基于代码分析,容器日志中可能出现以下错误:
|
||||
|
||||
1. **认证失败**:
|
||||
```
|
||||
httpx.HTTPStatusError: 401 Unauthorized
|
||||
```
|
||||
|
||||
2. **连接错误**:
|
||||
```
|
||||
httpx.ConnectError: Connection refused
|
||||
```
|
||||
|
||||
3. **超时错误**:
|
||||
```
|
||||
httpx.TimeoutException
|
||||
```
|
||||
|
||||
4. **健康检查失败**:
|
||||
```
|
||||
curl: (7) Failed to connect to localhost port 8001
|
||||
```
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 服务 | 影响原因 |
|
||||
|-----|---------|
|
||||
| `strapi_mcp` | `STRAPI_API_TOKEN` 为空 |
|
||||
| `order_mcp` | `HYPERF_API_TOKEN` 为空 |
|
||||
| `aftersale_mcp` | `HYPERF_API_TOKEN` 为空 |
|
||||
| `product_mcp` | `HYPERF_API_TOKEN` 为空 |
|
||||
| `agent` | 依赖所有 MCP 服务,可能间接受影响 |
|
||||
|
||||
## 修复建议
|
||||
|
||||
### 方案 1: 配置正确的 API Token(推荐)
|
||||
|
||||
1. 获取 Strapi API Token
|
||||
2. 获取 Hyperf API Token
|
||||
3. 更新 `.env` 文件
|
||||
|
||||
### 方案 2: 使服务在 Token 为空时也能启动(临时方案)
|
||||
|
||||
修改客户端代码,延迟初始化或添加容错逻辑。
|
||||
|
||||
### 方案 3: 禁用需要认证的服务
|
||||
|
||||
如果某些服务暂时不需要,可以将其从 docker-compose 中移除。
|
||||
|
||||
## 下一步行动
|
||||
|
||||
请确认:
|
||||
1. 是否有可用的 Strapi 和 Hyperf API Token?
|
||||
2. 是否需要修改代码以支持空 Token 的启动模式?
|
||||
3. 是否需要查看具体的容器日志以确认错误?
|
||||
1
postgres-with-pgvector.Dockerfile
Normal file
1
postgres-with-pgvector.Dockerfile
Normal file
@@ -0,0 +1 @@
|
||||
FROM pgvector/pgvector:pg15
|
||||
2
scripts/init-pgvector.sql
Normal file
2
scripts/init-pgvector.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Initialize pgvector extension
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
Reference in New Issue
Block a user