DEV Community

Cover image for Teach Your Customer Service Agent to Say "I Don't Know" — A Refusal-First Engineering Guide
Pangolinfo
Pangolinfo

Posted on

Teach Your Customer Service Agent to Say "I Don't Know" — A Refusal-First Engineering Guide

#ai

By Leo, AI & ecommerce data solutions lead at Pangolinfo

Most teams building a customer service agent measure success wrong from day one. They wire up a retrieval pipeline, point it at a vector store, and celebrate when the bot answers 92% of conversations without a human in the loop. That number is a trap. The bot you just shipped is probably confident about things it has no business being confident about — and the first time it invents a refund window or a coupon code, you lose a customer you spent months acquiring.

This article is the engineering take on a core thesis from our pillar on enterprise AI transformation: the most overlooked capability of a customer service agent is not answering more questions. It is refusing to answer when the evidence isn't there. I'll show you why this is a data problem and not a model problem, then give you a concrete, runnable design: a confidence-thresholded decision function, a policy-version resolver, an escalation-logging schema, and the integration pattern that actually keeps a bot honest.

If you just want code, skip to the Python section. But read the diagnosis first — most of your hallucination problems are not where you think they are.


The surface complaints are a smokescreen

When users dodge AI customer service, the feedback sounds like tone problems: "it has no human feel," "it doesn't understand me," "it makes things up." I've sat in too many post-mortems where a team responds to those by swapping the LLM or rewriting the prompt's personality. Stop. The root cause is almost never the model. It's the foundation under the model — an incomplete knowledge base and incomplete product data — and a missing safety behavior: controlled refusal.

Let me reframe each complaint with what's actually happening underneath.

Truth 1: "No human feel" is a recognition problem, not a tone problem

People assume human feel comes from phrasing — warmer greetings, more empathy tokens, a friendlier persona. Wrong. What a user actually wants is: you remember what I bought, where I'm stuck, and what we already discussed. When a bot greets every customer as a blank text box and opens with a policy recital, that isn't stiff tone. It's that the bot was never connected to the user's orders, logistics, and history. Polite without context is more annoying than impolite without context. The fix is a data integration, not a prompt.

Here is a concrete example from a brand I worked with. A customer who had already opened a replacement ticket for a damaged blender came back the next day and typed "any update?" The bot answered as if it were the first contact in human history: "Hello! I'd be happy to help with your blender. Could you tell me your order number and what's wrong?" The customer had to re-explain the damage, re-paste the order ID, and re-state the problem. Nothing about the bot's phrasing was unfriendly — but the bot had zero memory of the ticket it had helped create twelve hours earlier. The customer's "no human feel" complaint was really "this thing doesn't even know we were just talking." You cannot prompt your way out of that. You have to connect the agent to the ticketing system so the opening line reads "Hi Sam, I see your replacement ticket for order ORD-44190 is still in transit — want me to check the latest tracking?" That is recognition, not tone.

Truth 2: "Doesn't understand me" is a context-assembly failure

A user says "the cup arrived broken, I want a return." Behind that one sentence sit at least three system facts the agent must assemble: identify the SKU, check the return window, and decide refund versus reship. Many agents collapse the whole thing into a generic "return policy FAQ" dump. The failure isn't NLP quality — it's the inability to map one sentence onto a stack of system facts: product facts, order state, and policy version. All three have to be present and consistent, or the answer is wrong by construction.

A real failure story makes this concrete. A kitchenware seller rolled out a bot and a customer wrote: "the lid of my 8-cup French press shattered in the box, I want a replacement." The bot found the keyword "replacement," retrieved the generic returns FAQ, and replied with a link to print a return label and a note that refunds process in 5–7 days. What it failed to do was assemble the facts: the product had a known manufacturing defect on one variant (so the right move was a free reship, not a return-label-and-wait), the order was 9 days old (inside the window, so a refund was also possible but not required), and the policy version for defective-goods handling had a priority flag that beat the generic returns FAQ. The bot never pulled the variant, never checked order age, never loaded the defective-goods policy. It "understood" the word replacement and then guessed. The customer printed a label, shipped the whole set back, waited a week, and posted a one-star review about a "broken returns process." The model was fine. The context assembly was missing.

