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

@@ -322,16 +322,51 @@ async def customer_service_agent(state: AgentState) -> AgentState:
return state
except json.JSONDecodeError as e:
# JSON parsing failed
# JSON parsing failed - try alternative format: "tool_name\n{args}"
logger.error(
"Failed to parse LLM response as JSON",
error=str(e),
raw_content=content[:500],
conversation_id=state["conversation_id"]
)
# Don't use raw content as response - use fallback instead
state = set_response(state, "抱歉,我无法理解您的请求。请尝试重新表述或联系人工客服。")
return state
# Handle non-JSON format: "tool_name\n{args}"
if '\n' in content and not content.startswith('{'):
lines = content.split('\n', 1)
tool_name = lines[0].strip()
args_json = lines[1].strip() if len(lines) > 1 else '{}'
try:
arguments = json.loads(args_json) if args_json else {}
logger.info(
"Customer service agent calling tool (alternative format)",
tool_name=tool_name,
arguments=arguments,
conversation_id=state["conversation_id"]
)
state = add_tool_call(
state,
tool_name=tool_name,
arguments=arguments,
server="strapi"
)
state["state"] = ConversationState.TOOL_CALLING.value
return state
except json.JSONDecodeError:
# Args parsing also failed
logger.warning(
"Failed to parse tool arguments",
tool_name=tool_name,
args_json=args_json[:200],
conversation_id=state["conversation_id"]
)
state = set_response(state, "抱歉,我无法理解您的请求。请尝试重新表述或联系人工客服。")
return state
else:
# Not a recognized format
state = set_response(state, "抱歉,我无法理解您的请求。请尝试重新表述或联系人工客服。")
return state
except Exception as e:
logger.error("Customer service agent failed", error=str(e), exc_info=True)
@@ -342,11 +377,59 @@ async def customer_service_agent(state: AgentState) -> AgentState:
async def _generate_response_from_results(state: AgentState) -> AgentState:
"""Generate response based on tool results"""
# Build context from tool results
# Build context from tool results - extract only essential info to reduce prompt size
tool_context = []
for result in state["tool_results"]:
if result["success"]:
tool_context.append(f"Tool {result['tool_name']} returned:\n{json.dumps(result['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 == "get_company_info":
# Extract key contact info only
contact = data.get('contact', {})
emails = contact.get('email', [])
if isinstance(emails, list) and emails:
email_str = ", ".join(emails[:3]) # Max 3 emails
else:
email_str = str(emails) if emails else "N/A"
phones = contact.get('phone', [])
if isinstance(phones, list) and phones:
phone_str = ", ".join(phones[:2]) # Max 2 phones
else:
phone_str = str(phones) if phones else "N/A"
address = contact.get('address', {})
address_str = f"{address.get('city', '')}, {address.get('country', '')}".strip(', ')
summary = f"Contact Information: Emails: {email_str} | Phones: {phone_str} | Address: {address_str} | Working hours: {contact.get('working_hours', 'N/A')}"
tool_context.append(summary)
elif tool_name == "query_faq" or tool_name == "search_knowledge_base":
# Extract FAQ items summary
faqs = data.get('faqs', []) if isinstance(data, dict) else []
if faqs:
faq_summaries = [f"- Q: {faq.get('question', '')[:50]}... A: {faq.get('answer', '')[:50]}..." for faq in faqs[:3]]
summary = f"Found {len(faqs)} FAQ items:\n" + "\n".join(faq_summaries)
tool_context.append(summary)
else:
tool_context.append("No FAQ items found")
elif tool_name == "get_categories":
# Extract category names only
categories = data.get('categories', []) if isinstance(data, dict) else []
category_names = [cat.get('name', '') for cat in categories[:5] if cat.get('name')]
summary = f"Available categories: {', '.join(category_names)}"
if len(categories) > 5:
summary += f" (and {len(categories) - 5} more)"
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 {tool_name} returned: {data_str}...")
else:
tool_context.append(f"Tool {result['tool_name']} failed: {result['error']}")
@@ -357,7 +440,8 @@ User question: {state["current_message"]}
Tool returned information:
{chr(10).join(tool_context)}
Please generate a friendly and professional response. If the tool did not return useful information, honestly inform the user and suggest other ways to get help.
Please generate a friendly and professional response in Chinese. Keep it concise but informative.
If the tool did not return useful information, honestly inform the user and suggest other ways to get help.
Return only the response content, do not return JSON."""
messages = [
@@ -367,11 +451,12 @@ Return only the response content, do not return JSON."""
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
except Exception as e:
logger.error("Response generation failed", error=str(e))
state = set_response(state, "Sorry, there was a problem processing your request. Please try again later or contact customer support.")
state = set_response(state, "抱歉,处理您的请求时出现问题。请稍后重试或联系人工客服。")
return state