AI EngineeringZero to ProductionHome·About·What’s new·Contact
Project 8 · Design Chapter

Prompt-Powered Content Assistant

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

🎯 Beginner→Intermediate📈 very common📣 marketing / content teamsprompt-engineering-first
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on the code below):Either way, keep the key in a .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.
The most approachable project — and pure prompt engineeringNo RAG, no tools required to start. This is where the Prompt & Context Engineering module (E1–E3) pays off directly: templates, few-shot brand voice, and measured prompt optimization are the whole product.

What this project teaches you to design

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

The brief advanced

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.

don't hire a freelancer — build a newsroom 🧑 one freelancer writes each piece from scratch voice drifts run to run no truth check doesn't scale fast writer the model · drafts quickly style manual template library · one voice editor gate the human · approves to ship split the roles → consistent at any volume The product is a newsroom, not a freelancer. One person doing everything drifts and can't scale; splitting the work into a fast writer, a shared style manual, and an editor gate is exactly the model + template library + human this brief asks for.
🗺️ How to read this diagram

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.

rank by judgement — the model owns the top, the human owns the floor blank-page first draft · ⭐⭐⭐ model reformat → many channels · ⭐⭐⭐ model keep tone consistent · ⭐⭐ shared template editorial judgement + fact-check · ⭐ human high volume, low judgement a designed boundary more mechanical ↑ the dashed line is where automation stops — on purpose Leverage ranks top-to-bottom. The green rows are high-volume, low-judgement work the model earns its keep on; below the dashed line sits the one irreducibly human editorial call — a boundary you designed in, not a task still to automate.
🗺️ How to read this diagram

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

2 · Architecture advanced

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.

one-way assembly line — generation can never reach publish on its own brief topic + channel prompt template + brand voice LLM generate one call · multi-format structured out body + meta + social 👤 human review approve → publish automated stations → A pipeline, not a robot. The brief flows through fixed stations — fill the template, one multi-format model call, split into structured fields — but the arrow into publish leaves the human review box, never the model: that wall is the entire safety design.
🗺️ How to read this diagram

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 highlighted LLM generate box (a single call emitting every format at once) → structured out split into body + 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 → publish comes 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.

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

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

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

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

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

3 · Risk & safety model advanced

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.

sort every risk by WHO catches it — cheap check vs human a draft's risks what could go wrong mechanical banned word · missing meta judgement true? · on-brand? · ship? deterministic check free · automatic human editor · gate → approve → publish Sort risks by who catches them. Mechanical failures (a banned word, a too-long meta) fall to a free deterministic check; the judgement calls — is it true, is it on-brand, may it go live — route to a human, and only the human's approval reaches publish.
🗺️ How to read this diagram

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.

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

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

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.

one voice, many templates — the voice lives in ONE place BRAND_VOICE one shared string blog_post instructions + return JSON product_desc instructions + return JSON email / social instructions + return JSON render() fills a template edit here… …every type updates Voice in one place, one template per type. Every content template references the same 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.
🗺️ How to read this diagram

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[].""",
}
▶ How this works

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

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

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

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

5 · Tool surface (optional) advanced

"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.

sort every capability by what it can BREAK 🟢 read-only generate brand_check fact_sources agent calls freely worst case: wasted tokens 🟠 contained write save_draft draft state only 🔴 front door publish human-only · never agent ← safe → contained → world-visible → Colour = blast radius. Read-only tools are green (call them freely), save_draft is an amber contained write, and publish is the red front door — reserved for a human, never handed to the agent.
🗺️ How to read this diagram

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.

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

6 · Evaluation advanced

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.

two kinds of eval — and one free signal worth building around 🟢 automatic gates format validity banned-word scan hard pass / fail · free 🟠 judgement proxies LLM-as-judge (tone) edit distance stand-in for taste edit distance to published ↓ how much the editor changed it — free & honest falling number = templates & voice improving Automatic gates plus judgement proxies — anchored on one free signal. Format and banned-word checks pass/fail for nothing; tone needs a judge; but edit distance to the published piece measures the thing you actually care about — time saved — and needs no separate grader.
🗺️ How to read this diagram

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.

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

7 · Phased rollout expert

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.

each rung depends on the one below — trust and data accumulate upward 🚫 Never — auto-publish · unverified claim shown as checked Phase 3 · grounded & optimized Phase 2 · multi-format + brand check Phase 1 · draft assistant bottom rung first · the ceiling is a permanent wall, not a next phase Climb the rungs; the ceiling is fixed. Phase 1 builds the exemplar library, Phase 2 adds fan-out and a brand check, Phase 3 grounds and tunes by data — each depends on the rung below, and no phase ever raises past the red Never line.
🗺️ How to read this diagram

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.

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

Skills & course map expert

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

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

By the end you will have

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

How to use this page expert

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

Step 1 · Create the project folder expert

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

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

Confirm you are in the right place:

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

Step 2 · Create and activate a virtual environment expert

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

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

Step 3 · Install the one dependency expert

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

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

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

terminalpip freeze > requirements.txt

Step 4 · The brand voice and prompt templates expert

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

Step 4 — create this file

content-assistant/templates.py

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

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

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


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

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

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

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

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

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

Step 5 · The data model and the brand checker expert

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

Step 5 — create this file

content-assistant/content.py

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


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

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


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


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


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

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

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

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

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

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

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

Create engine.py. It builds the prompt from a template, then either calls a mock generator (default — deterministic, no key) or the real Claude 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.

Step 6 — create this file

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)")
▶ How this works

This is the engine that ties everything together: it builds the prompt, gets back some JSON (from a mock by default, or the real Claude 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.

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

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

Try this: Switching to the real model is one flag: set USE_REAL_API=1 (Step 8) and the same generate() calls 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:

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

Step 7 · Write and run the tests expert

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

Step 7 — create this file

content-assistant/tests/test_content.py

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


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


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


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


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


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


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

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

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

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

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

Run the tests from the content-assistant folder:

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

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

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

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

Step 8 — get a key and set it

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

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

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

On Windows (PowerShell):

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

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

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

Troubleshooting — every error you might hit expert

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

🪜 Practice ladder beginner → industry

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

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

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

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

Requirements:

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

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

Show solution

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

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

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

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

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

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

Exercise 2 · Multi-format structured output in one passIntermediate

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

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

Requirements:

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

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

Show solution

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

from pydantic import BaseModel

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

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

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

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

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

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

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

Requirements:

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

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

Show solution

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

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

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

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

# --- needs API key: LLM judge for tone (returns pass/fail + reason) ---
# judge = client.messages.create(model="claude-haiku-4-5", max_tokens=200,
#   system="Score tone vs this brand voice. Reply JSON {on_brand:bool, why:str}.",
#   messages=[{"role":"user","content":f"VOICE:{BRAND_VOICE}\nTEXT:{text}"}])
# 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?

Exercise 4 · A mandatory human approval gate before publishExpert

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

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

Requirements:

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

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

Show solution

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

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

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

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

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

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

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

Requirements:

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

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

Show solution

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

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

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

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

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

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

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

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

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

Requirements:

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

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

Show solution

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

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

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

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

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

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

✓ You are done when…

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

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

Knowledge check check yourself

✓ Knowledge check

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

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

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

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