AI EngineeringZero to ProductionHome·About·What’s new·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 API key for whichever vendor you follow (Claude or OpenAI):Either way, keep the key in a .env file (never in code) and let the zero-argument client pick it up from the environment.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

Picture the support inbox at 9am on a Monday. Two hundred tickets waiting, and if you actually read them you'd notice something deflating: the same handful of questions, over and over. "How do I reset my password?" "Where's my order?" "How do I change my plan?" A human agent spends the first hours of the day retyping answers that are already written down in the help center — while the genuinely tricky ticket, the angry enterprise customer, waits in the same queue behind the trivia.

That is the shape of the problem, and it's worth being precise about the job before you build anything. The brief is not "replace support." It's the far narrower, far more valuable "deflect the repetitive so humans get the hard cases sooner." Think of a good triage nurse: they don't treat everyone, they route — sending the sprained ankle to the fast lane and rushing the chest pain to a doctor. The value isn't in answering every question; it's in answering the easy, well-documented ones instantly and correctly, and knowing — reliably — which ones aren't easy. Why frame it this way and not "an AI that handles support"? Because the moment you promise to handle everything, you've promised the agent will answer questions the docs don't cover — which is exactly where it invents a confident, wrong answer in front of a paying customer. Scoping the brief to "the repetitive, documented tickets" is the first safety decision, made before a single line of code. The common mistake is optimizing for deflection rate alone: a bot that answers 95% of tickets by guessing on the 30% it shouldn't have touched is worse than one that answers 65% cleanly and escalates the rest.

"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.

naive brief: "replace support" every ticket bot answers confident wrong answer correct brief: "deflect the repetitive" every ticket triage / route easy +documented hard / novel cited answer human Scope is the first safety decision. "Replace support" (left) pushes even the tickets the docs don't cover into the bot, where it invents a confident wrong answer. "Deflect the repetitive" (right) triages first: the easy, documented tickets get a cited answer, and everything else goes to a human.
🗺️ How to read this diagram

Compare the two halves split by the dashed line: the left is the tempting-but-wrong brief, the right is the one this project actually builds. Follow the arrows down each side.

  • On the left, one path forces every ticket through the bot — including the ones the docs never covered — so the arrow lands in the red box: a confident wrong answer shipped straight to a customer.
  • On the right, the first stop is a triage / route step that splits the same tickets into two streams: easy + documented (green) and hard / novel (blue).
  • The payoff row is the two endings: the documented tickets get a cited answer from the bot, and everything else is handed to a human — no ticket the docs can't cover ever reaches the customer as a guess.

In short: narrowing the brief from "answer everything" to "answer the documented, route the rest" is what removes the confident-wrong-answer failure before you write any code.

1 · Discovery — where does support time go? advanced

Here's the trap that kills most support-bot projects: building for the tickets you imagine instead of the ones you actually have. A team excited about AI will build a dazzling conversational agent that reasons about complex edge cases — and then discover that 70% of real tickets are "I forgot my password" and the agent's clever reasoning was solving a problem nobody had. Discovery is the unglamorous step that stops you building the wrong thing.

The move is simple and you can do it in an afternoon: pull the last few hundred real tickets and sort them into buckets by where the human time actually went. You're looking for two signals at once — volume (which buckets are big?) and agent leverage (which of those can a grounded bot safely handle?). The sweet spot is the intersection: high-volume and highly documented. Answering repeat "how do I…" questions from the help center is the jackpot — huge volume, and the answer already exists in text a RAG system can cite. Genuinely novel, angry, or high-stakes issues are the opposite corner: low leverage, and the right design decision is to hand them straight to a human. The table below is that sort already done for a typical support desk; read the star ratings as "how much of this can we safely automate," not "how clever can the AI be." The point isn't to automate the hardest thing — it's to automate the highest-volume thing you can do safely, and route the rest.

sort real tickets by volume (→) and how safely a bot can handle them (↑) leverage ↑ ticket volume → safe but rare documented, low volume handle if cheap SWEET SPOT · automate repeat "how do I…" questions high volume + documented RAG answers & cites instantly tag & route low volume, needs judgement ESCALATE to a human novel · angry · high-stakes high volume ≠ safe to automate Automate the top-right, escalate the bottom-right. The prize is where high volume meets high leverage — documented, repetitive questions — not the hardest tickets. High volume alone (bottom-right) is a trap: those still need a human.
🗺️ How to read this diagram

