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:
wl
2026-01-14 19:25:22 +08:00
commit 3ad6eee0d9
59 changed files with 8078 additions and 0 deletions

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

View File

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

View 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

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

View File