Build an Observable AI Course Factory

Build a staged AI course generator with validation, review, caching, and logs.

Introduction

30 Second Summary

A polished response can still leave out details you expected. The problem only surfaces when you try to reuse its output.

In this project, you will build a local course factory with Python and the Gemini Developer API. Its staged AI orchestration exposes every model call, structural check, approval decision, saved artifact, and reused result.

What You'll Build

In macOS Terminal, you'll watch one course brief move through validation, quality review, and approval into an expanded lesson while each stage reveals a Gemini call or local cache reuse.

By the end of this project, you'll have:

  • A course planner that turns a topic, audience, outcome, and duration into exactly two modules with two lessons each.
  • A visible quality gate that combines deterministic validation with a separate AI review before human-in-the-loop approval unlocks lesson generation.
  • An observable cache and run log that reveals reused work while separate JSON artifacts preserve each stage for inspection.
  • Secret Mission: Add an automatic revision stage that improves the plan from review feedback before validating the result.

Are there any prerequisites?

You'll need a Mac with internet access plus a Google account. Step 1 includes the full setup, so basic Python familiarity is optional.

Before We Start

Before any setup, this is your moment to choose a non-sensitive sample course for your factory to generate. Defining why each stage must stay visible gives every model call, structured artifact, validation result, review, approval decision, cache result, and run log a clear purpose.

Set Up the Mac Development Environment

Your course factory needs a consistent Python runtime before any model stage can run. It also needs a server-side API key that stays outside your source files.

An isolated virtual environment keeps the project dependency reproducible. In this step, you will prepare the editor, workspace, SDK, plus your temporary credential.

In this step, get ready to:
  • Install Python 3.15 plus Visual Studio Code.
  • Create an isolated project workspace with its dependency files.
  • Export a Gemini API key for the current Terminal session.
Install Python and Visual Studio Code

Python runs every stage of the local factory. This project uses Python 3.15.x so the environment stays consistent with the tested setup.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal into Spotlight.
  • Press Enter to open Terminal.
  • Check your installed Python version by running this command:
python3 --version

What does this check do?

This asks the system Python executable to report its version. Match the result to the tab that describes your Terminal output.

✔️ I see Python 3.15.x

Python is ready for the project. You can continue to the editor setup.

ⓧ I see an older version

Your current Python cannot provide the pinned project runtime. Install Python 3.15.0 beside it using the signed macOS installer.

  • Open the Python downloads for macOS page in your browser.
  • Download the signed macOS installer for Python 3.15.0.
  • Open the downloaded installer package.
  • Complete the standard installer using its default options.
  • Run the certificate installer by entering this command in Terminal:
/Applications/Python 3.15/Install Certificates.command

Why run the certificate installer?

This script prepares trusted certificates for secure network connections from the new Python installation. Your SDK needs those connections when it contacts the Gemini Developer API.

  • Check the newly installed Python version by running:
python3 --version

What should this confirm?

The version check confirms which Python executable your shell finds first. Continue once the output starts with Python 3.15.

Still seeing the older Python?

Close Terminal after the installer finishes. Open a new Terminal window so the shell reloads its command paths.

If the older version remains, help me check which Python installation Terminal is using.

ⓧ Command not found

Python is not available through your shell yet. Install Python 3.15.0 with the signed macOS installer.

  • Open the Python downloads for macOS page in your browser.
  • Download the signed macOS installer for Python 3.15.0.
  • Open the downloaded installer package.
  • Complete the standard installer using its default options.
  • Run the certificate installer by entering this command in Terminal:
/Applications/Python 3.15/Install Certificates.command

Why run the certificate installer?

This script prepares trusted certificates for secure network connections from the new Python installation. Your SDK needs those connections when it contacts the Gemini Developer API.

  • Confirm the installation by running:
python3 --version

What should this confirm?

The command confirms that Terminal can now locate Python. Continue once the output starts with Python 3.15.

Terminal still cannot find Python?

Close Terminal after the installer finishes. Open a new Terminal window so the shell reloads its command paths.

If the command is still unavailable, help me connect the Python 3.15 installation to my Terminal PATH.

You will use Visual Studio Code to edit the project files. The command-line launcher lets Terminal open the current folder directly in the editor.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Choose the tab that matches your search result.

✔️ Visual Studio Code appears

  • Press Enter to open Visual Studio Code.

ⓧ Visual Studio Code is missing

  • Open the Visual Studio Code macOS setup page in your browser.
  • Download the macOS disk image.
  • Open the downloaded disk image.
  • Drag Visual Studio Code.app into Applications.
  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Enter to open Visual Studio Code.
  • Press Cmd+Shift+P in Visual Studio Code to open the Command Palette.
  • Type Shell Command: Install 'code' command in PATH into the Command Palette.
  • Select Shell Command: Install 'code' command in PATH.

Visual Studio Code adds the code launcher to your shell path. You will prove it works when the launcher opens your project folder.

Cannot find the shell command?

Confirm that you opened the installed copy from Applications. A copy still running from the disk image can behave differently.

If the Command Palette action is missing, help me install the Visual Studio Code command-line launcher on macOS.

Create the isolated project workspace

The project folder gives every script plus artifact one predictable home. The virtual environment keeps google-genai isolated from packages used by other Python projects.

  • Switch back to the Terminal window from earlier.
  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The cd command changes Terminal's current folder. The tilde points to your macOS home folder.

  • Create the ai-course-factory folder by running these commands:
mkdir ai-course-factory
cd ai-course-factory

What do these commands do?

  • The first command creates ~/Desktop/ai-course-factory.
  • The second command moves Terminal into that folder.

Your Terminal prompt now ends with the project folder name. This confirms that later files plus commands land inside ~/Desktop/ai-course-factory.

  • Open the current folder in Visual Studio Code by running:
code .

What does this command do?

The code launcher opens Visual Studio Code. The period represents the current ai-course-factory folder.

You should see ai-course-factory at the top of the Explorer sidebar. This proves the command-line launcher is available in your shell path.

Did the folder fail to open?

Close Terminal after installing the launcher. Open a new Terminal window so it loads the updated path.

Return to ~/Desktop/ai-course-factory before trying again. You can also ask for help with the code launcher.

  • Select the Explorer view in the left Activity Bar.
  • Select the New File button in the Explorer toolbar.
  • Enter requirements.txt as the filename.
  • Paste this dependency pin into requirements.txt.
google-genai==2.31.0

What does this dependency pin do?

The file tells pip to install Google Gen AI SDK version 2.31.0. Pinning the version keeps the documented API behavior reproducible.

  • Press Cmd+S to save requirements.txt.
  • Confirm the dependency file exists by running this command in Terminal:
