# How we catch wrong AI answers before they reach customers

> An AI step in a Databricks job can report success while tickets get no category or the wrong one. How we catch both before the results reach the support team.

Published: 2026-10-27
Author: Ihor Seleznov
Tags: Databricks, Knowledge Share
Canonical: https://www.techfabric.com/blog/how-we-catch-wrong-ai-answers-before-they-reach-customers

---

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.

```mermaid
%% caption: A contract-valid answer still waits for a release decision on coverage and labeled correctness.
flowchart TD
  A["Saved attempts"] --> B["Contract check"]
  B -->|"pass"| C["One accepted attempt per processing key"]
  B -->|"endpoint error or invalid output"| D["Retry or review by failure cause"]
  D -->|"new attempt"| A
  C --> E["Release decision on coverage and labeled correctness"]
  E -->|"approve"| F["Business use"]
  E -->|"hold"| G["Batch held"]
  class E accent
```

## 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](https://docs.databricks.com/aws/en/sql/language-manual/functions/ai_query) 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](https://docs.databricks.com/aws/en/machine-learning/model-serving/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](/blog/structured-output-from-claude-on-databricks-model-serving). 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.

```sql
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](https://docs.databricks.com/aws/en/large-language-models/batch-inference-pipelines) 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](https://docs.databricks.com/aws/en/sql/language-manual/functions/from_json).

```sql
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](https://docs.databricks.com/aws/en/ldp/expectation-patterns#quarantine-invalid-records).

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

```sql
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](https://docs.databricks.com/aws/en/tables/constraints). Databricks also says duplicate rows in a MERGE source have to be [deduplicated before merging](https://docs.databricks.com/aws/en/delta/merge#data-deduplication-when-writing-into-delta-lake-tables).

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.