Truth 3: "Makes things up" is the most trust-destroying of all

This is the one that makes users give up for good. When evidence is thin, a model will produce a plausible, fully fabricated answer: a wrong refund window, a coupon that doesn't exist, a delivery time pulled from nowhere. One confident lie destroys more goodwill than a hundred correct answers earn. The bug isn't that the model "can hallucinate" — every LLM can. The bug is that the system never told it to stay quiet when it should.

Here is the independent take I keep repeating to teams: to judge whether a support agent is trustworthy, don't ask "how much did it get right." Ask first: "when it didn't know, did it honestly say so?" The first measures demo quality; the second measures production reliability.

A hallucination war story worth memorizing: a team seeded their bot with a discount FAQ that mentioned a "WELCOME10" code for new customers. Six months later the promotion ended and the FAQ was deleted from the source folder — but a stale copy lingered in the vector store because nobody re-indexed. A returning customer asked "what coupon can I use today?" The bot, finding only the expired snippet, cheerfully told her to apply WELCOME10 at checkout. She tried it, the code failed at the cart, and she came back angry: "your own bot told me this code works." The bot had manufactured a fact from a document that was no longer true. No model swap fixes that. A retrieval pipeline with effective dates and an expiry rule would have made the stale snippet invisible.


Why the root cause is data, not the model

Eight out of ten bad AI support deployments fail not because someone picked the wrong model, but because they confused "documents" with "knowledge" and "fields" with "data." Let's pull each layer apart, because the fix for each is different and concrete.

Layer 1 — Incomplete knowledge base: documents ≠ knowledge

The default move is to dump PDFs, spreadsheets, and old chat logs into a vector store and declare the knowledge base done. But policies are not static. A return rule differs during a sale versus normal days, on the US site versus the CN site, across product categories. Without effective dates, scope, priority, and a conflict-resolution rule, the agent can retrieve a clause uploaded last year and long expired. The more it "knows," the more confidently it errs. You didn't build a knowledge base; you built a confidence amplifier for stale facts.

What "knowledge" actually requires, as structured metadata on every policy:

  • effective_date and expiry_date
  • marketplace scope (US / CN / EU / JP)
  • product_scope (category, brand, or specific ASIN list)
  • owner (who is accountable for the version)
  • priority (which version wins when two match)
  • conflict_rule (how to adjudicate overlapping matches)

A second, sharper failure story for this layer: a beauty brand ran a "holiday 45-day return" promotion scoped to the US marketplace only. The promo clause carried no marketplace tag and no expiry_date, so it sat in the store permanently. Two months after the promo ended, a UK customer asked about returning a holiday gift. The bot retrieved the US promo clause (higher lexical similarity than the plain UK 30-day rule), and told her she had 45 days. She returned the item past the real UK window, the warehouse rejected it, and the brand ate a chargeback plus a complaint to the payment processor. The document existed; the knowledge did not, because the document was not scoped or dated. Tagging the promo with marketplace: US and expiry_date: 2025-01-15 would have made the conflict rule pick the correct, narrower, in-scope policy.

Layer 2 — Incomplete product data: it can't even answer "can this SKU ship to the US?"

A huge share of support questions are about specific products: is it in stock, which variant still has the size, can this ZIP receive it, is the price shown on the ad placement current. These need not documents but real-time, structured, traceable product factsASIN, variant, inventory, ZIP pricing, ad placement status. If product data is hand-moved, scraped ad hoc, or lags by hours, the agent can only offer "probably" and "maybe." Users want certainty, and certainty requires a live, structured data layer behind the answer.

The classic failure here is the "size sold out but the bot didn't know" case. A customer asked a fashion brand's bot: "do you still have the medium in the blue hoodie?" The bot answered "yes, it's in stock" because the catalog snapshot it was pointed at had been refreshed that morning and still showed inventory. But the snapshot was six hours stale; the medium had sold out in a lunchtime drop an hour before the question. The customer ordered, got a backorder notice three days later, and felt misled. The field inventory existed, but it wasn't live data — it was a copy that lagged. Similarly, a seller running a ZIP-based pricing rule (lower price in ZIP 90210 promo zone) will burn trust if the bot quotes the national price because the ZIP pricing field was never wired in. The fix is not a better sentence. It's a live, structured product-facts layer the agent can read at query time.

