File size: 4,487 Bytes
8622dab
b1c949c
d2c5868
b1c949c
8622dab
 
 
 
 
 
 
 
 
 
 
 
d2c5868
 
 
 
 
8622dab
b1c949c
8622dab
 
 
 
 
 
 
 
 
 
 
 
b1c949c
8622dab
 
 
 
b1c949c
8622dab
 
 
 
 
 
 
 
 
b1c949c
 
8622dab
 
 
 
 
d2c5868
b1c949c
8622dab
d2c5868
8622dab
 
 
 
 
 
 
 
 
d2c5868
 
 
 
 
 
8622dab
 
c28ad62
8622dab
 
 
 
 
b1c949c
d2c5868
4c790c6
c28ad62
 
 
 
4c790c6
 
c28ad62
4c790c6
 
d2c5868
 
0a7460e
 
 
 
 
b1c949c
8622dab
0a7460e
 
 
 
 
 
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
import numpy as np
from src.state import AgentState
from langchain_core.messages import HumanMessage, AIMessage

# Anchor sentences — representative of in-scope and out-of-scope topics
IN_SCOPE_ANCHORS = [
    "where is my order",
    "track my delivery",
    "return policy refund",
    "payment method billing",
    "late shipment compensation",
    "seller review rating",
    "order status cancelled",
    "how long does shipping take",
    "I want to cancel my order",
    "product not delivered yet",
    "here is my order id",
    "the order number is",
    "my order id",
    "order reference number",
    "136cce7faa42fdb2cefd53fdc79a6098",  # Example order ID format
]

OUT_OF_SCOPE_ANCHORS = [
    "who is the president of the United States",
    "write me a poem about love",
    "explain quantum physics",
    "what is the recipe for pasta",
    "tell me a joke",
    "history of the Roman Empire",
    "help me write code in Python",
    "what is the weather today",
    "recommend a movie to watch",
    "translate this text to French",
]

# Lazy globals — reuses the same model already loaded by rag_retriever
_scope_model = None
_in_scope_embeddings = None
_out_scope_embeddings = None

def _init_scope_model():
    global _scope_model, _in_scope_embeddings, _out_scope_embeddings
    if _scope_model is not None:
        return
    from sentence_transformers import SentenceTransformer
    # Same model already cached by warmup_models.py — no re-download
    _scope_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
    _in_scope_embeddings = _scope_model.encode(IN_SCOPE_ANCHORS, normalize_embeddings=True)
    _out_scope_embeddings = _scope_model.encode(OUT_OF_SCOPE_ANCHORS, normalize_embeddings=True)

def validate_scope(state: AgentState) -> AgentState:
    """
    Zero-shot semantic scope classification using sentence-transformer embeddings.
    Compares user input against in-scope and out-of-scope anchor embeddings via
    cosine similarity. No LLM API call needed — runs in ~5ms after warmup.
    """
    print(f"[scope_validator] Starting validation for: {state.get('user_input', '')[:50]}...")
    try:
        _init_scope_model()
        print("[scope_validator] Model initialized")
        user_input = state.get("user_input", "")

        # Encode query (normalized = cosine sim is just dot product)
        query_emb = _scope_model.encode([user_input], normalize_embeddings=True)[0]

        # Max cosine similarity to each anchor set
        in_scope_score = float(np.max(_in_scope_embeddings @ query_emb))
        out_scope_score = float(np.max(_out_scope_embeddings @ query_emb))

        # Check if input looks like an order ID (32-char hex)
        import re
        is_order_id = bool(re.search(r'\b[a-f0-9]{32}\b', user_input.lower()))
        
        # If it's an order ID or in_scope_score wins, consider it in scope
        is_in_scope = is_order_id or in_scope_score >= out_scope_score or in_scope_score > 0.30

        if not is_in_scope:
            final_response = (
                "I'm Olist's customer support assistant — I can help with orders, "
                "deliveries, returns, payments, and seller queries. "
                f"Your question doesn't seem related to Olist support "
                f"(confidence: {in_scope_score:.2f}). Could you ask me something "
                "about your Olist experience?"
            )
            print(f"[scope_validator] Out of scope detected (score: {in_scope_score:.2f})")
            return {
                "scope_score": in_scope_score,
                "scope_checked": True,
                "scope_valid": False,
                "final_response": final_response,
                "messages": [
                    HumanMessage(content=state.get("user_input", "")),
                    AIMessage(content=final_response)
                ]
            }
        else:
            print(f"[scope_validator] In scope (score: {in_scope_score:.2f})")
            return {
                "scope_score": in_scope_score,
                "scope_checked": True,
                "scope_valid": True
            }
    except Exception as e:
        # On any failure, default to in-scope (fail open — better UX)
        print(f"[scope_validator] Exception: {str(e)}")
        return {
            "scope_checked": False,
            "scope_valid": True,
            "error_log": state.get("error_log", []) + [f"[scope_validator] Error: {str(e)}"]
        }