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.
- an Anthropic API key (
ANTHROPIC_API_KEY) +pip install anthropic
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 goes | Agent 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 |
2 · Architecture advanced
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 aContentobject. - 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
| Risk | Control |
|---|---|
| 🔴 A fabricated fact/stat/claim published as truth | Mark generated claims as unverified; require human fact-check; ground factual pieces in provided sources (RAG) rather than model memory |
| 🟠 Off-brand or off-tone output | Few-shot brand exemplars + an explicit style contract; a tone check before review |
| 🟠 Plagiarism / near-duplication | Originality check; cite sources when summarizing external material |
| 🔴 Publishing without a human in the loop | Approval gate is mandatory — the agent drafts, a person publishes (Ch 6) |
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[].""",
}
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.
BRAND_VOICEis 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.TEMPLATESis 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.- 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".
5 · Tool surface (optional) advanced
| Capability | Does | Risk |
|---|---|---|
generate | Fill a template and produce structured content | 🟢 produces a draft only |
brand_check | Score a draft against the style contract | 🟢 read-only |
fact_sources (optional) | Retrieve provided source material to ground claims (RAG) | 🟢 read-only |
save_draft | Store to the CMS as an unpublished draft | 🟠 write — draft state only |
publish | Make content live | 🔴 human-only — never the agent |
6 · Evaluation advanced
| Eval | Measures |
|---|---|
| Brand-voice adherence | LLM-as-judge vs the style contract + exemplars (Ch 5) |
| Format validity | 100% parse into the required structure (Ch 2) |
| Factual accuracy | Human/grounded check on any factual claim |
| Edit distance to published | How much humans changed it — the real productivity signal |
| Template A/B | Which template version produces less-edited drafts (E3) |
7 · Phased rollout expert
Skills & course map expert
| Skill | Learn it in |
|---|---|
| Prompt templates & few-shot brand voice | E1 |
| Structured multi-format output | Ch 2 |
| Prompt optimization by data | E3 |
| Grounding factual claims | Ch 3 |
| Quality/brand evals (LLM-as-judge) | Ch 5 |
| CMS integration, approval workflow | Ch 6 |
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.
terminalmkdir -p content-assistant/tests
cd content-assistant
Confirm you are in the right place:
terminalpwd
/Users/you/content-assistant
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.
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 $
(.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.)
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.
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)
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.
BRAND_VOICEis built by joining several string pieces (Python glues adjacent strings together automatically). It's the single source of truth for tone and banned words.TEMPLATESmaps a content-type name to a template string full of{placeholders}.{voice}will receiveBRAND_VOICE; the others ({topic},{audience},{words}) come from the caller.def render(kind, **fields)is the filler.**fieldsmeans "accept any named arguments" — so you can passtopic=...,audience=...and they all land infields.- 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. 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.
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.
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}"
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.
@dataclass class Contentdeclares a simple data container. Listingtitle: str,meta_description: str, etc. auto-generates the plumbing to create aContentobject.approved: bool = Falsestarts False — only a human ever flips it to True.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 itraises aValueErrorso bad data is caught early.BANNED_WORDSplusbrand_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.publish(content)is the safety gate. It refuses (raise PermissionError) unlessapprovedis 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.
publish() 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.
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)")
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.
_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._real_generate(prompt)is the live path: it creates ananthropic.Anthropic()client (which reads your key from the environment), sends the prompt withclient.messages.create(...), and returns the model's text.generate(kind, **brief)is the one function you call. It renders the template, checks theUSE_REAL_APIenvironment variable to decide mock vs real, thenjson.loads(raw)turns the JSON text into a Python dictionary.- It builds a
Content(...)from that dictionary and callsdraft.validate_shape()to reject anything malformed before returning. Note it never setsapproved— drafts are always unapproved. - 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:
terminalpython engine.py
TITLE: A Calm Guide to the Topic
SOCIAL POSTS: 3
APPROVED: False (not live yet)
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.
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
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.
- The imports pull in the pieces to test:
render, theContentmodel and its rules, andgenerate. Because the engine defaults to the mock, no key is needed. test_render_injects_brand_voiceandtest_unknown_template_raisescheck the template layer: the voice text appears in the prompt, and a bad name raisesKeyError.pytest.raises(...)means "this line is supposed to throw that error".test_generate_returns_valid_draftruns the whole pipeline and asserts you get a title, exactly 3 social posts, and — crucially —approved is False(never auto-approved).- 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:
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
| Test | Proves |
|---|---|
| render injects brand voice | every template carries the shared voice |
| unknown template raises | a typo fails loudly, not silently |
| generate returns valid draft | the full pipeline works and never auto-approves |
| meta length enforced | an over-long SEO field is rejected |
| publish blocked without approval | the human gate holds — the core safety control |
| brand violation blocks publish | banned 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.
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)
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
| What you see | What it means & the fix |
|---|---|
python3: command not found | Python isn't installed or not on PATH. Install from python.org; on Windows try py. |
No (.venv) in your prompt | The activate step didn't run. Re-run the Step 2 activate line for your OS. |
ModuleNotFoundError: anthropic | The 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 editing | Compare your file byte-for-byte with the block above; a missing comma or wrong indent is the usual cause. |
authentication_error in Step 8 | The 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.
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_VOICEstring (tone + banned words) - A
TEMPLATESdict keyed by content kind renderfills 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.
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:
Contentholds 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.parsewith 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.
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_brandhelper 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?
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:
publishrefuses 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.
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.
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.pyprints a title and 3 social posts, with APPROVED: False.python -m pytest tests/ -vshows 6 passed.- You understand that
publish()refuses without human approval and on banned words. - (Optional) With
USE_REAL_API=1and a key set, the same code calls Claude.
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)
| Dimension | Meets the bar | Above the bar (staff-level) |
|---|---|---|
| Human approval gate | Nothing 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 safeguard | A 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 fidelity | Voice 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 discipline | Prompts 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 output | One 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 & throughput | Per-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
Why is this project built as a reusable prompt-template library rather than a collection of individual prompts?
Show answer
Why is a human approval gate mandatory before any content publishes?