Context and Tools
Production Workflows

Build a Document Summarizer That Returns Valid Structured JSON

Build a Document Summarizer That Returns Valid Structured JSON
Learn how to build a reliable document summarizer that outputs valid, fixed-shape structured JSON. This practical guide covers schema definition, direct model requesting, manual validation, and an error-feedback retry path to handle common AI output failures cleanly, ensuring seamless integration into downstream production pipelines without messy parsing errors.

The first document summarizer I put in front of a real team returned beautiful prose and completely unusable data. The model would write a fluent paragraph, then occasionally wrap it in markdown fences, occasionally omit a required field, and occasionally invent a confidence score that was not in the schema. Downstream code that expected clean JSON kept throwing parse errors. After the third failure I stopped asking the model to “please return JSON” and started treating structured output as an engineering problem with explicit validation, clear failure modes, and a small retry path.

This post builds the smallest useful version of that solution: a document summarizer that returns a validated JSON object with a fixed shape. We will define the schema, call the model, validate the result, and handle the cases where the output is still wrong. No agent loop, no tools, no external storage—just a reliable extraction step you can drop into a larger pipeline later.

As always:

Build the smallest useful version, then test what breaks.

What You Will Have Working by the End

  • A fixed JSON schema for a document summary

  • A single request that asks the model for that shape

  • Validation that rejects missing fields, wrong types, or extra keys

  • A short retry path when validation fails

  • Three realistic failure modes and how to notice them early

The output will be something you can hand to another service without additional cleaning.

The Target Schema

A documentary close-up of a screen showing structured JSON schema definitions and type validation code.

We will extract four pieces of information from a short document:

JSON

{
  "title": "string",
  "one_sentence_summary": "string",
  "key_points": ["string"],
  "sentiment": "positive" | "neutral" | "negative"
}

In Python we can express the same shape with a simple dictionary schema or with Pydantic. For clarity we will start with a plain JSON Schema and a manual validator; later posts can introduce stricter libraries.

Python

SUMMARY_SCHEMA = {
    "type": "object",
    "properties": {
        "title": {"type": "string"},
        "one_sentence_summary": {"type": "string"},
        "key_points": {
            "type": "array",
            "items": {"type": "string"},
            "minItems": 1,
            "maxItems": 5
        },
        "sentiment": {
            "type": "string",
            "enum": ["positive", "neutral", "negative"]
        }
    },
    "required": ["title", "one_sentence_summary", "key_points", "sentiment"],
    "additionalProperties": False
}

The additionalProperties: False rule is deliberate. Models sometimes add helpful extra fields; those extras break downstream consumers that expect an exact shape.

The Smallest Useful Request

We will use the Messages API and ask for JSON directly. Modern Anthropic models support structured outputs via output_config (or the SDK’s messages.parse helper). For teaching purposes we will show an explicit approach that still works even if you are on an older SDK version, then note the newer helpers.

Python

import json
import os
from dotenv import load_dotenv
from anthropic import Anthropic

load_dotenv()
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

SYSTEM = """You are a precise document summarizer.
Return ONLY a valid JSON object that matches this schema:
{
  "title": string,
  "one_sentence_summary": string,
  "key_points": array of 1-5 strings,
  "sentiment": "positive" | "neutral" | "negative"
}
Do not wrap the JSON in markdown fences.
Do not add any keys that are not in the schema.
Do not include explanatory text before or after the JSON.
"""

def summarize_document(text: str) -> dict:
    response = client.messages.create(
        model="claude-sonnet-5",  # verify current ID in official docs
        max_tokens=512,
        system=SYSTEM,
        messages=[
            {
                "role": "user",
                "content": f"Summarize the following document:\n\n{text}"
            }
        ]
    )
    raw = response.content[0].text.strip()
    return raw

At this point we have a string that is supposed to be JSON. We have not yet validated it.

Validation: The Part Most Tutorials Skip

Python

def validate_summary(data: dict) -> list[str]:
    """Return a list of error messages. Empty list means valid."""
    errors = []

    if not isinstance(data, dict):
        return ["Root value is not an object"]

    required = ["title", "one_sentence_summary", "key_points", "sentiment"]
    for key in required:
        if key not in data:
            errors.append(f"Missing required key: {key}")

    if "title" in data and not isinstance(data["title"], str):
        errors.append("title must be a string")

    if "one_sentence_summary" in data and not isinstance(data["one_sentence_summary"], str):
        errors.append("one_sentence_summary must be a string")

    if "key_points" in data:
        if not isinstance(data["key_points"], list):
            errors.append("key_points must be an array")
        else:
            if not (1 <= len(data["key_points"]) <= 5):
                errors.append("key_points must contain 1-5 items")
            for i, item in enumerate(data["key_points"]):
                if not isinstance(item, str):
                    errors.append(f"key_points[{i}] must be a string")

    if "sentiment" in data and data["sentiment"] not in ("positive", "neutral", "negative"):
        errors.append("sentiment must be one of: positive, neutral, negative")

    extra = set(data.keys()) - set(required)
    if extra:
        errors.append(f"Unexpected keys: {sorted(extra)}")

    return errors

A production system would use a real JSON Schema validator or a Pydantic model. The manual version above makes every rule visible, which is useful while you are still learning the failure modes.

Parsing and Retrying

Python

