Defending Against LLM Output: Parsing Dirty JSON in C#

 Post 3 in my series on an automation hub for a ticket-resale business. If you've ever put an LLM into a production pipeline, you know the feeling: the prompt clearly says "return ONLY valid JSON," and the model responds with... a friendly greeting, a markdown code fence, and then the JSON — occasionally missing a bracket. This post collects the techniques I use to parse LLM output safely in C#.

The problem: an LLM is an "API" with no schema guarantee

In my email-classification pipeline (see post 2), the model must return a list of objects like:

{
  "uid": "452455",
  "subject": "Presale starts Friday!",
  "performer": "...",
  "venue": "...",
  "event_date": "Oct 10, 2026",
  "label": "PRESALES",
  "confidence": 0.92
}

In practice the model returns every possible variation: uid is sometimes a string and sometimes a number, label is occasionally null, confidence arrives as 0.92 one day and "0.92" the next, and the whole thing may be wrapped in ```json ... ``` with narrative text around it. Deserialize that directly with System.Text.Json and the pipeline dies on its second run.

The principle I landed on: treat LLM output like user input, not like an API response. Everything goes through a defensive layer.

Layer 1: Separating JSON from prose — a balanced scanner

Regex isn't reliable enough to cut JSON out of surrounding text (nested objects, strings containing braces...). I wrote ExtractFirstJsonObject: a scanner that tracks {}/[] depth, with state for being inside/outside a string literal and for escape characters. It finds the first opening character, runs until depth returns to zero, and returns exactly the first complete JSON block — surviving markdown fences, the model's greetings, and any "additional explanation" tacked on afterwards.

Layer 2: Custom JsonConverters for every temperamental type

String or number? Both.

// The LLM returns "452455" one day and 452455 the next
public class FlexibleStringConverter : JsonConverter<string>
{
    public override string Read(ref Utf8JsonReader reader, ...)
    {
        return reader.TokenType switch
        {
            JsonTokenType.String => reader.GetString(),
            JsonTokenType.Number => reader.GetInt64().ToString(),
            _ => throw new JsonException()
        };
    }
}

Enums by wire name, not by C# name

The label from the model is "PROMOTION_DISCOUNT", but the C# enum member is NatureType.PromotionsDiscounts. I wrote a JsonConverterFactory that reflects over [EnumMember] attributes to map wire names ↔ enum values in both directions, falling back to a case-insensitive Enum.TryParse when nothing matches.

Null must never crash the pipeline

The converter above is wrapped in a NullToEnumConverter<TEnum>: on null, it degrades to NatureType.Unknown instead of throwing. One unclassified email is far better than losing the whole batch.

Confidence: float, int, or string — all accepted

A ParseConfidence helper accepts all three shapes. Similarly, NormalizeText treats the literal string "null" (yes, the model actually prints the word null) as a real null.

Layer 3: Enforce business rules in code — never trust the prompt

The prompt asks for plenty of things, but the C# layer always has a backstop:

  • Prompt: "uids must be unique" → code: EnsureUniqueUids appends -1, -2 suffixes on collision.
  • Prompt: "refund amounts must be absolute values" → code: Math.Abs before writing to the sheet.
  • Prompt: "only return future events" → code: filter out past-dated events before sending the digest.

The general rule: prompts raise the hit rate; code guarantees correctness. The prompt is an optimization; the code is the invariant.

Bonus: the same techniques for human-written APIs

LLMs aren't the only source of temperamental data. One third-party API in this project (a purchasing-account management system) has fields that are sometimes a string, sometimes null, and sometimes an entire object. The solution: declare the property as a raw JsonElement plus a J() helper that stringifies based on the token type, along with [JsonExtensionData] to catch any unknown fields the upstream adds later — the API changes its schema, and the code doesn't fall over.

A checklist for putting LLMs into a production pipeline

  1. Always extract JSON with a balanced scanner; never trust "ONLY output JSON."
  2. Every field from the model goes through a lenient converter: string/number, null-safe, enums by wire name.
  3. Business invariants are enforced in code; the prompt is just high-quality advice.
  4. A failure on one item must never kill the whole batch — degrade item by item.
  5. Log the raw response before parsing: when the model gets "creative," you'll want the evidence.

Next post: when there's no API at all — syncing Lysted orders into SkyBox by scraping HTML emails out of Slack.

Comments

Popular posts from this blog

Featured Projects: Automation and AI Systems I Built and Run

AWS API gateway, S3

Business case: Monitor mailbox and auto-save the attachment to a SharePoint