File size: 6,542 Bytes
cf796c5
 
b6ae869
cf796c5
b6ae869
cf796c5
 
 
d2c5868
 
 
 
 
 
 
cf796c5
b6ae869
cf796c5
 
 
 
f9e8eed
cf796c5
d2c5868
 
 
 
 
 
 
 
f9e8eed
 
 
 
 
d2c5868
cf796c5
 
 
 
 
 
 
 
 
 
 
 
d2c5868
 
 
 
 
 
 
 
 
 
 
cf796c5
 
b6ae869
cf796c5
 
 
 
 
 
b6ae869
 
 
2e83d00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6ae869
 
 
 
 
 
 
 
 
 
 
cf796c5
 
f9e8eed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf796c5
2bfa9ba
 
 
 
 
f9e8eed
 
 
2bfa9ba
f9e8eed
 
 
2bfa9ba
f9e8eed
 
 
2bfa9ba
f9e8eed
 
 
2bfa9ba
f9e8eed
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
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]}"
            ]
        }