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

View 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

View 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