AI EngineeringZero to ProductionHome·About·Contact
Project 2 · Design Chapter

Customer Support Agent

The most widely-deployed agent in business today: it answers customer questions from your own knowledge base with citations, drafts and sends replies, files and updates tickets, and hands off to a human the moment it's unsure. A RAG-first project — the cleanest way to turn Chapters 3–6 into something a company would pay for.

🎯 Intermediate📚 RAG-heavy🔥 most common in production🎧 CX / support
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • an Anthropic API key (ANTHROPIC_API_KEY) + pip install anthropic
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

What this project teaches you to design

  • A grounded, cited Q&A system that refuses when it doesn't know.
  • A confidence + escalation model — the support-specific safety net.
  • Tools for the real workflow: search KB, draft reply, create/update ticket, escalate.
  • Support-specific evals: groundedness, resolution rate, escalation precision.

The brief advanced

"Deflect the repetitive support tickets so agents handle only what needs a human." Most support volume is the same few dozen questions answered in the docs. An agent that resolves those instantly — accurately, with sources, and knowing when to escalate — cuts response time and frees humans for the hard cases.

1 · Discovery — where does support time go? advanced

Where time goesAgent leverage
Answering the same "how do I…" questions⭐⭐⭐ high — RAG over the help center
Looking up account/order status⭐⭐ medium — a read-only lookup tool
Drafting replies in the right tone⭐⭐⭐ high — draft, human sends
Triaging & routing tickets⭐⭐ medium — classify + tag
Genuinely novel / angry / high-stakes issues⭐ low — escalate to a human
Problem statement"When a customer asks a question already answered in our docs, an agent spends minutes finding and rewording the answer. If a bot answered those instantly with a citation — and escalated anything it wasn't confident about — we'd cut first-response time and deflect the bulk of tickets, while never giving a wrong answer with false confidence."

2 · Architecture advanced

Customerchat / email GuardrailsPII / abuse RAG: help centercited answers Support agentanswer · draft · route confidence gate order lookup ticket CRUD draft reply Resolved + cited Escalate to human
🗺️ How to read this diagram