ls requirements.txt

What should this list?

The command prints the filename when it exists in the current folder. You should see requirements.txt in the output.

Dependency file not found?

Confirm that Visual Studio Code shows ai-course-factory at the top of the Explorer sidebar. Save the file before checking again.

If the file still does not appear, help me locate my requirements.txt file.

  • Select the New File button in the Explorer toolbar.
  • Enter .gitignore as the filename.
  • Paste these ignored paths into .gitignore.
.venv/
__pycache__/
artifacts/

What does this ignore file protect?

The rules keep the virtual environment, Python cache files, plus generated artifacts out of source control. Your source files remain separate from local runtime output.

  • Press Cmd+S to save .gitignore.
  • Confirm both project files exist by running:
ls requirements.txt .gitignore

What should this list?

The output should list requirements.txt plus .gitignore. This confirms both files were saved inside the project folder.

Is one file missing?

Check the Explorer sidebar for the missing filename. Files with names starting with a period can be easy to overlook.

If either file is missing, help me verify my project files in Visual Studio Code.

✔️ Awesome, I've got everything!

Both project files are saved in ~/Desktop/ai-course-factory. Continue to the virtual environment setup.

ⓧ I'd like to double check the full code

Compare your two files with these exact versions.

google-genai==2.31.0

What belongs in requirements.txt?

This complete file contains one pinned dependency. Keep the package name, double equals sign, plus version unchanged.

.venv/
__pycache__/
artifacts/

What belongs in .gitignore?

This complete file contains the three local paths created or used by the project. Keep each path on its own line.

The dependency file now defines the SDK version. Next, the virtual environment gives that dependency an isolated installation location.

  • Create the virtual environment by running these commands in Terminal:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt

What do these commands do?

  • The first command creates an isolated Python environment inside .venv.
  • The second command activates that environment for the current Terminal session.
  • The third command installs the pinned SDK from requirements.txt.

Your Terminal prompt should now begin with (.venv). The installation output should finish without a dependency error.

Did the environment setup fail?

Confirm that Terminal is still inside ~/Desktop/ai-course-factory. The activation command depends on the .venv folder being in the current location.

If package installation fails, help me troubleshoot my Python virtual environment.

Add the API key and verify the SDK

The Gemini Developer API authenticates model requests with an API key. Exporting it in the current Terminal session keeps the credential out of your project files.

Keep your course brief non-sensitive

Treat the API key like a password. Keep it out of requirements.txt, .gitignore, plus every future Python file.

Gemini 3.8 Flash input plus output are free of charge on the Free Tier as of 2026-10-18. Free-tier content is used to improve Google's products, so use only the non-sensitive sample brief you chose earlier.

  • Open the Google AI Studio API Keys page in your browser.
  • Sign in with your Google account.
  • Select Create API key if your account has no suitable key.
  • Open an existing key if you already have one for this project.
  • Copy the key without sharing or saving it in a project file.

Pasting a credential into Terminal can feel risky. This export stays in the current Terminal session instead of writing the key into the project.

  • Switch back to the active (.venv) Terminal session.
  • Replace the plain placeholder with your copied key before running this command:
export GEMINI_API_KEY="YOUR_API_KEY"

What does this export do?

The command adds GEMINI_API_KEY to the current shell environment. The Google Gen AI SDK reads that environment variable automatically.

The exported value disappears when this Terminal session closes. Your live key remains outside the project files.

Unsure whether the key is safe?

Do not paste the key into Visual Studio Code. Do not commit it to source control.

If you accidentally exposed it, retire the key through Google AI Studio. You can also ask for help securing a Gemini API key.

Before you run the final check, do you expect both the Python version test plus the SDK import test to succeed?

  • Verify the active Python runtime plus SDK import by running:
python --version && python -c "from google import genai; print('SDK ready')"

What does this verification test?

  • The first check reports the Python version selected by the active virtual environment.
  • The second check imports the Google Gen AI SDK from that same environment.
  • The second check prints a short confirmation after the import succeeds.

You should see Python 3.15.x followed by SDK ready. That confirms your isolated environment can load the pinned SDK.

Setup complete. Your Mac now has the reproducible local foundation that every course-factory stage uses.

Did the final verification fail?

If the Python version is wrong, confirm that your Terminal prompt still begins with (.venv). Activate the environment again from ~/Desktop/ai-course-factory.

If the SDK import fails, confirm that package installation completed in the active environment. You can ask for help with the verification command.

Environment checkpoint

  • Terminal is open inside ~/Desktop/ai-course-factory.
  • The .venv environment uses Python 3.15.x.
  • The environment contains google-genai==2.31.0.
  • The current Terminal session contains GEMINI_API_KEY.
  • The project contains the exact requirements.txt plus .gitignore files shown above.

Your development environment is ready. Next, you will run a one-shot course generator so its missing output contract becomes visible.

Expose the One-Shot Baseline

Your isolated Python environment can now load the Google Gen AI SDK. Your active Terminal session can also authenticate with the Gemini Developer API.

A single prompt can produce a course outline quickly. However, its free-form reply gives later stages no dependable contract to validate, review, cache, or expand.

In this step, get ready to:
  • Build a one-shot course generator that returns plain prose.
  • Run the generator with a sample learner brief.
  • Expose the missing output contract in the terminal.
Build the plain-text generator

A one-shot generation flow bundles the entire learner brief into one prompt. The model returns prose designed for a person to read.

  • Create baseline.py inside the ai-course-factory folder by using the new-file icon in the VS Code sidebar.
  • Add the input stage plus the plain-prose prompt by pasting this code into baseline.py:
from google import genai

MODEL_ID = "gemini-3.8-flash"


def main() -> None:
    topic = input("Course topic [Git basics]: ").strip() or "Git basics"
    audience = input("Target learner [complete beginner]: ").strip() or "complete beginner"

    prompt = f"""
You are a course designer.
Create a short course outline in plain prose for this brief:
- Topic: {topic}
- Audience: {audience}
- Format: two modules with two lessons per module
Include an activity and a knowledge check for every lesson.
""".strip()


if __name__ == "__main__":
    main()

What Does This Code Do?

  • The genai import gives the script access to the installed SDK.
  • MODEL_ID keeps the model selection in one reusable place.
  • topic captures the course subject.
  • audience captures the intended learner.
  • prompt turns those answers into one plain-text course-design request.
  • main() keeps the application flow inside one function.
  • Save baseline.py.
  • Confirm the input stage runs by returning to the active Terminal session from earlier.
  • Test the input stage by running this command:
python baseline.py

What Should I See?

You should see the course topic prompt. After you answer it, you should see the target learner prompt.

The script then returns you to the shell. This proves the input stage can collect a brief using your active virtual environment.

