## 主要修复 ### 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>
378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""
|
||
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
|