Module 3: Structured Outputs and System Prompts
6. Prompt Templates and Variables
Overview
In production, prompts are rarely static. Real prompts need to adapt to the user, the context, the language, the conversation history and the specific data of each request. A well-designed template system makes your prompts maintainable, testable and reusable.
In this capsule you'll learn: template systems with f-strings and .format(), Jinja2 for conditional logic, template composition and inheritance, managing templates in files, and how to handle common errors.
Why Templates Are Essential in Production
A comparison of approaches:
| Approach | Pros | Cons | When to use it |
|---|---|---|---|
| String concatenation | Simple | Unmaintainable, bug-prone | Never in production |
| f-strings | Pythonic, simple | No logic, no loops | Simple templates |
.format() | More flexible than f-strings | No logic | Templates with dynamic variables |
| Jinja2 | Full logic, inheritance, filters | Extra dependency | Complex templates |
| Template files | Separation of concerns | Requires file management | Large teams, many prompts |
Technique 1: f-strings and .format() (native Python)
f-strings for simple templates
from openai import OpenAI
client = OpenAI()
# Basic template with an f-string
def classify_text(text: str, categories: list[str], language: str = "English") -> str:
categories_str = ", ".join(categories)
system = f"You are an accurate classifier. Answer only with one of these categories: {categories_str}."
user = f"Classify in {language}:\n\n{text}"
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user}
],
temperature=0
)
return response.choices[0].message.content.strip()
# Test
result = classify_text(
text="I can't log in, it says error 401",
categories=["TECHNICAL", "BILLING", "ACCOUNT", "OTHER"]
)
print(result) # → TECHNICAL or ACCOUNT
.format() for reusable templates
# The .format() method lets you define templates as constants
PROMPT_CLASSIFICATION = """
Classify the following text into one of these categories: {categories}.
Rules:
- Return ONLY the category name
- If you aren't sure, return the most likely one
- Answer language: {language}
Text to classify:
{text}
"""
PROMPT_SUMMARY = """
Summarize the following {document_type} in at most {max_words} words.
Tone: {tone}
Audience: {audience}
{document_type}:
{content}
"""
def build_prompt(template: str, **kwargs) -> str:
"""
Builds a prompt from a template and its variables.
Args:
template: A template with {variable} placeholders
**kwargs: The variables to substitute into the template
Returns:
The complete prompt with the variables interpolated
Raises:
KeyError: If a required variable is missing
"""
try:
return template.format(**kwargs)
except KeyError as e:
raise KeyError(f"Required variable missing from the template: {e}")
# Basic test
prompt = build_prompt(
PROMPT_CLASSIFICATION,
categories="TECHNICAL, BILLING, ACCOUNT, OTHER",
language="English",
text="I was charged twice this month"
)
print(prompt)
Templates with default values
class PromptBuilder:
"""A builder for prompts with default values and validation."""
DEFAULTS = {
"language": "English",
"max_tokens": 200,
"tone": "professional",
"detail_level": "medium"
}
def __init__(self, template: str):
self.template = template
self._variables: dict = dict(self.DEFAULTS)
def set(self, **kwargs) -> "PromptBuilder":
"""Sets variables. Returns self for chaining."""
self._variables.update(kwargs)
return self
def build(self) -> str:
"""Builds the final prompt."""
# Check that every required variable is defined
import string
formatter = string.Formatter()
required = {field_name for _, field_name, _, _ in formatter.parse(self.template) if field_name}
missing = required - set(self._variables.keys())
if missing:
raise ValueError(f"Required variables missing: {missing}")
return self.template.format(**self._variables)
def reset(self) -> "PromptBuilder":
"""Resets to the default values."""
self._variables = dict(self.DEFAULTS)
return self
# Usage with chaining
builder = PromptBuilder(PROMPT_SUMMARY)
prompt = (builder
.set(document_type="email",
max_words=50,
tone="formal",
audience="executives",
content="[email content here]")
.build()
)
print(prompt)
Technique 2: Jinja2 for Complex Templates
Installation and basic setup
# pip install jinja2
from jinja2 import Template, Environment, BaseLoader, FileSystemLoader
from jinja2 import TemplateSyntaxError, UndefinedError
# A basic Jinja2 template
template_str = """
You are a classifier for {{ domain }}.
Available categories: {{ categories | join(", ") }}
{% if examples %}
Classification examples:
{% for inp, out in examples %}
- Input: "{{ inp }}" → Category: {{ out }}
{% endfor %}
{% endif %}
{% if additional_context %}
Important context: {{ additional_context }}
{% endif %}
Classify the following text:
{{ text }}
{% if json_format %}
Answer ONLY in JSON: {"category": "...", "confidence": 0.0}
{% else %}
Answer ONLY with the category name.
{% endif %}
"""
template = Template(template_str)
prompt = template.render(
domain="SaaS customer support",
categories=["TECHNICAL", "BILLING", "ACCOUNT", "OTHER"],
examples=[
("I can't get in", "TECHNICAL"),
("I was charged extra", "BILLING"),
("I want to change my plan", "ACCOUNT")
],
additional_context="The company handles payments in MXN and USD",
text="The payment button doesn't respond when I click it",
json_format=True
)
print(prompt)
Custom Jinja2 filters
from jinja2 import Environment, BaseLoader
def create_environment() -> Environment:
"""Creates a Jinja2 Environment with custom filters."""
env = Environment(loader=BaseLoader())
# Filter to truncate text
def truncate_words(text: str, max_words: int) -> str:
words = text.split()
if len(words) <= max_words:
return text
return " ".join(words[:max_words]) + "..."
# Filter to format a list as bullet points
def as_bullets(items: list, marker: str = "-") -> str:
return "\n".join(f"{marker} {item}" for item in items)
# Filter to format as a numbered list
def as_numbered(items: list) -> str:
return "\n".join(f"{i+1}. {item}" for i, item in enumerate(items))
# Filter to capitalize the first letter
def sentence_case(text: str) -> str:
return text[0].upper() + text[1:] if text else text
env.filters["truncate_words"] = truncate_words
env.filters["as_bullets"] = as_bullets
env.filters["as_numbered"] = as_numbered
env.filters["sentence_case"] = sentence_case
return env
env = create_environment()
# Use the custom filters
template = env.from_string("""
Analyze this report:
Key metrics:
{{ metrics | as_bullets }}
Findings for the period:
{{ findings | as_numbered }}
Context: {{ context | truncate_words(50) }}
""")
prompt = template.render(
metrics=["Users: 10,000", "Churn: 5%", "NPS: 42"],
findings=[
"15% growth in new users",
"Drop in mobile engagement",
"Spike in support tickets on Wednesdays"
],
context="This is a long report with a lot of context that we need to truncate so " * 20
)
print(prompt)
Template inheritance
from jinja2 import Environment, DictLoader
# Base and derived templates using inheritance
templates = {
"base_system.j2": """
You are {{ role }}.
{% block expertise %}
{% endblock %}
{% block general_rules %}
General rules:
- Be accurate and objective
- Don't invent information
- If you don't know something, say "I don't have enough information"
{% endblock %}
{% block output_format %}
Answer format: Free text.
{% endblock %}
""",
"expert_analyst.j2": """
{% extends "base_system.j2" %}
{% block expertise %}
You have expertise in {{ domain }} with {{ years_experience }} years of experience.
Your audience: {{ audience }}.
{% endblock %}
{% block output_format %}
Structure your answer:
1. Executive summary (2-3 sentences)
2. Main findings (a list)
3. Recommendations
{% endblock %}
""",
"json_formatter.j2": """
{% extends "base_system.j2" %}
{% block expertise %}
A specialist in data extraction and structuring.
{% endblock %}
{% block general_rules %}
{{ super() }}
- CRITICAL: Your output must be ONLY valid JSON
- No text before or after the JSON
- Use null for fields you don't find
{% endblock %}
{% block output_format %}
Required JSON schema:
{{ schema | tojson(indent=2) }}
{% endblock %}
"""
}
env = Environment(loader=DictLoader(templates))
# Render the derived template
expert_template = env.get_template("expert_analyst.j2")
system_prompt = expert_template.render(
role="a senior business consultant",
domain="business strategy for Latin American startups",
years_experience=12,
audience="early-stage startup founders"
)
print(system_prompt)
Technique 3: Templates from Files
The recommended folder structure
prompts/
├── base/
│ ├── base_system.j2
│ └── base_user.j2
├── classification/
│ ├── tickets.j2
│ ├── sentiment.j2
│ └── intent.j2
├── extraction/
│ ├── invoice.j2
│ ├── email.j2
│ └── contact.j2
└── analysis/
├── competitors.j2
└── metrics.j2
Loading templates from files
from jinja2 import Environment, FileSystemLoader, select_autoescape
from pathlib import Path
class PromptTemplateManager:
"""
A manager for prompt templates loaded from the filesystem.
It supports:
- Loading from directories
- Automatic template caching
- Reloading during development
- Validation of the required variables
"""
def __init__(self, templates_dir: str | Path, auto_reload: bool = False):
self.templates_dir = Path(templates_dir)
self.env = Environment(
loader=FileSystemLoader(str(self.templates_dir)),
autoescape=select_autoescape(["html", "xml"]),
auto_reload=auto_reload,
keep_trailing_newline=True
)
# Register the custom filters
self._register_filters()
def _register_filters(self):
"""Registers Jinja2 filters that are useful for prompts."""
self.env.filters["as_bullets"] = lambda items: "\n".join(f"- {i}" for i in items)
self.env.filters["as_numbered"] = lambda items: "\n".join(
f"{i+1}. {item}" for i, item in enumerate(items)
)
def render(self, template_name: str, **variables) -> str:
"""
Renders a template with the given variables.
Args:
template_name: The template's name (e.g. "classification/tickets.j2")
**variables: The variables for the template
Returns:
The rendered template as a string
Raises:
TemplateNotFound: If the template doesn't exist
UndefinedError: If a required variable is missing
"""
template = self.env.get_template(template_name)
return template.render(**variables)
def list_templates(self, category: str | None = None) -> list[str]:
"""Lists the available templates, optionally filtered by category."""
all_templates = self.env.list_templates()
if category:
return [t for t in all_templates if t.startswith(category)]
return all_templates
def validate_variables(self, template_name: str, variables: dict) -> list[str]:
"""
Checks that the required variables are present.
Returns:
A list of the missing variables (empty if everything is fine)
"""
import re
template = self.env.get_template(template_name)
source = template.module.__loader__.get_source(template_name) if hasattr(template, 'module') else ""
# Parse the template's variables
required = set(re.findall(r"\{\{\s*(\w+)\s*\}\}", source))
present = set(variables.keys())
return list(required - present)
# Create the example templates
import os
def setup_templates_demo():
"""Creates the demo template structure."""
os.makedirs("prompts/classification", exist_ok=True)
# Ticket classification template
ticket_template = """
You are an expert support ticket classifier for {{ company }}.
{% if company_description %}
Context: {{ company_description }}
{% endif %}
Available categories:
{% for cat in categories %}
- {{ cat.name }}: {{ cat.description }}
{% endfor %}
{% if examples %}
Examples:
{% for example in examples %}
Input: "{{ example.input }}" → {{ example.output }}
{% endfor %}
{% endif %}
Classify the following ticket and return JSON:
{"category": "name", "priority": "HIGH|MEDIUM|LOW", "confidence": 0.0-1.0}
No additional text.
"""
with open("prompts/classification/tickets.j2", "w") as f:
f.write(ticket_template)
setup_templates_demo()
# Use the manager
manager = PromptTemplateManager("prompts")
system_prompt = manager.render(
"classification/tickets.j2",
company="TechSaaS",
company_description="A project management platform for software teams",
categories=[
{"name": "TECHNICAL", "description": "Errors, bugs, things not working"},
{"name": "BILLING", "description": "Charges, plans, invoices"},
{"name": "ACCOUNT", "description": "Access, password, profile"},
{"name": "FEATURE_REQUEST", "description": "Requests for new functionality"}
],
examples=[
{"input": "The dashboard won't load", "output": "TECHNICAL"},
{"input": "I want to change my plan", "output": "ACCOUNT"}
]
)
print(system_prompt)
Technique 4: Template Composition
Composition lets you build complex prompts by assembling reusable parts.
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class PromptPart:
"""A part of a prompt, with a name and a generator function."""
name: str
generate: Callable[..., str]
required: bool = True
class PromptComposer:
"""
A composer for building modular prompts.
It lets you define reusable prompt parts and combine them.
"""
def __init__(self, separator: str = "\n\n"):
self._parts: list[PromptPart] = []
self.separator = separator
def add(self, name: str, required: bool = True):
"""A decorator to register a part of the prompt."""
def decorator(func: Callable) -> Callable:
self._parts.append(PromptPart(
name=name,
generate=func,
required=required
))
return func
return decorator
def compose(self, **kwargs) -> str:
"""
Composes the final prompt out of all the parts.
Args:
**kwargs: The variables to pass to each part
Returns:
The complete prompt
"""
generated_parts = []
for part in self._parts:
try:
content = part.generate(**kwargs)
if content and content.strip():
generated_parts.append(content.strip())
except TypeError:
# The function doesn't accept certain kwargs, try without them
try:
content = part.generate()
if content and content.strip():
generated_parts.append(content.strip())
except Exception as e:
if part.required:
raise ValueError(f"Error in required part '{part.name}': {e}")
return self.separator.join(generated_parts)
# An example of using the Composer
classifier_composer = PromptComposer()
@classifier_composer.add("base_role", required=True)
def generate_role(company: str, domain: str, **kwargs) -> str:
return f"You are an expert ticket classifier for {company} ({domain})."
@classifier_composer.add("categories", required=True)
def generate_categories(categories: list[str], **kwargs) -> str:
cats_str = "\n".join(f"- {c}" for c in categories)
return f"Available categories:\n{cats_str}"
@classifier_composer.add("examples", required=False)
def generate_examples(examples: list[tuple] | None = None, **kwargs) -> str:
if not examples:
return ""
lines = [f'- "{inp}" → {out}' for inp, out in examples]
return "Examples:\n" + "\n".join(lines)
@classifier_composer.add("output_format", required=True)
def generate_format(output_format: str = "text", **kwargs) -> str:
if output_format == "json":
return 'Answer ONLY with JSON: {"category": "...", "confidence": 0.0}'
return "Answer ONLY with the category name."
# Compose the prompt
system_prompt = classifier_composer.compose(
company="Acme SaaS",
domain="a CRM platform",
categories=["TECHNICAL", "BILLING", "ACCOUNT", "OTHER"],
examples=[("I can't export", "TECHNICAL"), ("I was charged extra", "BILLING")],
output_format="json"
)
print(system_prompt)
Technique 5: Templates with Dynamic Context
For RAG systems or anything with variable context, templates need to handle lists of documents of varying size.
from jinja2 import Template
import tiktoken
# Estimate tokens so we don't blow past the context window
def count_tokens(text: str, model: str = "gpt-4o-mini") -> int:
"""Counts a text's tokens for a specific model."""
try:
enc = tiktoken.encoding_for_model(model)
return len(enc.encode(text))
except Exception:
# A rough estimate if tiktoken fails
return len(text.split()) * 1.3
def build_rag_prompt(
question: str,
documents: list[dict],
max_context_tokens: int = 3000,
model: str = "gpt-4o-mini"
) -> str:
"""
Builds a RAG prompt with context window management.
Args:
question: The user's question
documents: A list of {"content": str, "source": str, "relevance": float}
max_context_tokens: The maximum tokens for the context
model: The model used to count tokens
Returns:
An optimized prompt that respects the token limit
"""
# Sort by relevance (highest first)
sorted_docs = sorted(documents, key=lambda x: x.get("relevance", 0), reverse=True)
# Select the documents that fit in the context window
selected_docs = []
used_tokens = 0
for doc in sorted_docs:
doc_text = f"Source: {doc['source']}\n{doc['content']}"
doc_tokens = count_tokens(doc_text, model)
if used_tokens + doc_tokens > max_context_tokens:
break
selected_docs.append(doc)
used_tokens += doc_tokens
template = Template("""
Answer the question based ONLY on the context documents provided.
{% if documents %}
## Relevant context
{% for doc in documents %}
### Document {{ loop.index }} (Source: {{ doc.source }})
{{ doc.content }}
{% endfor %}
{% else %}
There are no context documents available.
{% endif %}
## Question
{{ question }}
## Instructions
- If the answer is in the context, cite it
- If it isn't in the context, say: "I can't find information about this in the available documents"
- Don't invent information that isn't in the context
""")
return template.render(
documents=selected_docs,
question=question,
tokens_info={"total": used_tokens, "included_docs": len(selected_docs)}
)
# Test
example_documents = [
{
"content": "FastAPI is a modern framework for building APIs with Python 3.7+.",
"source": "docs.fastapi.tiangolo.com",
"relevance": 0.95
},
{
"content": "JWT authentication in FastAPI is implemented with OAuth2PasswordBearer.",
"source": "fastapi.tiangolo.com/tutorial/security",
"relevance": 0.87
},
{
"content": "Pydantic v2 is significantly faster than v1 thanks to Rust.",
"source": "docs.pydantic.dev",
"relevance": 0.60
}
]
prompt = build_rag_prompt(
question="How do I implement authentication in FastAPI?",
documents=example_documents,
max_context_tokens=2000
)
print(prompt)
Technique 6: Template Versioning
In production, versioning your templates matters for A/B testing and rollbacks.
from datetime import datetime
import json
from pathlib import Path
class VersionedTemplateManager:
"""
A template manager with versioning and A/B testing.
"""
def __init__(self, storage_dir: str = "prompt_versions"):
self.storage_dir = Path(storage_dir)
self.storage_dir.mkdir(exist_ok=True)
self._registry: dict = self._load_registry()
def _load_registry(self) -> dict:
registry_file = self.storage_dir / "registry.json"
if registry_file.exists():
return json.loads(registry_file.read_text())
return {}
def _save_registry(self):
registry_file = self.storage_dir / "registry.json"
registry_file.write_text(json.dumps(self._registry, indent=2))
def save_version(
self,
name: str,
template: str,
description: str = "",
author: str = "system"
) -> str:
"""Saves a new version of a template."""
version_id = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
version_data = {
"template": template,
"description": description,
"author": author,
"created_at": datetime.utcnow().isoformat(),
"version_id": version_id
}
if name not in self._registry:
self._registry[name] = {"versions": [], "active": None}
self._registry[name]["versions"].append(version_id)
# Save the template to a file
version_file = self.storage_dir / f"{name}_{version_id}.j2"
version_file.write_text(template)
# Save the metadata
meta_file = self.storage_dir / f"{name}_{version_id}.json"
meta_file.write_text(json.dumps(version_data, indent=2))
self._save_registry()
return version_id
def activate_version(self, name: str, version_id: str):
"""Activates a specific version as the current one."""
if name not in self._registry:
raise ValueError(f"Template '{name}' not found")
if version_id not in self._registry[name]["versions"]:
raise ValueError(f"Version '{version_id}' not found")
self._registry[name]["active"] = version_id
self._save_registry()
def get_active(self, name: str, **variables) -> str:
"""Renders the active version of a template."""
if name not in self._registry:
raise ValueError(f"Template '{name}' not found")
version_id = self._registry[name].get("active")
if not version_id:
# Use the most recent one if there's no active version
version_id = self._registry[name]["versions"][-1]
version_file = self.storage_dir / f"{name}_{version_id}.j2"
template_str = version_file.read_text()
from jinja2 import Template
return Template(template_str).render(**variables)
def list_versions(self, name: str) -> list[dict]:
"""Lists every version of a template with its metadata."""
if name not in self._registry:
return []
versions = []
active = self._registry[name].get("active")
for version_id in self._registry[name]["versions"]:
meta_file = self.storage_dir / f"{name}_{version_id}.json"
if meta_file.exists():
meta = json.loads(meta_file.read_text())
meta["is_active"] = version_id == active
versions.append(meta)
return versions
# Example usage
manager = VersionedTemplateManager()
# Version 1 of the template
v1 = manager.save_version(
name="ticket_classifier",
template="Classify into {{ categories | join(', ') }}. Text: {{ text }}",
description="Initial version without examples",
author="mike"
)
# An improved version 2
v2 = manager.save_version(
name="ticket_classifier",
template="""
You are an expert classifier. Categories: {{ categories | join(', ') }}.
{% if examples %}
Examples: {% for e in examples %}{{ e.input }}→{{ e.output }} {% endfor %}
{% endif %}
Classify: {{ text }}. Answer only with the category.
""",
description="Improved with few-shot examples",
author="mike"
)
manager.activate_version("ticket_classifier", v2)
# Render the active version
prompt = manager.get_active(
"ticket_classifier",
categories=["TECHNICAL", "BILLING", "OTHER"],
text="I can't download my invoice from last month",
examples=[
{"input": "Loading error", "output": "TECHNICAL"},
{"input": "Duplicate charge", "output": "BILLING"}
]
)
print(prompt)
Troubleshooting
1. An undefined variable in Jinja2
Symptom: UndefinedError: 'variable_name' is undefined
from jinja2 import Template, Undefined
# Fix 1: Use the default filter
template = Template("""
Hello {{ name | default('User') }}.
Language: {{ language | default('English') }}.
""")
# Fix 2: Use is defined
template = Template("""
{% if context is defined and context %}
Context: {{ context }}
{% endif %}
""")
# Fix 3: An Environment with silent undefined
from jinja2 import ChainableUndefined
env = Environment(
loader=BaseLoader(),
undefined=ChainableUndefined # Undefined variables return '' instead of erroring
)
# Fix 4: Pass default values at render time
template = Template("{{ name }}")
result = template.render({"name": None} | {"name": "Fallback"})
2. Code injection in user-supplied templates
from jinja2 import Environment, sandbox
# NEVER render user-created templates without sandboxing
# BAD:
user_template = "{% for i in range(10000) %}x{% endfor %}" # DoS
Template(user_template).render()
# GOOD: Use SandboxedEnvironment
from jinja2.sandbox import SandboxedEnvironment
safe_env = SandboxedEnvironment()
try:
result = safe_env.from_string(user_template).render()
except Exception as e:
print(f"Unsafe template blocked: {e}")
# Best practice: Only allow variables, no control logic
def render_user_safe(template_str: str, variables: dict) -> str:
"""
Renders a user template with variable interpolation only.
It doesn't allow {% %} control blocks.
"""
import re
# Check that there are no control blocks
if re.search(r"\{%-?\s*(for|if|while|import|from|include|extends)", template_str):
raise ValueError("User templates can't contain control logic")
return safe_env.from_string(template_str).render(variables)
3. Very long templates that exceed the context window
import tiktoken
def trim_prompt_to_limit(
prompt: str,
max_tokens: int = 3000,
model: str = "gpt-4o-mini"
) -> tuple[str, bool]:
"""
Truncates the prompt if it exceeds the token limit.
Returns:
A tuple (possibly_truncated_prompt, was_truncated)
"""
enc = tiktoken.encoding_for_model(model)
tokens = enc.encode(prompt)
if len(tokens) <= max_tokens:
return prompt, False
# Truncate and decode
truncated_tokens = tokens[:max_tokens]
truncated_prompt = enc.decode(truncated_tokens)
# Add a truncation marker
truncated_prompt += "\n\n[...content truncated by the token limit...]"
return truncated_prompt, True
# Usage
long_prompt = "Very long text..." * 1000
final_prompt, was_truncated = trim_prompt_to_limit(long_prompt, max_tokens=2000)
if was_truncated:
print(f"⚠️ Prompt truncated to 2000 tokens")
4. Templates with special characters that break the format
from jinja2 import Template
# Problem: Variables that contain braces or Jinja2 characters
user_input = "Use the format {{ data }} in your answer"
# Fix: Escape the variable before using it in the template
template = Template("""
The user asked: {{ question | e }}
Answer their question.
""")
# The |e (escape) filter handles HTML special characters
# For Jinja2 itself, the {{ }} inside variables are treated as text, they aren't executed
# To include literal braces in the template:
template_with_literal_braces = Template("""
The JSON format is: {{ '{{' }}field{{ '}}' }}
""")
# Or using raw blocks:
template_raw = Template("""
{% raw %}The model must answer in: {"key": "value"}{% endraw %}
""")
Exercises
Exercise 1: A template with a conditional for optional few-shot
Create a Jinja2 template that includes examples only if examples isn't empty, handling a list of variable size.
See solution
from jinja2 import Template
TEMPLATE_FEW_SHOT = Template("""
You are a classifier for the domain: {{ domain }}.
Categories: {{ categories | join(", ") }}.
{% if examples and examples | length > 0 %}
## Classification examples:
{% for example in examples %}
{{ loop.index }}. Input: "{{ example.input }}"
Category: {{ example.output }}
{% if example.reason is defined %}
Reason: {{ example.reason }}
{% endif %}
{% endfor %}
{% endif %}
## Task
Classify the following text into one of the categories listed.
Text: {{ text }}
{% if json_format %}
Answer ONLY in JSON: {"category": "NAME", "confidence": 0.0-1.0}
{% else %}
Answer ONLY with the category name.
{% endif %}
""")
# Test 1: Without examples
prompt_without_examples = TEMPLATE_FEW_SHOT.render(
domain="technical support",
categories=["TECHNICAL", "BILLING", "OTHER"],
examples=[],
text="I can't log in",
json_format=True
)
# Test 2: With examples
prompt_with_examples = TEMPLATE_FEW_SHOT.render(
domain="technical support",
categories=["TECHNICAL", "BILLING", "OTHER"],
examples=[
{"input": "Error 500", "output": "TECHNICAL", "reason": "Server error"},
{"input": "I was charged twice", "output": "BILLING"},
],
text="I can't log in",
json_format=True
)
print("=== Without examples ===")
print(prompt_without_examples)
print("\n=== With examples ===")
print(prompt_with_examples)
Exercise 2: A template from a file with FileSystemLoader
Create the prompts/ directory, save a template in prompts/summary.j2, and load it with FileSystemLoader.
See solution
import os
from jinja2 import Environment, FileSystemLoader
# 1. Create the directory and the template
os.makedirs("prompts", exist_ok=True)
template_content = """
You are a specialist in writing {{ summary_type }} summaries.
Generate a summary of the following {{ document_type }}.
Requirements:
- Length: roughly {{ length }} words
- Tone: {{ tone | default("professional") }}
- Include: {% for item in include %}"{{ item }}"{% if not loop.last %}, {% endif %}{% endfor %}
{% if exclude is defined and exclude %}
- Exclude: {% for item in exclude %}"{{ item }}"{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %}
The {{ document_type | capitalize }} to summarize:
{{ content }}
"""
with open("prompts/summary.j2", "w", encoding="utf-8") as f:
f.write(template_content)
# 2. Load and render with FileSystemLoader
env = Environment(loader=FileSystemLoader("prompts"))
template = env.get_template("summary.j2")
prompt = template.render(
summary_type="executive, for leadership",
document_type="article",
length=150,
tone="formal",
include=["key points", "conclusions", "recommendations"],
exclude=["technical details", "specialized jargon"],
content="""
AI adoption in Latin American companies grew 45% in 2024.
The main uses are process automation and data analysis.
The main barriers are implementation costs and a lack of talent.
Startups lead the adoption, followed by large corporations.
"""
)
print(prompt)
Exercise 3: A modular prompt composer for different tasks
Implement a PromptComposer with the parts: role, task, constraints, format. Every part should be optional except role and task.
See solution
from jinja2 import Template
class ModularPromptComposer:
"""A modular composer for prompts with configurable sections."""
SECTION_TEMPLATES = {
"role": Template("You are {{ role_description }}."),
"task": Template("""
## Task
{{ task_description }}
{% if subtasks %}
Steps to follow:
{% for subtask in subtasks %}
{{ loop.index }}. {{ subtask }}
{% endfor %}
{% endif %}
"""),
"constraints": Template("""
## Constraints
{% for constraint in constraints %}
- {{ constraint }}
{% endfor %}
"""),
"format": Template("""
## Answer format
{% if format_type == "json" %}
Answer ONLY with valid JSON: {{ schema_json }}
{% elif format_type == "list" %}
Answer with a numbered list.
{% elif format_type == "markdown" %}
Use Markdown format with clear sections.
{% else %}
{{ format_instructions }}
{% endif %}
"""),
"examples": Template("""
## Examples
{% for example in examples %}
Input: {{ example.input }}
Output: {{ example.output }}
{% endfor %}
""")
}
def __init__(self):
self._sections: dict = {}
self._order: list[str] = ["role", "task", "examples", "constraints", "format"]
def configure(self, section: str, **kwargs) -> "ModularPromptComposer":
"""Configures a section of the prompt."""
self._sections[section] = kwargs
return self
def compose(self) -> str:
"""Composes the final prompt."""
required = ["role", "task"]
for req in required:
if req not in self._sections:
raise ValueError(f"Required section missing: '{req}'")
parts = []
for section in self._order:
if section in self._sections:
template = self.SECTION_TEMPLATES[section]
try:
content = template.render(**self._sections[section]).strip()
if content:
parts.append(content)
except Exception as e:
print(f"Warning: Error in section '{section}': {e}")
return "\n\n".join(parts)
# Test
composer = ModularPromptComposer()
prompt = (composer
.configure("role", role_description="a security analyst who is an expert in REST APIs")
.configure("task",
task_description="Review the API's code and detect security vulnerabilities.",
subtasks=["Identify endpoints without authentication", "Check input validation", "Detect potential SQL injection"])
.configure("constraints", constraints=[
"Don't generate exploits or malicious code",
"Classify each vulnerability as CRITICAL, HIGH, MEDIUM or LOW",
"Provide remediation recommendations"
])
.configure("format",
format_type="json",
schema_json='{"vulnerabilities": [{"description": "", "level": "", "remediation": ""}]}')
.compose()
)
print(prompt)
Exercise 4: A template with token counting and automatic truncation
Implement a function that builds a RAG prompt and guarantees it doesn't exceed max_tokens by dropping the least relevant documents.
See solution
from jinja2 import Template
import tiktoken
def build_rag_prompt_limited(
question: str,
documents: list[dict],
system_base: str,
max_tokens: int = 4000,
model: str = "gpt-4o-mini"
) -> tuple[str, dict]:
"""
Builds a RAG prompt that respects the token limit.
Returns:
A tuple (prompt, metadata) where metadata includes the included/excluded docs
"""
enc = tiktoken.encoding_for_model(model)
def count(text: str) -> int:
return len(enc.encode(text))
# Fixed tokens for the system prompt and the question
base_tokens = count(system_base) + count(question) + 100 # 100 of overhead
available_tokens = max_tokens - base_tokens
# Sort by relevance
sorted_docs = sorted(
documents,
key=lambda x: x.get("relevance", 0.5),
reverse=True
)
included_docs = []
excluded_docs = []
used_tokens = 0
for doc in sorted_docs:
doc_str = f"[{doc['source']}]\n{doc['content']}"
doc_tokens = count(doc_str)
if used_tokens + doc_tokens <= available_tokens:
included_docs.append(doc)
used_tokens += doc_tokens
else:
excluded_docs.append(doc['source'])
template = Template("""
{{ system_base }}
## Reference documents ({{ docs | length }} of {{ total_docs }} available)
{% for doc in docs %}
### [{{ loop.index }}] {{ doc.source }} (relevance: {{ "%.0f%%" | format(doc.relevance * 100) }})
{{ doc.content }}
{% endfor %}
{% if excluded %}
({{ excluded | length }} additional documents excluded by the token limit)
{% endif %}
## The user's question
{{ question }}
Answer based only on the reference documents.
""")
prompt = template.render(
system_base=system_base,
docs=included_docs,
total_docs=len(documents),
excluded=excluded_docs,
question=question
)
metadata = {
"total_tokens": count(prompt),
"included_docs": len(included_docs),
"excluded_docs": excluded_docs,
"truncated": len(excluded_docs) > 0
}
return prompt, metadata
# Test
docs_test = [
{"content": "FastAPI is a modern, fast framework.", "source": "fastapi.io", "relevance": 0.95},
{"content": "Uvicorn is the recommended ASGI server for FastAPI.", "source": "uvicorn.org", "relevance": 0.80},
{"content": "Starlette is the foundation FastAPI uses for routing.", "source": "starlette.io", "relevance": 0.70},
{"content": "Python 3.11 introduces 60% performance improvements.", "source": "python.org", "relevance": 0.30},
]
prompt, meta = build_rag_prompt_limited(
question="How do I start a FastAPI server in production?",
documents=docs_test,
system_base="You are an expert in Python and REST APIs.",
max_tokens=500
)
print(prompt[:300] + "...")
print(f"\nMetadata: {meta}")
Exercise 5: An A/B testing system for prompts
Implement a function that runs the same query with two prompt versions and compares the results.
See solution
from openai import OpenAI
import json
from dataclasses import dataclass
client = OpenAI()
@dataclass
class ABTestResult:
version_a_output: str
version_b_output: str
tokens_a: int
tokens_b: int
latency_a_ms: float
latency_b_ms: float
def ab_test_prompts(
prompt_a: str,
prompt_b: str,
user_input: str,
model: str = "gpt-4o-mini",
n_trials: int = 3
) -> ABTestResult:
"""
Runs an A/B test between two prompt versions.
It runs several trials and returns the last result.
"""
import time
def call(system: str) -> tuple[str, int, float]:
start = time.time()
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user_input}
]
)
elapsed_ms = (time.time() - start) * 1000
return (
response.choices[0].message.content,
response.usage.total_tokens,
elapsed_ms
)
# Run the last trials
output_a, tokens_a, lat_a = call(prompt_a)
output_b, tokens_b, lat_b = call(prompt_b)
result = ABTestResult(
version_a_output=output_a,
version_b_output=output_b,
tokens_a=tokens_a,
tokens_b=tokens_b,
latency_a_ms=lat_a,
latency_b_ms=lat_b
)
print(f"\n=== A/B TEST RESULT ===")
print(f"Input: '{user_input[:50]}'")
print(f"\nVersionA ({tokens_a} tokens, {lat_a:.0f}ms):")
print(f" {output_a[:100]}...")
print(f"\nVersionB ({tokens_b} tokens, {lat_b:.0f}ms):")
print(f" {output_b[:100]}...")
print(f"\nToken difference: {tokens_b - tokens_a:+d}")
print(f"Latency difference: {lat_b - lat_a:+.0f}ms")
return result
# Test
PROMPT_V1 = "Classify the ticket as TECHNICAL, BILLING or ACCOUNT. Only the category."
PROMPT_V2 = """
You are an expert support classifier.
Categories: TECHNICAL (bugs, errors), BILLING (charges, payments), ACCOUNT (access, profile).
Classify the ticket and answer ONLY with the category.
"""
result = ab_test_prompts(
prompt_a=PROMPT_V1,
prompt_b=PROMPT_V2,
user_input="I can't download my invoice from January"
)
Summary
| Technique | Use case | Complexity |
|---|---|---|
| f-strings | Simple prompts with variables | Low |
.format() | Templates as reusable constants | Low |
| Jinja2 inline | Conditionals and loops in prompts | Medium |
| Jinja2 FileSystem | Teams with prompts in files | Medium |
| Jinja2 inheritance | A system with a base + variants | High |
| PromptComposer | Modular, reusable parts | High |
| Versioning | Production with A/B testing | High |