Layer 3 — No live access to orders and ticketing

The deepest gap: many support agents can only read, not look up or act. They can read policy but can't check this user's real order state; they can describe the flow but can't open a ticket or initiate a refund. So in a half-informed state, they fill the blanks with guesses — which is the main source of hallucination. To kill hallucination, first let the agent check the system when it should. And when it can't (read-only phase, or the system is down), it must refuse the conclusion rather than fabricate it.

A vivid example: a customer asked "where is my package, it's been a week?" The bot had no order-lookup tool, so instead of checking, it synthesized a plausible answer: "your package is currently at the regional carrier facility and will arrive in 2 days." It sounded specific and reassuring. In reality the order had been cancelled by the warehouse for a stock error the day before, and there was no package at all. The customer waited two more days, then wrote a furious message. The bot could have said "I can't see your order status right now — let me connect you with someone who can" and been both honest and useful. Instead, with no system access and no refusal, it guessed. This is the precise mechanism by which missing order access becomes hallucination.


The overlooked capability: refusal as a first-class behavior

Mainstream demos treat a high answer rate as proof of intelligence — every question gets a full reply, looks great on stage. But what a production system actually needs is the opposite: refuse, escalate, and log when evidence is insufficient. A weak refusal is a cold "I don't know, contact an agent." A good refusal has three parts:

  1. State what evidence is missing"I can't yet verify the shipping status of this order."
  2. Give a clear next step"I've routed you to a human, ~2 min wait."
  3. Record the gap"logged missing field: tracking trail, queued for knowledge update."

Users don't need an all-knowing bot. They need one that is honest, useful, and not making things worse.

And here's the part that changes how you budget: refusal is not failure — it's a demand sampler for the next round of knowledge-base work. Every refusal tells you "this knowledge or data isn't ready yet." Aggregate those refusals and you don't get a pile of failures; you get a priority list for the next round of knowledge and data work. An agent that refuses, records, and follows up is worth far more than one that guesses forever. It turns "I don't know" into "we'll know next week."

Think about what this does to your roadmap. Without refusal logging, knowledge-base work is driven by whoever complains loudest in the weekly meeting. With refusal logging, it's driven by evidence: the missing_fields keys that show up most often are the backlog. If order_state:unverified appears in 40% of refusals, you stop debating and you connect the order API. If evidence:insufficient_or_conflicting dominates, you stop blaming the model and you fix policy metadata. The refusal log converts a political argument into a measured one.


The three-piece design set: confidence + evidence + escalation

Refusal should not rely on the model's "judgment." It should be engineered with a confidence threshold + an evidence threshold + explicit escalation rules. Here is the trigger table we use, expanded with a scenario for each row so the behavior is unambiguous in code review:

Trigger Agent behavior Backend action Real scenario
Evidence found, confidence ≥ threshold Draft reply with cited sources Log trace for spot checks A customer asks "what's the return window for ASIN:B0XYZ123 in the US?" The agent retrieves a current, in-scope policy (effective_date valid, marketplace: US), a reranker scores confidence 0.91, sources ≥ 1. It drafts a reply quoting the 30-day window and logs the trace. Green path.
No evidence / conflicting sources State insufficiency, escalate Write to "missing field" list A customer asks "can I return an open food item?" Two policies match with opposite answers and no priority to break the tie. The agent has a conflict, sources are conflicting, so it escalates with "I can't give a definitive answer on opened food returns yet" and writes evidence:insufficient_or_conflicting to the gap log.
Order state incomplete / unverifiable Refuse the conclusion, give process only Retry read-only order lookup A customer asks "did my refund for ORD-99821 post yet?" The order API returns 503 / no order_state_verified. The agent refuses to state a refund status, hands the customer the self-service tracking link, and triggers a retry. It never says "yes it posted" from a guess.
Action exceeds permission (refund, ticket) No execution, draft for approval Push human approval, keep audit A customer says "just refund me." Refunds are not in allowed_write_actions in phase one. The agent drafts a refund request for a human approver, executes nothing, and keeps a full audit row. No money moves on a guess.

