LaelaZ's picture
Deploy SupportCopilot to HF Spaces (Docker)
8981bf6 verified
"""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