Script Not Reaching Both Prompts?

Confirm your Terminal prompt still shows the active .venv environment. Also check that you saved baseline.py inside ~/Desktop/ai-course-factory.

Compare the input lines with the code above if Python points to a syntax problem. You can also ask for help debugging the input stage.

Send the one-shot request

The collected brief now needs a live model request. The completed script displays the response before reporting its separate contract verdict.

  • Switch back to baseline.py in VS Code.
  • Place your cursor on the blank line after """.strip().
  • Complete the one-shot request by pasting this code:
    client = genai.Client()
    try:
        interaction = client.interactions.create(
            model=MODEL_ID,
            input=prompt,
        )
        print("\nMODEL OUTPUT\n")
        print(interaction.output_text)
    finally:
        client.close()

    print("\nCONTRACT CHECK: FAIL")
    print("The app received text, but it cannot guarantee or validate required course fields.")

What Does This Code Do?

  • genai.Client() creates a client that reads the API key from your active Terminal environment.
  • client.interactions.create sends the prompt through the Gemini Interactions API using gemini-3.8-flash.
  • interaction.output_text provides the generated prose for the terminal.
  • finally closes the client even if the request raises an exception.
  • The final print statements keep the model response separate from the program's contract verdict.
  • Save the completed baseline.py file.

✔️ Awesome, I've got everything!

Your one-shot generator is ready. Double-check that baseline.py is saved before you send the live request.

ⓧ I'd like to double check the full code

Compare your complete baseline.py file with this reference:

from google import genai

MODEL_ID = "gemini-3.8-flash"


def main() -> None:
    topic = input("Course topic [Git basics]: ").strip() or "Git basics"
    audience = input("Target learner [complete beginner]: ").strip() or "complete beginner"

    prompt = f"""
You are a course designer.
Create a short course outline in plain prose for this brief:
- Topic: {topic}
- Audience: {audience}
- Format: two modules with two lessons per module
Include an activity and a knowledge check for every lesson.
""".strip()

    client = genai.Client()
    try:
        interaction = client.interactions.create(
            model=MODEL_ID,
            input=prompt,
        )
        print("\nMODEL OUTPUT\n")
        print(interaction.output_text)
    finally:
        client.close()

    print("\nCONTRACT CHECK: FAIL")
    print("The app received text, but it cannot guarantee or validate required course fields.")


if __name__ == "__main__":
    main()

What Should You Compare?

Check the indentation inside main(). Confirm the model request sits after the prompt.

The two contract-check print statements belong after the finally block. The final guard belongs at the left edge of the file.

Before you run this, predict whether Python can guarantee the same required fields for every lesson in free-form prose.

  • Generate a sample course outline by running this command in the active Terminal session:
python baseline.py

What Should I See?

Enter Git basics for the topic. Enter complete beginner for the target learner.

You should see MODEL OUTPUT followed by a generated course outline. Beneath that prose, you should see CONTRACT CHECK: FAIL plus the contract explanation.

You now have a working one-shot baseline. It produces a course outline through a live model request.

No Model Output?

Confirm GEMINI_API_KEY remains exported in the current Terminal session. A new Terminal session does not inherit that temporary value.

Compare baseline.py with the full reference if Python reports a problem in your script. You can ask for help debugging the Gemini request.

  • Read the final contract message beneath the generated outline.
  • Compare the generated lessons for consistent objective fields.
  • Compare the generated lessons for consistent activity fields.
  • Compare the generated lessons for consistent assessment fields.

Why Does the Contract Fail?

The model returned text that a person can interpret. Python received one prose string without dependable named fields.

Later stages cannot safely select one lesson or prove that every lesson contains the required parts. The visible failure captures the exact limitation that the structured planner must solve.

Your one-shot baseline now makes the missing contract visible. Next, you will give the planning stage a structure that local Python code can validate.

Build the Structured Planning Pipeline

Your one-shot generator proved that Gemini can produce a readable outline. The failed contract check also exposed the missing boundary between model prose and dependable application data.

A JSON Schema defines that boundary. This step uses structured output plus deterministic validation to reject incomplete plans before another stage can use them.

In this step, get ready to:
  • Define a two-module course-plan contract.
  • Generate a structured plan from a learner brief.
  • Validate the plan and inspect its saved artifact.
Create the structured factory file

The planner needs one file that owns its schemas, model call, local checks, and saved artifacts. Keeping these boundaries explicit makes each result inspectable before the workflow continues.

Why use structured output?

A plain prompt asks the model to follow formatting instructions. A schema gives the response a machine-readable shape with required fields and fixed collection sizes.

Local validation then checks rules that still matter after the response matches the schema. The application decides whether a plan can move forward.

  • Return to the Explorer sidebar in Visual Studio Code.
  • Click the New File button beside the ai-course-factory folder.
  • Name the file course_factory.py.
  • Select the full-code tab below.
  • Copy the complete file into course_factory.py.

✔️ Awesome, I've got everything!

Great. Confirm that course_factory.py is open in your editor before you continue.

ⓧ I'd like to double check the full code

Compare your complete course_factory.py file with this reference:

from collections.abc import Callable
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
from typing import Any

from google import genai

MODEL_ID = "gemini-3.8-flash"
ARTIFACT_DIR = Path("artifacts")
CACHE_DIR = ARTIFACT_DIR / "cache"
RUN_LOG = ARTIFACT_DIR / "run_log.jsonl"

COURSE_SCHEMA: dict[str, Any] = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "title": {"type": "string", "description": "Specific course title."},
        "audience": {"type": "string", "description": "Intended learner."},
        "outcome": {"type": "string", "description": "Observable end result."},
        "prerequisites": {
            "type": "array",
            "items": {"type": "string"},
        },
        "modules": {
            "type": "array",
            "minItems": 2,
            "maxItems": 2,
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "id": {"type": "string"},
                    "title": {"type": "string"},
                    "outcome": {"type": "string"},
                    "duration_minutes": {"type": "integer", "minimum": 10},
                    "lessons": {
                        "type": "array",
                        "minItems": 2,
                        "maxItems": 2,
                        "items": {
                            "type": "object",
                            "additionalProperties": False,
                            "properties": {
                                "id": {"type": "string"},
                                "title": {"type": "string"},
                                "objective": {"type": "string"},
                                "activity": {"type": "string"},
                                "assessment": {"type": "string"},
                            },
                            "required": [
                                "id",
                                "title",
                                "objective",
                                "activity",
                                "assessment",
                            ],
                        },
                    },
                },
                "required": [
                    "id",
                    "title",
                    "outcome",
                    "duration_minutes",
                    "lessons",
                ],
            },
        },
    },
    "required": ["title", "audience", "outcome", "prerequisites", "modules"],
}

REVIEW_SCHEMA: dict[str, Any] = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "approved": {"type": "boolean"},
        "score": {"type": "integer", "minimum": 1, "maximum": 5},
        "strengths": {"type": "array", "items": {"type": "string"}},
        "issues": {"type": "array", "items": {"type": "string"}},
        "recommendations": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["approved", "score", "strengths", "issues", "recommendations"],
}

LESSON_SCHEMA: dict[str, Any] = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "title": {"type": "string"},
        "objective": {"type": "string"},
        "estimated_minutes": {"type": "integer", "minimum": 5, "maximum": 60},
        "explanation": {"type": "string"},
        "steps": {
            "type": "array",
            "minItems": 3,
            "maxItems": 5,
            "items": {"type": "string"},
        },
        "practice": {"type": "string"},
        "success_check": {"type": "string"},
    },
    "required": [
        "title",
        "objective",
        "estimated_minutes",
        "explanation",
        "steps",
        "practice",
        "success_check",
    ],
}


def write_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(data, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


def cache_path(stage: str, payload: dict[str, Any]) -> Path:
    cache_input = {
        "stage": stage,
        "model": MODEL_ID,
        "payload": payload,
    }
    canonical = json.dumps(
        cache_input,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    )
    digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    return CACHE_DIR / f"{stage}-{digest}.json"


def log_event(stage: str, status: str, cached_file: Path) -> None:
    ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
    event = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "stage": stage,
        "status": status,
        "cache_file": str(cached_file),
    }
    with RUN_LOG.open("a", encoding="utf-8") as log_file:
        log_file.write(json.dumps(event, ensure_ascii=False) + "\n")


def run_stage(
    stage: str,
    payload: dict[str, Any],
    producer: Callable[[], dict[str, Any]],
) -> dict[str, Any]:
    cached_file = cache_path(stage, payload)

    if cached_file.exists():
        result = json.loads(cached_file.read_text(encoding="utf-8"))
        status = "CACHE HIT"
    else:
        result = producer()
        write_json(cached_file, result)
        status = "AI CALL"

    write_json(ARTIFACT_DIR / f"{stage}.json", result)
    log_event(stage, status, cached_file)
    print(f"[{status}] {stage}")
    return result


def structured_call(
    client: genai.Client,
    prompt: str,
    schema: dict[str, Any],
) -> dict[str, Any]:
    interaction = client.interactions.create(
        model=MODEL_ID,
        input=prompt,
        response_format={
            "type": "text",
            "mime_type": "application/json",
            "schema": schema,
        },
    )
    if not interaction.output_text:
        raise RuntimeError("Gemini returned no text output.")
    return json.loads(interaction.output_text)


def validate_course(plan: dict[str, Any]) -> list[str]:
    errors: list[str] = []
    modules = plan.get("modules")

    if not isinstance(modules, list) or len(modules) != 2:
        return ["The plan must contain exactly two modules."]

    lesson_ids: set[str] = set()
    for module_index, module in enumerate(modules, start=1):
        lessons = module.get("lessons")
        if not isinstance(lessons, list) or len(lessons) != 2:
            errors.append(f"Module {module_index} must contain exactly two lessons.")
            continue

        for lesson_index, lesson in enumerate(lessons, start=1):
            label = f"Module {module_index}, lesson {lesson_index}"
            lesson_id = str(lesson.get("id", "")).strip()
            if not lesson_id:
                errors.append(f"{label} has no ID.")
            elif lesson_id in lesson_ids:
                errors.append(f"{label} repeats lesson ID {lesson_id}.")
            else:
                lesson_ids.add(lesson_id)

            for field in ("objective", "activity", "assessment"):
                if not str(lesson.get(field, "")).strip():
                    errors.append(f"{label} has an empty {field}.")

    return errors


def collect_brief() -> dict[str, Any]:
    topic = input("Course topic [Git basics]: ").strip() or "Git basics"
    audience = input("Target learner [complete beginner]: ").strip() or "complete beginner"
    outcome = (
        input("Observable outcome [publish a small repository]: ").strip()
        or "publish a small repository"
    )

    while True:
        raw_duration = input("Total duration in minutes [60]: ").strip() or "60"
        try:
            duration_minutes = int(raw_duration)
            if duration_minutes >= 40:
                break
        except ValueError:
            pass
        print("Enter a whole number of at least 40.")

    return {
        "topic": topic,
        "audience": audience,
        "outcome": outcome,
        "duration_minutes": duration_minutes,
    }


def planner_prompt(brief: dict[str, Any]) -> str:
    return f"""
