> Source URL: /unit-3/project-paths/duward-a/duward-a-2026-04-18.guide
# Duward's Project Guide

**Project:** GPT Workout Planner
**Category:** AI / CLI
**Last updated:** April 18

---

> Note: This guide reflects the latest state of your project repo. It may not match the most up-to-date version if you've worked since.

## Where You Are

Solid Checkpoint 1. You have a working CLI: ask for a goal, OpenAI returns a workout plan. Spec is clear, journal has a real entry, `uv` is set up.

The work this week turns "AI returns something generic" into "AI picks from a list I control, and I remember what it gave me last time." This is also when the project starts showing you what's *yours* vs what's library code.

---

## Project Structure

Your project splits into two kinds of code:

- **Business logic — you handwrite this.** The workout categories, how they're loaded, what goes in the prompt, how memory is stored, what counts as valid input. This is what makes your planner different from a plain ChatGPT session.
- **Library / view code — agent-assisted is fine.** The OpenAI API call itself, `.env` setup with `python-dotenv`, the CLI print formatting. Library code that's the same for any project.

Target layout by Thursday:

```
gpt-workout-planner/
├── main.py                 ← CLI driver + OpenAI call — agent-assisted OK
├── planner.py              ← business logic — handwrite (yours to own)
├── pyproject.toml
├── workouts/
│   ├── categories.txt      ← data (new)
│   └── history.txt         ← data (new)
└── .env                    ← secrets (not committed)
```

Why the split? From [Lecture 1: The MVP](../../lectures/01-the-mvp/01-the-mvp.lecture.md) — on demo day you'll be asked how your planner decides what to suggest. "The AI picks" is a weak answer. "I load these workouts, include them in the prompt, and constrain the AI to pick from the list" is the real answer — and it lives in `planner.py`.

**`planner.py` should not import `openai`.** That import belongs in `main.py`.

---

## Phase 1: Create `planner.py` + Workout Categories File

> **Handwrite this yourself.** The workout list IS your project — what exercises you decide are safe, beginner-friendly, and in scope. And `load_workouts` is your first handwritten business function.

### Objective

Move the idea of "what workouts exist" out of the model's head and into your data file, loaded by your code.

### Instructions

- [ ] Create `planner.py` at the project root (new file, will hold your business logic)
- [ ] Create `workouts/categories.txt` with 8–12 beginner-safe workouts (at least 2 per category: warm-up, main, cooldown)
- [ ] In `planner.py`, write `load_workouts(filename)` that reads the file and returns a list of dicts
- [ ] Also in `planner.py`, write `build_prompt(goal, workouts)` that produces a prompt string including the workouts

### Sample Data

`workouts/categories.txt` (one per line: `category, name, difficulty`):

```
warm-up, Jumping Jacks, easy
warm-up, Arm Circles, easy
main, Bodyweight Squats, medium
main, Push-Ups, medium
main, Dumbbell Rows, medium
cooldown, Standing Hamstring Stretch, easy
cooldown, Child's Pose, easy
```

### Hints

**`load_workouts` pattern:**

```python
def load_workouts(filename):
    workouts = []
    with open(filename, "r") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            category, name, difficulty = [p.strip() for p in line.split(",")]
            workouts.append({
                "category": category,
                "name": name,
                "difficulty": difficulty,
            })
    return workouts
```

**`build_prompt` pattern — the key is that the AI can ONLY pick from your list:**

```python
def build_prompt(goal, workouts):
    workout_list = "\n".join(f"- {w['name']} ({w['category']})" for w in workouts)
    return (
        "Create a beginner-friendly workout plan.\n"
        f"Goal: {goal}\n"
        "Use ONLY these workouts:\n"
        f"{workout_list}\n"
        "Structure: warm-up, main, cooldown. Keep it safe for beginners."
    )
```

> **Optional — get help from your agent:**
>
> ```text
> Review my load_workouts function in planner.py. Walk me through
> how dict(zip(header, parts)) style parsing would work as an
> alternative. Don't change my code — I want to understand the
> tradeoff.
> ```

---

## Phase 2: Memory — Save and Load Past Workouts

> **Handwrite this yourself.** Appending to a history file, reading the last entry — small, but it IS your project's memory model. Type it out.

### Objective

After each plan is generated, save it to `workouts/history.txt`. On startup, offer to show the last one.

### Instructions

- [ ] Create `workouts/history.txt` (can start empty)
- [ ] In `planner.py`, write `save_workout(goal, plan_text)` that appends goal + date + plan
- [ ] In `planner.py`, write `load_last_workout()` that returns the most recent entry, or `None` if empty
- [ ] In `main()`, before asking for a new goal, ask "Show your last workout? (y/n)"
- [ ] After generating a new plan, call `save_workout()`

### Sample Output

```
=== GPT Workout Planner ===
Show your last workout? (y/n): y

--- Last workout (2026-04-17 | goal: build muscle) ---
... the plan ...

What is your workout goal today?
```

### Hints

**Appending with a timestamp:**

```python
from datetime import date

def save_workout(goal, plan_text):
    with open("workouts/history.txt", "a") as f:
        f.write(f"\n--- {date.today()} | goal: {goal} ---\n")
        f.write(plan_text + "\n")
```

