AI EngineeringZero to ProductionHome·About·Contact
Project 8 · Design Chapter

Prompt-Powered Content Assistant

A content-generation workhorse: drafts blog posts, marketing copy, product descriptions, and social variants on-brand and at scale. The engineering isn't "write a prompt" — it's a reusable prompt-template library, brand-voice control, structured multi-format output, and a human-approval gate before anything publishes.

🎯 Beginner→Intermediate📈 very common📣 marketing / content teamsprompt-engineering-first
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • an Anthropic API key (ANTHROPIC_API_KEY) + pip install anthropic
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.
The most approachable project — and pure prompt engineeringNo RAG, no tools required to start. This is where the Prompt & Context Engineering module (E1–E3) pays off directly: templates, few-shot brand voice, and measured prompt optimization are the whole product.

What this project teaches you to design

  • A parameterized prompt-template system instead of ad-hoc prompts.
  • Brand-voice control via few-shot exemplars and a style contract.
  • Structured, multi-format output (title + body + meta + social variants) in one pass.
  • A human review/approval gate and a factual-accuracy safeguard.

The brief advanced

"We need ten times the content, on-brand, without ten times the writers." Marketing needs blog posts, product blurbs, email subject lines, and social variants — consistently in the company voice, factually correct, and fast. A content assistant drafts all of it from a brief, a human edits and approves, and throughput jumps without sacrificing brand or truth.

1 · Discovery — what's slow about content? advanced

Where time goesAgent leverage
The blank-page first draft⭐⭐⭐ high — models excel at drafts
Reformatting one piece into many channels⭐⭐⭐ high — one brief → many structured variants
Keeping tone consistent across writers⭐⭐ medium — a shared brand-voice template
Final editorial judgment & fact-checking⭐ low — human approves before publish
Problem statement"Producing on-brand content across channels is our bottleneck. If an assistant turned a short brief into an editable draft plus the social/email variants — in our voice — a human could edit-and-approve in minutes instead of writing from scratch, and we'd never let an unreviewed or unverified claim go live."

2 · Architecture advanced

brieftopic + channel prompt template+ brand voice LLM generatemulti-format structured outbody+meta+social human review approve → publish
🗺️ How to read this diagram

This diagram shows the whole content pipeline as a left-to-right flow: a short brief goes in on the left, and an approved, ready-to-publish piece comes out on the right. Follow the blue arrows.

  • brief (far left) is the input a human writes: just a topic and a target channel — a few lines, not a finished article.
  • prompt template + brand voice is the next box: the brief is dropped into a reusable template that carries the company's voice rules. This is the render() step from the code.
  • LLM generate (the highlighted purple box) is the single model call that produces everything at once — the body plus every channel variant — in one structured pass.
  • structured out splits that reply into body + meta + social — the named JSON fields the code parses into a Content object.
  • The flow then forks to the two boxes on the right: human review (top) and, only after a person signs off, approve → publish (bottom). Nothing reaches "publish" without passing through a human first.

In short: Left to right = brief → fill template → one model call → structured output → human → publish. The human standing between generation and publishing is the entire safety design.

Deliberately simple: a brief fills a prompt template carrying the brand voice; the LLM generates all formats in one structured call; the output is split into body, metadata, and per-channel variants; a human reviews and approves before publishing. Tier 1 — a single well-engineered call, not an agent loop.

3 · Risk & safety model advanced

RiskControl
🔴 A fabricated fact/stat/claim published as truthMark generated claims as unverified; require human fact-check; ground factual pieces in provided sources (RAG) rather than model memory
🟠 Off-brand or off-tone outputFew-shot brand exemplars + an explicit style contract; a tone check before review
🟠 Plagiarism / near-duplicationOriginality check; cite sources when summarizing external material
🔴 Publishing without a human in the loopApproval gate is mandatory — the agent drafts, a person publishes (Ch 6)
Content agents don't publish — they draftThe single most important control: the model produces a draft; a human approves before anything goes public. Fluent, confident, and wrong is the failure mode of every generator — an editor is the safeguard, not an optional extra.

4 · The prompt-template library — the real product advanced

The value isn't one clever prompt; it's a reusable, parameterized library where brand voice lives in one place and every content type is a template.

templates.py (shape)BRAND_VOICE = """Warm, precise, no hype. Short sentences. UK spelling.
Never use: 'revolutionary', 'game-changer', exclamation marks."""

TEMPLATES = {
  "blog_post": """{brand_voice}
Write a {word_count}-word blog post on: {topic}
Audience: {audience}. Include an H1, 3 H2 sections, and a meta description.
Return JSON: title, meta_description, body_markdown, social_variants[].""",
  "product_desc": """{brand_voice}
Write a product description for: {product}. Key features: {features}.
Return JSON: headline, body, bullet_points[], seo_keywords[].""",
}
▶ How this works

