The previous lessons always ended in text: a written answer, a category label. Fine as long as the reader is a human. But DocuBot won't live in a terminal: it will be embedded in Nubelia's support site, and that site is code — it needs to know which category the question fell into to render the right icon, which sources to cite as links, and how confident the answer is to decide whether to display it or hand it off to a human. In other words: the consumer of the LLM's output will be a program, and programs don't parse prose. This lesson teaches how to get structured, parseable outputs — above all JSON — from a system that, as we've known since 01-04, is non-deterministic by nature: how to ask for them, how to validate them in Python, how to make them more robust, and how to control other aspects of format (markdown, length, language). We're still not calling any API: we work with the text of the prompts, the expected responses, and the validation code that will receive them.
Contents
- Why a developer needs parseable outputs
- Asking for JSON with a schema in the prompt
- Validating in Python: json.loads and defensive programming
- Techniques to make the output more robust
- Native JSON modes: what awaits you in module 3
- Controlling other formats: markdown, length, and language
Why a developer needs parseable outputs
Imagine DocuBot's integration into Nubelia's support site with free-text output:
The question seems to be about permissions. To invite an external user you need the Administrator role... Source: "Roles and permissions" section. I'm fairly confident in this answer.
How does the code extract the category? With a regular expression looking for "seems to be about X"? And what if tomorrow the model writes "This is a permissions matter"? Every differently worded response (non-determinism, 01-04) would break the parser. The solution is to flip the contract: instead of adapting the code to the model's prose, we force the model to speak the code's language. DocuBot's contract with the support site will be:
{
"category": "permissions",
"answer": "To invite an external user you need the Administrator role. Go to Workspace → Members → Invite.",
"sources": ["Roles and permissions"],
"confidence": "high"
}Four fields, each with a clear consumer on the site:
| Field | Type | Who consumes it |
|---|---|---|
category |
string (enum of 6 values) | Routing and icon in the interface |
answer |
string | Shown to the employee |
sources |
list of strings | Rendered as links to the wiki |
confidence |
"high" | "medium" | "low" |
If it's "low", the site offers escalation to a human instead of trusting it |
The confidence field deserves a pause: it's the structured version of rule 3 of the 02-01 system prompt ("say you don't know"). Free text that admits ignorance is useful; a "confidence": "low" field is actionable: code can branch on it. Structuring the output isn't just parsing convenience — it's turning the assistant's behaviors into data you can program against. (That the final decision to escalate is made by a human-in-the-loop flow, and not by the model on its own, is a design principle we'll return to in module 6.)
Asking for JSON with a schema in the prompt
The base technique is to describe the schema in the prompt and — remembering the few-shot of 02-02 — show an example, because format is taught better with examples than with descriptions. We add to DocuBot's instructions:
RESPONSE FORMAT:
Respond ONLY with a valid JSON object, with no text before or after,
and no code-block quotes. The schema is:
{
"category": string, one of: "billing", "permissions",
"integrations", "api", "incidents", "other",
"answer": string, the answer to the user in the language of the
question, maximum 150 words,
"sources": array of strings, titles of the <documentation> sections
used; [] if you used none,
"confidence": "high" | "medium" | "low". Use "low" if the
documentation does not cover the question; in that case
"answer" must be "I can't find that information in the
available documentation." and "sources" must be [].
Example of a valid response:
{"category": "permissions", "answer": "Only Administrators can invite external users. Go to Workspace → Members.", "sources": ["Roles and permissions"], "confidence": "high"}Design decisions worth internalizing:
- "ONLY… no text before or after". Without this, models tend to wrap the JSON in courtesy ("Sure! Here you go: …") or in a markdown
```jsonblock. Both break a naivejson.loads. - Each field carries a type, allowed values, and a usage criterion. Not just "a confidence field": when it's low and what that implies for the other fields. Fields are defined together because they get filled in together.
- Closed value sets are enumerated (
"high" | "medium" | "low"). We'll come back to this in the robustness section. - The example is on a single line deliberately: it also teaches that pretty-printing isn't needed (fewer output tokens = less cost and latency, 01-04).
- The "I don't know" case is built into the schema. The anti-hallucination rule from 02-01 now produces coherent data:
confidence: "low"+ fixed answer + empty sources. The three fields tell the same story and the code can verify it.
With the question "Does Nubelia let me export a project to PDF?" (not covered in the documentation), the structured DocuBot's expected response is:
{"category": "other", "answer": "I can't find that information in the available documentation.", "sources": [], "confidence": "low"}Compare it with the naive DocuBot from 01-04, which invented the PDF export with total fluency. Same model; the prompt and the output contract make the difference.
Validating in Python: json.loads and defensive programming
Golden rule: an LLM's output is untrusted input, exactly like a web form. The model will almost always return valid JSON with this prompt… and "almost always" in production means "sometimes not". Your code must assume it. Defensive validation step by step:
import json
VALID_CATEGORIES = {"billing", "permissions", "integrations",
"api", "incidents", "other"}
CONFIDENCE_LEVELS = {"high", "medium", "low"}
FALLBACK_RESPONSE = {
"category": "other",
"answer": "I couldn't process the response. "
"A human agent will review your query.",
"sources": [],
"confidence": "low",
}
def clean_json(text: str) -> str:
"""Removes common wrappers: markdown blocks and extra text."""
text = text.strip()
if text.startswith("```"):
# Drop the first line (``` or ```json) and the closing fence
text = text.split("\n", 1)[1]
text = text.rsplit("```", 1)[0]
# If there's still prose around it, keep the first complete {...}
start, end = text.find("{"), text.rfind("}")
if start != -1 and end > start:
text = text[start:end + 1]
return text.strip()
def parse_docubot_response(model_text: str) -> dict:
"""Turns the model output into a dict that is safe for the site."""
# Step 1: parse (may fail → malformed JSON)
try:
data = json.loads(clean_json(model_text))
except json.JSONDecodeError:
return FALLBACK_RESPONSE
# Step 2: is it an object? (the model could return a list or a string)
if not isinstance(data, dict):
return FALLBACK_RESPONSE
# Step 3: fields with safe default values
result = {
"category": data.get("category", "other"),
"answer": data.get("answer", ""),
"sources": data.get("sources", []),
"confidence": data.get("confidence", "low"),
}
# Step 4: normalize values outside the enum
if result["category"] not in VALID_CATEGORIES:
result["category"] = "other"
if result["confidence"] not in CONFIDENCE_LEVELS:
result["confidence"] = "low"
if not isinstance(result["sources"], list):
result["sources"] = []
# Step 5: domain coherence rules
if not result["answer"].strip():
return FALLBACK_RESPONSE
if result["confidence"] == "low":
result["sources"] = [] # no confidence, no sources to cite
return resultExplanation of each layer of defense:
clean_jsonattacks the two most frequent failures in practice: the```json … ```wrapper and the courtesy sentence before the object. Grabbing from the first{to the last}is a pragmatic trick that rescues most cases (not all: JSON truncated mid-string will still fail, and it's fine that it fails — that's what step 1 is for).try/except json.JSONDecodeErroris non-negotiable: it's the only safe way to know whether a text is valid JSON. Never validate "by eye" withstartswith("{").data.get(field, default_value)instead ofdata["field"]: if the model omits a field, you get a safe value instead of aKeyErrorat 3 a.m. Note that each default is the conservative one: categoryother, confidencelow— when in doubt, the system distrusts.- Enum normalization: if the model returns
"Permissions"(capitalized) or"facturacion"(Spanish slipped in), the value falls back toother/lowinstead of propagating through your database as a phantom category. (You could be more lenient and lowercase before comparing; decide and document.) - Coherence rules: structural validation (is it JSON? correct types?) doesn't guarantee domain coherence (low confidence with three sources cited?). That last layer is yours.
FALLBACK_RESPONSEcloses the system: whatever happens, the support site receives a dict with the right shape and a dignified message. The model's failure degrades gracefully instead of turning into an exception for the user. In module 3 we'll add another option: retrying the call when parsing fails.
In real projects, this manual validation is usually delegated to schema libraries like Pydantic (you define the class, it validates types and enums); the principle is identical, and it's worth having written the manual version once to understand what the library gives you for free.
Techniques to make the output more robust
Validation is the safety net; these techniques reduce how often you need it:
- The "JSON only" instruction, explicit and repeated. "Respond ONLY with the JSON object, no explanations, no markdown" — and repeat it at the end of the prompt if the context is long (the "lost in the middle" of 01-04 affects format instructions too).
- Schema + example, always together. The description defines the contract; the example demonstrates it. If you observe recurring format errors, add a second example covering the conflicting case (few-shot applied to format): for instance, an example with
confidence: "low"so the model also sees the shape of the "I don't know" case. - Enum fields over free fields.
"confidence": "high"|"medium"|"low"is robust;"confidence": 0.87invites the model to invent a decimal precision it doesn't have (how does 0.87 differ from 0.79? In nothing measurable). General rule: the fewer degrees of freedom a field has, the more reliable it will be. Enums are also validated with a simplein. - Flat structures over deep nesting. A 4-field object rarely fails; one with three levels of nesting and arrays of objects fails more. If you need a lot of structure, consider whether it's really several chained calls (02-02) with simple outputs.
- Ask for the "hard" fields in an order that helps. Placing
answerbeforeconfidencelets the self-assessment lean on the already-generated answer — it's the chain-of-thought principle (02-02) applied inside the JSON: first the work, then the judgment about the work. - Low temperature. For structured outputs you want the deterministic end of the temperature table from 01-02. Creativity is the parser's enemy.
And the good news: you won't be alone in this. The major providers offer native JSON modes and structured outputs — API parameters with which the engine itself guarantees syntactically valid JSON or even conformance to a formal schema you declare. We'll use them as soon as we reach the API in 03-01. Why learn the "handcrafted" version then? Three reasons: not all models/providers offer it; native modes guarantee syntax, not domain coherence (the validation of steps 4-5 is still yours); and understanding the mechanism lets you debug when something fails. Later on you'll see that function calling (05-01) is the natural evolution of this same idea: instead of asking for JSON and hoping, the API formalizes the schema as the signature of a function the model "fills in".
Controlling other formats: markdown, length, and language
JSON is the star case, but format control is broader. Three fronts DocuBot needs:
Markdown. The answer field will be rendered on the support site, which accepts basic markdown. It's worth saying exactly which:
The "answer" field may use basic markdown: **bold**, lists with "-" and numbered steps. Do not use headings (#), tables or code blocks unless the answer includes an API example.
Without this restriction, the model may return ## headings that look outsized in a small panel on the site. The output format is specified for the real consumer: a support panel, an email, and a table cell each tolerate different things.
Length. We already saw it in 02-01: models don't count words exactly, but length instructions shift the average reliably. It works better to specify structural units than words: "at most 3 steps", "one sentence per source", "2 paragraphs". Discrete units are respected better than counts ("maximum 147 words" will never be more than a guideline). If the length is a hard limit (a database column, an SMS), truncate it in code: the instruction reduces the cases, the validation guarantees the limit.
Language. Nubelia operates in Spain and has teams that write in Spanish, Catalan, and English. DocuBot's rule already anticipates it ("answer in the language the user writes in"), but with JSON output there's a subtlety that surprises everyone the first time: you have to separate the language of the content from the language of the structure:
The "answer" field must be in the language of the user's question. The JSON keys and the values of "category" and "confidence" ALWAYS keep the exact values from the schema, untranslated.
Without the second sentence, a question in Spanish may produce "confidence": "alta" — perfectly valid JSON that your enum will reject. The schema is code; the content is language. (And notice that the validation from the previous section already protected you: "alta" would have fallen back to "low". The layers support each other.)
Common Mistakes and Tips
- Parsing with
eval()instead ofjson.loads. Never:evalexecutes arbitrary code, and you're processing text generated by an untrusted system. It's the difference between parsing and executing. - Assuming that "the model almost always returns valid JSON" equals "always". A 1-2% format-failure rate, with thousands of requests, means dozens of errors a day. The
try/exceptand the fallback aren't paranoia: they're the price of admission. - Validating only the syntax and not the domain.
{"category": "recipes", "confidence": "superhigh"}is impeccable JSON and garbage for your system. Enums and coherence rules, always. - Asking for confidence numbers with decimals. They invite false precision. 3-level enums: less expressive, much more honest and validatable.
- Mile-long schemas with 15 nested fields. Every extra field is an opportunity for error and more tokens. Ask for what the application consumes today; add fields when their consumer exists.
- Forgetting the error case in the schema. If the schema only contemplates successful responses, the model will shoehorn the "I don't know" into fields meant for something else. Design the failure schema (our
confidence: "low"+ fixed answer +sources: []combination) with the same care as the success one. - Tip: save the model outputs that break your parser. Each one is a free test case for lesson 02-04 and a candidate few-shot format example.
Exercises
Exercise 1. Nubelia's support team wants DocuBot to also detect the urgency of each question to prioritize the queue. Extend the JSON schema of DocuBot's prompt with a well-designed urgency field (values, assignment criterion, example) and justify your decisions against the alternative "urgency": 7 (a 1-10 numeric scale).
Exercise 2. Write down what parse_docubot_response (the function from the lesson) returns for each of these three model outputs, and which step of the function acts in each case:
a) ```json\n{"category": "api", "answer": "The limit is 100 requests/min.", "sources": ["API limits"], "confidence": "high"}\n```
b) {"category": "Billing", "answer": "You can download it in Settings → Billing.", "confidence": "medium"}
c) Sorry, I can't generate the response right now.
Exercise 3. Write the format instruction (the RESPONSE FORMAT block of the prompt) for another Nubelia case: a prompt that receives the text of a ticket and must return JSON with summary (string, at most 2 sentences, in the language of the ticket), affected_product (enum: "boards", "api", "reports", "other") and requires_engineer (boolean true/false with an explicit criterion). Include the "JSON only" instruction and a valid example.
Solutions
Solution 1. One possible schema extension:
"urgency": "critical" | "high" | "normal". Use "critical" only if the
user describes a service outage or data loss in progress;
"high" if work is blocked with no workaround; "normal" in
all other cases (questions, improvements, queries).
Example: {"category": "incidents", "answer": "...", "sources":
["Service status"], "confidence": "high", "urgency": "critical"}Justification: a 3-level enum with observable criteria ("service outage", "blocked with no workaround") can be assigned consistently and validated with an in. A 1-10 scale forces the model to invent distinctions no one has defined (what separates a 6 from a 7?), produces values that aren't comparable across runs (non-determinism, 01-04) and complicates the consuming code (is the priority threshold ≥6 or ≥7?). Rule: the field's granularity should be the one the consumer actually uses — and a support queue distinguishes three levels, not ten.
Solution 2.
a) clean_json removes the ```json … ``` wrapper, step 1's parsing works and all values pass the validations: the full dict is returned as-is, with category: "api" and confidence: "high".
b) The JSON parses fine (steps 1-2), but: sources is missing → step 3 fills it in with []; "Billing" is not in the enum (capitalized) → step 4 normalizes it to "other". Result: {"category": "other", "answer": "You can download it in Settings → Billing.", "sources": [], "confidence": "medium"}. (This illustrates the cost of strict normalization: a trivial capitalization variant loses the real category; it would be reasonable to apply .lower() before comparing.)
c) There's no { or }: clean_json returns the text as-is, json.loads raises JSONDecodeError, and step 1 returns FALLBACK_RESPONSE. The site shows the neutral message and offers human escalation: graceful degradation.
Solution 3. One possible solution:
RESPONSE FORMAT:
Respond ONLY with a valid JSON object, with no text before or after
and no code blocks. Schema:
{
"summary": string, at most 2 sentences, in the language of the ticket,
"affected_product": "boards" | "api" | "reports" | "other",
"requires_engineer": boolean. true only if the ticket describes a
reproducible product error (failure, incorrect data, outage);
false if it is a usage question, an improvement request, or a
configuration problem on the user's side.
}
The keys and the values of "affected_product" are never translated.
Example of a valid response:
{"summary": "The user cannot export the weekly report; the download fails with an error.", "affected_product": "reports", "requires_engineer": true}Key points: "JSON only" instruction at the start, closed enum, a boolean with an explicit assignment criterion (without it, requires_engineer would be a coin toss), the no-translation note, and a single-line example that demonstrates the contract.
Conclusion
DocuBot now speaks two languages: the language of people (the answer field, in the user's language, with its bounded markdown) and the language of programs (the JSON contract category/answer/sources/confidence, with closed enums and its failure case designed). You've learned the two inseparable halves of the discipline: ask well (schema + example, "JSON only", fields with few degrees of freedom) and always validate (protected json.loads, conservative defaults, domain coherence, and a dignified fallback), knowing that the native structured-output modes we'll see in 03-01 will take the syntactic work off your hands but never the domain validation, and that the function calling of 05-01 will take this idea one step further. But there's an uncomfortable question this module hasn't answered yet: DocuBot's prompt now has an identity, rules, few-shot examples, and an output schema… how do we know it works? And how will we know that the next prompt "improvement" doesn't break what already worked? The module's final lesson turns prompting into real engineering: versioning, test-case sets, and systematic prompt evaluation.
Generative AI and LLMs for Developers Course
Module 1: Generative AI Fundamentals
- What generative AI is and why it matters to developers
- How an LLM works: tokens, embeddings and attention
- The model and provider landscape
- Limitations and risks: hallucinations, context and costs
Module 2: Prompt Engineering
- Anatomy of a prompt: roles, instructions and context
- Prompting techniques: few-shot, chain of thought and templates
- Structured outputs: JSON and format control
- Prompt iteration and evaluation
Module 3: Integrating LLMs into Applications
- First integration with an LLM API
- Streaming, errors and retries
- Conversations and context management
- Costs, latency and caching
Module 4: RAG - Retrieval-Augmented Generation
- Embeddings and semantic search
- Vector databases
- Document ingestion and chunking
- Building a complete RAG pipeline
- Evaluating RAG systems
Module 5: Function Calling and Agents
- Function calling: connecting the LLM to your code
- From LLM to agent: the reasoning and action loop
- Orchestration frameworks
