from pydantic import BaseModel from typing import Literal, List from langchain_core.messages import SystemMessage, HumanMessage, AIMessage from src.state import AgentState from src.utils.llm_factory import get_llm, _invoke_with_backoff from src.utils.prompt_templates import INTENT_SYSTEM, INTENT_FEW_SHOTS import json # Emotion keywords that suggest hybrid intent EMOTION_KEYWORDS = [ "frustrated", "angry", "upset", "disappointed", "furious", "livid", "annoyed", "irritated", "unhappy", "dissatisfied", "terrible", "awful", "horrible", "unacceptable", "ridiculous", "outrageous" ] class IntentClassification(BaseModel): intent: Literal["transactional", "informational", "sentimental", "hybrid", "out_of_scope"] confidence: float sub_intents: List[str] reasoning: str def classify_intent(state: AgentState) -> dict: """Classify user intent with fallback to hybrid on errors""" user_input = state.get("user_input", "").lower() # Check for emotion keywords - if found with order context, force hybrid has_emotion = any(keyword in user_input for keyword in EMOTION_KEYWORDS) has_order_context = state.get("last_order_id") is not None if has_emotion and has_order_context: print(f"[intent_classifier] Emotion keyword detected with order context, forcing hybrid intent") return { "intent": "hybrid", "intent_confidence": 0.85, "sub_intents": ["order_tracking", "complaint"] } try: llm = get_llm(temperature=0.0) messages = [SystemMessage(content=INTENT_SYSTEM)] # Inject few-shots for shot in INTENT_FEW_SHOTS: if shot["role"] == "user": messages.append(HumanMessage(content=shot["content"])) else: messages.append(SystemMessage(content=shot["content"])) # Inject recent conversation history for pronoun resolution recent_msgs = state.get("messages", [])[-4:] # Last 2 turns if recent_msgs: history_context = "\n[Recent conversation context for pronoun resolution:]\n" for msg in recent_msgs: if isinstance(msg, HumanMessage): history_context += f"User: {msg.content[:150]}\n" else: history_context += f"Agent: {msg.content[:150]}\n" messages.append(SystemMessage(content=history_context)) messages.append(HumanMessage(content=state.get("user_input", ""))) response = _invoke_with_backoff(llm, messages, provider="groq") raw = response.content.strip() # Strip markdown fences if LLM wraps in ```json raw = raw.replace("```json", "").replace("```", "").strip() parsed = IntentClassification(**json.loads(raw)) # Handle out_of_scope as terminal path if parsed.intent == "out_of_scope": print(f"[intent_classifier] Out-of-scope query detected (confidence: {parsed.confidence:.2f})") # Check if it's a product browsing query for custom message user_input_lower = state.get("user_input", "").lower() is_product_search = any(keyword in user_input_lower for keyword in [ "product", "catalog", "browse", "list", "show items", "search products" ]) if is_product_search: final_response = ( "I'm a customer support assistant focused on helping with existing orders, " "deliveries, returns, and refunds. For browsing products or searching our " "catalog, please visit the Olist marketplace directly.\n\n" "Is there anything I can help you with regarding an existing order?" ) else: final_response = ( "I'm Olist's customer support assistant — I can help with orders, " "deliveries, returns, payments, and seller queries. " "Your question doesn't seem related to Olist support. " "Could you ask me something about your Olist experience?" ) return { "intent": "out_of_scope", "intent_confidence": parsed.confidence, "sub_intents": parsed.sub_intents, "final_response": final_response, "messages": [ HumanMessage(content=state.get("user_input", "")), AIMessage(content=final_response) ] } # Robustness: low confidence → force hybrid if parsed.confidence < 0.6: return { "intent": "hybrid", "intent_confidence": parsed.confidence, "sub_intents": parsed.sub_intents, "error_log": state.get("error_log", []) + [ f"[intent_classifier] Low confidence ({parsed.confidence:.2f}), forced hybrid" ] } return { "intent": parsed.intent, "intent_confidence": parsed.confidence, "sub_intents": parsed.sub_intents } except Exception as e: # Rule-based fallback to avoid complete failure user_input = state.get("user_input", "").lower() # Simple keyword matching if any(word in user_input for word in ["order", "package", "delivery", "tracking", "where", "status"]): intent = "transactional" confidence = 0.6 sub_intents = ["order_tracking"] elif any(word in user_input for word in ["policy", "return", "refund", "warranty", "payment", "shipping"]): intent = "informational" confidence = 0.6 sub_intents = ["policy_query"] elif any(word in user_input for word in ["angry", "frustrated", "upset", "terrible", "awful"]): intent = "sentimental" confidence = 0.6 sub_intents = ["complaint"] else: intent = "hybrid" confidence = 0.5 sub_intents = ["default"] return { "intent": intent, "intent_confidence": confidence, "sub_intents": sub_intents, "error_log": state.get("error_log", []) + [ f"[intent_classifier] LLM error, using rule-based fallback: {str(e)[:100]}" ] }