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:
wangliang
2026-01-27 19:10:06 +08:00
parent 754804219f
commit 965b11316e
12 changed files with 937 additions and 199 deletions

View File

@@ -30,9 +30,11 @@ PRODUCT_AGENT_PROMPT = """你是一个专业的 B2B 商品顾问助手。
2. **get_product_detail** - 获取商品详情
- product_id: 商品ID
3. **recommend_products** - 智能推荐
- context: 推荐上下文(可包含当前查询、浏览历史等
- limit: 推荐数量
3. **recommend_products** - 智能推荐(心动清单/猜你喜欢)
- page_size: 推荐数量(默认 6最大 100
- page: 页码(默认 1
- warehouse_id: 仓库ID默认 2
- 说明:此工具使用 Mall API /mall/api/loveList 接口,需要用户 token 认证,系统会自动注入用户 token
4. **get_quote** - B2B 询价
- product_id: 商品ID
@@ -81,6 +83,18 @@ PRODUCT_AGENT_PROMPT = """你是一个专业的 B2B 商品顾问助手。
}
```
用户说:"推荐一些商品"
返回:
```json
{
"action": "call_tool",
"tool_name": "recommend_products",
"arguments": {
"page_size": 6
}
}
```
当需要向用户询问更多信息时:
```json
{
@@ -104,6 +118,17 @@ PRODUCT_AGENT_PROMPT = """你是一个专业的 B2B 商品顾问助手。
- 报价通常有有效期
## 商品推荐策略
**重要规则:推荐 vs 搜索**
- **泛泛推荐**"推荐一些商品""推荐一下""有什么好推荐的" → 使用 recommend_products
- **具体商品推荐**"推荐ring相关的商品""推荐手机""推荐一些珠宝" → 使用 search_products (提取关键词ring、手机、珠宝)
- **商品搜索**"搜索ring""找ring商品" → 使用 search_products
**说明**
- 如果用户推荐请求中包含具体的商品关键词(如 ring、手机、珠宝等使用 search_products 进行精准搜索
- 只有在泛泛请求推荐时才使用 recommend_products基于用户行为的个性化推荐
**其他推荐依据**
- 根据用户采购历史推荐
- 根据当前查询语义推荐
- 根据企业行业特点推荐
@@ -119,12 +144,12 @@ PRODUCT_AGENT_PROMPT = """你是一个专业的 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
"""
@@ -133,11 +158,40 @@ async def product_agent(state: AgentState) -> AgentState:
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
# ========== FAST PATH: Image Search ==========
# Check if this is an image search request
image_search_url = state.get("context", {}).get("image_search_url")
if image_search_url:
logger.info(
"Image search detected, calling search_products_by_image",
conversation_id=state["conversation_id"],
image_url=image_search_url[:100] + "..." if len(image_search_url) > 100 else image_search_url
)
# 直接调用图片搜索工具
state = add_tool_call(
state,
tool_name="search_products_by_image",
arguments={
"image_url": image_search_url,
"page_size": 6,
"page": 1
},
server="product"
)
# 清除 image_search_url 防止无限循环
state["context"]["image_search_url"] = None
state["state"] = ConversationState.TOOL_CALLING.value
return state
# ==============================================
# Check if we have tool results to process
if state["tool_results"]:
return await _generate_product_response(state)
@@ -148,7 +202,8 @@ async def product_agent(state: AgentState) -> AgentState:
]
# Add conversation history
for msg in state["messages"][-6:]:
# 只保留最近 2 条历史消息以减少 token 数量和响应时间
for msg in state["messages"][-2:]:
messages.append(Message(role=msg["role"], content=msg["content"]))
# Build context info
@@ -233,7 +288,7 @@ async def product_agent(state: AgentState) -> AgentState:
# Set default page_size if not provided
if "page_size" not in arguments:
arguments["page_size"] = 5
arguments["page_size"] = 6
# Set default page if not provided
if "page" not in arguments:
@@ -249,8 +304,21 @@ async def product_agent(state: AgentState) -> AgentState:
# Inject context for recommendation
if tool_name == "recommend_products":
arguments["user_id"] = state["user_id"]
arguments["account_id"] = state["account_id"]
arguments["user_token"] = state.get("user_token")
# 如果没有提供 page_size使用默认值 6
if "page_size" not in arguments:
arguments["page_size"] = 6
# 如果没有提供 warehouse_id使用默认值 2
if "warehouse_id" not in arguments:
arguments["warehouse_id"] = 2
logger.info(
"Product agent recommend_products after injection",
user_token_present="user_token" in arguments,
user_token_preview=arguments.get("user_token", "")[:20] + "..." if arguments.get("user_token") else None,
arguments=arguments,
conversation_id=state["conversation_id"]
)
# Inject context for quote
if tool_name == "get_quote":
@@ -301,25 +369,55 @@ async def product_agent(state: AgentState) -> AgentState:
async def _generate_product_response(state: AgentState) -> AgentState:
"""Generate response based on product tool results"""
# 特殊处理:如果是 search_products 工具返回,直接发送商品卡片
has_product_search_result = False
# 特殊处理:如果是 search_products、recommend_products 或 search_products_by_image 工具返回,直接发送商品卡片
has_product_result = False
products = []
result_source = None # "search", "recommend" 或 "image_search"
# 添加日志:查看所有工具结果
import json as json_module
logger.info(
"All tool results",
tool_results_count=len(state.get("tool_results", [])),
tool_results=json_module.dumps(state.get("tool_results", []), ensure_ascii=False, indent=2)[:2000]
)
for result in state["tool_results"]:
if result["success"] and result["tool_name"] == "search_products":
logger.info(
"Processing tool result",
tool_name=result["tool_name"],
success=result["success"],
data_keys=list(result.get("data", {}).keys()) if isinstance(result.get("data"), dict) else "not a dict",
data_preview=json_module.dumps(result.get("data"), ensure_ascii=False)[:500]
)
if result["success"] and result["tool_name"] in ["search_products", "recommend_products", "search_products_by_image"]:
data = result["data"]
if isinstance(data, dict) and data.get("success"):
products = data.get("products", [])
has_product_search_result = True
# MCP 返回的数据结构: {"success": true, "result": {"success": true, "products": [...]}}
# 需要从 result.result 中提取实际数据
inner_data = data.get("result", data)
products = inner_data.get("products", [])
keyword = inner_data.get("keyword", "")
has_product_result = True
if result["tool_name"] == "recommend_products":
result_source = "recommend"
elif result["tool_name"] == "search_products_by_image":
result_source = "image_search"
else:
result_source = "search"
logger.info(
"Product search results found",
f"Product {result_source} results found",
products_count=len(products),
keyword=data.get("keyword", "")
keyword=keyword,
products_preview=json_module.dumps(products[:2], ensure_ascii=False, indent=2) if products else "[]"
)
break
# 如果有商品搜索结果,直接发送商品卡片
if has_product_search_result and products:
# 如果有商品结果,直接发送商品卡片product_list 格式)
if has_product_result and products:
try:
from integrations.chatwoot import ChatwootClient
from core.language_detector import detect_language
@@ -327,7 +425,7 @@ async def _generate_product_response(state: AgentState) -> AgentState:
# 检测语言
detected_language = state.get("detected_language", "en")
# 发送商品卡片
# 发送商品列表
chatwoot = ChatwootClient(account_id=int(state.get("account_id", 1)))
conversation_id = state.get("conversation_id")
@@ -339,10 +437,11 @@ async def _generate_product_response(state: AgentState) -> AgentState:
)
logger.info(
"Product cards sent successfully",
f"Product {result_source} cards sent successfully",
conversation_id=conversation_id,
products_count=len(products),
language=detected_language
language=detected_language,
result_source=result_source
)
# 清空响应,避免重复发送
@@ -352,17 +451,56 @@ async def _generate_product_response(state: AgentState) -> AgentState:
except Exception as e:
logger.error(
"Failed to send product cards, falling back to text response",
f"Failed to send product {result_source} cards, falling back to text response",
error=str(e),
products_count=len(products)
products_count=len(products),
result_source=result_source
)
# 常规处理:生成文本响应
tool_context = []
for result in state["tool_results"]:
if result["success"]:
data = result["data"]
tool_context.append(f"工具 {result['tool_name']} 返回:\n{json.dumps(data, ensure_ascii=False, indent=2)}")
tool_name = result['tool_name']
data = result['data']
# Extract only essential information based on tool type
if tool_name == "search_products" or tool_name == "recommend_products":
products = data.get("products", []) if isinstance(data, dict) else []
if products:
product_summaries = [f"- {p.get('product_name', 'N/A')}: {p.get('price', 'N/A')}" for p in products[:3]]
summary = f"Found {len(products)} products:\n" + "\n".join(product_summaries)
if len(products) > 3:
summary += f"\n(and {len(products) - 3} more)"
tool_context.append(summary)
else:
tool_context.append("No products found")
elif tool_name == "get_product_detail":
product = data.get("product", {}) if isinstance(data, dict) else {}
name = product.get("product_name", product.get("name", "N/A"))
price = product.get("price", "N/A")
stock = product.get("stock", product.get("stock_status", "N/A"))
summary = f"Product: {name} | Price: {price} | Stock: {stock}"
tool_context.append(summary)
elif tool_name == "check_inventory":
inventory = data.get("inventory", []) if isinstance(data, dict) else []
inv_summaries = [f"{inv.get('product_id', 'N/A')}: {inv.get('quantity', 'N/A')} available" for inv in inventory[:3]]
summary = "Inventory status:\n" + "\n".join(inv_summaries)
tool_context.append(summary)
elif tool_name == "get_pricing":
product_id = data.get("product_id", "N/A")
unit_price = data.get("unit_price", "N/A")
total_price = data.get("total_price", "N/A")
summary = f"Quote for {product_id}: Unit: {unit_price} | Total: {total_price}"
tool_context.append(summary)
else:
# For other tools, include concise summary (limit to 200 chars)
data_str = json.dumps(data, ensure_ascii=False)[:200]
tool_context.append(f"工具 {tool_name} 返回: {data_str}...")
# Extract product context
if isinstance(data, dict):
@@ -398,7 +536,8 @@ async def _generate_product_response(state: AgentState) -> AgentState:
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.7)
# Lower temperature for faster response
response = await llm.chat(messages, temperature=0.3)
state = set_response(state, response.content)
return state