It's a two-by-two grid. Read left-to-right for how common a ticket type is (volume), and bottom-to-top for how safely a grounded bot can handle it (leverage). Each quadrant is one kind of ticket.

  • The horizontal axis is volume (how many tickets arrive) and the vertical axis is agent leverage (how much a cited bot can safely do). The two dashed lines split the space into four boxes.
  • The green top-right box is the payoff: high-volume and highly documented — the repeat "how do I…" questions a RAG system can answer and cite instantly. This is what you build for.
  • The red bottom-right box is the trap that fools teams: also high-volume, but novel, angry, or high-stakes, so leverage is low — the right move is to escalate, not automate. The two left boxes (blue "safe but rare", amber "tag & route") are low-volume and matter less.

In short: chase the green corner (common + documented); hand the red corner to a human. Volume alone never justifies automating something the bot can't do safely.

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

The failure this architecture is designed to prevent looks like this: a customer asks a question the docs don't answer, the model — trained to be helpful — cheerfully makes something up, and it ships straight to the customer with total confidence. No error, no warning, just a wrong answer that erodes trust and maybe creates a support ticket about the support bot. Everything in the diagram below exists to make that outcome structurally impossible rather than merely unlikely.

Read the architecture as a pipeline with two exits, not a chatbot. A message enters on the left and can only leave through one of two doors on the right: a cited, resolved answer or a clean handoff to a human. Everything between those points is a series of checkpoints. First a guardrail strips personal data and abuse before the model ever sees the text — like a mailroom that opens and screens the envelope before it reaches your desk. Then the agent, standing on top of RAG over the help center, drafts an answer only from retrieved documents. The crucial box is the confidence gate underneath: it inspects the draft and decides which of the two exits to take. Why put the gate in its own box instead of trusting the model to "know when it's unsure"? Because a language model's stated confidence is just more generated text — it can be confidently wrong. The gate is code you control, checking two hard facts: is the confidence above the bar, and is every claim backed by a real retrieved doc? If either fails, the message takes the human exit. The tools on the right (order lookup, ticket CRUD, draft reply) are actions the agent can take, but notice they're deliberately read-only or reversible — no "send to customer" without a gate. The mental model to carry: the model proposes, your code disposes.

a message flows left → right through checkpoints, and can leave by only two doors customer chat / email guardrails PII · abuse support agent answer · draft · route RAG retriever help center · cited vector store order lookup ticket CRUD draft reply confidence gate code, not the model resolved + cited escalate to human A pipeline with two exits, not a chatbot. Customer → guardrails → agent (grounded by the RAG retriever over the vector store, calling read-only/reversible tools) → a deterministic confidence gate that opens exactly one door: a cited answer (green) or a human handoff (red).
🗺️ How to read this diagram

Trace the blue arrows left-to-right: a message crosses a series of checkpoints, and the only way out is one of the two coloured doors on the far right.

  • The customer message hits guardrails first — PII and abuse are stripped before the model ever sees the text — then reaches the purple support agent in the middle.
  • Above the agent sits the RAG retriever over the vector store (it drafts only from retrieved help-center docs); to its right are the tools it may call — order lookup, ticket CRUD, draft reply — deliberately read-only or reversible.
  • The payoff is the confidence gate: deterministic code, not the model, that opens exactly one door — the green "resolved + cited" exit or the red "escalate to human" exit.

In short: every message goes customer → guardrails → agent (grounded by RAG) → confidence gate → either a cited answer or a human — the gate, not the model, decides which.

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

When people think "AI safety" they picture the dramatic failure — the agent deletes the production database. A support agent almost never has that power; its dangers are quieter and, in their own way, more corrosive. The nightmare here isn't an explosion, it's a confident lie: the bot tells a customer their refund is processed when it isn't, or serves up someone else's order history because a clever prompt talked it into it. Nothing crashes. The damage is to trust, and it compounds.

