Skip to content

How we catch wrong AI answers before they reach customers

Ihor Seleznov8 min read

When an AI model sorts support tickets inside a Databricks job, the job can finish green while some tickets never got a usable category and others went to the wrong team. The run status doesn't say which tickets those are, and a report built on that run can overstate how many answers the business can trust.

We built a small Python harness that replays hand-written model responses for seven labeled support tickets. After nine attempts, four tickets had an accepted category and three had none. One of the four was wrong. The customer who wrote "I cannot sign in after resetting my password" went to Billing, in valid JSON that passed every format check.

Before any category reaches the support team, we check three things separately: whether the call finished, whether the answer has the agreed format, and whether the category is right. A failure goes to a retry or to a person, depending on its cause. For these seven tickets the release check held the whole batch. Start before the first model call by writing down which tickets are in the batch and which of them have labels.

pass

endpoint error or invalid output

new attempt

approve

hold

Saved attempts

Contract check

One accepted attempt per processing key

Retry or review by failure cause

Release decision on coverage and labeled correctness

Business use

Batch held

A contract-valid answer still waits for a release decision on coverage and labeled correctness.

Give records and attempts different identities

Take a support-ticket classifier with three categories, Billing, Access and General. Its processing key combines the ticket ID, the input version and the inference configuration version, and our harness keys every ticket the same way. The configuration version should identify the prompt, the endpoint or model configuration and the output contract. A corrected ticket or a changed prompt creates a new key; an ordinary retry keeps the key and adds an attempt.

Store the input or a durable reference to its exact version. Each attempt needs an ID, a sequence number, timestamps, the response, the error and the validation outcome. Keep the accepted result in its own table, pointing at the attempt that produced it. Without that split, a later retry can overwrite the evidence you'd need to explain why a ticket got its category.

An endpoint name isn't enough provenance when the model behind it can change. Keep whatever deployment and model information your environment exposes. A configuration version is an identifier your application audits, and the provider can still change what sits behind the endpoint.

Capture errors without accepting their outputs

With failOnError => false, ai_query returns a STRUCT with response and errorMessage, so endpoint failures inside a batch become rows. A successful call has a null error message. Model endpoint errors land in the result, but other errors can still fail the whole query, as the ai_query reference says.

The SQL below sets the inference boundary for a chat endpoint that accepts json_object. Check yours first. Databricks documents in its structured outputs guide that Claude models take only json_schema, and that the Claude 5.5 models return HTTP 400 for a response_format on the Chat Completions API. Our Fabric team hit that 400 and moved the schema into the prompt. Replace the placeholder, use a catalog and schema you're authorized to write to, and persist the result before you derive anything else from it.

CREATE TABLE ticket_attempt_batch USING DELTA AS
SELECT
  ticket_id,
  input_version,
  'ticket_classifier_a' AS config_version,
  ticket_text,
  ai_query(
    '<your_supported_chat_endpoint>',
    concat(
      'Classify this support ticket as Billing, Access or General. ',
      'Return JSON with one string field named category. Ticket: ',
      ticket_text
    ),
    responseFormat => '{"type":"json_object"}',
    failOnError => false
  ) AS answer
FROM ticket_inputs;

The statement covers one batch. A recurring writer also needs stable attempt IDs and execution metadata. Databricks lays out batch inference workflows as separate ingestion, inference and post-processing stages, and once the answers sit in a table, validation can run again without another model call.

Validate the contract, then evaluate meaning

Parse the saved JSON into a known schema and keep the corrupt-record field. Databricks from_json parses permissively by default, so a malformed record can come back partly parsed with its category recovered. Checking that field keeps such a record out of the accepted path, and null handling has to be just as explicit. The options are in the from_json reference.

CREATE OR REPLACE TEMP VIEW ticket_attempt_checks AS
WITH parsed AS (
  SELECT *,
    from_json(
      answer.response,
      'category STRING, _corrupt_record STRING',
      map(
        'mode', 'PERMISSIVE',
        'columnNameOfCorruptRecord', '_corrupt_record',
        'allowSingleQuotes', 'false'
      )
    ) AS output
  FROM ticket_attempt_batch
)
SELECT *,
  answer.errorMessage IS NULL
    AND answer.response IS NOT NULL
    AND output._corrupt_record IS NULL
    AND coalesce(
      output.category IN ('Billing', 'Access', 'General'),
      false
    ) AS contract_ok
FROM parsed;

The two processing paths use WHERE contract_ok and WHERE NOT contract_ok. In Lakeflow the default warn expectation keeps violating rows; it doesn't apply those filters or schedule recovery. Databricks describes quarantine as explicitly defined valid and invalid paths.

T002 shows where the contract stops. Billing is an allowed category, so the answer for "I cannot sign in after resetting my password" passes. Catching it takes labeled examples and a business definition for each category. Report results by expected category, because an overall average can hide a classifier that never recognizes an access problem, and structured output helps with the format without saying anything about whether the category is right.

Let the failure cause determine recovery