You are the planning stage in an AI course factory.
Design a compact, project-based course from the learner brief below.
Create exactly two modules with exactly two lessons in each module.
Sequence prerequisites before dependent skills.
Give every lesson an observable objective, a hands-on activity, and an assessment.
Keep the combined module duration within the requested total duration.
Use stable IDs such as M1 and M1L1.

Learner brief:
{json.dumps(brief, indent=2)}
""".strip()


def reviewer_prompt(brief: dict[str, Any], plan: dict[str, Any]) -> str:
    return f"""
You are the quality-review stage in an AI course factory.
Review the course plan against the learner brief.
Score it from 1 to 5 for alignment, sequencing, practical work, measurable objectives,
and assessment coverage. Approve only if the plan is coherent and immediately teachable.
List concrete strengths, issues, and recommendations. Do not rewrite the plan.

Learner brief:
{json.dumps(brief, indent=2)}

Course plan:
{json.dumps(plan, indent=2)}
""".strip()


def lesson_prompt(plan: dict[str, Any], lesson: dict[str, Any]) -> str:
    return f"""
You are the lesson-generation stage in an AI course factory.
Expand only the selected lesson. Preserve its title and objective.
Write a concise explanation, three to five actionable steps, one practice task,
and one observable success check. Do not generate the rest of the course.

Approved course plan:
{json.dumps(plan, indent=2)}