This is a preview of the idea behind the whole project: the brand voice lives in one string, and every content type is a fill-in-the-blanks template. You write these once and reuse them for every draft — that's what makes it a library, not a pile of one-off prompts.

  1. BRAND_VOICE is a plain string holding the rules the model must follow — tone, spelling, banned words. Because it's defined once, changing the voice here changes it everywhere.
  2. TEMPLATES is a dictionary: each key ("blog_post", "product_desc") maps to a template string. The {curly_braces} are placeholders{brand_voice}, {topic}, {word_count} — that get filled in with real values later.
  3. Each template ends by telling the model to return JSON with named fields (title, meta_description, body_markdown, social_variants). Asking for JSON is how you get structured, parseable output instead of a free-form paragraph.

Try this: This is just the shape — the next steps build the real, runnable versions. Notice the same pattern repeats for every content type: voice + instructions + "return JSON with these keys".

Brand voice = few-shot, not adjectives"Be professional" is vague; three real on-brand examples teach the voice precisely (E1). Store 2–3 approved exemplars per content type and include them — the model matches a demonstrated style far better than a described one.

5 · Tool surface (optional) advanced

CapabilityDoesRisk
generateFill a template and produce structured content🟢 produces a draft only
brand_checkScore a draft against the style contract🟢 read-only
fact_sources (optional)Retrieve provided source material to ground claims (RAG)🟢 read-only
save_draftStore to the CMS as an unpublished draft🟠 write — draft state only
publishMake content live🔴 human-only — never the agent

6 · Evaluation advanced

EvalMeasures
Brand-voice adherenceLLM-as-judge vs the style contract + exemplars (Ch 5)
Format validity100% parse into the required structure (Ch 2)
Factual accuracyHuman/grounded check on any factual claim
Edit distance to publishedHow much humans changed it — the real productivity signal
Template A/BWhich template version produces less-edited drafts (E3)
The best metric is "how much did the editor change it?"Track the edit distance between the draft and the finally-published piece. Falling edit distance means your templates and brand voice are working — a concrete, honest productivity number that beats any vibe check.

7 · Phased rollout expert

Phase 1 · Draft assistant — humans request drafts, edit heavily, publish. Build the exemplar library from what they approve. (E1)
Phase 2 · Multi-format + brand check — one brief yields all channel variants; a brand-voice check runs before review. (Ch 2 + Ch 5)
Phase 3 · Grounded & optimized — factual pieces retrieve sources; templates tuned by edit-distance data. (RAG + E3)
Never — auto-publish, or present an unverified factual claim as checked.

Skills & course map expert

SkillLearn it in
Prompt templates & few-shot brand voiceE1
Structured multi-format outputCh 2
Prompt optimization by dataE3
Grounding factual claimsCh 3
Quality/brand evals (LLM-as-judge)Ch 5
CMS integration, approval workflowCh 6
🛠️ Hands-on build — everything below is on this pageThe rest of this page is the complete, self-contained build: set up from an empty folder, paste in every file, run it (with a mock, so no API key is needed), and pass the tests. Follow it top to bottom — no other page required.

What you need before you startOnly Python 3.10 or newer installed. Check by running python3 --version in a terminal. That's it — no accounts, no API key, nothing else. If you see a version number 3.10+, you're ready.

By the end you will have

  • A project folder with a Python virtual environment and the code installed.
  • A working content generator that turns a short brief into a titled, on-brand draft with social posts.
  • A safety gate that refuses to "publish" anything a human hasn't approved.
  • Six passing tests — all running without any API key.
  • The exact one-line change to switch from the mock to the real Claude API.

How to use this page expert

Do the steps in order, top to bottom. When you see a terminal block, type (or paste) those commands into your terminal and press Enter. When you see a file block, create a file with exactly that name and paste in the entire contents. Do not skip anything — each step depends on the ones before it. Expected output is shown after each command so you can confirm you're on track.

Step 1 · Create the project folder expert

Open a terminal. These commands make a new folder called content-assistant and move into it. The mkdir -p also creates a tests sub-folder we'll use later.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 — run in your terminal
terminalmkdir -p content-assistant/tests
cd content-assistant

Confirm you are in the right place:

terminalpwd
/Users/you/content-assistant
If a command isn't foundOn Windows, use py instead of python3, and if mkdir -p fails, run mkdir content-assistant then mkdir content-assistant\tests. Everything else is identical.

Step 2 · Create and activate a virtual environment expert

A virtual environment keeps this project's packages separate from the rest of your system. Create one called .venv and activate it. After activating, your prompt will show (.venv) at the start of the line — that's how you know it worked.

Step 2 — macOS / Linux
terminalpython3 -m venv .venv
source .venv/bin/activate
Step 2 — Windows (PowerShell)
terminalpy -m venv .venv
.venv\Scripts\Activate.ps1
(.venv) /Users/you/content-assistant $
Keep this terminal openThe (.venv) only lasts for this terminal window. If you close it, re-run the activate line from this step before continuing. Everything below assumes the environment is active.

