""" 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 - 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"] ) # 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) 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 - extract only essential info to reduce prompt size tool_context = [] for result in state["tool_results"]: if result["success"]: 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']}") 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 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 = [ 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() # 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, "抱歉,处理您的请求时出现问题。请稍后重试或联系人工客服。") return state