feat: 添加图片搜索功能和 Qwen 模型支持
图片搜索功能(以图搜图): - Chatwoot webhook 检测图片搜索消息 (content_type="search_image") - 从 content_attributes.url 提取图片 URL - 调用 Mall API 图片搜索接口 (/mall/api/spu?searchImageUrl=...) - 支持嵌套和顶层 URL 位置提取 - Product Agent 添加 fast path 直接调用图片搜索工具 - 防止无限循环(使用后清除 context.image_search_url) Qwen 模型支持: - 添加 LLM provider 选择(zhipu/qwen) - 实现 QwenLLMClient 类(基于 DashScope SDK) - 添加 dashscope>=1.14.0 依赖 - 修复 API key 设置(直接设置 dashscope.api_key) - 更新 .env.example 和 docker-compose.yml 配置 其他优化: - 重构 Chatwoot 集成代码(删除冗余) - 优化 Product Agent prompt - 增强 Customer Service Agent 多语言支持 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,50 +82,118 @@ async def get_product_detail(
|
||||
@register_tool("recommend_products")
|
||||
@mcp.tool()
|
||||
async def recommend_products(
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
context: Optional[dict] = None,
|
||||
strategy: str = "hybrid",
|
||||
limit: int = 10
|
||||
user_token: str,
|
||||
page: int = 1,
|
||||
page_size: int = 6,
|
||||
warehouse_id: int = 2
|
||||
) -> dict:
|
||||
"""Get personalized product recommendations
|
||||
|
||||
"""Get recommended products from Mall API Love List
|
||||
|
||||
从 Mall API 获取推荐商品列表(心动清单/猜你喜欢)
|
||||
|
||||
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)
|
||||
|
||||
user_token: User JWT token for authentication
|
||||
page: Page number (default: 1)
|
||||
page_size: Number of products per page (default: 6, max 100)
|
||||
warehouse_id: Warehouse ID (default: 2)
|
||||
|
||||
Returns:
|
||||
List of recommended products with reasons
|
||||
List of recommended products with product details
|
||||
"""
|
||||
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)
|
||||
|
||||
from shared.mall_client import MallClient
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"recommend_products called: page={page}, page_size={page_size}, warehouse_id={warehouse_id}")
|
||||
|
||||
# 创建 Mall 客户端(使用 user_token)
|
||||
mall = MallClient(
|
||||
api_url=settings.mall_api_url,
|
||||
api_token=user_token, # 使用用户的 token
|
||||
tenant_id=settings.mall_tenant_id,
|
||||
currency_code=settings.mall_currency_code,
|
||||
language_id=settings.mall_language_id,
|
||||
source=settings.mall_source
|
||||
)
|
||||
|
||||
result = await mall.get_love_list(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
warehouse_id=warehouse_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Mall API love list returned: result_type={type(result).__name__}, "
|
||||
f"result_keys={list(result.keys()) if isinstance(result, dict) else 'not a dict'}, "
|
||||
f"total={result.get('total', 'N/A') if isinstance(result, dict) else 'N/A'}"
|
||||
)
|
||||
|
||||
# 解析返回结果
|
||||
# Mall API 实际返回结构: {"total": X, "data": {"data": [...]}}
|
||||
if isinstance(result, dict) and "data" in result:
|
||||
data = result.get("data", {})
|
||||
if isinstance(data, dict) and "data" in data:
|
||||
products_list = data.get("data", [])
|
||||
else:
|
||||
products_list = []
|
||||
total = result.get("total", 0)
|
||||
elif isinstance(result, dict) and "list" in result:
|
||||
# 兼容可能的 list 结构
|
||||
products_list = result.get("list", [])
|
||||
total = result.get("total", 0)
|
||||
else:
|
||||
products_list = []
|
||||
total = 0
|
||||
|
||||
logger.info(f"Extracted {len(products_list)} products from love list, total={total}")
|
||||
|
||||
# 格式化商品数据(与 search_products 格式一致)
|
||||
formatted_products = []
|
||||
for product in products_list:
|
||||
formatted_products.append({
|
||||
"spu_id": product.get("spuId"),
|
||||
"spu_sn": product.get("spuSn"),
|
||||
"product_name": product.get("spuName"),
|
||||
"product_image": product.get("masterImage"),
|
||||
"price": product.get("price"),
|
||||
"special_price": product.get("specialPrice"),
|
||||
"stock": product.get("stockDescribe"),
|
||||
"sales_count": product.get("salesCount", 0),
|
||||
# 额外有用字段
|
||||
"href": product.get("href"),
|
||||
"spu_type": product.get("spuType"),
|
||||
"spu_type_name": product.get("spuTypeName"),
|
||||
"min_price": product.get("minPrice"),
|
||||
"max_price": product.get("maxPrice"),
|
||||
"price_with_currency": product.get("priceWithCurrency"),
|
||||
"mark_price": product.get("markPrice"),
|
||||
"skus_count": len(product.get("skus", []))
|
||||
})
|
||||
|
||||
logger.info(f"Formatted {len(formatted_products)} products for recommendation")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"recommendations": result.get("recommendations", [])
|
||||
"products": formatted_products,
|
||||
"total": total,
|
||||
"keyword": "recommendation"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"recommend_products failed: {str(e)}", exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"recommendations": []
|
||||
"products": [],
|
||||
"total": 0
|
||||
}
|
||||
finally:
|
||||
# 关闭客户端
|
||||
if 'mall' in dir():
|
||||
await mall.close()
|
||||
|
||||
|
||||
@register_tool("get_quote")
|
||||
@@ -249,7 +317,7 @@ async def get_categories() -> dict:
|
||||
@mcp.tool()
|
||||
async def search_products(
|
||||
keyword: str,
|
||||
page_size: int = 5,
|
||||
page_size: int = 6,
|
||||
page: int = 1
|
||||
) -> dict:
|
||||
"""Search products from Mall API
|
||||
@@ -258,7 +326,7 @@ async def search_products(
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键词(商品名称、编号等)
|
||||
page_size: 每页数量 (default: 5, max 100)
|
||||
page_size: 每页数量 (default: 6, max 100)
|
||||
page: 页码 (default: 1)
|
||||
|
||||
Returns:
|
||||
@@ -299,6 +367,18 @@ async def search_products(
|
||||
)
|
||||
print(f"[DEBUG] Mall API returned: total={result.get('total', 'N/A')}, data_keys={list(result.get('data', {}).keys()) if isinstance(result.get('data'), dict) else 'N/A'}")
|
||||
|
||||
# 详细输出商品数据
|
||||
if "data" in result and isinstance(result["data"], dict):
|
||||
products = result["data"].get("data", [])
|
||||
print(f"[DEBUG] Found {len(products)} products in response")
|
||||
for i, p in enumerate(products[:3]): # 只打印前3个
|
||||
print(f"[DEBUG] Product {i+1}: spuId={p.get('spuId')}, spuName={p.get('spuName')}, price={p.get('price')}")
|
||||
else:
|
||||
products = result.get("list", [])
|
||||
print(f"[DEBUG] Found {len(products)} products in list")
|
||||
total = result.get("total", 0)
|
||||
print(f"[DEBUG] Total products: {total}")
|
||||
|
||||
# 解析返回结果
|
||||
# Mall API 返回结构: {"total": X, "data": {"data": [...], ...}}
|
||||
if "data" in result and isinstance(result["data"], dict):
|
||||
@@ -349,6 +429,116 @@ async def search_products(
|
||||
await mall.close()
|
||||
|
||||
|
||||
@register_tool("search_products_by_image")
|
||||
@mcp.tool()
|
||||
async def search_products_by_image(
|
||||
image_url: str,
|
||||
page_size: int = 6,
|
||||
page: int = 1
|
||||
) -> dict:
|
||||
"""Search products by image from Mall API
|
||||
|
||||
从 Mall API 根据图片搜索商品 SPU(以图搜图)
|
||||
|
||||
Args:
|
||||
image_url: 图片 URL(需要 URL 编码)
|
||||
page_size: 每页数量 (default: 6, max 100)
|
||||
page: 页码 (default: 1)
|
||||
|
||||
Returns:
|
||||
商品列表,包含 SPU 信息、商品图片、价格等
|
||||
Product list including SPU ID, name, image, price, etc.
|
||||
"""
|
||||
try:
|
||||
from shared.mall_client import MallClient
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"search_products_by_image called: image_url={image_url}")
|
||||
|
||||
# 创建 Mall 客户端(不需要 token)
|
||||
mall = MallClient(
|
||||
api_url=settings.mall_api_url,
|
||||
api_token=None, # 图片搜索不需要 token
|
||||
tenant_id=settings.mall_tenant_id,
|
||||
currency_code=settings.mall_currency_code,
|
||||
language_id=settings.mall_language_id,
|
||||
source=settings.mall_source
|
||||
)
|
||||
|
||||
# 调用 Mall API 图片搜索接口
|
||||
# 注意:httpx 会自动对 params 进行 URL 编码,不需要手动编码
|
||||
result = await mall.get(
|
||||
"/mall/api/spu",
|
||||
params={
|
||||
"pageSize": min(page_size, 100),
|
||||
"page": page,
|
||||
"searchImageUrl": image_url
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Mall API image search returned: result_type={type(result).__name__}, "
|
||||
f"result_keys={list(result.keys()) if isinstance(result, dict) else 'not a dict'}, "
|
||||
f"total={result.get('total', 'N/A') if isinstance(result, dict) else 'N/A'}"
|
||||
)
|
||||
|
||||
# 解析返回结果
|
||||
# Mall API 返回结构: {"total": X, "data": {"data": [...]}}
|
||||
if "data" in result and isinstance(result["data"], dict):
|
||||
products = result["data"].get("data", [])
|
||||
else:
|
||||
products = result.get("list", [])
|
||||
total = result.get("total", 0)
|
||||
|
||||
# 格式化商品数据(与 search_products 格式一致)
|
||||
formatted_products = []
|
||||
for product in products:
|
||||
formatted_products.append({
|
||||
"spu_id": product.get("spuId"),
|
||||
"spu_sn": product.get("spuSn"),
|
||||
"product_name": product.get("spuName"),
|
||||
"product_image": product.get("masterImage"),
|
||||
"price": product.get("price"),
|
||||
"special_price": product.get("specialPrice"),
|
||||
"stock": product.get("stockDescribe"),
|
||||
"sales_count": product.get("salesCount", 0),
|
||||
# 额外有用字段
|
||||
"href": product.get("href"),
|
||||
"spu_type": product.get("spuType"),
|
||||
"spu_type_name": product.get("spuTypeName"),
|
||||
"min_price": product.get("minPrice"),
|
||||
"max_price": product.get("maxPrice"),
|
||||
"price_with_currency": product.get("priceWithCurrency"),
|
||||
"mark_price": product.get("markPrice"),
|
||||
"skus_count": len(product.get("skus", []))
|
||||
})
|
||||
|
||||
logger.info(f"Formatted {len(formatted_products)} products from image search")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"products": formatted_products,
|
||||
"total": total,
|
||||
"keyword": "image_search",
|
||||
"image_url": image_url
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"search_products_by_image failed: {str(e)}", exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"products": [],
|
||||
"total": 0
|
||||
}
|
||||
finally:
|
||||
# 关闭客户端
|
||||
if 'mall' in dir():
|
||||
await mall.close()
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@register_tool("health_check")
|
||||
@mcp.tool()
|
||||
|
||||
@@ -316,6 +316,44 @@ class MallClient:
|
||||
except Exception as e:
|
||||
raise Exception(f"搜索商品失败 (Search SPU products failed): {str(e)}")
|
||||
|
||||
async def get_love_list(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 6,
|
||||
warehouse_id: int = 2
|
||||
) -> dict[str, Any]:
|
||||
"""Get recommended products (Love List) from Mall API
|
||||
|
||||
获取推荐商品列表
|
||||
|
||||
Args:
|
||||
page: Page number (default: 1)
|
||||
page_size: Number of products per page (default: 6)
|
||||
warehouse_id: Warehouse ID (default: 2)
|
||||
|
||||
Returns:
|
||||
Dictionary containing product list and metadata
|
||||
|
||||
Example:
|
||||
>>> client = MallClient()
|
||||
>>> result = await client.get_love_list(page=1, page_size=6, warehouse_id=2)
|
||||
>>> print(f"找到 {len(result.get('list', []))} 个推荐商品")
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"warehouseId": warehouse_id
|
||||
}
|
||||
|
||||
result = await self.get(
|
||||
"/mall/api/loveList",
|
||||
params=params
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"获取推荐商品失败 (Get love list failed): {str(e)}")
|
||||
|
||||
|
||||
# Global Mall client instance
|
||||
mall_client: Optional[MallClient] = None
|
||||
|
||||
Reference in New Issue
Block a user