Files
assistant/agent/agents/customer_service.py
wangliang 0f13102a02 fix: 改进错误处理和清理测试代码
## 主要修复

### 1. JSON 解析错误处理
- 修复所有 Agent 的 LLM 响应解析失败时返回原始内容的问题
- 当 JSON 解析失败时,返回友好的兜底消息而不是原始文本
- 影响文件: customer_service.py, order.py, product.py, aftersale.py

### 2. FAQ 快速路径修复
- 修复 customer_service.py 中变量定义顺序问题
- has_faq_query 在使用前未定义导致 NameError
- 添加详细的错误日志记录

### 3. Chatwoot 集成改进
- 添加响应内容调试日志
- 改进错误处理和日志记录

### 4. 订单查询优化
- 将订单列表默认返回数量从 10 条改为 5 条
- 统一 MCP 工具层和 Mall Client 层的默认值

### 5. 代码清理
- 删除所有测试代码和示例文件
- 刋试文件包括: test_*.py, test_*.html, test_*.sh
- 删除测试目录: tests/, agent/tests/, agent/examples/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-27 13:15:58 +08:00

378 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Customer Service Agent - Handles FAQ and general inquiries
"""
import json
from typing import Any
from core.state import AgentState, ConversationState, add_tool_call, set_response
from core.llm import get_llm_client, Message
from prompts import get_prompt
from utils.logger import get_logger
from utils.faq_library import get_faq_library
logger = get_logger(__name__)
async def customer_service_agent(state: AgentState) -> AgentState:
"""Customer service agent node
Handles FAQ, company info, and general inquiries using Strapi MCP tools.
Args:
state: Current agent state
Returns:
Updated state with tool calls or response
"""
logger.info(
"Customer service agent processing",
conversation_id=state["conversation_id"]
)
state["current_agent"] = "customer_service"
state["agent_history"].append("customer_service")
state["state"] = ConversationState.PROCESSING.value
# Check if we have tool results to process
if state["tool_results"]:
return await _generate_response_from_results(state)
# ========== FAST PATH: Check if FAQ was already matched at router ==========
# Router already checked FAQ and stored response if found
if "faq_response" in state and state["faq_response"]:
logger.info(
"Using FAQ response from router",
conversation_id=state["conversation_id"],
response_length=len(state["faq_response"])
)
return set_response(state, state["faq_response"])
# =========================================================================
# ========== FAST PATH: Check local FAQ library first (backup) ==========
# This provides instant response for common questions without API calls
# This is a fallback in case FAQ wasn't matched at router level
faq_library = get_faq_library()
faq_response = faq_library.find_match(state["current_message"])
if faq_response:
logger.info(
"FAQ match found, returning instant response",
conversation_id=state["conversation_id"],
response_length=len(faq_response)
)
return set_response(state, faq_response)
# ============================================================
# Get detected language
locale = state.get("detected_language", "en")
# Check if we have already queried FAQ
tool_calls = state.get("tool_calls", [])
has_faq_query = any(tc.get("tool_name") in ["query_faq", "search_knowledge_base"] for tc in tool_calls)
# ========== ROUTING: Use sub_intent from router if available ==========
# Router already classified the intent, use it for direct FAQ query
sub_intent = state.get("sub_intent")
# Map sub_intent to FAQ category
sub_intent_to_category = {
"register_inquiry": "register",
"order_inquiry": "order",
"payment_inquiry": "payment",
"shipment_inquiry": "shipment",
"return_inquiry": "return",
"policy_inquiry": "return", # Policy queries use return FAQ
}
# Check if we should auto-query FAQ based on sub_intent
if sub_intent in sub_intent_to_category and not has_faq_query:
category = sub_intent_to_category[sub_intent]
logger.info(
f"Auto-querying FAQ based on sub_intent: {sub_intent} -> category: {category}",
conversation_id=state["conversation_id"]
)
state = add_tool_call(
state,
tool_name="query_faq",
arguments={
"category": category,
"locale": locale,
"limit": 5
},
server="strapi"
)
state["state"] = ConversationState.TOOL_CALLING.value
return state
# ========================================================================
# Auto-detect category and query FAQ (fallback if sub_intent not available)
message_lower = state["current_message"].lower()
# 定义分类关键词支持多语言en, nl, de, es, fr, it, tr, zh
category_keywords = {
"register": [
# English
"register", "sign up", "account", "login", "password", "forgot",
# Dutch (Nederlands)
"registreren", "account", "inloggen", "wachtwoord",
# German (Deutsch)
"registrieren", "konto", "anmelden", "passwort",
# Spanish (Español)
"registrar", "cuenta", "iniciar", "contraseña",
# French (Français)
"enregistrer", "compte", "connecter", "mot de passe",
# Italian (Italiano)
"registrarsi", "account", "accesso", "password",
# Turkish (Türkçe)
"kayıt", "hesap", "giriş", "şifre",
# Chinese (中文)
"注册", "账号", "登录", "密码", "忘记密码"
],
"order": [
# English
"order", "place order", "cancel order", "modify order", "change order",
# Dutch
"bestelling", "bestellen", "annuleren", "wijzigen",
# German
"bestellung", "bestellen", "stornieren", "ändern",
# Spanish
"pedido", "hacer pedido", "cancelar", "modificar",
# French
"commande", "passer commande", "annuler", "modifier",
# Italian
"ordine", "ordinare", "cancellare", "modificare",
# Turkish
"sipariş", "sipariş ver", "iptal", "değiştir",
# Chinese
"订单", "下单", "取消订单", "修改订单", "更改订单"
],
"payment": [
# English
"pay", "payment", "checkout", "voucher", "discount", "promo",
# Dutch
"betalen", "betaling", "korting", "voucher",
# German
"bezahlen", "zahlung", "rabatt", "gutschein",
# Spanish
"pagar", "pago", "descuento", "cupón",
# French
"payer", "paiement", "réduction", "bon",
# Italian
"pagare", "pagamento", "sconto", "voucher",
# Turkish
"ödemek", "ödeme", "indirim", "kupon",
# Chinese
"支付", "付款", "结算", "优惠券", "折扣", "促销"
],
"shipment": [
# English
"ship", "shipping", "delivery", "courier", "transit", "logistics", "tracking",
# Dutch
"verzenden", "levering", "koerier", "logistiek", "volgen",
# German
"versand", "lieferung", "kurier", "logistik", "verfolgung",
# Spanish
"enviar", "envío", "entrega", "mensajería", "logística", "seguimiento",
# French
"expédier", "livraison", "coursier", "logistique", "suivi",
# Italian
"spedire", "spedizione", "consegna", "corriere", "logistica", "tracciamento",
# Turkish
"gönderi", "teslimat", "kurye", "lojistik", "takip",
# Chinese
"发货", "配送", "快递", "物流", "运输", "配送单"
],
"return": [
# English
"return", "refund", "exchange", "defective", "damaged",
# Dutch
"retour", "terugbetaling", "ruilen", "defect",
# German
"rückgabe", "erstattung", "austausch", "defekt",
# Spanish
"devolución", "reembolso", "cambio", "defectuoso",
# French
"retour", "remboursement", "échange", "défectueux",
# Italian
"reso", "rimborso", "cambio", "difettoso",
# Turkish
"iade", "geri ödeme", "değişim", "defekt",
# Chinese
"退货", "退款", "换货", "有缺陷", "损坏"
],
}
# 检测分类(仅在未通过 sub_intent 匹配时使用)
detected_category = None
for category, keywords in category_keywords.items():
if any(keyword in message_lower for keyword in keywords):
detected_category = category
break
# 如果检测到分类且未查询过 FAQ自动查询
if detected_category and not has_faq_query:
logger.info(
f"Auto-querying FAQ for category: {detected_category}",
conversation_id=state["conversation_id"]
)
# 自动添加 FAQ 工具调用
state = add_tool_call(
state,
tool_name="query_faq",
arguments={
"category": detected_category,
"locale": locale,
"limit": 5
},
server="strapi"
)
state["state"] = ConversationState.TOOL_CALLING.value
return state
# 如果询问营业时间或联系方式,自动查询公司信息
if any(keyword in message_lower for keyword in ["opening hour", "contact", "address", "phone", "email"]) and not has_faq_query:
logger.info(
"Auto-querying company info",
conversation_id=state["conversation_id"]
)
state = add_tool_call(
state,
tool_name="get_company_info",
arguments={
"section": "contact",
"locale": locale
},
server="strapi"
)
state["state"] = ConversationState.TOOL_CALLING.value
return state
# Build messages for LLM
# Load prompt in detected language
system_prompt = get_prompt("customer_service", locale)
messages = [
Message(role="system", content=system_prompt),
]
# Add conversation history
for msg in state["messages"][-6:]:
messages.append(Message(role=msg["role"], content=msg["content"]))
# Add current message
messages.append(Message(role="user", content=state["current_message"]))
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.7)
# Log raw response for debugging
logger.info(
"Customer service LLM response",
conversation_id=state["conversation_id"],
response_preview=response.content[:300] if response.content else "EMPTY",
response_length=len(response.content) if response.content else 0
)
# Parse response
content = response.content.strip()
# Handle markdown code blocks
if content.startswith("```"):
parts = content.split("```")
if len(parts) >= 2:
content = parts[1]
if content.startswith("json"):
content = content[4:]
content = content.strip()
try:
result = json.loads(content)
action = result.get("action")
if action == "call_tool":
# Add tool call to state
state = add_tool_call(
state,
tool_name=result["tool_name"],
arguments=result.get("arguments", {}),
server="strapi"
)
state["state"] = ConversationState.TOOL_CALLING.value
elif action == "respond":
state = set_response(state, result["response"])
state["state"] = ConversationState.GENERATING.value
elif action == "handoff":
state["requires_human"] = True
state["handoff_reason"] = result.get("reason", "User request")
else:
# Unknown action, treat as plain text response
logger.warning(
"Unknown action in LLM response",
action=action,
conversation_id=state["conversation_id"]
)
state = set_response(state, response.content)
return state
except json.JSONDecodeError as e:
# JSON parsing failed
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
except Exception as e:
logger.error("Customer service agent failed", error=str(e), exc_info=True)
state["error"] = str(e)
return state
async def _generate_response_from_results(state: AgentState) -> AgentState:
"""Generate response based on tool results"""
# Build context from tool results
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)}")
else:
tool_context.append(f"Tool {result['tool_name']} failed: {result['error']}")
prompt = f"""Based on the following tool returned information, generate a response to the user.
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.
Return only the response content, do not return JSON."""
messages = [
Message(role="system", content="You are a professional B2B customer service assistant, please answer user questions based on tool returned information."),
Message(role="user", content=prompt)
]
try:
llm = get_llm_client()
response = await llm.chat(messages, temperature=0.7)
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.")
return state