The only principle that matters: when it answers, it must show evidence; when it can't, it must say what's missing and to whom it handed off. Make both "answer" and "refuse" explainable and auditable, and the system earns trust. Notice that the first and last rows are about acting safely (cite or don't execute), while the middle two are about not lying (escalate or refuse). The table is really two promises: I will show my work when I answer, and I will not invent work when I can't.

A practical note on tuning the thresholds: don't set confidence_threshold to 0.9 on day one and wonder why everything escalates. Calibrate it against your evaluation set from the pilot (below). Start at 0.7, then look at the cases that passed at 0.7–0.8 and were later judged wrong by humans — those tell you whether to raise the floor. The threshold is a knob you turn with data, not a number you copy from a blog post. Likewise, min_sources of 1 is intentionally permissive for a greenfield agent; once you have conflicting-source detection, consider requiring 2 non-conflicting sources for high-liability answers like refunds and shipping promises.


A runnable refusal decision function

Below is a working Python sketch. It is intentionally framework-agnostic — no LangChain, no vendor lock-in — so you can drop it into whatever orchestration you run. It takes the retrieved evidence, a confidence score, an order-state check, and a permission check, then returns one of four decisions plus an escalation record.

from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
import time
import json


class Decision(Enum):
    ANSWER = "answer"                  # confident, evidence-backed -> draft with citations
    ESCALATE = "escalate"              # no/conflicting evidence -> human handoff + log gap
    REFUSE_CONCLUSION = "refuse"       # order state incomplete -> process only, retry lookup
    DRAFT_FOR_APPROVAL = "draft"       # action exceeds permission -> no execution


@dataclass
class Evidence:
    sources: list[str]                 # e.g. ["policy:pricing:us:2026-08-01", "order:ORD-99821"]
    has_conflict: bool = False
    order_state_verified: bool = False
    confidence: float = 0.0            # 0.0 - 1.0 from your reranker / grader


@dataclass
class Policy:
    confidence_threshold: float = 0.7
    # evidence_threshold: minimum number of distinct, non-conflicting sources required
    min_sources: int = 1
    allowed_write_actions: set = field(default_factory=set)  # e.g. {"refund", "open_ticket"}


@dataclass
class EscalationRecord:
    decision: Decision
    missing_fields: list[str]
    next_step: str
    timestamp: float = field(default_factory=time.time)
    ticket_id: Optional[str] = None

    def to_json(self) -> str:
        return json.dumps(self.__dict__, default=str)


def decide_reply(
    user_query: str,
    evidence: Evidence,
    requested_action: Optional[str],
    policy: Policy,
) -> tuple[Decision, str, EscalationRecord]:
    """
    Returns (decision, draft_or_message, escalation_record).
    The escalation_record is ALWAYS produced so refusals feed the knowledge pipeline.
    """
    missing: list[str] = []

    # 1) Action permission gate — checked FIRST, before anything executes.
    if requested_action and requested_action not in policy.allowed_write_actions:
        rec = EscalationRecord(
            decision=Decision.DRAFT_FOR_APPROVAL,
            missing_fields=[f"permission:{requested_action}"],
            next_step=f"Generated a draft for '{requested_action}'. Routed to human approver; no action taken.",
        )
        return (
            Decision.DRAFT_FOR_APPROVAL,
            f"I've prepared a draft for '{requested_action}' and sent it for human approval. "
            f"Nothing has been changed yet.",
            rec,
        )

    # 2) Order-state gate — if a concrete conclusion needs verified order state.
    if not evidence.order_state_verified:
        missing.append("order_state:unverified")
        rec = EscalationRecord(
            decision=Decision.REFUSE_CONCLUSION,
            missing_fields=missing,
            next_step="Refused to state a conclusion. Gave self-service process; retrying read-only order lookup.",
        )
        return (
            Decision.REFUSE_CONCLUSION,
            "I can't yet confirm the current state of your order, so I won't guess. "
            "Here's the self-service process to check it, and I've triggered a retry on our side.",
            rec,
        )

    # 3) Evidence + confidence gates.
    if evidence.has_conflict or len(evidence.sources) < policy.min_sources:
        missing.append("evidence:insufficient_or_conflicting")
        rec = EscalationRecord(
            decision=Decision.ESCALATE,
            missing_fields=missing,
            next_step="Stated insufficiency, escalated to human, wrote gap to missing-field list.",
        )
        return (
            Decision.ESCALATE,
            "I don't have reliable evidence for this yet, so I'm connecting you with a specialist "
            "(~2 min wait) rather than guessing. I've logged what's missing so we can answer faster next time.",
            rec,
        )

    if evidence.confidence < policy.confidence_threshold:
        missing.append(f"confidence:{evidence.confidence:.2f}<{policy.confidence_threshold}")
        rec = EscalationRecord(
            decision=Decision.ESCALATE,
            missing_fields=missing,
            next_step="Confidence below threshold, escalated to human, wrote gap to missing-field list.",
        )
        return (
            Decision.ESCALATE,
            "I'm not confident enough to give you a definitive answer, so I'm routing you to a human "
            "instead of risking a wrong one.",
            rec,
        )

    # 4) Passed all gates -> answer with citations.
    rec = EscalationRecord(
        decision=Decision.ANSWER,
        missing_fields=[],
        next_step=f"Drafted cited reply from {len(evidence.sources)} sources; trace logged for QA.",
    )
    return (
        Decision.ANSWER,
        f"[Cited draft reply based on {', '.join(evidence.sources)}]",
        rec,
    )


# --- Example usage ---
if __name__ == "__main__":
    policy = Policy(confidence_threshold=0.7, min_sources=1, allowed_write_actions=set())

    # Case A: user asks for a refund, but refunds are not yet permitted in phase 1.
    decision, msg, rec = decide_reply(
        "please refund my broken cup",
        Evidence(sources=["order:ORD-99821"], order_state_verified=True, confidence=0.9),
        requested_action="refund",
        policy=policy,
    )
    print(decision.value, "|", msg)
    print("ESCALATION:", rec.to_json())

    # Case B: thin evidence -> escalate.
    decision, msg, rec = decide_reply(
        "what's my return window for this item?",
        Evidence(sources=[], has_conflict=False, order_state_verified=True, confidence=0.3),
        requested_action=None,
        policy=policy,
    )
    print(decision.value, "|", msg)
    print("ESCALATION:", rec.to_json())
Enter fullscreen mode Exit fullscreen mode

The escalation record is the quiet hero here. Every branch produces one, including the "answer" branch (for QA sampling). That record is what turns refusal into a pipeline: you stream missing_fields into a "missing field" list, and that list is your next sprint's knowledge-base backlog.


A policy-version resolver (so stale clauses never win)

The decision function above assumes the right policy is already in evidence.sources. But which policy is right depends on resolving the version conflicts that Layer 1 produces. Here is a small, standalone resolver you run at retrieval time so the agent never sees two contradictory clauses at once. It picks the single best policy for a (marketplace, product, date) tuple using the metadata we defined earlier.

from dataclasses import dataclass
from datetime import date
from typing import Optional


@dataclass
class PolicyDoc:
    policy_id: str
    effective_date: date
    expiry_date: Optional[date]          # None == still active
    marketplace: str                     # "US" / "CN" / "EU" / "JP"
    product_scope: str                   # "category:kitchen" / "ASIN:B0XXXXXX"
    priority: int                        # higher wins on tie
    text: str


class PolicyResolver:
    """
    Given a query context, return the single best-matching active policy,
    or None if no active policy covers the case (=> agent must escalate).
    """

    def __init__(self, policies: list[PolicyDoc]):
        self.policies = policies

    def resolve(
        self,
        marketplace: str,
        product_scope: str,
        on: date,
    ) -> Optional[PolicyDoc]:
        candidates = [
            p for p in self.policies
            if p.marketplace == marketplace
            and self._scope_matches(p.product_scope, product_scope)
            and (p.effective_date <= on)
            and (p.expiry_date is None or p.expiry_date >= on)
        ]
        if not candidates:
            return None  # nothing active -> escalate, do not guess
        # highest priority wins; tie-break by most recent effective_date
        return sorted(candidates, key=lambda p: (p.priority, p.effective_date), reverse=True)[0]

    @staticmethod
    def _scope_matches(rule_scope: str, query_scope: str) -> bool:
        # "category:kitchen" matches "ASIN:B0XXXXXX" if the ASIN belongs to kitchen;
        # for the resolver we keep a simple rule: exact match or category covers ASIN.
        if rule_scope == query_scope:
            return True
        if rule_scope.startswith("category:") and query_scope.startswith("ASIN:"):
            # in production, look up the ASIN's category from the product facts layer
            return True  # placeholder for catalog join
        return False


# --- Example ---
if __name__ == "__main__":
    policies = [
        PolicyDoc("returns_base_us", date(2024, 1, 1), None, "US", "category:kitchen", 10,
                  "Standard 30-day return for kitchen items."),
        PolicyDoc("holiday_promo_us", date(2025, 11, 1), date(2026, 1, 15), "US", "category:kitchen", 20,
                  "Holiday 45-day return for kitchen items."),
    ]
    resolver = PolicyResolver(policies)

    # On Dec 20 2025 the higher-priority holiday promo is active and wins.
    active = resolver.resolve("US", "ASIN:B0XXXXXX", date(2025, 12, 20))
    print(active.policy_id, "->", active.text)

    # On Feb 1 2026 the promo has expired; resolver falls back to the base rule.
    active = resolver.resolve("US", "ASIN:B0XXXXXX", date(2026, 2, 1))
    print(active.policy_id, "->", active.text)
Enter fullscreen mode Exit fullscreen mode

The key property: when nothing is active (or the join fails), resolve() returns None, and your decision function should treat that as evidence:insufficient_or_conflicting and escalate. This is how you stop the bot from quoting a dead promo. The resolver is deliberately dumb and explicit — no LLM deciding "which policy feels right." Version selection is a data lookup, not a language task.


A schema for the "missing field" list, plus an escalation-log query

To make the sampler real, structure the gap log. Here's a JSON schema you can store in any queue or table:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "MissingFieldLog",
  "type": "object",
  "required": ["conversation_id", "missing_fields", "decision", "logged_at", "status"],
  "properties": {
    "conversation_id": { "type": "string" },
    "decision": {
      "type": "string",
      "enum": ["answer", "escalate", "refuse", "draft"]
    },
    "missing_fields": {
      "type": "array",
      "items": {
        "type": "string",
        "description": "Stable field keys, e.g. order_state:unverified, evidence:insufficient_or_conflicting, permission:refund, confidence:0.42<0.70"
      }
    },
    "detected_at": { "type": "string", "format": "date-time" },
    "marketplace": { "type": "string", "examples": ["US", "CN", "EU", "JP"] },
    "product_scope": { "type": "string", "examples": ["ASIN:B0XXXXXX", "category:kitchen"] },
    "status": {
      "type": "string",
      "enum": ["open", "in_progress", "resolved", "wont_fix"],
      "default": "open"
    },
    "resolved_by": { "type": "string" },
    "reused_in_kb": { "type": "boolean", "description": "Did the human rewrite become a reusable knowledge item?" }
  }
}
Enter fullscreen mode Exit fullscreen mode

