Build a Prompt Template for Blog Outlines
Project Goal Build a reusable prompt template that turns a topic and a few editorial settings into a structured blog post outline, then validates the outline and renders it as Markdown, ready for a writer or a drafting step. The template must:
Accept variables: topic, audience, reader's level, target length, keywords, number of sections, and sections to avoid (Lesson 38) Ask for a progressive structure, from basic to advanced (the same principle this tutorial follows) Return JSON so the outline can be checked in code (Lesson 99) Validate the outline against the settings: section count, duplicate headings, excluded sections, key points per section, and keyword coverage Render the validated outline as Markdown Outlines are a good place to apply prompt engineering in a content workflow: decisions about structure are made and reviewed before any full text is written, when changes are cheap.
Requirements Requirement Details Python 3.10 or later Packages pip install openai API key OPENAI_API_KEY environment variable Reuse llm.py from Project 128 Project Structure blog_outliner/ ├── llm.py # call_model(instructions, prompt) ├── outline.py # template, validation, Markdown rendering └── main.py # generate, validate, retry, save outline.md Step 1: The Template outline.py (first part)
import json from string import Template
OUTLINE_TEMPLATE = Template("""Create a blog post outline.
Topic: $topic Audience: $audience Reader's level: $level Target length of the final post: about $word_count words Keywords to cover naturally: $keywords Number of main sections: $min_sections to $max_sections Do not include sections named: $excluded_sections
For each section, give a clear heading and 2 to 4 key points the section must explain. Order the sections from basic to advanced, so each section builds on the previous one.
Return only JSON: {"title": "...", "sections": [{"heading": "...", "key_points": ["...", "..."]}]}""")
def build_outline_prompt(options) -> str: options = { options, "keywords": ", ".join(options["keywords"]), "excluded_sections": ", ".join(options["excluded_sections"]) or "none", } return OUTLINE_TEMPLATE.substitute(options) Design Decisions string.Template Uses $name placeholders, which do not conflict with the JSON braces in the template. With str.format(), every { and } in the JSON example would need to be doubled (Lesson 38). substitute() raises an error if any variable is missing.
Variables that shape quality
Variable Why It Matters audience and level Decide which concepts need explaining and which can be assumed (Lesson 89) word_count Helps the model choose a realistic number of sections and depth keywords Topics the post must cover, written naturally rather than stuffed min_sections, max_sections Keep outlines within a predictable size excluded_sections Encodes editorial rules, such as avoiding generic sections a publication does not use Key points per section Asking for 2 to 4 key points turns each heading into a concrete plan of what to explain, rather than a vague title.
Basic to advanced Progressive ordering makes the post easier to learn from, and matches how readers build understanding.
Step 2: Validating the Outline def validate_outline(outline: dict, options: dict) -> list[str]: problems = [] sections = outline.get("sections", []) if not outline.get("title"): problems.append("missing title") if not options["min_sections"] <= len(sections) <= options["max_sections"]: problems.append(f"{len(sections)} sections; expected {options['min_sections']}-{options['max_sections']}") headings = [section.get("heading", "").strip() for section in sections] if len({heading.lower() for heading in headings}) != len(headings): problems.append("duplicate headings") excluded = {name.lower() for name in options["excluded_sections"]} for heading in headings: if any(name in heading.lower() for name in excluded): problems.append(f"excluded section present: {heading!r}") for section in sections: if not 2 <= len(section.get("key_points", [])) <= 4: problems.append(f"section {section.get('heading')!r} needs 2-4 key points") text = json.dumps(outline).lower() missing = [keyword for keyword in options["keywords"] if keyword.lower() not in text] if missing: problems.append(f"keywords not covered: {missing}") return problems Code Explanation Section count must fall within the configured range. Duplicate headings are detected case-insensitively. Excluded sections are detected if a heading contains an excluded name, so "Introduction to Relationships" counts as an introduction section. Key points must number between 2 and 4 per section. Keyword coverage checks the whole outline, converted to lowercase text, for each keyword. These checks turn editorial requirements into rules that are applied the same way every time, regardless of who reviews the outline.
Step 3: Rendering as Markdown def to_markdown(outline: dict) -> str: lines = [f"# {outline['title']}", ""] for section in outline["sections"]: lines.append(f"## {section['heading']}") lines.extend(f"- {point}" for point in section["key_points"]) lines.append("") return "\n".join(lines).rstrip() + "\n" The title becomes a level-1 heading, each section a level-2 heading, and each key point a bullet. The result can be pasted into a document, a content management system, or the next prompt in a drafting chain (Lesson 61).
Step 4: Testing Without the API options = { "topic": "Django model relationships", "audience": "beginner Python developers", "level": "knows Python and basic Django views", "word_count": 1800, "keywords": ["ForeignKey", "ManyToManyField", "related_name"], "min_sections": 4, "max_sections": 6, "excluded_sections": ["Introduction", "Conclusion"], }
prompt = build_outline_prompt(**options) print(prompt.splitlines()[5], "|", prompt.splitlines()[7])
outline = { "title": "Django Model Relationships Explained", "sections": [ {"heading": "Introduction to Relationships", "key_points": ["Why models relate"]}, {"heading": "One-to-Many with ForeignKey", "key_points": ["Defining a ForeignKey", "on_delete options"]}, {"heading": "Many-to-Many Relationships", "key_points": ["ManyToManyField basics", "Through models"]}, {"heading": "Querying Related Objects", "key_points": ["Reverse lookups", "select_related and prefetch_related"]}, ], } print(validate_outline(outline, options))
fixed = {**outline, "sections": [ {"heading": "Why Models Need Relationships", "key_points": ["Real data is connected", "Avoiding duplicate data"]}, *outline["sections"][1:3], {"heading": "Querying Related Objects", "key_points": ["Reverse lookups with related_name", "select_related and prefetch_related"]}, ]} print(validate_outline(fixed, options)) print(to_markdown(fixed)) Output:
Target length of the final post: about 1800 words | Number of main sections: 4 to 6 ["excluded section present: 'Introduction to Relationships'", "section 'Introduction to Relationships' needs 2-4 key points", "keywords not covered: ['related_name']"] []
Django Model Relationships Explained
Why Models Need Relationships
- Real data is connected
- Avoiding duplicate data
One-to-Many with ForeignKey
- Defining a ForeignKey
- on_delete options
Many-to-Many Relationships
- ManyToManyField basics
- Through models
Querying Related Objects
- Reverse lookups with related_name
- select_related and prefetch_related
What the Test Confirms Template filling works: the word count and section range appear in the prompt. The first outline fails three checks: an excluded "Introduction" section, a section with only one key point, and the missing keyword related_name. The corrected outline passes and renders as clean Markdown, with the first section now explaining why relationships are needed, which is real content rather than a generic introduction. Step 5: Generate, Validate, Retry main.py
import json
from llm import call_model from outline import build_outline_prompt, to_markdown, validate_outline
OPTIONS = { "topic": "Django model relationships", "audience": "beginner Python developers", "level": "knows Python and basic Django views", "word_count": 1800, "keywords": ["ForeignKey", "ManyToManyField", "related_name"], "min_sections": 4, "max_sections": 6, "excluded_sections": ["Introduction", "Conclusion"], }
prompt = build_outline_prompt(**OPTIONS) for attempt in range(1, 3): raw = call_model("You plan clear, practical technical tutorials.", prompt) try: outline = json.loads(raw) except json.JSONDecodeError: problems = ["output is not valid JSON"] else: problems = validate_outline(outline, OPTIONS) if not problems: with open("outline.md", "w", encoding="utf-8") as file: file.write(to_markdown(outline)) print("Saved outline.md") break prompt += "\n\nFix these problems in a new outline:\n" + "\n".join(f"- {p}" for p in problems) else: print("Outline needs manual editing:", problems) Code Explanation The outline is generated, parsed, and validated. If problems are found, they are appended to the prompt as feedback, and the model tries once more (Lesson 73). The for ... else construction runs the else block only if the loop ends without break, meaning no attempt passed validation. For reliable JSON, you can also use Structured Outputs with a schema (Lesson 99) and keep validate_outline() for the editorial rules that a schema cannot express.
Step 6: Using Outlines in a Content Workflow Stage Who or What Why Generate outline This template Fast, consistent structure Review outline Editor or author Cheap to change structure before writing Draft sections One prompt per section, using the section's key points (Lesson 61) Focused, controllable writing Verify facts and code Author, tests, documentation (Lesson 89) Technical accuracy Final edit Author Voice, originality, and quality Generating one section at a time from approved key points keeps each drafting prompt focused, makes long posts manageable, and keeps the author in control of structure and content.
Common Mistakes Mistake 1: Generating Full Posts Without an Outline What it is: Asking for a complete article in one prompt.
Why it happens: It is the fastest path to a draft.
What it causes: Structural problems are discovered only after everything is written.
How to fix it: Generate and approve an outline first.
Mistake 2: Editorial Rules Only in the Prompt What it is: Writing "avoid generic sections" in the prompt without checking.
Why it happens: The instruction seems clear.
What it causes: Outlines that break the rules occasionally reach writers.
How to fix it: Encode rules as settings and validate them in code.
Mistake 3: Keyword Stuffing What it is: Demanding that keywords appear many times.
Why it happens: Keywords are associated with search visibility.
What it causes: Unnatural content that serves readers poorly.
How to fix it: Ask for natural coverage, and check only that each keyword is covered.
Best Practices Make every editorial decision a variable. Audience, level, length, keywords, structure, and exclusions.
Use string.Template for prompts containing JSON. Avoid brace conflicts.
Ask for key points, not just headings. They make outlines actionable.
Validate outlines against the settings. Then render consistently.
Review structure before writing. Draft section by section from approved outlines.
Try It Yourself Exercise 1: Outlines for Your Topics Generate outlines for three tutorial topics you plan to write, with different audiences and lengths. Run validate_outline() on each, and record which rules the model broke most often.
Exercise 2: Add a Rule Add a setting required_sections, a list of headings that must appear, such as "Common Mistakes" for tutorials. Update the template and validate_outline(), and test it with an outline that omits one required section.
Exercise 3: Draft One Section Write a second template that takes one section from a validated outline, with its heading, key points, audience, and level, and drafts that section only. Generate one section for an outline from Exercise 1 and check that it covers every key point.