Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/projects/blog-post-generator/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"label": "Outline→Blog Post Generator",
"position": 18
}
357 changes: 357 additions & 0 deletions docs/projects/blog-post-generator/index.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/projects/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ They're optional and ungraded. Browse them any time — each project's intro say

<ProjectChooser
projects={mergeProjectMeta([
{
id: 'blog-post-generator',
title: 'Build an Outline→Blog Post Generator',
summary:
"Turn a rough bullet-point outline into a polished blog post draft with a free-tier LLM — you own the structure and ideas, the model does the writing, and you edit the result until it's yours.",
},
{
id: '2027-dependency-freshness-checker',
title: 'Build a Dependency-Freshness Checker',
Expand Down
26 changes: 26 additions & 0 deletions examples/blog-post-generator/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copy this file to .env (already gitignored) and fill in the key for
# whichever provider you choose -- you only need ONE of the keys below.
# Never commit a real key.

# Which provider to use: github (default), gemini, groq, mistral, cerebras,
# or openrouter. See generate.py's PROVIDERS dict for what each one needs.
LLM_PROVIDER=github

# github (default) -- a GitHub personal access token with the "models: read"
# scope. Free, no separate signup: https://github.com/settings/tokens
GITHUB_TOKEN=

# gemini -- free-tier key from https://aistudio.google.com/
GOOGLE_API_KEY=

# groq -- free-tier key from https://console.groq.com/keys
GROQ_API_KEY=

# mistral -- free-tier key from https://console.mistral.ai/api-keys
MISTRAL_API_KEY=

# cerebras -- free-tier key from https://cloud.cerebras.ai/
CEREBRAS_API_KEY=

# openrouter -- free-tier key from https://openrouter.ai/keys
OPENROUTER_API_KEY=
4 changes: 4 additions & 0 deletions examples/blog-post-generator/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv
__pycache__
*.pyc
.env
1 change: 1 addition & 0 deletions examples/blog-post-generator/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
62 changes: 62 additions & 0 deletions examples/blog-post-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Outline→Blog Post Generator Example

The local companion to the course's [Build an Outline→Blog Post Generator](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/blog-post-generator) lesson -- a real, runnable CLI that reads a rough Markdown outline from a file and asks a free-tier LLM to expand it into a polished blog post draft. You keep control of the structure and ideas; the model does the writing.

## What's here

`generate.py` -- a single-file CLI with:

- `load_outline(path)` -- reads a Markdown outline from a `.md` or `.txt` file with `pathlib`.
- `SYSTEM_PROMPT` -- an expansion-specific system prompt with a hard **faithfulness rule**: cover every section and bullet in order, never invent sections or examples, and mark outline holes with honest `[expand: ...]` notes instead of fabricating.
- `generate(outline, provider=...)` -- sends the outline to whichever free-tier provider you've configured and returns the expanded draft.
- `truncate(outline)` -- caps oversized outlines before they're sent, so a long outline doesn't silently blow past a free-tier context window or token quota.

`sample_outline.md` -- a bundled outline (about building a habit-tracking heatmap) with real substance -- a thesis, a story arc, a deliberate "TODO" bullet -- so the generated draft has something genuine to work with.

**You're free to use whichever free-tier provider you like** -- this isn't locked to any one of them. Six are wired up already: **GitHub Models** (the default -- no separate signup, uses a GitHub account you already have), Gemini, Groq, Mistral, Cerebras, and OpenRouter, all through the same `openai` client pointed at each provider's own OpenAI-compatible endpoint.

## Running it

1. **Get a free-tier API key** from your chosen provider -- see the table in the [lesson's Setup section](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/blog-post-generator#get-a-free-llm-api-key) for where to get one for each.
2. **Copy `.env.example` to `.env`** and fill in the key for your provider (and `LLM_PROVIDER` if you're not using the default):
```bash
cp .env.example .env
# then edit .env
```
`.env` is already gitignored -- never commit a real key.
3. **Run it with `uv`** -- no manual virtual environment setup needed:
```bash
uv run python generate.py sample_outline.md
# or with your own outline:
uv run python generate.py my_outline.md
uv run python generate.py my_outline.md --provider groq
```

`uv` reads `pyproject.toml`/`uv.lock` and creates an isolated environment for this project automatically on first run.

### The iteration loop

The tool is a draft engine, not a text factory: generate a draft, edit it by hand in your editor (`uv run python generate.py my_outline.md > draft.md`), then when you change the outline, regenerate and watch the draft track your structural changes. If a change to the outline *doesn't* show up in the next draft, that's a signal your prompt's faithfulness rule needs strengthening -- not a reason to ignore it.

## Running it in GitHub Codespaces

Click into a [Codespace for the whole repo](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python, and `uv` are preinstalled) -- the bundled sample outline is already there, so you can run every command above immediately, no setup beyond copying `.env.example` to `.env`.

## Try it with zero setup: `notebook.ipynb`

[`notebook.ipynb`](./notebook.ipynb) in this folder is a runnable notebook version of this same tool, for Colab, Kaggle, or Binder:

<!-- TODO: update these badge links to point at main once this PR merges -->
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb)
[![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/blog-post-generator/notebook.ipynb)
[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fblog-post-generator%2Fnotebook.ipynb)

A notebook environment has no local files, which is this tool's whole premise -- so rather than pretending that gap doesn't exist, the notebook fetches the bundled `sample_outline.md` straight from the course repo and asks for your API key interactively with `getpass`. Every other part of the tool -- the `pathlib` file reading, the system prompt, the LLM call, the structured output -- runs unmodified. It's a fast way to see the whole thing work end to end before setting it up locally; once you want to run it against your real outlines, come back to `uv run python generate.py` above or a Codespace.

## A note on staying current

Model names and provider free-tier terms change fast -- the model IDs and endpoints in `generate.py`'s `PROVIDERS` dict were verified against a live run while writing this example, but check each provider's own docs before relying on them, since they may have drifted by the time you read this.

## Built your own version?

See [`examples/student-projects/`](../student-projects/) for how to share it with the class via a pull request -- no git experience required, it walks through every step.
158 changes: 158 additions & 0 deletions examples/blog-post-generator/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Outline→Blog Post Generator -- a CLI that turns a rough Markdown outline
into a polished blog post draft using a free-tier LLM.

See docs/projects/blog-post-generator/index.md for the walkthrough this
file accompanies.

You're free to use whichever free-tier provider you like -- this isn't
locked to any one of them. Set LLM_PROVIDER in a .env file (copy
.env.example) or a real environment variable to pick one; see PROVIDERS
below for the full list and which API key each one needs. Defaults to
"github" (GitHub Models) since it's free with no separate signup, tied to
a GitHub account every student here already has.

Never hardcode a real API key here or commit one to the repo.

Usage:
uv run python generate.py sample_outline.md
uv run python generate.py my_outline.md --provider groq
"""

import argparse
import os
from pathlib import Path

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv() # reads a local .env file, if present; real env vars always win

# Outlines longer than this get truncated before being sent to the model --
# see the "overlong outlines" pitfall in the lesson for why this matters:
# free-tier context windows and per-request token quotas are both limited.
MAX_OUTLINE_CHARS = 12_000

SYSTEM_PROMPT = """\
You are an experienced, clear-writing blog post editor who expands outlines
into prose.

You will be given a Markdown outline. Expand it into a complete, well-
structured blog post draft. Follow these rules:

- Faithfulness: Cover every section and bullet in the outline, in the order
given. NEVER invent sections, claims, or examples that are not in the
outline. If a bullet is a question or a placeholder ("TODO", "need an
example here"), write it as an honest rough passage and mark it with a
bracketed note like [expand: find a concrete example], rather than
inventing something to fill it.
- Structure: Preserve the outline's headings (##, ###) as your section
headings. Add an engaging intro paragraph after the title, and a short
conclusion, IF the outline calls for them -- but do not add sections the
outline doesn't imply.
- Prose: Write in clear, conversational but professional prose. Expand each
bullet into one or more paragraphs. Do not pad with fluff, repetition, or
generic filler sentences.
- Voice: Write in the first person, in a confident but plain voice, as if
the outline's author were writing it.

Output ONLY the draft. No preamble, no "here is your draft", no commentary.
"""


def load_outline(path: str | Path) -> str:
"""Reads a Markdown outline file and returns its contents as a single string."""
return Path(path).read_text(encoding="utf-8")


def truncate(outline: str, max_chars: int = MAX_OUTLINE_CHARS) -> str:
"""Cuts an oversized outline down to a size that fits a free-tier context window.

Keeps the front of the outline (usually the most structure-dense part)
and appends a clear marker so the model -- and you -- know the draft was
based on a partial view, rather than silently trimming.
"""
if len(outline) <= max_chars:
return outline
return outline[:max_chars] + f"\n\n... [outline truncated -- {len(outline) - max_chars} more characters not shown] ..."


def _build_github_client() -> OpenAI:
return OpenAI(api_key=os.environ["GITHUB_TOKEN"], base_url="https://models.github.ai/inference")


def _build_gemini_client() -> OpenAI:
# Gemini exposes an OpenAI-compatible endpoint, so the same openai client
# works here too, just with a different base_url and key.
return OpenAI(
api_key=os.environ["GOOGLE_API_KEY"],
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
)


def _build_groq_client() -> OpenAI:
return OpenAI(api_key=os.environ["GROQ_API_KEY"], base_url="https://api.groq.com/openai/v1")


def _build_mistral_client() -> OpenAI:
return OpenAI(api_key=os.environ["MISTRAL_API_KEY"], base_url="https://api.mistral.ai/v1")


def _build_cerebras_client() -> OpenAI:
return OpenAI(api_key=os.environ["CEREBRAS_API_KEY"], base_url="https://api.cerebras.ai/v1")


def _build_openrouter_client() -> OpenAI:
return OpenAI(api_key=os.environ["OPENROUTER_API_KEY"], base_url="https://openrouter.ai/api/v1")


# Every provider here is free-tier at the time of writing, with no credit
# card required -- but check the provider's own pricing page before relying
# on that, since free tiers change. Each tuple is (client builder, model ID).
PROVIDERS = {
"github": (_build_github_client, "gpt-4o-mini"),
"gemini": (_build_gemini_client, "gemini-3.5-flash"),
"groq": (_build_groq_client, "llama-3.3-70b-versatile"),
"mistral": (_build_mistral_client, "mistral-small-latest"),
"cerebras": (_build_cerebras_client, "llama-3.3-70b"),
"openrouter": (_build_openrouter_client, "meta-llama/llama-3.3-70b-instruct:free"),
}


def generate(outline: str, provider: str | None = None) -> str:
"""Sends an outline to a free-tier LLM and returns the expanded blog post draft."""
provider = provider or os.environ.get("LLM_PROVIDER", "github")
if provider not in PROVIDERS:
raise ValueError(f"Unknown LLM_PROVIDER '{provider}'. Choose one of: {', '.join(PROVIDERS)}")
build_client, model = PROVIDERS[provider]
client = build_client()

response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Here is my outline:\n\n```markdown\n{truncate(outline)}\n```"},
],
)
return response.choices[0].message.content


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Turn a rough Markdown outline into a polished blog post draft."
)
parser.add_argument("outline", help="Path to your outline as a .md or .txt file.")
parser.add_argument("--provider", help="Override LLM_PROVIDER for this run, e.g. 'groq'.")
return parser.parse_args()


def main() -> None:
args = parse_args()
outline = load_outline(args.outline)

print(f"Loaded outline: {len(outline)} characters\n")
print("Generating your draft...\n")
print(generate(outline, provider=args.provider))


if __name__ == "__main__":
main()
Loading
Loading