Aggregate missing_fields by key weekly. The top keys are your knowledge debt. If order_state:unverified dominates, your problem is the order API, not the LLM. If evidence:insufficient_or_conflicting dominates, your problem is policy metadata (Layer 1). The schema makes the diagnosis measurable instead of political.

Once those records pile up, you want to query them. Here is a small SQL-style query (works in Postgres / BigQuery / SQLite with minor syntax tweaks) that produces the weekly knowledge-debt report you actually read in the steering meeting:

-- Weekly knowledge-debt report: which gaps hurt most, and are we closing them?
SELECT
    mf.missing_field,
    COUNT(*)                                            AS occurrences,
    COUNT(*) FILTER (WHERE l.status = 'resolved')       AS resolved,
    ROUND(
        100.0 * COUNT(*) FILTER (WHERE l.status = 'resolved')
        / NULLIF(COUNT(*), 0), 1
    )                                                   AS close_rate_pct,
    COUNT(DISTINCT l.marketplace)                       AS markets_affected
FROM missing_field_log l,
     UNNEST(l.missing_fields) AS mf(missing_field)
WHERE l.detected_at >= DATE_TRUNC('week', CURRENT_DATE) - INTERVAL '7 days'
GROUP BY mf.missing_field
ORDER BY occurrences DESC
LIMIT 15;
Enter fullscreen mode Exit fullscreen mode