Outcome Recovery policy Publication decision
Endpoint failure classified as transient Bounded retries with backoff; respect provider retry guidance Unresolved until a later attempt passes the contract
Endpoint failure requiring permission or configuration changes Fix the cause before retrying Keep unresolved
Invalid JSON, missing field or unsupported category A small regeneration budget may help; investigate repeated rejection Retain rejected attempts for investigation
Contract-valid but wrong classification Review labels, category definitions, input and prompt or model choice Evaluate whether the configuration is fit for release

Retryability comes from an explicit error policy and whatever diagnostics the application has. A non-null errorMessage doesn't mean "retry." Store attempt limits and the next eligible retry time durably, so a restarted worker doesn't reset the budget. In our fixtures T003's endpoint error is marked retryable and T006's isn't, and the harness allows at most two attempts per ticket.

T003 and T005 each used both of their attempts. T003 passed on the second one and ends as one accepted record, while T005's two rejected answers leave one unresolved record.

The attempt count tells you how hard the endpoint was worked, and the record count tells you how many tickets still have no category.

Publish once for each processing key

Here "accepted" means contract-accepted. The table below holds the selected classification for each key, and downstream use still waits for the release decision on unresolved records and semantic evaluation. Deduplicate the publication source on the full processing key before an insert-only MERGE.

MERGE INTO ticket_classifications AS target
USING accepted_candidates AS source
ON  target.ticket_id = source.ticket_id
AND target.input_version = source.input_version
AND target.config_version = source.config_version
WHEN NOT MATCHED THEN INSERT (
  ticket_id, input_version, config_version, category, attempt_id
) VALUES (
  source.ticket_id, source.input_version, source.config_version,
  source.category, source.attempt_id
);

accepted_candidates holds one selected attempt per key, and the target starts with no duplicate keys. Key fields must be non-null, since nulls defeat ordinary equality matching. Our harness picks the earliest contract-valid attempt by attempt number. Serialize the writers or coordinate them some other way, because a declared primary or unique key in Databricks is informational and not enforced. Databricks also says duplicate rows in a MERGE source have to be deduplicated before merging.

This policy stops ordinary recovery from replacing an accepted result by accident. Replacing one on purpose is a separate decision. When consumers only need the current classification, define how they pick the active input and configuration version instead of exposing every historical result to them.

Seven labeled tickets, three denominators

We wrote the harness in Python on an in-memory SQLite ledger. Each ticket has a label and a scripted list of responses, so the percentages below count what the recovery policy did with those responses. They aren't a model benchmark.

Ticket Scripted behavior Final outcome
T001 Correct Billing response Contract accepted; correct
T002 Billing response for an Access ticket Contract accepted; incorrect
T003 Transient endpoint error, then correct General response Recovered on attempt two
T004 Invalid JSON, then correct Billing response Recovered after regeneration
T005 Empty category, then unsupported category Retry budget exhausted
T006 Endpoint error the fixture policy marks non-retryable Needs investigation
T007 No attempt scheduled Missing; still unresolved

The run produced nine attempts, four contract-accepted and five rejected. At record level, four of seven tickets have an accepted result and three remain unresolved. Three of the four accepted results match their labels, which is 75% accuracy on the evaluated subset, with evaluation covering four of seven inputs. Of the two Access tickets, one was evaluated and is wrong, and the other is unresolved.

Recovery raised coverage from two accepted tickets after the first pass to four after the second. It didn't touch the contract-valid Access mistake. A report that drops unresolved inputs hides T005 to T007, and one that counts every allowed category as correct hides T002.

We also replayed the same attempt events and ran publication twice. Neither added an attempt or an accepted row. SQLite enforces the processing key in that ledger and Delta doesn't, and the harness never runs two writers at once or restarts a worker mid-batch. The next test we'd run is the SQL above on a workspace table, with two writers hitting the MERGE together.

Choose batch acceptance before inference

Reconcile the fixed input cohort against accepted and unresolved keys, including records that never got an attempt. Set separate thresholds for unresolved records and for labeled correctness. Report the labeled evaluation denominator and results by expected category, with unresolved counts wherever labels exist.

Our harness uses the strictest rule, every input accepted and every evaluated label correct, and this batch fails it on T002 and on the three unresolved tickets. Automatic ticket routing can usually hold a few tickets for review and still run; a financial extraction workflow may need complete reconciliation. The thresholds belong to the workflow that consumes the categories and to its fallback when a batch is held.

Start by fixing the cohort before the first ai_query call. Write down which tickets are in the batch and which of them have labels, so the release decision counts against the same denominators after every retry.

Frequently asked questions

Does failOnError => false make every failed call retryable?

No. It changes how documented model endpoint errors are handled, and other errors can still fail the query. A captured endpoint error still needs a recovery classification, such as transient or configuration.

Can a Lakeflow expectation verify that a category is correct?

It can enforce a Boolean data rule, such as membership in an allowed set. Comparing a classification with its business meaning takes labels or another evaluation process. The default warn action also keeps violating rows.

Does a clean retry replace quality evaluation?

No. A retry can return parseable JSON with the wrong category, just as T002's first attempt did. Coverage, contract validity and correctness stay separate acceptance criteria.

What should happen when the prompt changes?

Record a new configuration version and evaluate it on the same fixed labeled cohort. Keep the previous attempts and define which version consumers should read. An ordinary retry shouldn't silently turn into a configuration migration.