Selected lesson:
{json.dumps(lesson, indent=2)}
""".strip()


def print_plan_summary(plan: dict[str, Any]) -> None:
    print(f"\nCOURSE: {plan['title']}")
    for module in plan["modules"]:
        print(f"- {module['id']}: {module['title']}")
        for lesson in module["lessons"]:
            print(f"  - {lesson['id']}: {lesson['title']}")


def main() -> None:
    ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
    brief = collect_brief()
    client = genai.Client()

    try:
        plan = run_stage(
            "course_plan",
            brief,
            lambda: structured_call(client, planner_prompt(brief), COURSE_SCHEMA),
        )

        validation_errors = validate_course(plan)
        if validation_errors:
            print("\nVALIDATION: FAIL")
            for error in validation_errors:
                print(f"- {error}")
            return

        print("\nVALIDATION: PASS")
        print_plan_summary(plan)

        review_payload = {"brief": brief, "plan": plan}
        review = run_stage(
            "quality_review",
            review_payload,
            lambda: structured_call(
                client,
                reviewer_prompt(brief, plan),
                REVIEW_SCHEMA,
            ),
        )

        print(f"\nQUALITY SCORE: {review['score']}/5")
        print(f"AI REVIEW APPROVED: {review['approved']}")
        for issue in review["issues"]:
            print(f"- Issue: {issue}")

        approval = input("\nGenerate the first lesson? [y/N]: ").strip().lower()
        if approval != "y":
            print("Stopped at the human approval gate.")
            return

        selected_lesson = plan["modules"][0]["lessons"][0]
        lesson_payload = {
            "plan": plan,
            "selected_lesson": selected_lesson,
        }
        lesson = run_stage(
            "first_lesson",
            lesson_payload,
            lambda: structured_call(
                client,
                lesson_prompt(plan, selected_lesson),
                LESSON_SCHEMA,
            ),
        )

        print(f"\nLESSON READY: {lesson['title']}")
        print(f"SUCCESS CHECK: {lesson['success_check']}")
        print(f"Artifacts saved in: {ARTIFACT_DIR.resolve()}")
    finally:
        client.close()


if __name__ == "__main__":
    main()

How is this file organized?

  • The three schemas define stable contracts for plans, reviews, and expanded lessons.
  • The artifact helpers persist stage results as readable local files.
  • The stage runner connects model work to local persistence and logging.
  • The planner functions collect a brief, request structured data, and apply local validation.
  • Save course_factory.py.
  • Confirm that course_factory.py appears beneath baseline.py in the Explorer sidebar.

File missing from the Explorer?

Confirm that the Explorer shows the ai-course-factory folder from earlier. Save the file inside that folder if your editor opened it in another location.

Ask for help with placing the planner file in the correct folder.

Trace the planning boundary

The planning boundary turns a learner brief into a predictable JSON object. Gemini supplies the content while the schema and local validator control what the application accepts.

How does the planner flow?

  • The collect_brief() function collects the topic, audience, outcome, and duration.
  • The planner_prompt() function turns that brief into focused planning instructions.
  • The structured_call() function requests application/json output that conforms to COURSE_SCHEMA.
  • The validate_course() function applies the hard structural gate after parsing.
  • The write_json() function saves the accepted result as an inspectable artifact.
  • Find COURSE_SCHEMA in course_factory.py.
  • Confirm that the modules array uses minItems and maxItems values of 2.
  • Confirm that each lessons array also uses limits of 2.
  • Find structured_call() in the same file.
  • Confirm that response_format supplies COURSE_SCHEMA to the model request.
  • Find validate_course() in the same file.
  • Identify the checks for module count, lesson count, unique lesson IDs, and non-empty lesson fields.
Run and inspect the planner

The first run collects a complete brief before making its model request. The generated plan is saved only after the response has been parsed into structured data.

What inputs will you enter?

The terminal asks for a topic, target learner, observable outcome, and total duration. Use the values below so the generated artifact is easy to inspect.

  • Enter Git basics for the course topic.
  • Enter complete beginner for the target learner.
  • Enter publish a small repository for the observable outcome.
  • Enter 60 for the total duration.
  • Enter n when the lesson-generation approval prompt appears.

Before you run the planner, do you expect the same contract failure as the baseline or a validated structured plan?

  • Run the course factory from the active virtual-environment Terminal by using this command:
python course_factory.py

What does this command prove?

The command executes the planner with your exported API key. The first stage sends your brief through COURSE_SCHEMA before parsing the response with json.loads.

The local validator then checks structural rules that remain important after schema generation. A passing result proves the plan is safe for the next stage to consume.

You should see [AI CALL] course_plan followed by VALIDATION: PASS. The summary should list two modules with two lessons beneath each module.

  • Expand the artifacts folder in the Explorer sidebar.
  • Open course_plan.json from the artifacts folder.
  • Confirm that the top level contains title, audience, outcome, prerequisites, and modules.
  • Confirm that modules contains exactly two objects.
  • Confirm that each module contains exactly two lessons.
  • Confirm that every lesson has an objective, activity, and assessment.

That is the contract doing real work. Your planner now produces a stable artifact that both people and later application stages can inspect.

Planner did not pass validation?

Confirm that your active Terminal still has the virtual environment from earlier. Confirm that the API key remains exported in that same Terminal session.

Compare COURSE_SCHEMA and validate_course() with the complete reference above. A missing brace or renamed field can break the boundary.

Ask for help with debugging the structured planner.

Your planner now has a dependable machine-readable boundary. Next, you will add a separate quality review, a human approval gate, and visible cache behavior.

Add Review, Approval, and Caching

Your planner now guarantees a valid structure through JSON Schema plus deterministic checks. A valid plan can still contain weak teaching choices.

This step adds a separate Gemini review stage plus human-in-the-loop approval. A content-addressed cache records reusable results while a JSON Lines log makes every stage traceable.

In this step, get ready to:
  • Generate a separate quality review for the validated course plan.
  • Require human approval before expanding the first lesson.
  • Run the complete pipeline twice to observe model calls plus cache hits.
Generate a separate quality review

Structural validation proves that required fields exist. The reviewer examines teaching quality through a separate structured stage.

  • Return to the open course_factory.py tab in Visual Studio Code.
  • Find the print_plan_summary(plan) line inside main().
  • Add the review stage below that line by pasting this code:
        review_payload = {"brief": brief, "plan": plan}
        review = run_stage(
            "quality_review",
            review_payload,
            lambda: structured_call(
                client,
                reviewer_prompt(brief, plan),
                REVIEW_SCHEMA,
            ),
        )

        print(f"\nQUALITY SCORE: {review['score']}/5")
        print(f"AI REVIEW APPROVED: {review['approved']}")
        for issue in review["issues"]:
            print(f"- Issue: {issue}")

What does this code do?

  • The review_payload value combines the learner brief with its validated plan.
  • The quality_review stage passes that payload through run_stage().
  • The producer uses REVIEW_SCHEMA to require a score plus review findings.
  • The final loop exposes every issue so the review remains inspectable.

The script asks for the four brief fields from the planner. Use the same answers that created your current course plan.

  • Save course_factory.py.
  • Test the new review stage in the active virtual environment by running:
python course_factory.py

What does this command test?

This reruns the planner before passing its validated result to the reviewer. The script waits for your brief answers in the Terminal.

  • Enter the same course topic that produced your current plan.
  • Enter the same target learner.
  • Enter the same observable outcome.
  • Enter the same total duration.

You should see VALIDATION: PASS followed by a quality score plus any review issues. You should also see a status line for quality_review.

Review stage did not finish?

Check that the review block sits inside the existing try: block. Its indentation should align with print_plan_summary(plan).

Confirm every occurrence of review uses the same lowercase spelling.

Ask for help with the exact traceback: help me debug the quality review stage

Add the human approval gate

The reviewer provides advice without taking control of the pipeline. The approval gate keeps lesson generation behind an explicit decision from you.

  • Return to the review block in course_factory.py.
  • Find the loop that prints each review issue.
  • Add the approval gate immediately below that loop by pasting:
        approval = input("\nGenerate the first lesson? [y/N]: ").strip().lower()
        if approval != "y":
            print("Stopped at the human approval gate.")
            return

How does the gate work?

Only the exact lowercase value y lets the pipeline continue. Calling .lower() also accepts an uppercase response.

Every other response returns from main() before lesson generation. The model review remains advisory while you control the next stage.

This test deliberately declines approval. It proves that the pipeline can stop after review without generating a lesson.

  • Save course_factory.py.
  • Run the pipeline again with the same brief by running:
python course_factory.py

What does this run prove?

The pipeline reaches the new approval prompt after validation plus review. Reusing your previous brief also gives the existing stages a chance to load their cached results.

  • Re-enter the same course topic.
  • Re-enter the same target learner.
  • Re-enter the same observable outcome.
  • Re-enter the same total duration.
  • Press Enter at the approval prompt to keep the default response.

You should see Stopped at the human approval gate. after the review. That result proves no lesson stage can run without your approval.

Pipeline skipped the approval prompt?

Make sure the approval block appears after the review issue loop. Keep it before the existing finally: line.

Check that return is indented inside the if approval != "y": block.

Ask for help checking the control flow: help me fix the human approval gate

Expand one lesson and prove the cache

Approval unlocks one focused generation stage. The pipeline selects the first lesson from the validated plan instead of asking the model to recreate the entire course.

Each stage stores a snapshot in artifacts/ plus a hashed result in artifacts/cache/. An unchanged stage payload maps to the same SHA-256 cache key.

  • Return to the approval block in course_factory.py.
  • Find the return inside the declined-approval branch.
  • Add the lesson stage below that branch by pasting:
        selected_lesson = plan["modules"][0]["lessons"][0]
        lesson_payload = {
            "plan": plan,
            "selected_lesson": selected_lesson,
        }
        lesson = run_stage(
            "first_lesson",
            lesson_payload,
            lambda: structured_call(
                client,
                lesson_prompt(plan, selected_lesson),
                LESSON_SCHEMA,
            ),
        )

        print(f"\nLESSON READY: {lesson['title']}")
        print(f"SUCCESS CHECK: {lesson['success_check']}")
        print(f"Artifacts saved in: {ARTIFACT_DIR.resolve()}")

What does the lesson stage do?

  • The selected_lesson lookup chooses the first lesson from the first module.
  • The lesson_payload includes the complete approved plan plus that selected lesson.
  • The first_lesson stage uses LESSON_SCHEMA to create a focused expansion.
  • The final messages expose the lesson title plus its success check.

Use a fresh brief for this complete run so every stage needs a new result. The script also pauses for your approval before it generates the lesson.

  • Save course_factory.py.
  • Run the complete pipeline by running:
python course_factory.py

What happens during this run?

The planner creates a plan before deterministic validation checks it. The reviewer then scores that plan before the approval prompt unlocks lesson generation.

Each completed stage writes a current snapshot plus a cache entry. The run log records whether that result came from Gemini or local storage.

  • Enter HTTP API fundamentals as the course topic.
  • Enter junior backend developers as the target learner.
  • Enter send and inspect an API request as the observable outcome.
  • Enter 60 as the total duration.
  • Enter y at the lesson approval prompt.

You should see AI CALL status lines for course_plan, quality_review, plus first_lesson. The run ends with LESSON READY plus the artifact directory path.

  • Return to the file sidebar in Visual Studio Code.
  • Expand the artifacts folder.
  • Confirm that course_plan.json is listed.
  • Confirm that quality_review.json is listed.
  • Confirm that first_lesson.json is listed.
  • Confirm that run_log.jsonl is listed.
  • Expand the cache folder to confirm it contains hashed JSON files.

Lesson artifact missing?

Confirm you entered y at the approval prompt. Any other response stops the pipeline before first_lesson.json is created.

Check that the lesson stage remains inside the existing try: block. Its indentation should align with the approval assignment.

Ask for help using the traceback: help me debug the first lesson stage

✔️ Awesome, I've got everything!

Your completed pipeline now plans, validates, reviews, waits for approval, expands one lesson, logs every stage, plus reuses cached results. Double check that you saved course_factory.py.

ⓧ I'd like to double check the full code

Compare your complete course_factory.py file with this reference:

from collections.abc import Callable
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
from typing import Any

from google import genai

MODEL_ID = "gemini-3.8-flash"
ARTIFACT_DIR = Path("artifacts")
CACHE_DIR = ARTIFACT_DIR / "cache"
RUN_LOG = ARTIFACT_DIR / "run_log.jsonl"

COURSE_SCHEMA: dict[str, Any] = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "title": {"type": "string", "description": "Specific course title."},
        "audience": {"type": "string", "description": "Intended learner."},
        "outcome": {"type": "string", "description": "Observable end result."},
        "prerequisites": {
            "type": "array",
            "items": {"type": "string"},
        },
        "modules": {
            "type": "array",
            "minItems": 2,
            "maxItems": 2,
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "id": {"type": "string"},
                    "title": {"type": "string"},
                    "outcome": {"type": "string"},
                    "duration_minutes": {"type": "integer", "minimum": 10},
                    "lessons": {
                        "type": "array",
                        "minItems": 2,
                        "maxItems": 2,
                        "items": {
                            "type": "object",
                            "additionalProperties": False,
                            "properties": {
                                "id": {"type": "string"},
                                "title": {"type": "string"},
                                "objective": {"type": "string"},
                                "activity": {"type": "string"},
                                "assessment": {"type": "string"},
                            },
                            "required": [
                                "id",
                                "title",
                                "objective",
                                "activity",
                                "assessment",
                            ],
                        },
                    },
                },
                "required": [
                    "id",
                    "title",
                    "outcome",
                    "duration_minutes",
                    "lessons",
                ],
            },
        },
    },
    "required": ["title", "audience", "outcome", "prerequisites", "modules"],
}

REVIEW_SCHEMA: dict[str, Any] = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "approved": {"type": "boolean"},
        "score": {"type": "integer", "minimum": 1, "maximum": 5},
        "strengths": {"type": "array", "items": {"type": "string"}},
        "issues": {"type": "array", "items": {"type": "string"}},
        "recommendations": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["approved", "score", "strengths", "issues", "recommendations"],
}

LESSON_SCHEMA: dict[str, Any] = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "title": {"type": "string"},
        "objective": {"type": "string"},
        "estimated_minutes": {"type": "integer", "minimum": 5, "maximum": 60},
        "explanation": {"type": "string"},
        "steps": {
            "type": "array",
            "minItems": 3,
            "maxItems": 5,
            "items": {"type": "string"},
        },
        "practice": {"type": "string"},
        "success_check": {"type": "string"},
    },
    "required": [
        "title",
        "objective",
        "estimated_minutes",
        "explanation",
        "steps",
        "practice",
        "success_check",
    ],
}


def write_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(data, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


def cache_path(stage: str, payload: dict[str, Any]) -> Path:
    cache_input = {
        "stage": stage,
        "model": MODEL_ID,
        "payload": payload,
    }
    canonical = json.dumps(
        cache_input,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    )
    digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    return CACHE_DIR / f"{stage}-{digest}.json"


def log_event(stage: str, status: str, cached_file: Path) -> None:
    ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
    event = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "stage": stage,
        "status": status,
        "cache_file": str(cached_file),
    }
    with RUN_LOG.open("a", encoding="utf-8") as log_file:
        log_file.write(json.dumps(event, ensure_ascii=False) + "\n")


def run_stage(
    stage: str,
    payload: dict[str, Any],
    producer: Callable[[], dict[str, Any]],
) -> dict[str, Any]:
    cached_file = cache_path(stage, payload)

    if cached_file.exists():
        result = json.loads(cached_file.read_text(encoding="utf-8"))
        status = "CACHE HIT"
    else:
        result = producer()
        write_json(cached_file, result)
        status = "AI CALL"

    write_json(ARTIFACT_DIR / f"{stage}.json", result)
    log_event(stage, status, cached_file)
    print(f"[{status}] {stage}")
    return result


def structured_call(
    client: genai.Client,
    prompt: str,
    schema: dict[str, Any],
) -> dict[str, Any]:
    interaction = client.interactions.create(
        model=MODEL_ID,
        input=prompt,
        response_format={
            "type": "text",
            "mime_type": "application/json",
            "schema": schema,
        },
    )
    if not interaction.output_text:
        raise RuntimeError("Gemini returned no text output.")
    return json.loads(interaction.output_text)


def validate_course(plan: dict[str, Any]) -> list[str]:
    errors: list[str] = []
    modules = plan.get("modules")

    if not isinstance(modules, list) or len(modules) != 2:
        return ["The plan must contain exactly two modules."]

    lesson_ids: set[str] = set()
    for module_index, module in enumerate(modules, start=1):
        lessons = module.get("lessons")
        if not isinstance(lessons, list) or len(lessons) != 2:
            errors.append(f"Module {module_index} must contain exactly two lessons.")
            continue

        for lesson_index, lesson in enumerate(lessons, start=1):
            label = f"Module {module_index}, lesson {lesson_index}"
            lesson_id = str(lesson.get("id", "")).strip()
            if not lesson_id:
                errors.append(f"{label} has no ID.")
            elif lesson_id in lesson_ids:
                errors.append(f"{label} repeats lesson ID {lesson_id}.")
            else:
                lesson_ids.add(lesson_id)

            for field in ("objective", "activity", "assessment"):
                if not str(lesson.get(field, "")).strip():
                    errors.append(f"{label} has an empty {field}.")

    return errors


def collect_brief() -> dict[str, Any]:
    topic = input("Course topic [Git basics]: ").strip() or "Git basics"
    audience = input("Target learner [complete beginner]: ").strip() or "complete beginner"
    outcome = (
        input("Observable outcome [publish a small repository]: ").strip()
        or "publish a small repository"
    )

    while True:
        raw_duration = input("Total duration in minutes [60]: ").strip() or "60"
        try:
            duration_minutes = int(raw_duration)
            if duration_minutes >= 40:
                break
        except ValueError:
            pass
        print("Enter a whole number of at least 40.")

    return {
        "topic": topic,
        "audience": audience,
        "outcome": outcome,
        "duration_minutes": duration_minutes,
    }


def planner_prompt(brief: dict[str, Any]) -> str:
    return f"""
