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.pyalready pulls the CLP feed from Trumba's Atom XML and writes a realdata/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.pyis your new business-logic module. It has two stub functions with the TODO comments I left you:get_clp_events()(load the CSV) andrecommend_clps(events, interests)(build a prompt → call the LLM → reshape the response).ai.pyis where Victor lives. It has an OpenAI client, a PydanticVictorResponseschema (so the LLM has to return structured data, not JSON-ish strings), and aget_victor_response(prompt)function. There's also a quick__main__smoke test at the bottom so you can run it standalone.app.pyis 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
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 inai.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.pyroutes,templates/*.htmlchat 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
Hints
The shape of the approach:
import csvat the top- use
csv.DictReader(f)to return a list of dictionaries based keyed by the CSV columns. See here for how that works.
Test your function under a if __name__ == "__main__": guard:
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:
- A compact list of every available event (so it can pick from what's actually happening).
- The user's interests.
A good prompt format for this kind of task:
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:
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:
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:
[
{
"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.
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
idthat 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
Hints
A shape of the function body (fill in the ... parts):
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):
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
The four elements of a good persona prompt
- Identity — Who is Victor? (Furman Paladins mascot, a knightly CLP concierge on campus.)
- Voice — Pick a lane and commit. Earnest and enthusiastic? Dry and witty? Slightly theatrical with medieval-flavored puns? You choose — but be specific.
- Format rules —
messageis a short greeting that acknowledges the student's interests in Victor's voice; eachcommentaryis ~2 sentences connecting that specific event back to what they said they liked. - 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):
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
Hints
Topic pill pattern (drive colors from a class name derived from the topic):
<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):
.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:
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
- What you handwrote (
clps.pyprompt + reshape, Victor's system prompt inai.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
- What you handwrote (
Hints
Commit message ideas:
checkpoint 2: victor paladin agent + chat UI
clps.py: prompt + reshape; ai.py: victor v1 persona