That single query turns your refusal log into a backlog you can argue about with numbers. When order_state:unverified is at the top for the third week running, the argument "the model is the problem" collapses, because the data says the order API is the problem. This is the loop that makes refusal pay for itself: refuse → log → measure → fix → answer more, safely.


From 200 conversations to a first evaluation set

Don't aim for full automation on day one. A realistic pilot starts with 200 real conversations. Here's the five-step shape we recommend, with what to watch for at each step and the common mistake that sinks pilots:

  1. Label 200 recent conversations (last 30 days) as directly-answerable, system-dependent, or human-judgment.

    • What to watch for: your labelers will disagree on the boundary between system-dependent and human-judgment. Get two labelers per conversation and measure inter-rater agreement; below 0.7, your labeling rubric is too vague and the eval set is noise.
    • Common mistake: labeling by topic ("returns," "shipping") instead of by dependency (can the bot verify the fact itself, or does it need a system lookup?). Topic labels don't tell you what to build.
  2. Add effective_date + marketplace + product_scope + owner to every policy so version conflicts can be adjudicated.

    • What to watch for: legacy policies with no owner. If nobody is accountable, the tag is decorative. Assign a real person per policy before go-live.
    • Common mistake: back-dating effective_date to "make the numbers look clean." The resolver depends on these dates being true; fake dates turn the resolver into a liar.
  3. Suggested replies only, zero write permissions in phase one — no refunds, no ticket changes.

    • What to watch for: the bot "helpfully" describing an action it can't take ("I've started your refund"). That wording is how Northbrook-style incidents happen. Ban any verb that implies execution.
    • Common mistake: giving write access "just for one safe action" in week two. Permission creep is how pilots blow up. Keep the gate closed until the eval set proves the green path is safe.
  4. Save low-confidence cases, final human handling, and human rewrites as an evaluation set (question, evidence, model answer, human edit, outcome).

    • What to watch for: only saving the wrong cases. You also need the correct-but-close cases to tune the confidence threshold. A balanced set beats a pile of failures.
    • Common mistake: treating the eval set as fixed. It should grow every week as new refusal types appear, or your threshold tuning goes stale.
  5. Weekly review of refusal rate, human adoption rate, error escalation rate, and new knowledge items; use the data to decide whether to open order-lookup tools.

    • What to watch for: a refusal rate that spikes after a product catalog change — that's a data-layer signal, not a model regression. Read refusals as telemetry.
    • Common mistake: declaring victory when auto-resolution climbs. Auto-resolution climbing while error-escalation also climbs is the danger zone. Watch the bundle, not the one number.