You are the planning stage in an AI course factory.
Design a compact, project-based course from the learner brief below.
Create exactly two modules with exactly two lessons in each module.
Sequence prerequisites before dependent skills.
Give every lesson an observable objective, a hands-on activity, and an assessment.
Keep the combined module duration within the requested total duration.
Use stable IDs such as M1 and M1L1.

Learner brief:
{json.dumps(brief, indent=2)}
""".strip()


def reviewer_prompt(brief: dict[str, Any], plan: dict[str, Any]) -> str:
    return f"""
You are the quality-review stage in an AI course factory.
Review the course plan against the learner brief.
Score it from 1 to 5 for alignment, sequencing, practical work, measurable objectives,
and assessment coverage. Approve only if the plan is coherent and immediately teachable.
List concrete strengths, issues, and recommendations. Do not rewrite the plan.

Learner brief:
{json.dumps(brief, indent=2)}

Course plan:
{json.dumps(plan, indent=2)}
""".strip()


def lesson_prompt(plan: dict[str, Any], lesson: dict[str, Any]) -> str:
    return f"""
You are the lesson-generation stage in an AI course factory.
Expand only the selected lesson. Preserve its title and objective.
Write a concise explanation, three to five actionable steps, one practice task,
and one observable success check. Do not generate the rest of the course.