def parse_and_validate(raw: str) -> tuple[dict | None, list[str]]:
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as e:
        return None, [f"JSON parse error: {e}"]

    errors = validate_summary(data)
    if errors:
        return None, errors
    return data, []


def summarize_with_retry(text: str, max_attempts: int = 2) -> dict:
    last_errors = []
    current_text = text

    for attempt in range(1, max_attempts + 1):
        print(f"Attempt {attempt}")
        raw = summarize_document(current_text)
        print(f"Raw output: {raw[:200]}...")

        data, errors = parse_and_validate(raw)
        if data is not None:
            return data

        last_errors = errors
        print(f"Validation failed: {errors}")

        # Feed the errors back so the model can correct itself
        current_text = (
            f"The previous summary was invalid for these reasons:\n"
            f"{json.dumps(errors, indent=2)}\n\n"
            f"Please try again and return ONLY valid JSON.\n\n"
            f"Original document:\n{text}"
        )

    raise ValueError(f"Failed after {max_attempts} attempts. Last errors: {last_errors}")

Two attempts are usually enough for well-formed schemas. More than that often indicates the schema itself is ambiguous or the document is too noisy.

A documentary photo of a developer reviewing terminal retry logs and error handling output on a computer screen.

A Complete Runnable Example

Python

SAMPLE_DOC = """
Acme Corp announced record quarterly revenue of $42 million, 
up 18% from the previous year. CEO Jane Rivera credited strong 
demand in the Midwest and a successful product launch in March. 
The company also noted ongoing supply-chain pressure that may 
affect margins next quarter. Overall the tone of the release 
was optimistic.
"""

if __name__ == "__main__":
    result = summarize_with_retry(SAMPLE_DOC)
    print("\n=== Validated Summary ===")
    print(json.dumps(result, indent=2))

When it works you receive a clean dictionary you can pass to any other Python code without further cleaning.

Three Failure Modes Worth Testing

1. Markdown Fences and Extra Prose

Even with a strict system prompt the model sometimes returns:

text

Here is the summary:
```json
{ ... }
```

The json.loads call fails. The retry path that feeds the parse error back to the model usually corrects it on the second attempt. Logging the raw output makes the problem obvious.

2. Missing or Extra Keys

The model omits sentiment or adds a helpful confidence field. The validator catches both cases. Feeding the exact error list back in the retry message is more effective than a generic “please follow the schema.”

3. Wrong Types Inside Arrays

key_points comes back as a list of objects instead of a list of strings, or as a single string. The manual validator surfaces the precise index that failed. That precision helps the model correct the mistake.

I keep a small folder of “ugly documents” that historically trigger these three failures. Running the summarizer against that folder is part of every change to the schema or the prompt.

Practical Rules I Use for Structured Extraction

  • Define the schema before you write the prompt. The schema is the contract; the prompt is only a way to ask for it.

  • Reject extra keys. Helpful additional fields become breaking changes for downstream code.

  • Keep the schema small. Large, deeply nested schemas increase the chance of partial or malformed output.

  • Always log the raw model output when validation fails. The raw text is the only way to see what actually went wrong.

  • Prefer a short retry with explicit error feedback over a long, complicated prompt that tries to prevent every possible mistake in advance.

Modern SDK Helpers (Worth Knowing)

Newer versions of the Anthropic Python SDK offer client.messages.parse and output_config / output_format options that can constrain decoding more tightly and integrate with Pydantic models. Those helpers reduce the amount of manual validation you need to write. They do not eliminate the need to think about failure modes, retries, and logging. Use the helpers when they are available; keep the validation mindset regardless.

What This Summarizer Does Not Do Yet

It does not:

  • Handle documents longer than the context window

  • Split large documents into chunks and merge results

  • Attach source citations or page numbers

  • Run as a background job with retries and dead-letter queues

  • Store results in a database or object store

Those capabilities belong to later production posts. The goal today is a single, reliable extraction step that returns valid JSON or fails loudly with clear reasons.

Verification Checklist

  1. Run the sample document and confirm you receive a dictionary that matches the schema.

  2. Temporarily break the system prompt so the model returns markdown fences; confirm the retry path recovers or raises a clear error.

  3. Change the schema to require an extra field and confirm validation fails with a precise message.

  4. Feed a document that is pure noise and confirm the system either returns valid (if limited) JSON or fails after the retry limit.

  5. Inspect the logs and confirm every validation failure includes the raw model output.

If all five steps pass, you have a working structured summarizer.

Update Note

Anthropic’s structured-output features continue to evolve. Model identifiers, the exact parameter names (output_config, output_format, etc.), and the degree of decoding constraints change. Always verify the current recommended approach and model ID in the official documentation before you copy examples into longer-lived code. The validation-and-retry pattern shown here remains useful even when the platform offers stronger guarantees.

Looking Ahead

The next post builds a human-reviewed support-ticket triage workflow on top of the same structured-output foundation. Once you can reliably extract a fixed JSON shape, it becomes straightforward to route tickets, assign priorities, and keep a person in the loop for the cases that still need judgment.

Keep the summarizer. When you later embed it inside a larger pipeline, you will already know how to detect and recover from the ways structured output still breaks.

Build the smallest useful version, then test what breaks.

— Caleb Fletcher

Boise, Idaho

Updated · 2026-10-15 14:57
Feedback

No feedback yet — submit the first.

Submit feedback
© 2026 Context & Tools. All rights reserved. data-driven, published weekly ♥