2026-01-14 19:25:22 +08:00
"""
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
2026-01-16 16:28:47 +08:00
from prompts import get_prompt
2026-01-14 19:25:22 +08:00
from utils . logger import get_logger
2026-01-20 14:51:30 +08:00
from utils . faq_library import get_faq_library
2026-01-14 19:25:22 +08:00
logger = get_logger ( __name__ )
async def customer_service_agent ( state : AgentState ) - > AgentState :
""" Customer service agent node
2026-01-16 16:28:47 +08:00
2026-01-14 19:25:22 +08:00
Handles FAQ , company info , and general inquiries using Strapi MCP tools .
2026-01-16 16:28:47 +08:00
2026-01-14 19:25:22 +08:00
Args :
state : Current agent state
2026-01-16 16:28:47 +08:00
2026-01-14 19:25:22 +08:00
Returns :
Updated state with tool calls or response
"""
logger . info (
" Customer service agent processing " ,
conversation_id = state [ " conversation_id " ]
)
2026-01-16 16:28:47 +08:00
2026-01-14 19:25:22 +08:00
state [ " current_agent " ] = " customer_service "
state [ " agent_history " ] . append ( " customer_service " )
state [ " state " ] = ConversationState . PROCESSING . value
2026-01-16 16:28:47 +08:00
2026-01-14 19:25:22 +08:00
# Check if we have tool results to process
if state [ " tool_results " ] :
return await _generate_response_from_results ( state )
2026-01-16 16:28:47 +08:00
2026-01-20 14:51:30 +08:00
# ========== 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 )
# ============================================================
2026-01-16 16:28:47 +08:00
# Get detected language
locale = state . get ( " detected_language " , " en " )
2026-01-27 13:15:58 +08:00
# 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)
2026-01-16 16:28:47 +08:00
message_lower = state [ " current_message " ] . lower ( )
2026-01-23 18:49:40 +08:00
# 定义分类关键词( 支持多语言: en, nl, de, es, fr, it, tr, zh)
2026-01-16 16:28:47 +08:00
category_keywords = {
2026-01-23 18:49:40 +08:00
" 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
" 退货 " , " 退款 " , " 换货 " , " 有缺陷 " , " 损坏 "
] ,
2026-01-16 16:28:47 +08:00
}
2026-01-27 13:15:58 +08:00
# 检测分类(仅在未通过 sub_intent 匹配时使用)
2026-01-16 16:28:47 +08:00
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
2026-01-14 19:25:22 +08:00
# Build messages for LLM
2026-01-16 16:28:47 +08:00
# Load prompt in detected language
system_prompt = get_prompt ( " customer_service " , locale )
2026-01-14 19:25:22 +08:00
messages = [
2026-01-16 16:28:47 +08:00
Message ( role = " system " , content = system_prompt ) ,
2026-01-14 19:25:22 +08:00
]
# 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 )
2026-01-27 13:15:58 +08:00
# 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
)
2026-01-14 19:25:22 +08:00
# Parse response
content = response . content . strip ( )
2026-01-27 13:15:58 +08:00
# Handle markdown code blocks
2026-01-14 19:25:22 +08:00
if content . startswith ( " ``` " ) :
2026-01-27 13:15:58 +08:00
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 :
2026-01-27 19:10:06 +08:00
# JSON parsing failed - try alternative format: "tool_name\n{args}"
2026-01-27 13:15:58 +08:00
logger . error (
" Failed to parse LLM response as JSON " ,
error = str ( e ) ,
raw_content = content [ : 500 ] ,
conversation_id = state [ " conversation_id " ]
2026-01-14 19:25:22 +08:00
)
2026-01-27 19:10:06 +08:00
# 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
2026-01-27 13:15:58 +08:00
2026-01-14 19:25:22 +08:00
except Exception as e :
2026-01-27 13:15:58 +08:00
logger . error ( " Customer service agent failed " , error = str ( e ) , exc_info = True )
2026-01-14 19:25:22 +08:00
state [ " error " ] = str ( e )
return state
async def _generate_response_from_results ( state : AgentState ) - > AgentState :
""" Generate response based on tool results """
2026-01-16 16:28:47 +08:00
2026-01-27 19:10:06 +08:00
# Build context from tool results - extract only essential info to reduce prompt size
2026-01-14 19:25:22 +08:00
tool_context = [ ]
for result in state [ " tool_results " ] :
if result [ " success " ] :
2026-01-27 19:10:06 +08:00
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 } ... " )
2026-01-14 19:25:22 +08:00
else :
2026-01-16 16:28:47 +08:00
tool_context . append ( f " Tool { result [ ' tool_name ' ] } failed: { result [ ' error ' ] } " )
2026-01-14 19:25:22 +08:00
2026-01-16 16:28:47 +08:00
prompt = f """ Based on the following tool returned information, generate a response to the user.
2026-01-14 19:25:22 +08:00
2026-01-16 16:28:47 +08:00
User question : { state [ " current_message " ] }
Tool returned information :
2026-01-14 19:25:22 +08:00
{ chr ( 10 ) . join ( tool_context ) }
2026-01-27 19:10:06 +08:00
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 .
2026-01-16 16:28:47 +08:00
Return only the response content , do not return JSON . """
2026-01-14 19:25:22 +08:00
messages = [
2026-01-16 16:28:47 +08:00
Message ( role = " system " , content = " You are a professional B2B customer service assistant, please answer user questions based on tool returned information. " ) ,
2026-01-14 19:25:22 +08:00
Message ( role = " user " , content = prompt )
]
2026-01-16 16:28:47 +08:00
2026-01-14 19:25:22 +08:00
try :
llm = get_llm_client ( )
2026-01-27 19:10:06 +08:00
# Lower temperature for faster response
response = await llm . chat ( messages , temperature = 0.3 )
2026-01-14 19:25:22 +08:00
state = set_response ( state , response . content )
return state
2026-01-16 16:28:47 +08:00
2026-01-14 19:25:22 +08:00
except Exception as e :
logger . error ( " Response generation failed " , error = str ( e ) )
2026-01-27 19:10:06 +08:00
state = set_response ( state , " 抱歉,处理您的请求时出现问题。请稍后重试或联系人工客服。 " )
2026-01-14 19:25:22 +08:00
return state