Approved course plan:
{json.dumps(plan, indent=2)}

Selected lesson:
{json.dumps(lesson, indent=2)}
""".strip()


def print_plan_summary(plan: dict[str, Any]) -> None:
    print(f"\nCOURSE: {plan['title']}")
    for module in plan["modules"]:
        print(f"- {module['id']}: {module['title']}")
        for lesson in module["lessons"]:
            print(f"  - {lesson['id']}: {lesson['title']}")


def main() -> None:
    ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
    brief = collect_brief()
    client = genai.Client()

    try:
        plan = run_stage(
            "course_plan",
            brief,
            lambda: structured_call(client, planner_prompt(brief), COURSE_SCHEMA),
        )

        validation_errors = validate_course(plan)
        if validation_errors:
            print("\nVALIDATION: FAIL")
            for error in validation_errors:
                print(f"- {error}")
            return

        print("\nVALIDATION: PASS")
        print_plan_summary(plan)

        review_payload = {"brief": brief, "plan": plan}
        review = run_stage(
            "quality_review",
            review_payload,
            lambda: structured_call(
                client,
                reviewer_prompt(brief, plan),
                REVIEW_SCHEMA,
            ),
        )

        print(f"\nQUALITY SCORE: {review['score']}/5")
        print(f"AI REVIEW APPROVED: {review['approved']}")
        for issue in review["issues"]:
            print(f"- Issue: {issue}")

        approval = input("\nGenerate the first lesson? [y/N]: ").strip().lower()
        if approval != "y":
            print("Stopped at the human approval gate.")
            return

        selected_lesson = plan["modules"][0]["lessons"][0]
        lesson_payload = {
            "plan": plan,
            "selected_lesson": selected_lesson,
        }
        lesson = run_stage(
            "first_lesson",
            lesson_payload,
            lambda: structured_call(
                client,
                lesson_prompt(plan, selected_lesson),
                LESSON_SCHEMA,
            ),
        )

        print(f"\nLESSON READY: {lesson['title']}")
        print(f"SUCCESS CHECK: {lesson['success_check']}")
        print(f"Artifacts saved in: {ARTIFACT_DIR.resolve()}")
    finally:
        client.close()


if __name__ == "__main__":
    main()

Your first complete run created every stage artifact. The final check proves that identical inputs skip repeated model work.

Before you run it, predict which stages will need Gemini when every answer stays unchanged.

  • Return to the active virtual environment Terminal.
  • Run the complete pipeline again by running:
python course_factory.py

What is this run checking?

The same brief produces the same canonical payloads plus cache keys. The script still asks for each input because those answers determine which cache entries it can reuse.

  • Enter HTTP API fundamentals as the course topic.
  • Enter junior backend developers as the target learner.
  • Enter send and inspect an API request as the observable outcome.
  • Enter 60 as the total duration.
  • Enter y at the lesson approval prompt.

You should now see [CACHE HIT] course_plan, [CACHE HIT] quality_review, plus [CACHE HIT] first_lesson. The lesson result remains available without another model request.

  • Return to Visual Studio Code.
  • Open artifacts/run_log.jsonl from the file sidebar.
  • Confirm that recent entries contain the three stage names.
  • Confirm that the recent status values show CACHE HIT.
  • Confirm that each entry contains a UTC timestamp plus its cache file path.

Still seeing model calls?

A changed letter or space in any brief answer creates a different payload. Re-enter every answer exactly as shown in the first complete run.

Keep the duration as the integer 60. A different duration also changes the cache key.

Ask for help comparing the cache inputs: help me explain this cache miss

You have turned one validated planner into an observable course factory. Every stage now has a contract, a saved artifact, a human checkpoint, plus a traceable cache decision.

Secret mission

Add an Automatic Revision Stage

Your quality review already identifies problems in a course plan. In this Secret Mission, you will close the feedback loop by generating a revised plan that reuses the existing schema, validator, cache, artifacts, and run log.

Clean Up Your Resources

Clean Up Your Resources

Your local files have no ongoing cost, while the Gemini Developer API requests used here are free of charge on the Free Tier. Choose a tab based on whether you want to keep working, pause the active session, or remove the project.

Resources you used:

  • The ~/Desktop/ai-course-factory folder containing your Python files, virtual environment, cache, logs, and generated JSON artifacts.
  • The active .venv Terminal session.
  • The exported GEMINI_API_KEY value in the current Terminal session.
  • The Gemini API key managed through the Google AI Studio API Keys page.

Keep everything running

No action is needed. Choose this option if you want to keep experimenting with the course factory.

  • Leave the active .venv Terminal session available for more runs.
  • Keep ~/Desktop/ai-course-factory to preserve your source files, cached responses, generated artifacts, and run history.
  • Keep the Gemini API key active for future requests from this project.
  • Enter only non-sensitive course ideas because Free Tier content is used to improve Google's products.

Pause - I'll come back to this later

Pausing frees the current Terminal session while preserving every project file. Your Gemini API key remains available in Google AI Studio for later use.

  • End the virtual environment and remove the exported key from the current Terminal session by running:
deactivate
unset GEMINI_API_KEY

What does this cleanup do?

  • The deactivate command leaves the project virtual environment.
  • The second command removes GEMINI_API_KEY from the current Terminal session without deleting the key from Google AI Studio.
  • Keep the ~/Desktop/ai-course-factory folder on your Desktop.
  • Prepare to replace YOUR_API_KEY with your actual Gemini API key when you return.
  • Restart the project session later by running these commands:
cd ~/Desktop/ai-course-factory
source .venv/bin/activate
export GEMINI_API_KEY="YOUR_API_KEY"

How does this restore the session?

  • The first command returns your Terminal to the ai-course-factory folder.
  • The source command activates the existing virtual environment.
  • The final command makes your Gemini API key available to the SDK in that Terminal session.

Delete - I don't want to use this again

Deletion is permanent, so this option fits when you are sure the course factory is no longer needed. The local cleanup removes the source files, virtual environment, cache, logs, and generated artifacts.

  • Close the ai-course-factory window in Visual Studio Code.
  • Remove the active virtual environment and exported key from the current Terminal session by running:
deactivate
unset GEMINI_API_KEY

What does this clear?

  • The first command exits the virtual environment before its folder is removed.
  • The second command clears GEMINI_API_KEY from the current Terminal session.
  • Delete the complete ai-course-factory folder from your Desktop by running:
cd ~/Desktop
rm -rf ai-course-factory

What does this remove?

The first command moves the Terminal outside the project folder. The rm command then removes the folder containing your code, .venv, cache entries, generated artifacts, and run log.

  • Confirm the local deletion by selecting Desktop in the Finder sidebar.

You should no longer see the ai-course-factory folder on your Desktop.

  • Return to the Google AI Studio API Keys page from earlier.
  • Select the Gemini API key used for this project.
  • Delete or revoke the selected key.

You should see that the selected key is deleted or inactive. It can no longer authorize Gemini API requests.

Nice Work!

Nice Work!

You made it! Your observable course factory now uses AI orchestration to turn a learner brief into a staged course with inspectable artifacts at every boundary.

You've learned how to:

  • Build a stable two-module course plan with JSON Schema. Apply deterministic validation before later stages can use it.
  • Run planning and quality review as separate stages. Use human-in-the-loop approval to control when lesson expansion begins.
  • Save each stage as a separate JSON artifact. Trace every model request through a JSON Lines run log. Reuse unchanged results through a local cache.
  • Secret Mission: Build an automatic revision stage that turns quality-review findings into revised_course_plan.json. Reuse the existing schema, validator, cache, and logs to confirm REVISION VALIDATION: PASS.

Ready to quiz yourself?