Step 3 · Install the one dependency expert

We need the official Anthropic SDK. Install it with pip. (We won't call the API until Step 8, but installing now means the same code runs in both mock and live mode.)

Step 3 — run in your terminal
terminalpip install "anthropic>=0.40" pytest
Successfully installed anthropic-0.69.0 pytest-8.3.4 ...

Record it so the project is reproducible. This command writes the exact versions into requirements.txt:

terminalpip freeze > requirements.txt

Step 4 · The brand voice and prompt templates expert

Create a file named templates.py in the content-assistant folder (the one you're in). Paste in everything below. This holds the brand voice in ONE place and one template per content type. render() fills a template with your brief.

Step 4 — create this file

content-assistant/templates.py

templates.py"""Prompt templates + the single source of truth for brand voice."""

BRAND_VOICE = (
    "Voice rules: warm, precise, no hype. Short sentences. UK spelling. "
    "Never use the words 'revolutionary' or 'game-changer', and never use "
    "exclamation marks."
)

# One template per content type. {voice} is filled with BRAND_VOICE,
# the rest come from the caller's brief.
TEMPLATES = {
    "blog_post": (
        "{voice}\n\n"
        "Write a {words}-word blog post about: {topic}\n"
        "Audience: {audience}.\n"
        "Return JSON with keys: title, meta_description (max 160 chars), "
        "body_markdown, social_variants (a list of 3 short posts)."
    ),
    "product_desc": (
        "{voice}\n\n"
        "Write a product description for: {product}\n"
        "Key features: {features}.\n"
        "Return JSON with keys: title, meta_description, body_markdown, "
        "social_variants (a list of 3 short posts)."
    ),
}


def render(kind: str, **fields) -> str:
    """Fill a template. Raises KeyError if the template name is unknown,
    so typos fail loudly instead of silently producing nothing."""
    if kind not in TEMPLATES:
        raise KeyError(f"unknown template: {kind!r}. "
                       f"known: {list(TEMPLATES)}")
    return TEMPLATES[kind].format(voice=BRAND_VOICE, **fields)
▶ How this works

This is the real, runnable template library. It does two jobs: store the brand voice and the templates, and fill a chosen template with a specific brief. Nothing here talks to a model yet — it just produces the finished prompt text.

  1. BRAND_VOICE is built by joining several string pieces (Python glues adjacent strings together automatically). It's the single source of truth for tone and banned words.
  2. TEMPLATES maps a content-type name to a template string full of {placeholders}. {voice} will receive BRAND_VOICE; the others ({topic}, {audience}, {words}) come from the caller.
  3. def render(kind, **fields) is the filler. **fields means "accept any named arguments" — so you can pass topic=..., audience=... and they all land in fields.
  4. The if kind not in TEMPLATES: raise KeyError(...) guard checks the name is real first. A typo like "blogpost" stops with a clear error instead of silently doing nothing.
  5. TEMPLATES[kind].format(voice=BRAND_VOICE, **fields) does the actual substitution: .format() replaces every {name} in the string with the matching value and returns the finished prompt.

What the output means: Calling render("blog_post", topic="async Python", audience="developers", words=600) returns one big string: the brand-voice rules followed by the blog-post instructions with your topic and audience filled in.

Try this: Call render("blog_post", topic="x", audience="y", words=100) and print the result — you'll see the placeholders replaced by real text. Then try a bad name like render("nope") and read the KeyError.

Why brand voice lives hereEvery template pulls in the same BRAND_VOICE string. Change the voice once and every content type updates — that's the whole point of a template library rather than scattering instructions across the code.

Step 5 · The data model and the brand checker expert

Create content.py. It defines the Content object (what a finished draft looks like) and two pure functions: one that finds banned words, one that decides if publishing is allowed. Everything here is plain Python — no model, no network — so it's fast and testable.

Step 5 — create this file

content-assistant/content.py

content.py"""The Content data model + brand/publish rules (pure, testable)."""
from dataclasses import dataclass, field


@dataclass
class Content:
    title: str
    meta_description: str
    body_markdown: str
    social_variants: list[str] = field(default_factory=list)
    approved: bool = False            # only a human sets this to True

    def validate_shape(self) -> None:
        """Enforce the structural rules a CMS would need."""
        if not self.title.strip():
            raise ValueError("title is empty")
        if len(self.meta_description) > 160:
            raise ValueError("meta_description exceeds 160 chars")
        if len(self.social_variants) < 1:
            raise ValueError("need at least one social variant")


BANNED_WORDS = ["revolutionary", "game-changer", "!"]


def brand_violations(text: str) -> list[str]:
    """Return the banned tokens present in text (case-insensitive)."""
    low = text.lower()
    return [w for w in BANNED_WORDS if w in low]


def publish(content: Content) -> str:
    """The safety gate. Refuses unless a human approved AND the brand
    check passes. Returns a confirmation string when allowed."""
    if not content.approved:
        raise PermissionError("cannot publish: not human-approved")
    hits = brand_violations(content.body_markdown)
    if hits:
        raise ValueError(f"brand violation(s): {hits}")
    return f"PUBLISHED: {content.title}"
▶ How this works

This file defines what a finished draft looks like and the rules for publishing it. It's all plain Python — no model, no network — which is exactly why it's fast to run and easy to test. This is where the project's safety story lives.

  1. @dataclass class Content declares a simple data container. Listing title: str, meta_description: str, etc. auto-generates the plumbing to create a Content object. approved: bool = False starts False — only a human ever flips it to True.
  2. validate_shape() enforces structural rules a real CMS would need: a non-empty title, a meta description under 160 characters, at least one social post. If any fails it raises a ValueError so bad data is caught early.
  3. BANNED_WORDS plus brand_violations(text) scan a draft for forbidden tokens. It lowercases the text first, then returns the list of banned words found — an empty list means clean.
  4. publish(content) is the safety gate. It refuses (raise PermissionError) unless approved is True, then refuses again (ValueError) if any banned word appears. Only if both checks pass does it return the "PUBLISHED" confirmation.

What the output means: On a real draft, publish() either returns "PUBLISHED: <title>" or raises an error explaining why it was blocked — never silently publishes.

Try this: Create a Content(...) with approved=False and call publish() — you'll get a PermissionError. That refusal is the feature: the machine drafts, a human approves.

The gate is the safety storypublish() raises unless approved is True. Nothing in the generation code ever sets that flag — only a human action would. This is what stops a fluent-but-wrong or off-brand draft from going live automatically.

Step 6 · The engine (with a built-in mock, no key needed) expert

Create engine.py. It builds the prompt from a template, then either calls a mock generator (default — deterministic, no key) or the real Claude API (Step 8). It parses the JSON into a Content object. The mock lets you run and test the whole flow with zero setup.

Step 6 — create this file

content-assistant/engine.py

engine.py"""Turns a brief into a Content draft. Mock by default; real API optional."""
import json, os
from templates import render
from content import Content

MODEL = "claude-opus-4-8"


def _mock_generate(prompt: str) -> str:
    """A deterministic stand-in for the model. Returns on-brand JSON so
    the whole pipeline runs without an API key. Note: no banned words."""
    return json.dumps({
        "title": "A Calm Guide to the Topic",
        "meta_description": "A short, clear overview written in plain language.",
        "body_markdown": "## Overview\n\nThis is a clear, on-brand draft.\n",
        "social_variants": ["Post one.", "Post two.", "Post three."],
    })


def _real_generate(prompt: str) -> str:
    """Call the real Claude API. Used only when USE_REAL_API=1."""
    import anthropic
    client = anthropic.Anthropic()      # reads ANTHROPIC_API_KEY from env
    msg = client.messages.create(
        model=MODEL, max_tokens=1500,
        messages=[{"role": "user", "content": prompt}],
    )
    return msg.content[0].text


def generate(kind: str, **brief) -> Content:
    """Render the template, get JSON, parse into a Content draft."""
    prompt = render(kind, **brief)
    use_real = os.environ.get("USE_REAL_API") == "1"
    raw = _real_generate(prompt) if use_real else _mock_generate(prompt)
    data = json.loads(raw)
    draft = Content(
        title=data["title"],
        meta_description=data["meta_description"],
        body_markdown=data["body_markdown"],
        social_variants=data["social_variants"],
    )
    draft.validate_shape()      # reject malformed drafts early
    return draft


if __name__ == "__main__":
    # Run this file directly to see a draft printed.
    d = generate("blog_post", topic="async Python",
                 audience="developers", words=600)
    print("TITLE:", d.title)
    print("SOCIAL POSTS:", len(d.social_variants))
    print("APPROVED:", d.approved, "(not live yet)")
▶ How this works

This is the engine that ties everything together: it builds the prompt, gets back some JSON (from a mock by default, or the real Claude API), and turns that JSON into a Content draft. The mock is the trick that lets the whole project run with no API key and no cost.

  1. _mock_generate(prompt) ignores the prompt and just returns a fixed, on-brand JSON string. "Deterministic" means it returns the same thing every time — perfect for tests, and it contains no banned words.
  2. _real_generate(prompt) is the live path: it creates an anthropic.Anthropic() client (which reads your key from the environment), sends the prompt with client.messages.create(...), and returns the model's text.
  3. generate(kind, **brief) is the one function you call. It renders the template, checks the USE_REAL_API environment variable to decide mock vs real, then json.loads(raw) turns the JSON text into a Python dictionary.
  4. It builds a Content(...) from that dictionary and calls draft.validate_shape() to reject anything malformed before returning. Note it never sets approved — drafts are always unapproved.
  5. The if __name__ == "__main__": block runs only when you execute the file directly (python engine.py); it prints a sample draft so you can see it work.

What the output means: Running python engine.py prints TITLE: A Calm Guide to the Topic, SOCIAL POSTS: 3, and APPROVED: False (not live yet) — proof the full pipeline ran on the mock.

Try this: Switching to the real model is one flag: set USE_REAL_API=1 (Step 8) and the same generate() calls Claude instead — every other line stays identical.

Now run it. Because the mock is the default, this works immediately with no key:

Step 6 — run it
terminalpython engine.py
TITLE: A Calm Guide to the Topic
SOCIAL POSTS: 3
APPROVED: False (not live yet)
Why a mock?The mock returns a fixed, on-brand draft so you can build and test the whole system — templates, parsing, validation, the publish gate — without spending money or needing a key. The real model plugs into the exact same generate() in Step 8; nothing else changes.

Step 7 · Write and run the tests expert

Create tests/test_content.py. These six tests prove the safety-critical behaviour without a key. Paste the whole file.

Step 7 — create this file

content-assistant/tests/test_content.py

tests/test_content.py"""Offline tests — no API key needed (engine uses the mock)."""
import pytest
from templates import render
from content import Content, brand_violations, publish
from engine import generate


def test_render_injects_brand_voice():
    prompt = render("blog_post", topic="x", audience="y", words=100)
    assert "UK spelling" in prompt        # the voice made it in


def test_unknown_template_raises():
    with pytest.raises(KeyError):
        render("nope", topic="x")


def test_generate_returns_valid_draft():
    d = generate("blog_post", topic="x", audience="y", words=100)
    assert d.title and len(d.social_variants) == 3
    assert d.approved is False              # never auto-approved


def test_meta_description_length_enforced():
    bad = Content(title="t", meta_description="x" * 200,
                  body_markdown="b", social_variants=["s"])
    with pytest.raises(ValueError):
        bad.validate_shape()


def test_publish_blocked_without_approval():
    d = generate("blog_post", topic="x", audience="y", words=100)
    with pytest.raises(PermissionError):
        publish(d)                          # not approved -> blocked


def test_brand_violation_blocks_publish():
    d = generate("blog_post", topic="x", audience="y", words=100)
    d.body_markdown = "This is revolutionary."   # banned word
    d.approved = True                          # even approved...
    with pytest.raises(ValueError):
        publish(d)                          # ...brand check still blocks
▶ How this works

These six tests prove the important behaviour works — automatically, with no API key, in a fraction of a second. Each function starting with test_ is one check that pytest runs and reports as PASSED or FAILED.

  1. The imports pull in the pieces to test: render, the Content model and its rules, and generate. Because the engine defaults to the mock, no key is needed.
  2. test_render_injects_brand_voice and test_unknown_template_raises check the template layer: the voice text appears in the prompt, and a bad name raises KeyError. pytest.raises(...) means "this line is supposed to throw that error".
  3. test_generate_returns_valid_draft runs the whole pipeline and asserts you get a title, exactly 3 social posts, and — crucially — approved is False (never auto-approved).
  4. The last three tests target the safety rules: an over-long meta description is rejected; publish() is blocked without approval; and a banned word blocks publishing even when approved. These are the checks that must never break.

What the output means: python -m pytest tests/ -v lists all six tests as PASSED and ends with 6 passed. A green run means the safety gate and format rules all hold.

Try this: Break something on purpose — delete the if not content.approved check in content.py — and re-run the tests. test_publish_blocked_without_approval will turn red, showing you exactly what the test protects.

Run the tests from the content-assistant folder:

Step 7 — run the tests
terminalpython -m pytest tests/ -v
tests/test_content.py::test_render_injects_brand_voice PASSED
tests/test_content.py::test_unknown_template_raises PASSED
tests/test_content.py::test_generate_returns_valid_draft PASSED
tests/test_content.py::test_meta_description_length_enforced PASSED
tests/test_content.py::test_publish_blocked_without_approval PASSED
tests/test_content.py::test_brand_violation_blocks_publish PASSED

6 passed in 0.05s
✅ What each test proves
TestProves
render injects brand voiceevery template carries the shared voice
unknown template raisesa typo fails loudly, not silently
generate returns valid draftthe full pipeline works and never auto-approves
meta length enforcedan over-long SEO field is rejected
publish blocked without approvalthe human gate holds — the core safety control
brand violation blocks publishbanned words never reach the CMS, even if approved

Step 8 · Go live with the real Claude API (optional) expert

Everything above ran on the mock. To use the real model, you need an API key and one environment variable — no code changes.

Step 8 — get a key and set it

1. Create a key at console.anthropic.com → API Keys. It looks like sk-ant-....

2. Set it and the live-mode flag in your terminal (macOS / Linux):

terminalexport ANTHROPIC_API_KEY="sk-ant-your-key-here"
export USE_REAL_API=1

On Windows (PowerShell):

terminal$env:ANTHROPIC_API_KEY="sk-ant-your-key-here"
$env:USE_REAL_API="1"

3. Run the same file — it now calls Claude:

terminalpython engine.py
TITLE: A Measured Introduction to Async Python
SOCIAL POSTS: 3
APPROVED: False (not live yet)
The mock and the real model are interchangeableThe only difference is USE_REAL_API=1. To go back to free, offline runs (for tests or development), run unset USE_REAL_API (or $env:USE_REAL_API="" on Windows). Your tests always use the mock, so they stay fast and free.

Troubleshooting — every error you might hit expert

⚠️ If something doesn't match the expected output
What you seeWhat it means & the fix
python3: command not foundPython isn't installed or not on PATH. Install from python.org; on Windows try py.
No (.venv) in your promptThe activate step didn't run. Re-run the Step 2 activate line for your OS.
ModuleNotFoundError: anthropicThe venv isn't active or Step 3 was skipped. Activate, then pip install "anthropic>=0.40" pytest.
ModuleNotFoundError: templates (in tests)Run pytest from inside the content-assistant folder, not from tests/.
KeyError: 'unknown template'Check the first argument to generate() is exactly "blog_post" or "product_desc".
Tests fail after editingCompare your file byte-for-byte with the block above; a missing comma or wrong indent is the usual cause.
authentication_error in Step 8The key is wrong or unset. Re-run the export line; confirm with echo $ANTHROPIC_API_KEY.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · A reusable prompt-template library with brand voiceBeginner

Context: Brand voice belongs in one place, not copy-pasted into every prompt. One voice string plus a template dict means a single tweak updates every prompt at once — the reusable unit the whole assistant is built on.

Your task: Build a BRAND_VOICE string and a TEMPLATES dict with a render(kind, **fields) that fills a template from a brief.

Requirements:

  • A single BRAND_VOICE string (tone + banned words)
  • A TEMPLATES dict keyed by content kind
  • render fills the chosen template from keyword fields
  • The brand voice is injected automatically unless overridden
  • Demonstrate rendering a full instruction from a short brief

💡 Hint: Default the voice field to BRAND_VOICE so a voice change propagates to every template without touching call sites.

Show solution

Centralizing voice + templates is what makes output consistent and cheap to change:

BRAND_VOICE = ("Warm, concise, expert. Active voice. "
               "Banned words: leverage, synergy, revolutionary.")

TEMPLATES = {
    "blog_post":   "Voice: {voice}\nWrite a blog post titled '{title}' for {audience}.",
    "product_desc":"Voice: {voice}\nDescribe {product} for {audience} in 2 sentences.",
}

def render(kind, **fields):
    fields.setdefault("voice", BRAND_VOICE)
    return TEMPLATES[kind].format(**fields)

print(render("product_desc", product="AcmeDB", audience="developers"))

One BRAND_VOICE string means a voice tweak updates every prompt at once. Templates turn a short brief into a full, consistent instruction — the reusable unit the assistant is built on.

Exercise 2 · Multi-format structured output in one passIntermediate

Context: One brief should yield title, body, meta and social variants together, so the tweet matches the title. A shape validator catches the SEO-breaking mistakes before a human sees the draft.

Your task: Define a Pydantic Content schema and a labelled generate call, with a shape validator.

Requirements:

  • Content holds title, meta description, body, and social variants
  • A validator enforces meta length ≤ 160 chars for SEO
  • It requires at least one social post
  • A hand-built instance validates offline
  • The one-pass fill is a labelled messages.parse with the schema

💡 Hint: Generate all formats in one call so they stay consistent, and let the validator reject an over-long meta before publish.

Show solution

Structured multi-format output means one API call produces everything, and the shape is checkable:

from pydantic import BaseModel

class Content(BaseModel):
    title: str
    meta_description: str
    body_markdown: str
    social_variants: list[str]
    approved: bool = False

    def validate_shape(self):
        assert len(self.meta_description) <= 160, "meta too long for SEO"
        assert len(self.social_variants) >= 1, "need at least one social post"
        return self

c = Content(title="AcmeDB 2.0", meta_description="Faster queries, same API.",
    body_markdown="# AcmeDB 2.0\n...", social_variants=["AcmeDB 2.0 is here!"])
print(c.validate_shape().title)   # AcmeDB 2.0

# --- needs API key: fill Content from a brief in one pass ---
# c = client.messages.parse(model="claude-opus-4-8", output_format=Content,
#     system=BRAND_VOICE, messages=[{"role":"user","content":render(...)}]).parsed_output

Generating all formats together keeps them consistent (the tweet matches the title) and the shape validator catches the SEO-breaking mistakes (a 300-char meta) before a human ever sees the draft.

Exercise 3 · Brand-voice check with an LLM judge and a banned-word scanAdvanced

Context: Enforce voice two ways: a free, deterministic banned-word scan for the certain violations, and an LLM judge for the fuzzy question of tone that a regex can't answer.

Your task: Build brand_violations(text) offline and a labelled LLM-judge call for tone.

Requirements:

  • A banned-word scan returning every banned term present
  • An on_brand helper that fails when violations exist
  • The scan runs offline and is the cheap first check
  • The judge call is labelled (needs API key) and returns a pass/fail + reason
  • Run the free scan before paying for the judge

💡 Hint: If the banned-word scan already fails, skip the paid judge entirely; the judge only answers what the scan can't — is it actually warm and concise?

Show solution

Cheap code catches the certain violations; the judge catches the fuzzy ones:

BANNED = ("leverage", "synergy", "revolutionary", "game-changer")

def brand_violations(text):
    low = text.lower()
    return [w for w in BANNED if w in low]

print(brand_violations("Leverage our revolutionary synergy"))
# ['leverage', 'synergy', 'revolutionary']

# --- needs API key: LLM judge for tone (returns pass/fail + reason) ---
# judge = client.messages.create(model="claude-haiku-4-5", max_tokens=200,
#   system="Score tone vs this brand voice. Reply JSON {on_brand:bool, why:str}.",
#   messages=[{"role":"user","content":f"VOICE:{BRAND_VOICE}\nTEXT:{text}"}])
def on_brand(text):
    return len(brand_violations(text)) == 0    # + judge verdict in real code

print(on_brand("Fast, simple, honest."))       # True

Run the free banned-word scan first — if it fails, skip the paid judge call entirely. The judge handles what regexes can't: is it actually warm and concise, or just free of banned words?

Exercise 4 · A mandatory human approval gate before publishExpert

Context: Nothing publishes without a human. The approval gate is a hard code invariant — not a suggestion a confident model can talk its way past.

Your task: Build publish(content) that raises unless approved is True AND there are no brand violations.

Requirements:

  • publish refuses when brand violations are present
  • It refuses when the content isn't human-approved
  • It publishes only when both conditions hold
  • Approval is a flag a human flips; everything else is draft-only
  • Demonstrate the blocked-then-approved flow

💡 Hint: Check violations and the approved flag as hard preconditions that raise — no prompt injection or model output can flip them.

Show solution

The approval gate is a hard code invariant, not a suggestion the model can talk its way past:

def publish(content):
    violations = brand_violations(content.body_markdown + content.title)
    if violations:
        raise PermissionError(f"blocked: brand violations {violations}")
    if not content.approved:
        raise PermissionError("blocked: needs human approval before publish")
    return f"PUBLISHED: {content.title}"

c = Content(title="AcmeDB 2.0", meta_description="Faster.",
    body_markdown="Clean and honest.", social_variants=["hi"])
try: publish(c)                               # not approved yet
except PermissionError as e: print(e)         # blocked: needs human approval
c.approved = True
print(publish(c))                             # PUBLISHED: AcmeDB 2.0

The gate lives in code, so no prompt injection or over-eager model can publish unreviewed copy. Approval is a human action that flips one flag; everything else the assistant does is draft-only.

Exercise 5 · Ground factual pieces and A/B test templatesProfessional

Context: Factual copy must be grounded (no invented specs), and template changes must be measured. Grounding guards against confident fiction; an edit-distance A/B gives an objective proxy for on-brand drift.

Your task: Add a source-grounding check and an A/B comparison by edit distance to the approved baseline.

Requirements:

  • A grounding check flags any factual claim not supported by a source
  • It treats a source as support when the claim appears in it
  • An edit-distance function compares a variant to the approved baseline
  • Smaller distance = closer to baseline; larger = more drift
  • Both run offline; decide template changes by data, not taste

💡 Hint: Substring-match each claim against the joined sources for grounding; use Levenshtein distance to the baseline as the A/B drift signal.

Show solution

Grounding stops invented facts; edit-distance A/B tells you if a template change actually helped:

def grounded(claims, sources):
    # every factual claim must appear (substring) in a source doc
    joined = " ".join(sources).lower()
    return [c for c in claims if c.lower() not in joined]   # -> unsupported

facts = ["query latency is 5ms", "made of unicorns"]
srcs  = ["AcmeDB query latency is 5ms at p50."]
print("unsupported:", grounded(facts, srcs))   # ['made of unicorns']

def edit_distance(a, b):
    dp = list(range(len(b)+1))
    for i, ca in enumerate(a, 1):
        prev, dp[0] = dp[0], i
        for j, cb in enumerate(b, 1):
            prev, dp[j] = dp[j], min(dp[j]+1, dp[j-1]+1, prev+(ca!=cb))
    return dp[-1]

# A/B: which variant drifts less from the approved baseline?
base = "Fast, simple, honest database."
print(edit_distance(base, "Fast, simple database."))      # small = closer
print(edit_distance(base, "Revolutionary synergy DB!"))   # large = off-brand

Grounding is the guard against confident fiction in product copy; edit-distance A/B gives an objective proxy for "did this template drift on-brand or off" so template changes are decided by data, not taste.

Exercise 6 · A publish pipeline with per-channel adaptation and audit logIndustry scenario

Context: One approved piece fans out to blog, email and social with per-channel limits, and every publish is logged. The append-only audit log answers the question compliance always asks after the fact: what did we publish, where, when.

Your task: Build the fan-out with channel constraints and an append-only audit log.

Requirements:

  • Per-channel character limits are respected automatically
  • Fan-out refuses to run on unapproved content
  • Each channel gets an adapted variant within its limit
  • Every publish appends an entry to an audit log
  • Demonstrate the fan-out and the resulting log

💡 Hint: Map each channel to its limit and truncate the adapted text to it; append {channel, title, chars} to a module-level log on each publish.

Show solution

One approval, many channels, full audit trail — the shape a marketing team actually ships on:

CHANNELS = {"twitter": 280, "linkedin": 3000, "email": 100_000}
AUDIT = []

def adapt(content, channel):
    limit = CHANNELS[channel]
    text = content.social_variants[0] if channel != "email" else content.body_markdown
    return text[:limit]                       # respect per-channel limit

def fan_out(content):
    if not content.approved:
        raise PermissionError("cannot fan out unapproved content")
    results = {}
    for ch in CHANNELS:
        out = adapt(content, ch)
        AUDIT.append({"channel": ch, "title": content.title, "chars": len(out)})
        results[ch] = out
    return results

c.approved = True
fan_out(c)
for entry in AUDIT: print(entry)

Per-channel adaptation respects platform limits automatically; the append-only audit log answers "what did we publish, where, when" — the question compliance always asks after the fact.

✓ You are done when…

  • python engine.py prints a title and 3 social posts, with APPROVED: False.
  • python -m pytest tests/ -v shows 6 passed.
  • You understand that publish() refuses without human approval and on banned words.
  • (Optional) With USE_REAL_API=1 and a key set, the same code calls Claude.
📁 Your finished folder
content-assistant/
├─ .venv/                 (virtual environment)
├─ requirements.txt       (pinned dependencies)
├─ templates.py           (brand voice + templates)
├─ content.py             (data model + publish gate)
├─ engine.py              (generate: mock or real)
└─ tests/
   └─ test_content.py     (6 offline tests)
📋 Master rubric — grade your content assistant
DimensionMeets the barAbove the bar (staff-level)
Human approval gateNothing can be published without human approval; the gate is enforced in code and proven by a test, not a prompt instruction.The gate is unbypassable — no path (retry, batch, tool call) can slip content past review — and every approval is logged with who and when.
Factual-accuracy safeguardA factual safeguard flags claims that need checking rather than letting the model assert facts about products/prices unchecked.The safeguard distinguishes checkable claims (specs, numbers, names) from style, and routes the checkable ones to review with the source it should match.
Brand-voice fidelityVoice is controlled via few-shot exemplars and a style contract, not ad-hoc adjectives; drafts are consistently on-brand across runs.Voice adherence is evaluated (against exemplars or a rubric), so a prompt change that drifts the voice is caught before it reaches marketing.
Template disciplinePrompts are parameterized templates, not one-off strings; the same brief reliably produces the same shape of output.Templates are versioned and testable, so a change to one format (e.g. email subject) can't silently regress the others.
Structured multi-format outputOne pass emits the full set (title + body + meta + social variants) in a validated structure, not free text a human must reformat.Output validates against a schema per channel (length limits, required fields), so an over-length social post or missing meta fails fast.
Cost & throughputPer-brief token/dollar cost is tracked; the mock→real swap is a known one-line change so cost is understood before scaling.Batch generation is bounded and cost-per-published-piece is a tracked metric, so 10x throughput doesn't mean 10x surprise spend.

Score each row 0 (missing) / 1 (meets) / 2 (above). A passing content build is 9+/12 with the human approval gate at 2 — any path that can publish without human sign-off is an automatic fail, because the whole value proposition is throughput without sacrificing brand or truth, and an unreviewed factual error ships both away at once.

Knowledge check check yourself

✓ Knowledge check

Why is this project built as a reusable prompt-template library rather than a collection of individual prompts?

Show answer
The value isn't one clever prompt; it's a parameterized library where brand voice lives in one place and each content type is a template. Change the voice once and every draft updates — that consistency and reuse is the actual product.
✓ Knowledge check

Why is a human approval gate mandatory before any content publishes?

Show answer
The failure mode of every generator is fluent, confident, and wrong — a fabricated fact published as truth. The model only produces a draft; a human editor must fact-check and approve before anything goes public, so the human in the loop is the entire safety design.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in