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.
- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
.env file (never in code) and let the zero-argument client pick it up from the environment.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.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
You have watched a "quick" LLM content experiment collapse before. Someone wires a prompt into a chat box, generates a blog post in thirty seconds, and the demo dazzles. Then it meets reality: the second post sounds nothing like the first, a founder spots the word "revolutionary" that your style guide bans, and a statistic in paragraph three turns out to be invented. The demo was easy; the product — consistent voice, structured output, and a truth check — is the hard part, and that is what this brief is really about.
Think of the difference between a talented freelancer and a newsroom. A freelancer can write one great piece; a newsroom ships hundreds a week that all sound like the same publication, because the voice lives in a style manual and every draft crosses an editor's desk before it prints. This project builds the newsroom, not the freelancer. The model is the fast writer, the template library is the style manual, and the human approval gate is the editor. Why frame it that way? Because the instinct — "just write a better prompt" — optimizes the one thing that was never the bottleneck. The bottleneck is doing it the same way a thousand times and never letting a confident falsehood slip through. The common mistake is to judge success by how impressive a single output looks, when the real signal is how little an editor has to change the draft before it ships. Read the scenario below with the newsroom in mind: the "brief" a human writes is deliberately tiny, because the leverage comes from the reusable machinery around it, not from a longer prompt.
"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.
Read left to right: the red block on the left is the tempting-but-wrong approach, and the three boxes on the right are the roles you split it into instead.
- The red block is the "one talented freelancer" instinct — write each piece from scratch. It produces good one-offs, but the voice drifts between runs, nothing checks the facts, and it can't scale.
- The three right-hand boxes are the newsroom roles the arrows split the work into: a fast writer (the model), a style manual (the reusable template library that holds the brand voice once), and an editor gate (the human who approves).
- The payoff is the bottom label — consistent at any volume: because voice lives in the manual and every draft crosses the editor's desk, draft #900 sounds like draft #1.
In short: the brief isn't "write a better prompt" — it's build the newsroom: model to draft, templates to hold the voice, a human to approve.
1 · Discovery — what's slow about content? advanced
The seductive failure of any content project is automating the wrong step. It is tempting to point the model at everything a marketing team touches and let it rip. That is how you end up with an assistant that confidently publishes an unchecked claim — automating the exact step that most needs a human. Before you build, you have to know which parts of the work are genuinely slow-and-mechanical (great for a model) versus slow-and-judgement-heavy (must stay human). Getting that split wrong is the difference between a helper and a liability.
The discipline here is the same one a good consultant uses on their first day: don't ask "what could AI do?", ask "where does the time actually go, and how much of that is judgement?" A blank first draft eats an afternoon but needs almost no judgement — perfect leverage. Fact-checking a pricing claim takes two minutes but is pure judgement with a lawsuit on the downside — terrible leverage. The table below is that audit, and it is ranked deliberately: the model earns its keep on the boring, high-volume drafting and reformatting at the top, while the irreducibly human editorial call sits at the bottom marked low-leverage on purpose. The common mistake is to read a low-leverage row as a to-do ("let's automate fact-checking too") instead of what it is — a boundary you designed in. Read each row as a decision about where the human stays, not just where the model helps.
Read top to bottom, most-mechanical first: each band is a slice of content work, coloured by how much of it is judgement versus grunt.
- The two green bands at the top — blank-page drafting and reformatting into many channels — are high-volume and need almost no judgement, so the model owns them (⭐⭐⭐).
- The light-blue band (keeping tone consistent) is medium leverage: a shared brand-voice template does most of the work, but it still leans on the human's taste.
- The red band below the dashed line is final editorial judgement and fact-checking — low leverage on purpose. The dashed line marks where automation stops; the red row isn't a to-do, it's the boundary where the human stays.
In short: automate the mechanical, high-volume top; keep the judgement-heavy floor human — the dashed line is a design decision, not an unfinished task.
| 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
When people first hear "content agent" they reach for the wrong shape. They imagine an autonomous loop that researches, drafts, revises itself, checks its own facts, and posts — a mini-employee. That instinct quietly imports all the fragility of a multi-step agent (loops that don't terminate, tool calls that fail, cost that spirals) into a problem that does not need any of it. The first architectural decision is therefore a subtraction: notice that this job is one input and one output, and build the simplest thing that does it.
The mental model is an assembly line, not a robot. Raw material goes in one end, passes through fixed stations in order, and a finished part comes out — no station decides to loop back, and a quality inspector stands at the exit. Here the stations are: fill a template with the brief, make one model call that emits every format at once, split that reply into named fields, then hand it to a human before anything goes live. That "one call, many formats" choice is worth pausing on: asking for the title, body, meta, and social posts together is both cheaper (one request, not five) and more coherent (the tweet is written knowing the headline), which is exactly why the diagram shows a single highlighted generate box rather than a chain. Why this matters: the boundary between the automated stations and the human inspector is the whole safety story, so the architecture is designed to make that boundary a hard wall — generation physically cannot reach "publish" without passing a person. The common mistake is blurring that wall to save a click; resist it. Trace the diagram left to right and watch where the arrow to "publish" comes from — it comes from the human, never from the model.
publish leaves the human review box, never the model: that wall is the entire safety design.
Trace it left to right along the top row, then follow the fork down the right side: raw material enters on the left and a finished, approved piece exits at the bottom right.
- The top row is the automated assembly line —
brief→prompt template + brand voice→ the highlightedLLM generatebox (a single call emitting every format at once) →structured outsplit intobody + meta + social. - The flow then turns down to the right-hand fork: the amber structured output feeds the red human review gate, and the green arrow into
approve → publishcomes only out of that human box. - The payoff is what is missing: there is no arrow from the model straight to publish, so generation physically cannot go live without a person — the hard wall the prose promised.
In short: brief → template → one model call → structured output → human → publish; the human wedged between generation and publishing is the whole architecture.
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
The dangerous thing about a content generator is that its failures are beautiful. A buggy calculator returns a number that is obviously wrong; a content model returns fluent, confident, perfectly-formatted prose that happens to contain an invented statistic. Nothing in the output looks broken — which is precisely why an unreviewed generator will, sooner or later, publish a falsehood in your company's name and no automated check will have flinched. Naming that failure mode out loud is the first job of a safety model.
Think of the model as a brilliant intern on their first day: quick, eager, produces polished work, and will state something completely made-up with total conviction because they would rather answer than admit they don't know. You would never let that intern publish straight to the company blog — not because they're bad, but because confidence is not the same as correctness. So the controls below are organized by who catches what: some risks (a banned word, a missing meta) are mechanical and a cheap deterministic check catches them for free; others (is this claim actually true? is this on-brand?) are judgement calls that route to a human. Why a whole section for this? Because the single most expensive mistake in content AI is treating the review gate as a nice-to-have you can drop under deadline pressure. The table pairs each risk with its control, but read it top-down for the pattern: the two red rows — a fabricated claim, and publishing without a human — both resolve to the same answer, a mandatory human editor. The common mistake is believing a clever enough prompt ("only state true facts") removes the need for that gate. It never does; a probabilistic writer cannot promise truth, so the human is not optional insurance, it is the design.
Start at the box on the left — every risk in a draft — and follow the two blue arrows: risks split into an upper "mechanical" lane and a lower "judgement" lane, then flow right to whoever catches them.
- The upper lane (light-blue → green) holds mechanical risks like a banned word or a missing meta description. A deterministic check catches these automatically and for free — no human needed.
- The lower lane (amber → red) holds the judgement calls: is this claim true, is it on-brand, may it publish? These route to the human editor, the red gate box.
- The payoff is the bottom green box: the dashed arrow shows that approve → publish is reachable only out of the human gate — generation can never jump straight to publishing.
In short: cheap checks handle the mechanical failures; a mandatory human handles the judgement ones — and publish sits behind that human on purpose.
| 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
Every team that builds this makes the same early mistake: they scatter prompts. One prompt lives in the blog script, another in the email tool, a third pasted into someone's notes app. Each carries its own slightly-different version of the brand voice. Then marketing decides to drop exclamation marks, and now you are hunting through five files to change the same rule five times — and you miss one, so half your output is on-brand and half isn't. The fix is not a better prompt; it is a different structure, and that structure is the actual product of this project.
Borrow the idea from software engineering's oldest rule: don't repeat yourself. The brand voice is a single fact about your company, so it should exist in exactly one place — one string — and every template should reference it rather than restate it. Picture a brand style guide that the whole team links to: change the guide once and everyone is instantly working from the new rules. That is what a template library does. Each content type (blog post, product description) is a fill-in-the-blanks form that pulls in the shared voice and adds its own instructions and its own "return JSON with these keys" contract. Why this earns the label "the real product": the value your users pay for isn't a moment of prompt cleverness, it's the guarantee that draft #900 sounds exactly like draft #1 and that the voice can be tuned in one edit. The common mistake is optimizing the wording of an individual prompt while leaving the prompts duplicated — you can have the world's best prompt and still ship inconsistent content. The shape below is small on purpose; the discipline it encodes (voice in one place, one template per type, structured output) is the whole idea.
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.
BRAND_VOICE string through render(), so a single edit to the voice propagates to blog posts, product descriptions, and social alike — the reuse is the product.
Read left to right: the shared voice on the left flows through render() in the middle and out to each content template on the right.
- The light-blue box on the left is
BRAND_VOICE— a single string holding the tone rules and banned words. It exists exactly once. render()(green, centre) is the function that fills a chosen template, injecting that shared voice into whichever content type you asked for.- The three white boxes on the right are the per-type templates —
blog_post,product_desc, email/social — each adding its own instructions and a "return JSON" contract, but all pulling the same voice. - The payoff is the labels: edit the voice once on the left and every type updates on the right — you never touch three prompts to change one rule.
In short: the brand voice is defined once and referenced by every template, so consistency and single-edit changes come for free — that reuse is the actual deliverable.
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
"Optional" is doing real work in that heading. The base version of this assistant needs no tools at all — it renders a template, makes one call, and returns a draft. So why list a tool surface? Because the moment you want to ground a factual piece in real sources, or store a draft in the CMS, or actually make something live, you are handing the system capabilities — and every capability is also a way for things to go wrong. Listing the tools up front, each tagged with its blast radius, is how you decide what the assistant may touch before you wire any of it.
The useful lens is to sort every capability by what it can break, the way you'd think about handing out office keys. A key to read the filing cabinet is low-stakes; a key to the front door that lets anyone in is not. Here the read-only tools — generate, brand_check, fact_sources — are the filing-cabinet keys: worst case they waste a few tokens, so they're green. save_draft writes, but only into an unpublished-draft state, so it's amber — a contained write. And publish is the front door: it makes content visible to the world, which is why the table marks it human-only and hands that key to no automated caller, ever. Why the color-coding matters: it turns an abstract "be careful" into a concrete rule you can enforce in code — green tools the model calls freely, the red one it literally cannot invoke. The common mistake is granting the publish capability to the agent "just to finish the loop," which quietly demolishes the entire safety model from section 3. Read the risk column as a permissions policy, not a warning label.
save_draft is an amber contained write, and publish is the red front door — reserved for a human, never handed to the agent.
Scan left to right: the tools get more dangerous as you go, and the colour of each block tells you how much damage it could do.
- The green block on the left holds the read-only tools —
generate,brand_check,fact_sources. They only read or produce a draft, so the worst case is a few wasted tokens; the agent may call them freely. - The amber block in the middle is
save_draft: it writes, but only into an unpublished draft state — a contained write, not a public change. - The red block on the right is
publish— the "front door" that makes content visible to the world. It is marked human-only, and the dashed arrows show it is the end of the danger gradient, not something the agent reaches.
In short: green tools the agent calls at will, the amber one writes only drafts, and the red one is a human key — the colours are an enforceable permissions policy.
| 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
Ask a team how their content assistant is doing and you'll usually hear "it's great, the drafts look really good." That is not a measurement — it's a vibe, and vibes quietly rot. Six weeks later someone changes a template, the voice drifts, and nobody notices because nobody was counting. The reason evaluation gets its own section is that "looks good" cannot tell you whether a change helped or hurt, and content work is all changes: new templates, tweaked voice, a model upgrade. Without numbers you are flying blind through exactly the moments that matter.
The trick is to find measurements that are honest even though quality feels subjective. Some are easy: does every draft parse into the required structure? That's a hard yes/no you can automate. Others need a stand-in for taste — brand-voice adherence is fuzzy, so you use an LLM-as-judge scored against your exemplars, the same way a magazine has a style editor rather than a ruler. But the metric worth building the whole eval story around is quietly brilliant: edit distance to the published version — how much did a human actually change the draft before it went live? It is objective, it needs no separate grader, and it measures the one thing you actually care about, which is time saved. If that number falls over time, your templates and voice are genuinely improving; if it rises after a change, you just made things worse. Why this beats a quality score: a human editing less is the product working, with no interpretation required. The common mistake is chasing a subjective "quality" rating while ignoring the free, truthful signal sitting in your own edit history. Read the table as a mix of automatic gates (format, banned words) and judgement proxies (the judge, edit distance), and lean hardest on the one that costs nothing to collect.
Read the two top boxes as the two families of eval, then follow the dashed arrows down to the one metric worth anchoring on.
- The green box (left) is the automatic gates — format validity and the banned-word scan. They give a hard yes/no for free, no human or model needed.
- The amber box (right) is the judgement proxies — an LLM-as-judge for fuzzy tone, plus edit distance. These stand in for subjective taste where a hard rule can't.
- The payoff is the bottom box: edit distance to the published version — how much a human changed the draft. Both families feed into it, it's free to collect, and a falling number means your templates and voice are genuinely improving.
In short: cheap gates catch the mechanical failures, a judge covers tone, but lean hardest on edit distance — the free, honest signal that measures time saved.
| 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
The failure here is trying to ship the finished vision on day one. A team reads the architecture, gets excited, and attempts to launch grounded-and-optimized multi-format generation before a single human has ever edited a draft — before anyone even knows what "on-brand" concretely means for their exemplar library. It stalls, because the later capabilities depend on data and trust that only the earlier phases produce. A rollout ladder exists to stop you from skipping the rungs that make the top ones possible.
Think of it like earning trust with a new hire, not flipping a switch. On week one the intern drafts and you rewrite most of it — but every piece you approve becomes a teaching example. That is Phase 1, and it is not a throwaway: the heavily-edited drafts you approve are precisely how you build the exemplar library and learn your own voice well enough to encode it. Only once that foundation exists does Phase 2 (one brief fanning out to every channel, with a brand check before review) make sense, and only after you've collected edit-distance data does Phase 3 (grounding factual claims, tuning templates by that data) have anything to optimize against. Each rung buys the ingredients for the next. Why the ladder ends the way it does: the final rung is a Never, and that is deliberate — no phase, however mature, ever unlocks auto-publishing or presenting an unverified claim as checked, because those aren't advanced features, they're the two things that break the whole product's promise. The common mistake is treating "Never" as "not yet"; it is a permanent boundary, not a future phase. Read the rungs as a dependency chain where trust and data accumulate upward, and the ceiling is fixed on purpose.
Read bottom to top, like climbing a ladder: the lowest rung is where you start, and each rung sits on the capabilities the one below produced.
- Phase 1 (green, bottom) is the draft assistant — humans edit heavily and every approval builds the exemplar library that later phases need.
- Phase 2 (light-blue) adds one-brief-to-many-channels plus a brand check before review; the blue arrow shows it stands on Phase 1's foundation.
- Phase 3 (amber) grounds factual pieces in sources and tunes templates using the edit-distance data collected earlier — it cannot exist until that data does.
- The payoff is the red bar at the very top: Never auto-publish or present an unverified claim as checked. It is a permanent ceiling — the ladder never reaches it, no matter how mature the phases get.
In short: phases climb upward as trust and data accumulate, but the red line is a fixed wall, not a future rung.
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 or OpenAI 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
The generation engine — in Claude or OpenAI
The template rendering, the offline mock, JSON parsing, and validation are all vendor-neutral — only _real_generate and the MODEL id change. Claude uses client.messages.create(...) → msg.content[0].text; OpenAI uses client.responses.create(...) → resp.output_text. Toggle the tabs.
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)")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 = "gpt-5.5"
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 OpenAI API. Used only when USE_REAL_API=1."""
import openai
client = openai.OpenAI() # reads OPENAI_API_KEY from env
resp = client.responses.create(
model=MODEL, max_output_tokens=1500,
input=prompt,
)
return resp.output_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 or OpenAI 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 a vendor client (Claude or OpenAI — each reads your key from the environment), sends the prompt with the vendor's API (client.messages.create(...)for Claude,client.responses.create(...)for OpenAI), and returns the model's text. Toggle the tabs on the code above to see both.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 your chosen vendor (Claude or OpenAI) 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
# OpenAI: c = client.responses.parse(model="gpt-5.5", text_format=Content,
# instructions=BRAND_VOICE, input=render(...)).output_parsed
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}"}])
# OpenAI: judge = client.responses.create(model="gpt-5.5", max_output_tokens=200,
# instructions="Score tone vs this brand voice. Reply JSON {on_brand:bool, why:str}.",
# input=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?