**Reading the last entry:**

```python
def load_last_workout():
    try:
        with open("workouts/history.txt", "r") as f:
            text = f.read().strip()
        if not text:
            return None
        entries = text.split("\n---")
        return "---" + entries[-1]
    except FileNotFoundError:
        return None
```

---

## Phase 3: Validate User Input

> **Handwrite this yourself.** Deciding what's a valid goal is product design, not library code.

### Objective

If the user types nothing or `"asdf"` as a goal, don't waste an API call.

### Instructions

- [ ] In `planner.py`, write `is_valid_goal(goal)` that returns `True` or `False`
- [ ] In `main()`, after reading the goal, call `is_valid_goal` and print a message if it's bad
- [ ] Reject: empty, shorter than 3 characters, all digits

### Hints

**Simple validator:**

```python
def is_valid_goal(goal):
    goal = goal.strip()
    if not goal:
        return False
    if len(goal) < 3:
        return False
    if goal.isdigit():
        return False
    return True
```

**In `main()`:**

```python
goal = input("What is your workout goal today? ").strip()
if not is_valid_goal(goal):
    print("Please provide a real goal (example: build muscle, lose weight).")
    return
```

---

## Phase 4: Wire It All Together in `main.py`

> **Agent-assisted is fine here.** `main.py` is the CLI driver: prompts the user, calls OpenAI (library), prints results. The interesting code is already in `planner.py`.

### Objective

Thin `main.py` that orchestrates: load workouts → ask goal → validate → call AI with your custom prompt → save to history → print.

### Instructions

- [ ] Update `main.py` to import from `planner.py`
- [ ] The OpenAI call itself can stay in `main.py` (or go in its own function there)
- [ ] All business logic calls come from `planner.py`

### Hints

**Skeleton:**

```python
from openai import OpenAI
from planner import load_workouts, build_prompt, save_workout, load_last_workout, is_valid_goal

def generate_workout(prompt):
    client = OpenAI()   # reads OPENAI_API_KEY from env
    response = client.responses.create(model="gpt-4.1-mini", input=prompt)
    return response.output_text.strip()

def main():
    print("=== GPT Workout Planner ===")

    # offer to show last
    show_last = input("Show your last workout? (y/n): ").strip().lower()
    if show_last == "y":
        last = load_last_workout()
        print(last if last else "(no history yet)")

    # new goal
    goal = input("\nWhat is your workout goal today? ").strip()
    if not is_valid_goal(goal):
        print("Please provide a real goal.")
        return

    # build prompt from YOUR workouts and call the AI
    workouts = load_workouts("workouts/categories.txt")
    prompt = build_prompt(goal, workouts)
    plan = generate_workout(prompt)

    print("\nYour Workout Plan:\n")
    print(plan)
    save_workout(goal, plan)

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

**Read what you get back.** The agent can scaffold this wiring, but you should be able to point at every line and say what it does.

> **Optional — get help from your agent:**
>
> ```text
> Help me wire up main.py to use the functions in planner.py:
> load_workouts, build_prompt, save_workout, load_last_workout,
> is_valid_goal. Keep main.py thin — the OpenAI call is the only
> real thing it does. Show me the final file.
> ```

---

## Phase 5 (Optional): Move the API Key to `.env`

> **Agent-assisted is fine here.** `python-dotenv` setup is library code

Not urgent (it's the shared class key in a private repo), but good hygiene for any project you'll show off later.

### Instructions

- [ ] Run `uv add python-dotenv`
- [ ] Create `.env` with `OPENAI_API_KEY=sk-...`
- [ ] Add `.env` to `.gitignore`
- [ ] At the top of `main.py`, add `from dotenv import load_dotenv; load_dotenv()`

### Hints

```python
# at the top of main.py, before the OpenAI import usage
from dotenv import load_dotenv
load_dotenv()   # reads .env into os.environ
```

Then `OpenAI()` with no arguments picks up `OPENAI_API_KEY` automatically.

> **Optional — get help from your agent:**
>
> ```text
> Walk me through moving my OpenAI key from main.py to .env using
> python-dotenv, and adding .env to .gitignore. Show me the exact diff.
> ```

---

## Checkpoint 2 Readiness

By Thursday April 23 at 3pm:

- [ ] `planner.py` exists with `load_workouts`, `build_prompt`, `save_workout`, `load_last_workout`, `is_valid_goal`
- [ ] `planner.py` does **not** import `openai`
- [ ] `workouts/categories.txt` has 8+ workouts
- [ ] Startup offers to show the last workout
- [ ] Past workouts saved to `workouts/history.txt`
- [ ] CLI handles empty / nonsense input
- [ ] Checkpoint 2 entry in `project.journal.md`
- [ ] Committed and pushed

## Helpful Resources

- [Checkpoint 2 Instructions](../../projects/final-project-checkpoint-2.project.md)
- [Lecture 1: The MVP](../../lectures/01-the-mvp/01-the-mvp.lecture.md)
- [python-dotenv docs](https://pypi.org/project/python-dotenv/) — for Phase 5


---

## Backlinks

The following sources link to this document:

- [April 18 -- Checkpoint 2 (Working MVP)](/unit-3/project-paths/duward-a/duward-a.path.llm.md)