The point isn't "replace people fast." It's to first carry the low-risk, verifiable part, free human agents for real exceptions, and turn the judgment living in senior agents' heads into a business asset the company can inspect and regression-test. Only after the evaluation set is solid and adoption stable do you open transactional tools.


The counter-intuitive metrics to watch

Most teams stare at one number: auto-resolution rate. That is the most misleading metric — it tells you how many the agent caught, not whether what it caught was right, nor whether it quietly amplified errors. Here is why it misleads, concretely:

Auto-resolution rate is resolved_by_bot / total. It is blind to correctness and harm. Suppose your bot resolves 80% of chats. If 15 of those 80 were confident lies (wrong refund windows, fake coupon codes, invented shipping dates), you didn't resolve 80% — you manufactured 15 incidents at machine speed. The metric rewards the behavior that destroys trust, because a fabricated answer still counts as "resolved" until a human happens to catch it. Worse, auto-resolution gives managers a reason to turn off the human safety net ("we're at 80%, let's cut the queue"), which means the 15 lies never get caught. The number points up and to the right while the brand burns. That is why we never report it alone.

Track "errors not amplified by automation" instead, as a bundle:

  • High-quality refusal rate — did it refuse the right things? Direct signal of production reliability. Example: of 100 chats where the bot lacked verified order state, it correctly refused 97 and guessed on 3. The 97 is your reliability number; the 3 are your incidents. A high-quality refusal rate near 100% means the bot is honest even when it can't help — exactly what earns trust.
  • Error escalation rate — of wrong answers, how many were caught by humans? Size of the risk from guessing. Example: the bot gave 20 wrong answers this week; humans caught 18 before they reached the customer. Escalation rate 90% means your safety net works; 40% means lies are leaking to customers and you must tighten the gates.
  • Post-handoff handling time — did handoff actually save time? Whether the agent lightens human load. Example: if handing off takes a human 6 minutes to re-orient (because the bot logged nothing), the "agent" added work. If the handoff carries the escalation record and prior context, the human resolves in 2 minutes. This metric tells you if refusal is helping or just relabeling the problem.
  • Reusable knowledge share — how much of human edits reflowed as knowledge? Whether the system self-improves. Example: a human rewrote 30 bot answers this week; 22 of those became tagged policies or product-fact fixes. A rising share means the bot gets better every week; a flat share means you're paying humans to patch the same gaps forever.
  • Recontact rate — did users come back after a refusal? The ultimate trust gauge. Example: 8% of users who got a clean "I can't verify that, here's a human" recontacted with the same issue. If that number is low, your refusals are landing as honest and useful. If it's high, your "next step" is failing and users are bouncing.