This is the whole system on one line: a customer's message flows left-to-right through safety checks, into the agent, and out to one of two endings. Follow the blue arrows.

  • Far left, Customer (chat / email) is where a question enters. It first hits Guardrails — a filter that strips or blocks PII (personal data) and abuse before the agent ever sees the text.
  • The Support agent (the purple box in the middle) is the brain: it can answer, draft a reply, or route the conversation. Above it sits RAG: help center — the search over your docs that gives the agent cited facts to answer from.
  • Below the agent is the confidence gate — the single most important box. It asks "is the agent sure enough, and is the answer backed by a real doc?" and decides which of the two right-hand endings happens.
  • The three small boxes on the right (order lookup, ticket CRUD, draft reply) are the agent's tools — actions it can take, like looking up an order or filing a ticket.
  • The two green/red boxes are the only two outcomes: Resolved + cited (the agent answered, with sources) or Escalate to human (it wasn't sure, so it hands off safely).

In short: a message goes Customer → Guardrails → Agent (helped by RAG) → confidence gate → either a cited answer or a human. When unsure, it escalates rather than guesses.

The Chapter 4 agent, grounded by Chapter 3 RAG over your help center, with a confidence gate that routes low-confidence or high-stakes conversations to a human instead of guessing.

3 · Risk & safety model advanced

Support's risks aren't "delete prod" — they're wrong answers stated confidently, leaking another customer's data, and mishandling an upset customer. The controls:

RiskControl
🟠 Confident wrong answer (hallucination)Answer only from retrieved docs, always cite, and say "I don't know → escalate" when unsupported (Ch 3 grounding rules)
🔴 Leaking another customer's dataEvery lookup tool filters by the authenticated customer ID — enforced in code, never trusted to the model (Ch 3 tenant filter, Ch 6 guardrails)
🟡 Sending a bad replyStart in draft mode — human sends; auto-send only for high-confidence FAQ later
🟠 Frustrated / high-stakes customerSentiment + keyword triggers → immediate human escalation; the agent never argues
🟠 Prompt injection via a ticketTreat customer text as untrusted data; it can't change the agent's instructions (Ch 6)
The golden rule of support agentsA confident wrong answer is worse than "let me get a human." The single most important design choice is a low-confidence → escalate path, tested by an eval. Trust is the product.

4 · Tool surface advanced

ToolDoesRisk
search_kbRAG over help center / docs — returns cited passages🟢 read-only
get_order_statusLook up this customer's order (ID-filtered)🟢 read-only (scoped)
get_accountThis customer's plan/status (ID-filtered)🟢 read-only (scoped)
draft_replyCompose a suggested response for human review🟡 reversible (draft)
create_ticket / update_ticketFile or tag a ticket in the helpdesk🟡 reversible
send_replyActually send to the customer🟠 gated — human-approved (or auto only for proven FAQ)
escalateHand the conversation to a human with a summary🟢 always safe — the escape hatch

5 · Knowledge — RAG done right advanced

This is a RAG-first project, so the Chapter 3 quality levers are the product quality:

6 · Evaluation expert

EvalMeasures
GroundednessIs every sentence supported by a cited doc? (LLM-judge, Ch 5)
Answer relevanceDid it address what the customer actually asked?
Retrieval recall@kDid the answer-bearing article make the top-k? (Ch 3)
Escalation precisionOf things it escalated, how many truly needed a human? And — critically — did it escalate the ones it should have, instead of guessing?
Deflection rate% of conversations fully resolved without a human (the business metric)
No-PII-leak (deterministic)Never returns data for a different customer ID
The hard-fail eval here"Does the agent ever answer with a claim not in the retrieved docs?" and "does it ever return another customer's data?" Both must be zero. These gate the build, exactly like the DevOps agent's safety eval.

7 · Phased rollout expert

Phase 1 · Suggest to agents — the bot drafts answers & citations in the agent console; humans edit & send. Builds trust + a golden set. (Ch 3, 2)
Phase 2 · Auto-answer easy FAQ — for high-confidence, well-covered questions, reply directly; everything else stays human. (Ch 5 gates which questions qualify)
Phase 3 · Full front-line + smart escalation — the bot handles first contact, resolves what it can, escalates the rest with a summary. (Ch 6 hardening)
Never — argue with a customer, make promises/refunds without policy + approval, or answer outside the knowledge base.

Skills & course map expert

SkillLearn it in
Grounded, cited RAG answersCh 3
Confidence field + structured routingCh 2
Tools (lookup, ticket, escalate) + the loopCh 4
Groundedness / escalation evalsCh 5
PII guardrails, tenant scoping, deployCh 6
Pydantic schema for the answer objectPython P4

Build-along plan expert

  1. Ch 3 base: index a real help center (or sample docs), get cited answers working, prove it refuses on out-of-KB questions.
  2. Add a confidence field (Ch 2 Pydantic) and route confidence < thresholdescalate.
  3. Add tools (Ch 4): a mock order-lookup (ID-filtered), draft_reply, escalate.
  4. Evals (Ch 5): a golden set of real questions; measure groundedness + escalation precision; add the no-PII-leak hard-fail.
  5. Harden (Ch 6): PII redaction, tenant scoping in code, logging, then pilot in draft mode.
Best first project for most peopleIf you want one deployable, résumé-worthy agent fast, this is it — it's mostly Chapter 3 done well, plus a confidence gate. Want full build labs + runnable code for it? Ask and I'll build them like the DevOps capstone.
🛠️ Hands-on build — everything below is on this pageThe rest of this page is the complete, self-contained build: set up from an empty folder, paste in every file, run it (with a mock, so no API key is needed), and pass the tests. Follow it top to bottom — no other page required.

Learning objectives

  • Build a keyword KB retriever (swap-in point for the Ch 3 vector store).
  • Return a validated SupportAnswer with citations & confidence.
  • Enforce the confidence/grounding gate in code, not the prompt.
  • Guarantee tenant isolation and test it without an API key.

What you'll build expert

A support agent that answers only from your KB with citations, escalates when unsure, and can look up a customer's own orders — never another's. It's a single structured call (Tier 1) plus a code-enforced safety gate.

Finished code includedEverything below already exists complete in llm-course-starter/support-agent/. Type it out to learn, or read & run it. The design rationale is in the design chapter.

Step 1 · Skeleton & the answer schema expert

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1
terminalcd llm-course-starter && source .venv/bin/activate
mkdir -p support-agent/{agent,kb,tests}
cd support-agent && touch agent/__init__.py
agent/schemas.pyfrom typing import Literal, Optional
from pydantic import BaseModel, Field

class SupportAnswer(BaseModel):
    answer: str
    citations: list[str] = Field(default_factory=list)
    confidence: float = Field(ge=0.0, le=1.0)
    action: Literal["answer", "escalate"]
    escalation_reason: Optional[str] = None
▶ How this works

Before writing any logic, we declare the exact shape of an answer we will accept from the model. This is a Pydantic model — a data blueprint that automatically rejects anything malformed. Every field here exists to make the safety gate possible.

  1. answer is the reply text; citations is a list of KB article ids (it starts empty via default_factory=list, so an un-cited answer is the default, not a crash).
  2. confidence is a number the model reports about itself, and Field(ge=0.0, le=1.0) forces it to stay between 0 and 1 — Pydantic will reject 1.5 or -3.
  3. action is a Literal["answer", "escalate"]: the model is only allowed to pick one of those two words — it literally cannot invent a third option.
  4. escalation_reason is Optional (may be None) — filled in only when the agent hands off, so a human knows why.

What the output means: Nothing runs yet — this is a definition. Later, when the model replies, Pydantic checks the reply against this shape and gives you a clean, typed SupportAnswer object (or a clear error).

Try this: Imagine the model returns confidence: 2.0. Because of le=1.0, Pydantic rejects it — a bug is caught at the door instead of silently sailing through your gate.

The schema encodes the policyaction is a Literal — the model must pick answer or escalate. citations + confidence are what the gate inspects. This is Ch 2 structured output + Python P4.

Step 2 · KB retrieval (RAG grounding) expert

Step 2

Each ## [kb-N] Title section of kb/faq.md becomes one citable chunk. The retriever scores by keyword overlap (swap in the Ch 3 VectorStore for semantic search — same retrieve() interface).

Setup to run this snippet
class _Any:
    '''stands in for any undefined demo value; supports call/attr/index/
    iteration and basic arithmetic (as 0.7) so demo snippets run.'''
    def __call__(self, *a, **k): return _Any()
    def __getattr__(self, k): return _Any()
    def __getitem__(self, k): return _Any()
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __contains__(self, o): return True
    def __enter__(self, *a): return _Any()
    def __exit__(self, *a): return False
    def __float__(self): return 0.7
    def __int__(self): return 1
    def __lt__(self, o): return True
    def __gt__(self, o): return False
    def __le__(self, o): return True
    def __ge__(self, o): return False
    def __add__(self, o): return o
    def __radd__(self, o): return o
    def __bool__(self): return True
    def __repr__(self): return 'demo'
    def __str__(self): return 'demo'
def retrieve(*a, **k):  # demo stub
    return _Any()
agent/kb.py (core)def context_for(query):
    hits = retrieve(query)                     # top-k by keyword overlap
    if not hits:
        return "(no relevant KB article found)", []
    ctx = "\n\n".join(f"[{c['id']}] {c['text']}" for c in hits)
    return ctx, [c["id"] for c in hits]   # ctx for the prompt, ids for the gate
terminalpython agent/kb.py       # no API key needed
cited: ['kb-1']
Resetting your password. Go to Settings > Security...
▶ How this works

This is the RAG grounding step: given a customer's question, find the most relevant help-center passages and package them so the model can answer from your docs rather than from memory. context_for is the one function the rest of the agent calls.

  1. hits = retrieve(query) searches the knowledge base and returns the best-matching chunks ("top-k" = the top few). Here it scores by keyword overlap; you could swap in the Chapter 3 vector search behind the same call.
  2. if not hits: means "if nothing matched." Rather than guess, it returns a clear "(no relevant KB article found)" message and an empty citation list [] — which later tells the gate to escalate.
  3. The ctx = "\n\n".join(...) line stitches the matching chunks into one text block, each tagged with its id like [kb-1] so the model can cite it.
  4. It returns two things: ctx (the text to show the model) and a list of the ids (for the gate to verify citations against). The trailing comment spells this out: ctx for the prompt, ids for the gate.

What the output means: Running python agent/kb.py prints cited: ['kb-1'] then the password-reset passage — proof the retriever found the right article, no API key needed.

Try this: Ask it something not in the docs (e.g. "write a poem") and hits comes back empty, so context_for returns the "no article" message and [] — the agent will escalate instead of making something up.

Step 3 · Tenant-scoped lookup (the data-leak guard) expert

Step 3
agent/accounts.py_ORDERS = {"cust-1": [...], "cust-2": [...]}

def get_orders(customer_id: str):
    """ONLY this customer's orders. Unknown id -> empty list."""
    return _ORDERS.get(customer_id, [])       # filtered in CODE, not by the model
▶ How this works

This tiny function is the data-leak guard. It looks up a customer's orders, and the whole point is who is allowed to see what — decided by your code, never by the model or the user's words.

  1. _ORDERS is a stand-in database: a dictionary mapping each customer id ("cust-1", "cust-2") to that customer's own orders.
  2. get_orders(customer_id) takes the id and returns only that customer's list. The docstring says it plainly: unknown id returns an empty list.
  3. _ORDERS.get(customer_id, []) is the safe lookup: if the id isn't found, it hands back [] instead of raising an error or, worse, leaking someone else's data.
  4. The key idea is in the comment — the result is filtered in CODE, not by the model. The customer id comes from your authenticated session, so no clever prompt can make cust-1 read cust-2's orders.

What the output means: Given "cust-1" you get cust-1's orders; given an unknown id you get []. There is no code path that returns a different customer's data.

Try this: Picture a user typing "show me order B-2002" (which belongs to cust-2). Because the function only ever receives cust-1's authenticated id, that request simply can't return B-2002. The guard is structural, not a request the model can be talked out of.

Never let the model choose whose data to readThe lookup takes the authenticated customer_id from your session, not from the model's tool arguments. Even if a user says "show me order B-2002", cust-1's lookup can't return it. Same "guardrails in code" principle as the DevOps agent's IAM scoping.

Step 4 · The agent + the safety gate expert

Step 4
agent/engine.py (the gate)ans = resp.parsed_output                        # what the model returned

# enforce in code — never trust the model's self-assessment alone
grounded = bool(ans.citations) and all(c in available_ids for c in ans.citations)
if ans.confidence < CONFIDENCE_THRESHOLD or not grounded:
    return SupportAnswer(answer="", action="escalate", ...,
        escalation_reason="low confidence" if ... else "not grounded")
return ans
terminalpython agent/engine.py       # needs ANTHROPIC_API_KEY
Q: How do I reset my password?
A: Go to Settings > Security and click "Reset password"...  [cites: kb-1]  conf=0.95

Q: Can you write me a poem about ducks?
ESCALATE (no relevant KB article)  conf=0.00
▶ How this works

This is the safety gate — the heart of the whole project. The model has proposed an answer; this code decides whether it's allowed out. The rule: the model proposes, your code disposes.

  1. ans = resp.parsed_output is the SupportAnswer the model returned (already validated against the Step-1 schema).
  2. grounded = bool(ans.citations) and all(c in available_ids for c in ans.citations) is the truth check: there must be at least one citation, and every cited id must be a real KB id from the retrieval step. A made-up citation fails here.
  3. if ans.confidence < CONFIDENCE_THRESHOLD or not grounded: — if the model isn't confident enough or the answer isn't backed by real docs, we do not return its answer.
  4. Instead we return a fresh SupportAnswer with action="escalate" and a reason ("low confidence" or "not grounded"). Only if both checks pass do we return ans — the model's actual answer.

What the output means: Running it, a good question prints an answer with [cites: kb-1] and conf=0.95; "write me a poem" prints ESCALATE (no relevant KB article) conf=0.00.

Try this: Even if the model lies and claims confidence=0.99 on a hallucinated answer with a fake citation, grounded is False (the id isn't real), so the gate escalates anyway. The agent physically cannot emit a confident, ungrounded answer.

Why enforce grounding in codeThe model might claim high confidence on a hallucinated answer. By checking that its citations are real KB ids and confidence clears the bar — in code — the agent physically cannot return a confident, ungrounded answer. The model proposes; your code decides.

5 · Tests (no API key) expert

Step 5
terminalpython -m pytest tests/ -v
test_kb_retrieves_password_article PASSED
test_kb_returns_nothing_for_offtopic PASSED
test_tenant_isolation PASSED
test_unknown_customer_gets_nothing PASSED
4 passed
▶ How this works

These tests prove the two things that matter most — grounding and no data leaks — without needing an API key. They exercise the retriever and the tenant guard directly, so you can trust the safety net before spending a cent on the model.

  1. python -m pytest tests/ -v runs every test file in the tests/ folder; -v (verbose) lists each test name and its result.
  2. test_kb_retrieves_password_article confirms retrieval finds the right article, and test_kb_returns_nothing_for_offtopic confirms it returns nothing for an unrelated question (so the agent will escalate, not guess).
  3. test_tenant_isolation is the important one: it checks cust-1 can never see cust-2's orders. test_unknown_customer_gets_nothing checks an unknown id leaks nothing.

What the output means: 4 passed with every line marked PASSED means all four guarantees hold. If any turned to FAILED, that safety property is broken and you must fix it before shipping.

Try this: Temporarily change get_orders to return all orders, re-run, and watch test_tenant_isolation flip to FAILED — that's the test doing its job of catching a data leak.

✅ Test cases
TestProves
retrieves the right articleRAG grounding works
returns nothing off-topicengine will escalate, not guess
tenant isolationcust-1 can never see cust-2's data
unknown customer → emptyno data leak to an attacker

6 · Evals (needs key) expert

Step 6
terminalpython evals.py
[PASS] answer   How do I reset my password?
[PASS] escalate Do you integrate with SAP?
answer accuracy:   3/3
escalation safety: 2/2
✅ evals passed

The eval hard-fails if the agent ever answers an out-of-KB question — the support equivalent of the DevOps agent's "never exceed your rung" safety eval.

▶ How this works

Tests check the plumbing; evals check the judgement — does the agent answer the questions it should and escalate the ones it shouldn't? This needs the real model (an API key), because it's grading actual answers.

  1. python evals.py runs a small graded set of real questions through the full agent.
  2. Each line shows the verdict: [PASS] answer How do I reset my password? means it correctly answered an in-KB question; [PASS] escalate Do you integrate with SAP? means it correctly escalated an out-of-KB one.
  3. answer accuracy: 3/3 and escalation safety: 2/2 summarise how many of each category it got right.

What the output means: ✅ evals passed means the agent both answered known questions and escalated unknown ones correctly. The eval hard-fails if it ever answers an out-of-KB question — the one thing a support agent must never do.

Try this: Add a question you know isn't in the KB to the eval set. The agent must escalate it; if it tries to answer, the eval fails on purpose — that's the guardrail that keeps a confident wrong answer from ever reaching a customer.

Troubleshooting expert

⚠️ Common issues
SymptomFix
Agent answers off-topic questionsConfirm the gate checks not grounded; verify context_for returns [] for off-topic (it should escalate before calling the model)
Citations don't match KB idsEnsure the [kb-N] ids in the prompt match your faq.md headers; the gate rejects citations not in available_ids
Everything escalatesThreshold too high, or KB too small/irrelevant; lower CONFIDENCE_THRESHOLD or add articles
ModuleNotFoundError: agentcd support-agent first; ensure agent/__init__.py exists
No module named pydanticpip install -r ../requirements.txt in the active venv

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Scaffold: the answer schemaBeginner

Context: A typed answer contract is the backbone of the agent — both the safety gate and the UI depend on it. Validating in __post_init__ lets it run without pydantic here while the real project uses it.

Your task: Define the SupportAnswer shape (answer, citations, confidence 0-1, action in {answer, escalate}, optional escalation_reason) and prove valid/invalid instances.

Requirements:

  • Fields: answer, action, confidence, citations, escalation_reason
  • action is restricted to answer or escalate
  • confidence is bounded to [0, 1]
  • Validation runs in __post_init__
  • A bad confidence is rejected

💡 Hint: A dataclass with asserts in __post_init__ gives a typed contract with no external dependency; the real project swaps in pydantic.

Show solution

Design. A typed contract is the backbone — the gate and the UI both depend on it. Use a " "dataclass with validation in __post_init__ so it runs without pydantic here (the real project " "uses pydantic).

from dataclasses import dataclass, field

@dataclass
class SupportAnswer:
    answer: str
    action: str                       # 'answer' | 'escalate'
    confidence: float = 0.0
    citations: list = field(default_factory=list)
    escalation_reason: str = None
    def __post_init__(self):
        assert self.action in ("answer", "escalate")
        assert 0.0 <= self.confidence <= 1.0

ok = SupportAnswer("Your plan renews monthly.", "answer", 0.9, ["kb1"])
print(ok.action, ok.citations)          # answer ['kb1']
try: SupportAnswer("x", "answer", confidence=1.5)
except AssertionError: print("rejected bad confidence")
Exercise 2 · Core feature: KB retrieval for groundingIntermediate

Context: RAG grounding needs two outputs: the prose context for the prompt and the list of article ids for the gate. Grounding is only enforceable if you know which sources were available.

Your task: Add context_for(query) that retrieves top-k KB articles by keyword overlap and returns both the context string and the list of article ids.

Requirements:

  • Retrieval ranks KB articles by keyword overlap
  • Returns both a context string and the id list
  • The ids are what the gate later verifies citations against
  • A no-match query returns a clear no-context marker and empty ids
  • Only articles with actual overlap are returned

💡 Hint: Return the prose and the ids together; the ids are the contract the safety gate checks citations against, so a no-match must yield an empty id list.

Show solution

Design. Return two things: the prose context and the id list. The ids are what the safety gate " "later verifies citations against — grounding is only enforceable if you know which sources were " "available.

import re
def toks(s): return set(re.findall(r"[a-z0-9]+", s.lower()))
KB = {"kb1": "Plans renew on the first of each month.",
      "kb2": "Refunds are issued within 30 days."}

def retrieve(query, k=2):
    q = toks(query)
    scored = sorted(KB, key=lambda i: len(q & toks(KB[i])), reverse=True)
    return [i for i in scored[:k] if q & toks(KB[i])]

def context_for(query):
    hits = retrieve(query)
    if not hits: return "(no relevant KB article found)", []
    ctx = "\n\n".join(f"[{i}] {KB[i]}" for i in hits)
    return ctx, hits

ctx, ids = context_for("when does my plan renew")
print(ids)                 # ['kb1']
print(context_for("weather"))   # ('(no relevant KB article found)', [])
Exercise 3 · Harder variant: tenant-scoped account lookup (the data-leak guard)Advanced

Context: Account tools must never leak across customers. The isolation lives in the tool, not the prompt: it keys strictly on the customer id and defaults to empty, so the model cannot request another tenant's data.

Your task: Add get_orders(customer_id) that returns only that customer's orders and empty for an unknown id, with filtering done in code.

Requirements:

  • Orders are keyed by customer id
  • A customer sees only their own orders
  • An unknown id returns empty, leaking nothing
  • Filtering happens in code, not by trusting the model
  • One customer's call never returns another's data

💡 Hint: Key on customer_id and default to an empty list; the model literally can't reach another tenant's data through a function that only looks up its own key.

Show solution

Design. Tenant isolation is enforced in the tool, not the prompt. The function keys strictly " "on customer_id and defaults to empty — the model literally cannot request another tenant's " "data through it.

_ORDERS = {
    "cust-1": [{"id": "o1", "item": "Widget"}],
    "cust-2": [{"id": "o2", "item": "Gadget"}],
}
def get_orders(customer_id):
    return _ORDERS.get(customer_id, [])       # filtered in CODE

print(get_orders("cust-1"))       # [{'id': 'o1', ...}]
print(get_orders("cust-2"))       # [{'id': 'o2', ...}] -- never cust-1's
print(get_orders("attacker"))     # [] -- unknown id leaks nothing
Exercise 4 · Subtle correctness: the code-enforced grounding gateExpert

Context: The keystone is a code-enforced grounding gate. After the model returns a SupportAnswer, code verifies it may cite only available ids and forces an escalate on low confidence or ungrounded citations — never trusting the model's self-assessment.

Your task: Enforce the gate: after the model returns a SupportAnswer, allow only citations to available ids and force an escalate on low confidence or ungrounded citations.

Requirements:

  • Every citation must be in the available id set
  • Citations must be non-empty to count as grounded
  • Confidence below a floor forces an escalate
  • An ungrounded or low-confidence answer escalates with a reason
  • A valid, grounded, confident answer passes through

💡 Hint: Verify citations against available_ids and floor the confidence; any failure returns an escalate with a reason instead of a fabricated answer.

Show solution

Design. The gate is the difference between a demo and a safe agent: verify every citation is in " "available_ids, require non-empty citations, and floor confidence. Any failure -> escalate " "with a reason, not a fabricated answer.

from dataclasses import dataclass, field
@dataclass
class SupportAnswer:
    answer: str; action: str; confidence: float = 0.0
    citations: list = field(default_factory=list); escalation_reason: str = None

def gate(ans, available_ids, min_conf=0.6):
    grounded = bool(ans.citations) and all(c in available_ids for c in ans.citations)
    if ans.confidence < min_conf or not grounded:
        return SupportAnswer("", "escalate", ans.confidence, [],
            escalation_reason="low confidence" if ans.confidence < min_conf
                              else "not grounded")
    return ans

avail = ["kb1", "kb2"]
good = SupportAnswer("Renews monthly.", "answer", 0.9, ["kb1"])
bad  = SupportAnswer("Made up.", "answer", 0.9, ["kb9"])   # invented citation
print(gate(good, avail).action)                       # answer
print(gate(bad, avail).action, gate(bad, avail).escalation_reason)  # escalate not grounded
Exercise 5 · Production concerns: idempotent handling + a retry/circuit breakerProfessional

Context: Support requests get retried by double-clicks and network retries. The handler must be idempotent on a request id and wrap the model call in a circuit breaker that escalates fast after repeated failures.

Your task: Make handle idempotent on a request id (same id → cached answer, no duplicate side effects) and wrap the model call in a circuit breaker.

Requirements:

  • A seen request id returns the cached result and re-runs no side effects
  • New request ids are processed and cached
  • After N consecutive failures the circuit opens
  • An open circuit escalates immediately instead of calling the model
  • A success resets the failure count

💡 Hint: Cache by request id for idempotency and count consecutive failures for the breaker; an open circuit should escalate fast rather than hammer a downed dependency.

Show solution

Design. Idempotency: cache by request id so a retry returns the first result and never " "re-runs side effects. Circuit breaker: after N consecutive failures, open the circuit and escalate " "immediately instead of hammering a downed dependency.

class Support:
    def __init__(self, fail_max=2):
        self.cache = {}; self.fails = 0; self.fail_max = fail_max
    def handle(self, req_id, msg, model):
        if req_id in self.cache:                 # idempotent replay
            return self.cache[req_id]
        if self.fails >= self.fail_max:          # circuit OPEN
            return {"action": "escalate", "reason": "circuit open"}
        try:
            result = {"action": "answer", "answer": model(msg)}
            self.fails = 0
        except Exception:
            self.fails += 1
            result = {"action": "escalate", "reason": "model error"}
        self.cache[req_id] = result
        return result

def flaky(msg): raise RuntimeError("down")
s = Support(fail_max=2)
print(s.handle("r1", "hi", flaky)["reason"])   # model error
s.handle("r2", "hi", flaky)
print(s.handle("r3", "hi", flaky)["reason"])   # circuit open -- fast escalate
print(s.handle("r1", "hi", flaky)["reason"])   # model error -- cached replay, not re-run
Exercise 6 · Real-world: structured outputs with Claude + an offline eval harnessIndustry scenario

Context: Shipping uses structured outputs so the model must fill the SupportAnswer schema, removing parsing risk, and an offline eval harness turns 'seems fine' into groundedness and correct-escalation numbers.

Your task: Get a real SupportAnswer via the Anthropic SDK's structured outputs (needs a key) and build an offline eval set (question → expected action) scoring groundedness and correct-escalation rate.

Requirements:

  • The real call uses structured outputs to fill the typed schema
  • The same code gate runs on the model's output
  • An eval set maps questions to expected actions
  • The harness scores groundedness and correct-escalation rate
  • The eval runs offline

💡 Hint: Structured outputs return a typed SupportAnswer so no parsing is needed; the eval set checks it escalates when it should and never cites an unavailable source.

Show solution

Design. Structured outputs remove parsing risk — the model returns a typed " "SupportAnswer, then the same code gate runs. The eval harness turns 'seems fine' into " "numbers: does it escalate when it should, and never cite unavailable sources?

# --- real structured call (needs creds / API key) ---
# from pydantic import BaseModel
# import anthropic
# class SupportAnswer(BaseModel):
#     answer: str; citations: list[str]; confidence: float; action: str
# client = anthropic.Anthropic()
# def ask(msg, context):
#     r = client.messages.parse(model="claude-opus-4-8", max_tokens=400,
#         system="Answer only from context; cite article ids; set action.",
#         messages=[{"role":"user","content":f"{context}\nQ: {msg}"}],
#         output_format=SupportAnswer)
#     return r.parsed_output

# --- offline eval harness (runnable) ---
EVALS = [
    ("when does my plan renew", "answer"),   # in KB -> should answer
    ("can I sue you",           "escalate"),  # not in KB -> should escalate
]
def mock_agent(q):
    kb = {"plan", "renew"}
    hit = bool(kb & set(q.lower().split()))
    return "answer" if hit else "escalate"

correct = sum(1 for q, want in EVALS if mock_agent(q) == want)
print(f"escalation accuracy: {correct}/{len(EVALS)}")   # 2/2

✓ Checkpoint — done when…

  • KB retrieval returns the right article and nothing for off-topic.
  • The agent answers with citations and escalates when unsure.
  • Tenant isolation tests pass with no API key.
  • The eval passes, including the "never answer out-of-KB" hard-fail.
📋 Master rubric — grade your support agent
DimensionMeets the barAbove the bar (staff-level)
Grounding & citationEvery answer cites the KB article(s) it drew from; the confidence/grounding gate is enforced in code, and low-grounding answers refuse rather than guess.Citations are span-level (which sentence, not just which doc); the gate is tuned against a labelled set so refusals track real ignorance, not model mood.
Escalation precisionThe agent escalates when it is unsure or the query needs a human, and escalation precision/recall is measured — not just an on/off flag.Escalation thresholds are cost-weighted (a wrong auto-answer on billing costs more than a needless handoff) and the mix is monitored for drift over time.
Tenant isolationA tenant can never retrieve another tenant's KB; isolation is proven by a test that runs with no API key.Isolation is enforced at the retrieval layer (filter before rank), covered by an adversarial cross-tenant probe, and a leak fails CI hard.
Resolution qualityResolution rate and groundedness are measured on a held-out ticket set, not eyeballed on a demo.Evals separate deflected-and-correct from deflected-and-wrong; a wrong auto-resolution is scored as worse than an escalation, matching real support economics.
Cost & latencyPer-conversation token/dollar cost and p95 latency are tracked; retrieval is bounded (top-k cap) so a long thread can't blow the budget.Cost is bounded on the tail, not the mean; the answer streams, and cheap KB hits skip the expensive path entirely.
ObservabilityStructured logs carry request_id, tenant, retrieved_ids, confidence, and the escalate/answer verdict for every turn.Logs feed a dashboard that alerts on falling groundedness or a spiking escalation rate before customers file complaints.

Score each row 0 (missing) / 1 (meets) / 2 (above). A passing support build is 9+/12 with tenant isolation at 2 — a cross-tenant leak or an ungated hallucination is an automatic fail regardless of the total, because both erode the trust the deflection depends on.

Knowledge check check yourself

✓ Knowledge check

What is the role of the confidence gate in the support agent, and what are its two possible outcomes?

Show answer
The gate asks whether the agent is confident enough and whether the answer is backed by a real retrieved doc, then routes to one of two endings: a resolved, cited answer, or escalation to a human. When unsure it escalates rather than guessing, so it never gives a wrong answer with false confidence.
✓ Knowledge check

Why is the support agent grounded in RAG over the help center with citations, instead of answering from the model's own knowledge?

Show answer
Grounding forces every answer to come from a real, cited help-center document so a human can verify it, and lets the agent refuse or escalate when the docs don't cover a question. Answering from parametric memory risks confident, unsourced hallucinations in front of a customer.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in