File size: 12,222 Bytes
8981bf6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"""The support agent: intent routing, RAG answers, order lookup, refunds, escalation.

This is the brain. Given a customer message (and optional order id / email) it:

1. classifies intent (order status, refund/return, or general question);
2. gathers grounded context (mock orders API and/or KB retrieval);
3. produces an answer via the LLM provider, with citations;
4. decides whether to auto-resolve or escalate to a human, with a confidence score.

Determinism note: intent routing, refund logic, and the escalation decision are
rule-based so behaviour is testable and reproducible. The provider only phrases the
final natural-language answer, and the offline stub keeps even that deterministic.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from datetime import date
from typing import Sequence

from .config import Settings, get_settings
from .knowledge import Document, load_knowledge_base
from .orders import Order, OrdersService
from .providers import LLMProvider, get_provider
from .refunds import RefundDecision, decide_refund
from .retrieval import RetrievalResult, Retriever

# Intent keyword cues. Order/refund intents are detected before falling back to a
# general knowledge-base answer.
_ORDER_TERMS = re.compile(
    r"\b(where.s my order|order status|status of (my |the )?order|status of my|"
    r"tracking|track my|shipped|deliver(ed|y)?|cancel(led|lation)?|"
    r"did my .* (ship|cancel))\b",
    re.IGNORECASE,
)
_REFUND_TERMS = re.compile(
    r"\b(refund|return|money back|send .* back|exchange|warranty|defect|broke|broken|"
    r"snapped|replacement)\b",
    re.IGNORECASE,
)
_ORDER_ID = re.compile(r"\bNW-\d{3,}\b", re.IGNORECASE)
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")

# Phrases that should always reach a human regardless of confidence (frustration,
# legal/financial risk, manager requests).
_HARD_ESCALATION = re.compile(
    r"\b(speak to a manager|talk to a human|lawyer|legal|charged twice|double charge|"
    r"double charged|fraud|chargeback|compensation|unacceptable|ruined|complaint)\b",
    re.IGNORECASE,
)


@dataclass(frozen=True)
class Citation:
    source_id: str
    title: str
    kind: str  # "policy" | "product" | "order"
    snippet: str


@dataclass(frozen=True)
class AgentResponse:
    intent: str
    answer: str
    citations: list[Citation] = field(default_factory=list)
    confidence: float = 0.0
    escalated: bool = False
    auto_resolved: bool = False
    refund: RefundDecision | None = None
    order: Order | None = None

    def to_dict(self) -> dict:
        return {
            "intent": self.intent,
            "answer": self.answer,
            "confidence": round(self.confidence, 3),
            "escalated": self.escalated,
            "auto_resolved": self.auto_resolved,
            "citations": [c.__dict__ for c in self.citations],
            "refund": self.refund.__dict__ if self.refund else None,
            "order": {
                "order_id": self.order.order_id,
                "status": self.order.human_status(),
            }
            if self.order
            else None,
        }


def classify_intent(message: str) -> str:
    """Return one of ``order_status`` | ``refund`` | ``general``."""
    if _REFUND_TERMS.search(message):
        return "refund"
    if _ORDER_TERMS.search(message):
        return "order_status"
    return "general"


def _snippet(text: str, limit: int = 220) -> str:
    text = " ".join(text.split())
    return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"


class SupportAgent:
    """Stateless support agent. Construct once, call :meth:`handle` per ticket."""

    def __init__(
        self,
        settings: Settings | None = None,
        documents: Sequence[Document] | None = None,
        orders: OrdersService | None = None,
        provider: LLMProvider | None = None,
    ):
        self.settings = settings or get_settings()
        docs = list(documents) if documents is not None else load_knowledge_base(self.settings.data_dir)
        self.retriever = Retriever(docs)
        self.orders = orders or OrdersService(self.settings.data_dir / "orders.json")
        self.provider = provider or get_provider(self.settings)

    # -- public API ---------------------------------------------------------------

    def handle(
        self,
        message: str,
        order_id: str | None = None,
        email: str | None = None,
        today: date | None = None,
    ) -> AgentResponse:
        """Process one customer message and return a structured response."""
        order_id = order_id or self._extract_order_id(message)
        email = email or self._extract_email(message)
        intent = classify_intent(message)

        # Hard escalations short-circuit everything: frustration / legal / billing.
        if _HARD_ESCALATION.search(message):
            return AgentResponse(
                intent=intent,
                answer=(
                    "I want to make sure this is handled properly, so I'm connecting "
                    "you with a human support specialist who can help right away."
                ),
                confidence=0.0,
                escalated=True,
                auto_resolved=False,
            )

        if intent == "order_status":
            return self._handle_order_status(message, order_id, email)
        if intent == "refund":
            return self._handle_refund(message, order_id, email, today)
        return self._handle_general(message)

    # -- intent handlers ----------------------------------------------------------

    def _handle_order_status(
        self, message: str, order_id: str | None, email: str | None
    ) -> AgentResponse:
        if not order_id:
            return self._escalate_for_info(
                "order_status",
                "I can look that up right away — could you share your order number "
                "(it looks like NW-1234)?",
            )
        order = self.orders.get(order_id, email)
        if order is None:
            return self._escalate_for_info(
                "order_status",
                f"I couldn't find an order matching {order_id}. A teammate will help "
                "you verify the details.",
            )
        answer = f"Order {order.order_id} is currently {order.human_status()}."
        if order.status == "processing":
            answer += " It hasn't shipped yet; you'll get tracking by email once it does."
        elif order.status == "cancelled":
            answer += " No charge is collected for cancelled orders."
        citation = Citation(
            source_id=order.order_id,
            title=f"Order {order.order_id}",
            kind="order",
            snippet=_snippet(
                f"Status {order.status}; "
                + ", ".join(f"{i['qty']}x {i['name']}" for i in order.items)
            ),
        )
        return AgentResponse(
            intent="order_status",
            answer=answer,
            citations=[citation],
            confidence=0.95,
            escalated=False,
            auto_resolved=True,
            order=order,
        )

    def _handle_refund(
        self,
        message: str,
        order_id: str | None,
        email: str | None,
        today: date | None,
    ) -> AgentResponse:
        if not order_id:
            # Refund questions with no order id are usually policy questions
            # ("what's your return policy?") — answer from the KB instead of stalling.
            return self._handle_general(message, force_intent="refund")
        order = self.orders.get(order_id, email)
        if order is None:
            return self._escalate_for_info(
                "refund",
                f"I couldn't find order {order_id} to process a return. A teammate "
                "will verify your details and help.",
            )

        decision = decide_refund(order, message=message, today=today)
        policy_ctx = self.retriever.search(decision.policy_citation, top_k=1)
        citations = [
            Citation(
                source_id=order.order_id,
                title=f"Order {order.order_id}",
                kind="order",
                snippet=_snippet(f"Total ${order.total:.2f}; status {order.status}"),
            )
        ]
        for r in policy_ctx:
            citations.append(
                Citation(
                    source_id=r.document.doc_id,
                    title=r.document.title,
                    kind=r.document.kind,
                    snippet=_snippet(r.document.text),
                )
            )

        if decision.outcome == "approve":
            answer = (
                f"Good news — your return for order {order.order_id} is approved. "
                f"{decision.reason} You'll be refunded ${decision.refund_amount:.2f} "
                "to your original payment method within 5 business days once we receive "
                "the item."
            )
        elif decision.outcome == "deny":
            answer = f"I looked into your return for order {order.order_id}. {decision.reason}"
        else:  # escalate
            answer = (
                f"Thanks for flagging this on order {order.order_id}. {decision.reason} "
                "I've routed this to a specialist who will follow up shortly."
            )

        escalated = decision.outcome == "escalate"
        return AgentResponse(
            intent="refund",
            answer=answer,
            citations=citations,
            confidence=0.4 if escalated else 0.9,
            escalated=escalated,
            auto_resolved=not escalated,
            refund=decision,
            order=order,
        )

    def _handle_general(self, message: str, force_intent: str | None = None) -> AgentResponse:
        results = self.retriever.search(message, top_k=self.settings.top_k)
        confidence = self._confidence(results)
        intent = force_intent or "general"

        if confidence < self.settings.escalation_threshold:
            return AgentResponse(
                intent=intent,
                answer=(
                    "I'm not fully confident I can answer that accurately, so I'm "
                    "passing you to a human teammate who can help."
                ),
                citations=[],
                confidence=confidence,
                escalated=True,
                auto_resolved=False,
            )

        context = [r.document.text for r in results]
        answer = self.provider.answer(message, context)
        citations = [
            Citation(
                source_id=r.document.doc_id,
                title=r.document.title,
                kind=r.document.kind,
                snippet=_snippet(r.document.text),
            )
            for r in results
        ]
        return AgentResponse(
            intent=intent,
            answer=answer,
            citations=citations,
            confidence=confidence,
            escalated=False,
            auto_resolved=True,
        )

    # -- helpers ------------------------------------------------------------------

    def _confidence(self, results: Sequence[RetrievalResult]) -> float:
        """Confidence = top retrieval score, lightly boosted by margin over #2.

        A clear winner (high top score, big gap to the runner-up) is more trustworthy
        than a flat distribution of weak matches.
        """
        if not results:
            return 0.0
        top = results[0].score
        margin = top - (results[1].score if len(results) > 1 else 0.0)
        return float(min(1.0, top + 0.25 * margin))

    def _escalate_for_info(self, intent: str, answer: str) -> AgentResponse:
        return AgentResponse(
            intent=intent,
            answer=answer,
            confidence=0.0,
            escalated=True,
            auto_resolved=False,
        )

    @staticmethod
    def _extract_order_id(message: str) -> str | None:
        m = _ORDER_ID.search(message)
        return m.group(0).upper() if m else None

    @staticmethod
    def _extract_email(message: str) -> str | None:
        m = _EMAIL.search(message)
        return m.group(0) if m else None