Read together, these show the system is building trust rather than manufacturing polite incidents. The auto-resolution rate is allowed on the dashboard only as context next to these five — never as the headline.


Where the external Amazon data layer fits (and where it doesn't)

Back to the root cause: incomplete product data is a major source of support-agent hallucination. Your order/refund/ERP/ticketing systems are your own to integrate — no vendor can substitute for that. But the external Amazon data layer — the structured facts about products, search, rankings, categories, and ad placements that live outside your walls — is exactly where a specialized provider earns its place.

At Pangolinfo we supply that layer rather than packaging all internal systems into a prebuilt agent. The difference shows up in the specific fields the agent can finally cite instead of guess:

  • Amazon Review API — real customer feedback including the structured Customer Says field, so when a customer asks "is this blender reliable?" the agent can ground its answer in what buyers actually wrote ("blades dull after a month" appears in 40 Customer Says snippets) instead of emitting a generic reassurance. That single field turns a guess into a citation.
  • Amazon Scraper API — structured facts keyed by ASIN and variant, so "which color of B0XYZ123 is still in stock?" resolves to a concrete variant row; ZIP pricing so a location-specific price quote is real, not national-average; and ad placement status so "is my Sponsored Products placement live?" returns active/paused rather than a hopeful "it should be." These are the exact fields Layer 2 says you need live.
  • Amazon Data MCP — an agent-facing tool layer, so you don't rewrite scraping and parsing logic inside every agent; the structured fields above are exposed as callable tools with consistent schemas.
  • Amazon Scraper Skill — packages common Amazon data tasks into conversational workflows your agents can call.

Shorten and stabilize the external Amazon data path, and the support agent earns the right to speak when it knows and stay quiet when it doesn't. When ASIN, variant, ZIP pricing, ad placement status, and Customer Says are live and structured, the agent stops saying "probably" and starts saying "here is the fact, here is its source" — or, when the fact isn't there yet, "I don't know, and here's who does."


Conclusion: make the agent honest before you make it smart

Users dodge AI support because they're dodging a conversation that pretends to understand. The fix isn't a bigger model. It's to first lay the foundation of knowledge base and product data, then teach the agent the most critical and most overlooked lesson: when evidence is insufficient, say "I don't know," and turn that into a better answer next time. When refusal becomes an explainable, logged, follow-up-able action, a support agent moves from a demo prop to a production system you can actually trust.

If you're planning support or data agents for an Amazon business, start from the pillar on why ecommerce AI transformation shouldn't start by buying agents, then come back to this refusal design to execute.

Want the full refusal framework, trigger table, and pilot plan? Read the deep-dive on Customer Service Agent Refusal at Pangolinfo, and grab the Amazon Scraper API or Amazon Data MCP to give your agent real product facts to stand on.

Top comments (0)