> Source URL: /unit-3/project-paths/makayla-c/makayla-c-2026-04-21.guide
# Makayla's Project Guide

**Project:** CLP Curator
**Category:** Web App (Flask) + LLM Agent
**Last updated:** April 21

---

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

## Where You Are

Big pivot from last week, and it's a good one. We scaffolded a new shape for the project in office hours today:

- **`data_scraper.py`** already pulls the CLP feed from Trumba's Atom XML and writes a real `data/clp_events.csv`. That's 16+ real events with titles, dates, topics, and descriptions. **You're done with scraping.** This is library code — you don't need to touch it again unless the feed changes.
- **`clps.py`** is your new business-logic module. It has two stub functions with the TODO comments I left you: `get_clp_events()` (load the CSV) and `recommend_clps(events, interests)` (build a prompt → call the LLM → reshape the response).
- **`ai.py`** is where Victor lives. It has an OpenAI client, a Pydantic `VictorResponse` schema (so the LLM has to return structured data, not JSON-ish strings), and a `get_victor_response(prompt)` function. There's also a quick `__main__` smoke test at the bottom so you can run it standalone.
- **`app.py`** is wired to the new functions but the template shape doesn't quite match yet — we'll fix that in Phase 2.

The recommender is no longer a keyword-matcher. It's now **Victor Paladin** — the Furman Paladins mascot, reimagined as a personified CLP concierge who asks what you're into and recommends events with his own commentary. That's a much more interesting demo-day story, and it gives you two real authorship hooks: the **prompt-engineering work in `ai.py`** (giving Victor a voice) and the **`clps.py` logic** that glues the CSV, the prompt, and the LLM response together.

**Make Victor yours.** His voice, his jokes, the color palette of the UI — all of that is creative territory. The guide below sets the shape; you pick the vibe.

---

## UX flow

```mermaid
flowchart LR
  Home["Home /<br/>Victor greets you<br/>open textarea: tell me your interests"]
  Results["Results /results<br/>Victor's message + 1-3 event cards<br/>with topic pills and commentary"]
  Home -->|"POST interests"| Results
  Results -->|"Ask Victor again"| Home
```

Three screens, one loop. Home is where Victor introduces himself and asks for your interests; results is where Victor comments on the events that match.

---

## Project Structure

Your project splits into two kinds of code:

- **Business logic — you handwrite this.** `clps.py` (prompt building + reshaping the LLM response into the rendered data structure) and the Victor system prompt in `ai.py` (prompt engineering IS authorship). These are the pieces that make "CLP Curator" different from "a chatbot with a CSV stapled to it."
- **Library / view code — agent-assisted is fine.** `app.py` routes, `templates/*.html` chat UI, CSS for topic colors, `data_scraper.py` (already done). Standard Flask patterns you'd write for any app.

Target layout by Thursday:

```
final-project-makaylacarnahan/
├── app.py                  ← Flask routes — agent-assisted OK
├── clps.py                 ← business logic — handwrite (yours to own)
├── ai.py                   ← OpenAI client + Victor persona — handwrite the prompt
├── data_scraper.py         ← Atom feed scraper — already done
├── pyproject.toml
├── templates/
│   ├── base.html           ← layout with Victor's strip
│   ├── home.html           ← Victor's greeting + interest input
│   └── results.html        ← Victor's commentary + event cards
├── static/
│   └── style.css
└── data/
    └── clp_events.csv
```

**`clps.py` should not import `flask` or `openai` directly.** It talks to `ai.get_victor_response(prompt)` only. That keeps the modules composable — if you ever swap OpenAI for Anthropic, you only touch `ai.py`.

---

## Phase 1: Implement `get_clp_events()` in `clps.py`

> **Handwrite this yourself.** It's small, but it's the doorway into the rest of the app.

### Objective

Read `data/clp_events.csv` and return a list of dicts — one dict per event, with every CSV column as a key.

### Instructions

- [ ] Open `clps.py`
- [ ] Implement `get_clp_events()` to return a list of dicts from `data/clp_events.csv`
- [ ] Test it in a REPL (or a scratch `test.py`) — confirm you got ~16 events and that each has `id`, `title`, `topics`, `description`, etc.

### Hints

**The shape of the approach:**

- `import csv` at the top
- use `csv.DictReader(f)` to return a list of dictionaries based keyed by the CSV columns. See [here](../../resources/csvs.guide.md) for how that works.

Test your function under a `if __name__ == "__main__":` guard:

```python
if __name__ == "__main__":
    events = get_clp_events()
    print(events)
```

> **Optional — get help from your agent:**
>
> Skip. This is a 5-line function and the point is to feel comfortable with `csv.DictReader`. If you get stuck for more than 15 minutes, then ask — but try first.

---

## Phase 2: Implement `recommend_clps(events, interests)` in `clps.py`

> **Handwrite this yourself.** This is the heart of your project.

### Objective

Turn a list of events and a user's interest text into a Victor response: a greeting message plus 1-3 recommended events with Victor's commentary on each.

You'll do this in three parts (matching the TODO comments in `clps.py`).

### Part 1 — Build the prompt

You need to hand the LLM two things:

1. A compact list of every available event (so it can pick from what's actually happening).
2. The user's interests.

**A good prompt format for this kind of task:**

```text
events:
<id>\t<title>\t<topics>\t<short description>
<id>\t<title>\t<topics>\t<short description>
...

user_input:
> <whatever the user typed>
```

Loop through `events`, build one line per event (tab-separated or comma-separated is fine — pick one and be consistent), join them with `\n`, then append the user's interests at the bottom with a clear `user_input:` label.

**Why include the `id`?** Because in Part 3 you're going to look up the full event row by `id`. The LLM only needs to say "pick event 1390694770" — you do the lookup yourself.

### Part 2 — Call the LLM

You already have the function — import and call it:

```python
from ai import get_victor_response

response = get_victor_response(prompt)
```

That's it. The function returns a parsed `VictorResponse` object (thanks to the Pydantic schema in `ai.py`). No JSON parsing, no string wrangling — `response.message` is a string, `response.events` is a list of `CLPEvent` objects with `.id`, `.topics`, and `.commentary`.

**Why the Pydantic schema matters.** LLMs love to return JSON that's _almost_ valid — trailing commas, markdown code fences around the JSON, extra prose before or after. `client.responses.parse(..., text_format=VictorResponse)` makes the API guarantee the output matches the schema. Less code for you to babysit.

### Part 3 — Reshape the response

`response.events` only has `id`, `topics`, and `commentary`. Your template needs to show title, date, location, description — so you need to **look up each recommended event back in the CSV data** using its `id`.

**The cheap lookup trick:**

This is a one-liner that converts a list of events into a dictionary of events keyed by id:

```python
events_by_id = {e["id"]: e for e in events}
```

Now `events_by_id[victor_event.id]` gives you the full row with title, date, everything.

Build the return value as a list of dicts shaped like what your template will render:

```python
[
  {
    "id": "...",
    "title": "...",         # from the CSV
    "date": "...",          # from the CSV
    "location": "...",      # from the CSV
    "topics": [...],        # from Victor
    "summary": "..."        # from Victor's commentary
  },
  ...
]
```

Don't lose `response.message`, that's Victor's top-level greeting, and the template needs it.

- [ ] **Return a dict:** `return {"message": response.message, "events": recommendations}`

### Edge cases to think about

- **Interests comes in as a raw string** (e.g. `"politics, music, art"`). You can pass it straight to the LLM — it's good at parsing loose human text. Splitting on commas is optional.
- **Empty interests.** What should Victor say if the user submits nothing? You could short-circuit and return a default greeting, or let the LLM handle it (it probably will, but test it).
- **The LLM returns an `id` that isn't in your CSV.** Rare, but it happens — hallucinated IDs. Decide: skip it, or keep Victor's commentary with just the title. (Skip is safer.)

### Instructions

- [ ] Implement `recommend_clps(events, interests)` in three steps matching Parts 1-3 above
- [ ] Fix in passing: in `app.py`, rename `reccomendations` → `recommendations`
- [ ] Update `templates/home.html` (or rename to `results.html`) to match whatever return shape you chose — Victor's `message` at the top, then the event list
- [ ] Test end-to-end: `uv run flask --app app run --debug`, open http://127.0.0.1:5000, type "I'm into music and activism", submit

### Hints

**A shape of the function body (fill in the `...` parts):**

```python
from ai import get_victor_response


def recommend_clps(events, interests):
    # Part 1: build the prompt
    event_lines = []
    for e in events:
        ...  # one line per event: id, title, topics, short description
    prompt = "events:\n" + "\n".join(event_lines) + f"\n\nuser_input:\n> {interests}"

    # Part 2: call Victor
    response = get_victor_response(prompt)

    # Part 3: reshape — look up each recommended event by id
    events_by_id = {e["id"]: e for e in events}
    recommendations = []
    for v in response.events:
        ...  # build a dict with data from the CSV + commentary from Victor
    return {"message": response.message, "events": recommendations}
```

> **Optional — get help from your agent** (use AFTER you've written a first version yourself):
>
> ```text
> Walk me through my recommend_clps function. For an interests string
> of "politics, music", what does the prompt look like that I'm sending
> to Victor, and what shape does it return to the template? Don't
> change my code — I want to verify my mental model.
> ```

---

## Phase 3: Give Victor a personality in `ai.py`

> **Write this yourself.** The system prompt IS Victor. This is the prompt engineering part of your project.

### Objective

Iterate the system prompt in `ai.py` until Victor sounds like _a character_.

Right now the prompt is one line:

```
You are Victor Paladin a helpful Furman agent... you help students find CLP events that match their interests
```

That's a starting point, not Victor. Let's give him a voice.

### Instructions

- [ ] Rewrite the system prompt in `ai.py` with the four elements below
- [ ] Run `uv run python ai.py` to hit the `__main__` to test, read the response out loud
- [ ] Change the hardcoded `user_input` at the bottom of `ai.py` to 3-5 different test inputs (one at a time) and re-run
- [ ] Keep iterating the system prompt until Victor sounds consistent, specific, and fun
- [ ] Commit with a message like `victor personality v1`

### The four elements of a good persona prompt

1. **Identity** — Who is Victor? (Furman Paladins mascot, a knightly CLP concierge on campus.)
2. **Voice** — Pick a lane and commit. Earnest and enthusiastic? Dry and witty? Slightly theatrical with medieval-flavored puns? You choose — but be specific.
3. **Format rules** — `message` is a short greeting that acknowledges the student's interests in Victor's voice; each `commentary` is ~2 sentences connecting that specific event back to what they said they liked.
4. **One or two examples (few-shot)** — Write out what a great reply _looks like_ for a sample interest. One example is worth five adjectives.

### Prompt engineering cheat sheet

- **Specific beats clever.** "Open with a one-line knightly hello that references the campus or the season" beats "Be cool."
- **Show, don't tell.** An example reply baked into the system prompt anchors the model more than a paragraph of tone guidance.
- **Constrain output.** The Pydantic schema already forces structure. Use the prompt to enforce _tone_ — length, formality, whether Victor is allowed to make jokes.
- **Test boring inputs too.** "I don't know what I like" is a harder prompt than "I love opera" — make sure Victor handles both.

### Test inputs to try

Swap these into the `user_input` at the bottom of `ai.py` one at a time:

- `"I'm into music and activism"`
- `"something chill I can do Friday night"`
- `"I hate politics"`
- `"idk surprise me"`
- `"I only like stuff with free food"`

If every response sounds the same, your prompt is too vague — add more voice. If responses are hilarious but Victor ignores the actual interests, tighten the format rules.

> **Optional — get help from your agent** (AFTER you have a v1 prompt you like):
>
> ```text
> Here's my current system prompt for Victor Paladin and three test
> inputs + their responses. Suggest 2 specific tweaks to the system
> prompt to make Victor sound more like [insert your chosen vibe —
> "a slightly theatrical knight with a heart of gold", "a dry sarcastic
> upperclassman", whatever]. Don't change the Pydantic schema or the
> response shape.
> ```

---

## Phase 4: Build the chat/agent UI

> **Agent-assisted OK here** since this is library/glue code.

### Objective

A home page that feels like you're chatting with Victor, and a results page where Victor comments on each recommended CLP with colored topic pills.

### Instructions

- [ ] Rewrite `templates/base.html` as a proper layout with a Victor avatar/name strip at the top and a `{% block content %}`
- [ ] Home: Victor's greeting speech bubble + a big textarea ("Tell Victor what you're into...") + a "Send to Victor" button
- [ ] Results: Victor's top-level `message` in a speech bubble + a vertical stack of event cards showing title, date, location, topic pills, and Victor's commentary. Add an "Ask Victor again" link back to `/`
- [ ] Pick a color palette keyed by topic — at minimum: Fine Arts → violet, Civic Education → blue, Diversity Equity & Inclusion → amber, Social Justice → rose, World Cultures → emerald, everything else → slate
- [ ] Use [Tailwind via CDN](https://tailwindcss.com/docs/installation/play-cdn) (one `<script>` tag in `base.html`) or stick with `static/style.css` — your call

### Hints

**Topic pill pattern** (drive colors from a class name derived from the topic):

```html
<article class="card">
  <header>
    <h3>{{ rec.title }}</h3>
    <div class="pills">
      {% for t in rec.topics %}
      <span class="pill pill-{{ t|lower|replace(' ', '-') }}">{{ t }}</span>
      {% endfor %}
    </div>
  </header>
  <p class="victor-says">{{ rec.summary }}</p>
</article>
```

Then in CSS (or Tailwind `@apply`):

```css
.pill {
  padding: 0.2rem 0.6rem;
  border-radius: 999px;
  font-size: 0.8rem;
}
.pill-fine-arts {
  background: #ede9fe;
  color: #5b21b6;
}
.pill-civic-education {
  background: #dbeafe;
  color: #1e40af;
}
.pill-diversity-equity-inclusion {
  background: #fef3c7;
  color: #92400e;
}
.pill-social-justice {
  background: #ffe4e6;
  color: #9f1239;
}
.pill-world-cultures {
  background: #d1fae5;
  color: #065f46;
}
```

**Victor's speech bubble** — keep it simple: a rounded card with a left border in a signature "Victor" color (the Furman purple #582C83 works), with a little avatar on the top-left.

**Why color-code topics?** Students scan, not read. If "Fine Arts" is always violet, they recognize the category before reading the title. It also makes the results page feel alive instead of a wall of grey cards.

> **Optional — get help from your agent:**
>
> ```text
> Here's my results.html rendering Victor's message + a list of event
> dicts with {title, date, location, topics, summary}. Style it as a
> chat transcript with Victor's avatar on the left of his bubble, and
> event cards that use colored pills for each topic (I've started the
> palette in style.css). Keep my Jinja loops and data keys exactly the
> same.
> ```

---

## Phase 5: Spec, journal, README, commit

> **Handwrite the journal.** This is what I use to write next week's guide, and what you'll say on demo day.

### Objective

Bring the spec and journal up to date with the LLM pivot, and ship everything.

### Instructions

- [ ] Update `project.spec.md`: rewrite the description + MVP list to reflect the agent-based version. "Keyword extraction" and "pandas + scikit-learn" come out; "LLM with structured output (Pydantic)" and "Victor persona" go in. Move old ideas (iCal export, time filtering) to stretch.
- [ ] Fill the **Checkpoint 2** section of `project.journal.md` with:
  - What you handwrote (`clps.py` prompt + reshape, Victor's system prompt in `ai.py`)
  - What was agent-assisted (UI templates, CSS palette, scraper)
  - The pivot from keyword-matching to LLM — in your own words, why
  - Anything still rough
- [ ] Quick README pass: one paragraph on what CLP Curator is, plus how to run it (`uv run flask --app app run --debug`)
- [ ] Commit and push

### Hints

**Commit message ideas:**

```
checkpoint 2: victor paladin agent + chat UI
clps.py: prompt + reshape; ai.py: victor v1 persona
```

---

## Checkpoint 2 Readiness

By Thursday April 23 at 3pm:

- [ ] `clps.py` `get_clp_events()` reads the CSV and returns a list of dicts
- [ ] `clps.py` `recommend_clps(events, interests)` builds a prompt, calls Victor, reshapes the response
- [ ] `clps.py` does **not** import `flask` or `openai` (only `csv` and `ai`)
- [ ] `ai.py` has a Victor persona system prompt with identity + voice + format rules + at least one example
- [ ] `app.py` + templates render Victor's message and 1-3 event cards end-to-end
- [ ] Chat-style UI with a topic color palette (at least 3 topics color-coded)
- [ ] `reccomendations` typo fixed
- [ ] `project.spec.md` updated for the LLM pivot
- [ ] 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)
- [Flask Setup Guide](../../resources/flask-setup.guide.md)
- [REPL Guide](../../../resources/REPL.guide.md)
- [OpenAI Structured Outputs (Responses API + Pydantic)](https://platform.openai.com/docs/guides/structured-outputs)
- [OpenAI prompt engineering guide](https://platform.openai.com/docs/guides/prompt-engineering)
- [Tailwind Play CDN](https://tailwindcss.com/docs/installation/play-cdn)


---

## Backlinks

The following sources link to this document:

- [makayla-c's April 21 guide](/unit-3/project-paths/nate-m/nate-m-2026-04-23.guide.llm.md)
- [April 21 -- Victor Paladin agent pivot](/unit-3/project-paths/makayla-c/makayla-c.path.llm.md)