So the discipline is to enumerate the risks that actually apply to this class of agent and pin a concrete control to each — the same way an airline doesn't just say "be safe," it has a checklist item for every failure mode. Think of the controls as belonging to two families. Some are about truth: hallucination is caught by answering only from retrieved docs and refusing when unsupported; prompt injection is neutralized by treating all customer text as untrusted data that can never rewrite the agent's instructions. Others are about authority: data leaks are prevented by filtering every lookup on the authenticated customer id in code, and irreversible actions (sending a reply, issuing a refund) start life as human-approved drafts. The pattern that unifies every row of the table below: never let the model be the thing enforcing the rule. The model suggests; a deterministic piece of code — the tenant filter, the confidence gate, the draft-only default — actually enforces. A rule the model "promises" to follow in its prompt is a rule an adversarial customer can talk it out of; a rule enforced in code is one it physically cannot break.

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:

every control belongs to one of two families — and code, not the model, enforces it 🔵 truth · is the answer real? answer only from retrieved docs → refuse customer text = untrusted data (can't rewrite the instructions) 🟣 authority · who / what may act? every lookup filtered on the authenticated customer id irreversible → human-approved draft enforced in deterministic CODE tenant filter · confidence gate · draft-only default— never the model Two families, one enforcer. Truth controls keep the answer honest; authority controls keep the agent inside its permissions. Both converge on the same rule — the model suggests, but a deterministic piece of code actually enforces the rule, so an adversarial customer can't talk it out of one.
🗺️ How to read this diagram

Read the two top boxes as the two kinds of risk, then follow both arrows down into the single green enforcement layer they share.

  • The blue box (left) is the truth family: is the answer real? Its controls make the agent answer only from retrieved docs (and refuse otherwise), and treat all customer text as untrusted data that can never rewrite the agent's instructions.
  • The purple box (right) is the authority family: who and what is allowed to act? Its controls filter every lookup on the authenticated customer id, and keep irreversible actions (send a reply, issue a refund) as human-approved drafts.
  • The green box at the bottom is where both arrows land — the payoff: every one of these rules lives in deterministic code (the tenant filter, the confidence gate, the draft-only default), never in a prompt the model merely promises to obey.

In short: sort each risk into truth or authority, then enforce its control in code — a rule the model only promises to follow is one an adversary can talk it out of.

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

The most tempting mistake in agent design is to hand the model one giant "do anything" tool and hope it behaves. It won't — or rather, it will right up until an edge case or a crafted prompt makes it send the wrong customer a refund. The tool surface is where you decide, up front and in code, the exact set of actions your agent is capable of. Anything not on this list is something the agent simply cannot do, no matter how it's prompted.

The useful way to design a tool surface is to think like someone handing out keys to a building. A janitor gets the closet key, not the vault key; a teller gets the drawer, not the safe. You classify each tool by the blast radius of its worst outcome and grant accordingly. Read the risk column in the table below as that classification. Read-only, scoped tools (search_kb, get_order_status) are the safest — they can reveal information but change nothing, and the scoped ones only ever see the authenticated customer's own data. Reversible tools (draft_reply, create_ticket) can be undone if wrong, so they're a middle tier. Then there's the one genuinely dangerous action — send_reply, which puts words in front of a real customer — and notice it's gated: human-approved by default, auto-send reserved only for questions a proven eval says are safe. Finally, escalate is the deliberate escape hatch, always safe to call, because "get a human" is never the wrong move. Why enumerate and rank rather than just give the agent capabilities? Because an explicit, ranked tool surface is a contract you can audit and test: you can point at send_reply and prove in code that it can't fire without approval. A vague "the agent can respond to customers" gives you nothing to test and nothing to defend when it misbehaves.

rank each tool by the blast radius of its worst outcome 🟢 read-only · safest search_kb · get_order_status · get_account (scoped) 🟡 reversible · middle tier draft_reply · create_ticket · update_ticket (undo if wrong) 🟠 dangerous · gated send_reply → human-approved (auto only for proven-safe FAQ) ↩ escape hatch · always safe escalate → hand to a human (never the wrong move) rising blast radius Grant keys by blast radius. Read-only tools change nothing, reversible ones can be undone, the single dangerous action (send_reply) is gated behind human approval, and escalate is always safe — an explicit, testable contract of what the agent can do.
🗺️ How to read this diagram

Read the four stacked bands top to bottom — they go from the safest tools to the most dangerous. The colour of each band is its risk tier, and the dashed arrow on the right shows blast radius rising as you descend.

  • The green band is read-only: search_kb, get_order_status, get_account. They reveal information but change nothing, and the scoped ones only ever see the authenticated customer's own data.
  • The amber band is reversible — draft_reply, create_ticket, update_ticket — a middle tier you can undo if it's wrong. The red band is the one genuinely dangerous action, send_reply, which is gated: human-approved by default.
  • The blue band at the bottom is escalate, the deliberate escape hatch — drawn apart because "get a human" is always safe to call, regardless of tier.

In short: safety climbs as you read up, danger as you read down; only the red band can put words in front of a customer, and only through a gate.

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

Every support agent that gives a wrong answer has the same root cause, and it's almost never the model. It's retrieval. The model faithfully answered from the passages it was given — but the right passage never made it into that context, so the model filled the gap from its own memory, or the passage it did get was stale. In a RAG-first project like this one, retrieval quality is answer quality. You can have the best model on earth and still ship garbage if it's grounded in the wrong three paragraphs.

The mental picture worth holding: the model is only as good as the open book you hand it. If you hand a brilliant student the wrong page of the textbook, you get a confident wrong answer — the student isn't the problem, the page is. So "RAG done right" is a set of levers that each fix a specific way the wrong page gets handed over. Chunking on article sections keeps each retrieved piece self-contained and carries the title and URL so the answer can cite its source. Hybrid search exists because customers don't behave like one kind of searcher — they paste exact error codes and product names (which keyword search nails) and they paraphrase in their own words (which vector search catches); running only one leaves half your customers unmatched. Re-ranking to the best 3–4 passages fights the opposite problem — feeding the model ten loosely-related chunks buries the signal in noise. Citing every claim is what turns the whole thing verifiable: a customer, or a reviewing human, can click through and check. And freshness is the one people forget until it burns them — a support answer pulled from a doc that changed last week is a wrong answer, so re-indexing on doc change isn't optional hygiene, it's correctness. The through-line: get the right, current passage in front of the model with a source attached, and most "the AI hallucinated" bugs simply never happen.

the right, current passage in front of the model — with a source attached chunk by article section hybrid search keyword + vector re-rank best 3–4 only cite every claim verifiable answer freshness · re-index when docs change a stale passage is a wrong answer Retrieval quality is answer quality. Chunk → hybrid search → re-rank → cite; freshness feeds back into the chunks so the model always sees the right, current passage — which is where most "the AI hallucinated" bugs actually stop.
🗺️ How to read this diagram

Follow the blue arrows left to right: that's the journey of a customer's question from raw docs to a cited answer. The amber box underneath is a feedback loop, not a step in the line.

  • Chunk (blue) splits each help-center article on its sections so every retrieved piece is self-contained and carries its title/URL. Hybrid search then runs both keyword and vector matching — because customers paste exact error codes and paraphrase in their own words.
  • Re-rank trims the matches down to the best 3–4 so the model isn't buried in loosely-related noise. The green cite-every-claim box is the payoff: each sentence points at a source a human can click and verify.
  • The amber "freshness" box and its dashed arrow loop back to chunk: when a doc changes you re-index, because a passage that went stale last week produces a wrong answer today.

In short: get the right, current passage in front of the model with a source attached, and the "hallucination" bug mostly disappears — the model was only ever as good as the page you handed it.

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

6 · Evaluation expert

"It seemed to work when I tried it" is how every support-bot disaster begins. You demo a dozen questions, they all look great, you ship — and three weeks later someone shows you the transcript where it confidently told a customer the wrong return policy. The problem with eyeballing a demo is that it tests the questions you thought of, in a good mood, not the long tail of weird phrasings and out-of-scope asks real customers throw at it. Evaluation is what replaces "seemed fine" with a number you can trust.

The key insight is that a support agent has to be graded on two different kinds of correctness, and confusing them is a classic mistake. The first is the obvious one: when it answers, is the answer good — grounded in a cited doc (measured by an LLM-judge), relevant to what was actually asked, and did retrieval even surface the right article (recall@k)? But the second kind is what separates a safe agent from a dangerous one: did it escalate the things it should have, rather than guessing? That's escalation precision, and it's easy to game — an agent that answers everything scores 100% on "resolution" while quietly being a liability. Think of it like a medical test: you care about false positives and false negatives, not just overall accuracy, because the two errors have wildly different costs. A needless escalation wastes a few minutes of a human's time; a confident wrong answer on a billing question damages the customer relationship. That asymmetry is why the last two rows below are deterministic hard-fails, not scored metrics — "never answer outside the docs" and "never leak another customer's data" must be exactly zero, checked in code, gating the build the way a smoke detector doesn't get graded on a curve. The rule: measure answer quality and escalation quality separately, and make the safety properties pass/fail rather than best-effort.

grade two kinds of correctness separately — then gate on the hard-fails scored · answer quality groundedness (LLM-judge) answer relevance retrieval recall@k scored · escalation quality escalation precision did it escalate what it should? deflection rate (business metric) deterministic hard-fails — must be ZERO never answer outside the docsnever leak another customer Two scored families, one pass/fail gate. Answer quality and escalation quality are measured on separate scales — an agent that answers everything scores high on resolution while being a liability — and the two safety properties are deterministic hard-fails, not metrics on a curve.
🗺️ How to read this diagram

Read the two top boxes as the two scored families, then follow both blue arrows down into the single red gate that every build must pass.

  • The blue box (left) is answer quality: when it answers, is the answer good — grounded in a cited doc, relevant to the question, and did retrieval even surface the right article (recall@k)?
  • The purple box (right) is a different question: did it escalate the things it should have, rather than guessing? Escalation precision and deflection rate live here — kept separate because an agent that answers everything looks great on resolution while quietly being dangerous.
  • The red box at the bottom is where both arrows converge: two deterministic hard-fails — never answer outside the docs, never leak another customer's data — that must be exactly zero, checked in code, gating the build like a smoke detector that isn't graded on a curve.

In short: score answer quality and escalation quality on their own scales; make the two safety properties pass/fail, not best-effort.

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

The most expensive way to launch a support agent is to flip it on for every customer at once. If it's subtly wrong, you find out from a wave of complaints instead of a dashboard — and you've spent your one shot at customer trust before you had any evidence the thing works. The alternative isn't slower for its own sake; it's how you buy evidence before you buy risk.

The right shape is a ladder from suggestion to autonomy, where each rung earns the next by producing data. Think of how you'd onboard a new human support hire: they don't answer customers unsupervised on day one. First they draft replies a senior agent reviews and sends (Phase 1) — which, as a bonus, generates a golden set of "here's what a good answer looks like" you can later evaluate against. Once their drafts on the easy questions are consistently good, you let them auto-handle just those well-covered FAQs while everything else still routes to a human (Phase 2). Only after that track record do they take first contact and escalate what they can't handle (Phase 3). Why climb instead of leap? Because each rung is reversible and observable: if Phase 2's auto-answers start drifting, you drop back to Phase 1 having harmed no one. The rungs also map cleanly onto the course chapters — trust is built with the RAG and structured-output work, gated by evals, and hardened before it ever runs unsupervised. And note the final "Never" rung isn't a phase at all — it's the permanent fence: some things (arguing, unapproved refunds, answering outside the KB) stay off-limits at every level of autonomy, forever. The mistake to avoid is treating rollout as a switch instead of a ladder — autonomy is something you earn per question type, backed by numbers, not something you grant wholesale on launch day.

each rung earns the next by producing data — climb, don't leap Phase 1 · suggest bot drafts · human edits & sends · builds a golden set Phase 2 · auto-answer easy FAQ high-confidence & well-covered only · rest stays human Phase 3 · full front-line takes first contact · resolves or escalates more autonomy ↑ ⛔ NEVER · argue · unapproved refunds · answer outside the KB Autonomy is earned per rung, not granted on launch day. Suggest → auto-answer easy FAQ → full front-line, each step reversible and backed by numbers — while the red "Never" fence (argue, unapproved refunds, answer outside the KB) stays off-limits at every level.
🗺️ How to read this diagram

Read the three lower boxes bottom to top — that's the order you climb, and each rung sits higher because it grants the bot more autonomy. The red bar across the top is a wall, not a rung.

  • Phase 1 · suggest (blue, lowest) is day one: the bot only drafts, a human edits and sends — which also builds a golden set of good answers to evaluate against later.
  • The blue arrows mean each rung earns the next with data. Phase 2 (green) lets it auto-answer only high-confidence, well-covered FAQs while everything else still routes to a human; Phase 3 (amber) lets it take first contact and escalate what it can't handle. Every step is reversible — if Phase 2 drifts you drop back to Phase 1, having harmed no one.
  • The red bar at the top is the permanent "Never" fence: arguing, unapproved refunds, and answering outside the KB stay off-limits at every level of autonomy — it is not a phase you ever climb into.

In short: climb suggest → auto-FAQ → front-line one reversible rung at a time, backed by numbers, and never cross the red fence no matter how autonomous the bot becomes.

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 < threshold → escalate.
  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

These tests exercise the two safety-critical seams directly — the retriever and the tenant guard — with the model replaced by a test double, so the whole guard runs without an API key. A request is checked, then routed to an allowed or blocked outcome.

no API key: the real model is swapped for a test double, the guards run for real test request pytest fixture code under test retriever · tenant guard (model → test double) allowed ✓ right article · own orders blocked ✓ off-topic → noneforeign id → none both branches must hold — a leak or a guess is a FAILED test The guard is the thing under test. With the model faked out, pytest drives real requests through the retriever and tenant guard and asserts both branches: what should be allowed is answered, and what should be blocked (off-topic, another customer) returns nothing.
🗺️ How to read this diagram

Follow one request from the left: it enters the code under test, and the fork on the right is the two outcomes every test asserts.

  • The test request (blue, left) is a plain pytest call — no API key — because the code under test (amber) has the real model swapped for a test double, leaving the retriever and tenant guard running for real.
  • The green "allowed" arrow is the pass path: the right article is retrieved, and a customer sees only their own orders.
  • The red "blocked" arrow is equally a pass: an off-topic query returns nothing (so the agent will escalate), and an unknown or foreign customer id leaks nothing. Both branches must hold — a leak or a guess flips the test to FAILED.

In short: fake the model, test the guard — the safety seam passes only when the allowed path answers and the blocked path refuses.

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

Evals run a fixed golden set of real questions through the live agent, score each verdict against the expected outcome, and gate the build: it passes only if answer accuracy and escalation safety clear the bar — and hard-fails the instant it answers anything outside the KB.

golden set → run the live agent → score vs threshold → pass / hard-fail gate golden set labelled Q&A run the agent needs API key score vs bar accuracy · escalation ✅ pass — ship scores clear the bar ⛔ hard-fail answered out-of-KB A graded gate, not a demo. The same golden set runs every time; the agent's verdicts are scored against a threshold; the build ships only when accuracy and escalation safety pass — and one out-of-KB answer is an automatic hard-fail.
🗺️ How to read this diagram

Read the blue arrows left-to-right — it is a four-stage pipeline that ends at a fork with a green door and a red door.

  • The golden set (blue) is a fixed list of labelled questions with their expected outcome; it feeds run the agent (purple), which needs an API key because it calls the real model.
  • Score vs bar (amber) grades each verdict on two axes — answer accuracy and escalation safety — against a threshold.
  • The payoff is the fork: the green door ships the build only when both scores clear the bar, while the red door is an automatic hard-fail the instant the agent answers a question outside the KB — the one thing a support agent must never do.

In short: golden set → live run → score → gate; a passing average is not enough, because a single out-of-KB answer hard-fails the whole build.

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 — Claude OR OpenAI) ---
# from pydantic import BaseModel
# class SupportAnswer(BaseModel):
#     answer: str; citations: list[str]; confidence: float; action: str
#
# Claude (Anthropic):
# import anthropic
# 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
#
# OpenAI: same SupportAnswer model, Responses API structured parse:
# from openai import OpenAI
# client = OpenAI()
# def ask(msg, context):
#     r = client.responses.parse(model="gpt-5.5", max_output_tokens=400,
#         instructions="Answer only from context; cite article ids; set action.",
#         input=f"{context}\nQ: {msg}", text_format=SupportAnswer)
#     return r.output_parsed

# --- 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