Built on top of VibeFinder CLI, a content-based music recommender that ranked songs from a local CSV catalog using genre, mood, and energy preferences. VibeFinder established the core scoring and explanation logic, and tested the idea that a small set of audio features could produce meaningful, explainable recommendations. MoodMatch extends that foundation into a full AI-powered application with natural-language input, live Spotify search, and an interactive feedback loop.
MoodMatch lets a user describe what they want to hear in plain English β "chill lofi beats for studying" or "surprise me with something upbeat" β and returns five ranked song recommendations with explanations for each match. It demonstrates a complete AI pipeline: natural-language understanding, external data retrieval, intelligent ranking, and user feedback that improves future results.
The project shows how the core techniques behind commercial music platforms (preference modeling, retrieval-augmented generation, iterative refinement) can be implemented with accessible tools and a small codebase.
User Prompt (natural language)
β
βΌ
βββββββββββββββββββββββ
β Preference β OpenAI (gpt-4.1-mini) extracts genre, mood,
β Extractor β energy, activity context, acoustic preference,
β β and is_music_request guardrail flag.
β Keyword fallback β If no API key, regex-style keyword matching.
ββββββββββ¬βββββββββββββ
β ExtractedPreferences
βΌ
βββββββββββββββββββββββ
β Guardrail Check β Blocks non-music requests (recipes, code help,
β β math, etc.) before hitting any external API.
ββββββββββ¬βββββββββββββ
β valid request
βΌ
βββββββββββββββββββββββ
β Knowledge Base β data/genre_profiles.json β 23 hand-authored
β (RAG document) β genre profiles with curated Spotify search
β β terms, mood lists, and energy ranges.
ββββββββ¬βββββββββββββββ
β enriched query + genre description
βΌ
βββββββββββββββββββββββ ββββββββββββββββββββββββ
β Spotify Search β β Local CSV Catalog β
β (RAG retrieval) ββββββββΆ β Fallback (20 songs) β
β β fail ββββββββββββββββββββββββ
β Client Credentials β
β OAuth β Search API β
ββββββββββ¬βββββββββββββ
β up to 10 tracks
βΌ
βββββββββββββββββββββββ
β Recommender β Weighted scoring:
β Scorer β genre 20% Β· mood 20% Β· energy 35%
β + Explanation β acousticness 10% Β· popularity 15%
β Engine β Appends KB genre description to each explanation.
ββββββββββ¬βββββββββββββ
β ranked (song, score, explanation)
βΌ
βββββββββββββββββββββββ
β Streamlit UI β Displays results with π Like / π Dislike /
β + Feedback Loop β β Skip buttons. "Refresh" re-ranks with
β β adjusted preferences based on feedback.
βββββββββββββββββββββββ
The system uses two RAG sources: the Spotify Web API retrieves live tracks from an external catalog, and data/genre_profiles.json is a custom knowledge-base document that enriches the search query with curated Spotify search terms before any request is made. Together they ground the recommendations in real, current music rather than a fixed internal list.
- Python 3.10+
- A Spotify Developer account (free) for live search
- An OpenAI API key (optional β the app works without one using keyword extraction)
git clone <your-repo-url>
cd Applied_AI_final_Project_Codepath.git
pip install -r requirements.txtCreate a .env file in the project root, or export variables in your shell:
# Required for live Spotify search
SPOTIFY_CLIENT_ID=your_client_id_here
SPOTIFY_CLIENT_SECRET=your_client_secret_here
# Optional β enables OpenAI preference extraction
OPENAI_API_KEY=your_openai_key_hereWithout Spotify credentials the app falls back to the local 20-song CSV catalog. Without an OpenAI key it uses keyword matching for preference extraction.
streamlit run src/app.pyOpen http://localhost:8501 in your browser.
python -m src.mainpytest -vExpected output: 15 tests pass.
Input:
"I want chill lofi music for studying"
Extracted preferences:
- Genre:
lofiΒ· Mood:chillΒ· Energy:0.40Β· Activity:studying
Top result:
Midnight Coding by LoRoom Score: 0.87 "This track fits because it suits your studying session, matches the lofi genre you requested, carries the chill mood you're looking for, and closely matches your energy level."
Input:
"Surprise me with something"
Behaviour: No music signals detected β random mode activates. Two genres are picked randomly from a 21-genre pool, Spotify is searched, results are shuffled.
Sample result:
Blinding Lights by The Weeknd Score: 0.87 (popularity-based) "A random Spotify pick β something new to discover!"
Each run returns a different shuffle of genres and tracks.
Input:
"Can you write me a Python script to sort a list?"
Behaviour: OpenAI sets is_music_request: false. No Spotify call is made.
UI response:
β οΈ "This app is for music recommendations only. Try something like: 'chill lofi for studying' or 'surprise me with something upbeat'."
Input:
"High energy rock songs for working out"
Flow:
- Five rock tracks returned, scored by energy match and popularity.
- User clicks π Dislike on two tracks with heavy metal vibes.
- User clicks π Like on a track with high danceability.
- User clicks π Refresh Recommendations.
- Disliked tracks are excluded;
target_energynudges 30% toward the liked track's energy; new top-5 returned.
| Decision | Why | Trade-off |
|---|---|---|
| OpenAI for preference extraction | Natural language is too varied for reliable regex alone; structured JSON output via response_format gives consistent, typed fields |
Adds API cost and latency; keyword fallback ensures the app still works without a key |
| Spotify Client Credentials (not user OAuth) | Zero friction β no login, no user scope required | Can't access personalized user data like listening history or saved tracks |
| RAG over pure generation | Grounds recommendations in real, current Spotify catalog instead of the model's training data | Adds network dependency; falls back to local CSV if Spotify is unavailable |
| Weighted scoring (not ML model) | Transparent, explainable, easy to tune; explanations map directly to score components | Static weights don't adapt to individual users over time |
| Session-only feedback | Simple; no database or auth needed for a demo | Feedback resets on page refresh; can't learn across sessions |
is_music_request in extraction schema |
Single API call doubles as guardrail; no extra latency | Only works when OpenAI key is present; keyword blocklist is the fallback |
| Random mode via genre sampling | Spotify has no "random" endpoint; sampling from a genre pool + shuffling gives genuine variety | Results still reflect genre popularity biases in Spotify's index |
15 tests across two modules (tests/test_recommender.py, tests/test_spotify_client.py).
| Area | Tests |
|---|---|
| Recommendation scoring and sort order | test_recommend_returns_songs_sorted_by_score |
| Explanation generation | test_explain_recommendation_returns_non_empty_string |
| Keyword preference extraction | 3 tests covering happy/pop, chill/lofi/studying, and high-energy workout |
| Default fallback values | test_extract_preferences_falls_back_to_defaults |
| OpenAI extraction with mock client | test_extract_preferences_uses_openai_structured_payload |
| OpenAI unavailable β keyword fallback | test_extract_preferences_openai_unavailable_falls_back_without_crashing |
| End-to-end text-to-recommendations | test_recommend_songs_from_text_returns_explanations |
| Spotify auth, search, normalization | 4 tests in test_spotify_client.py |
| KB query enrichment (pop + lofi) | 2 tests verifying KB expands bare genre names into richer Spotify queries |
- The mock-based OpenAI test proved the Chat Completions integration was correct before any live API calls.
- Keyword fallback tests gave confidence that the app degrades gracefully when no API key is present.
- Running
pytest -vafter every change caught regressions immediately β for example, when the scoring weights were adjusted, the sort-order test confirmed the top song still scored highest.
- The original code used the Responses API (
client.responses.create) for OpenAI calls. The schema shape was wrong for that API, so extraction silently failed and fell back to keywords every time. Switching to Chat Completions (client.chat.completions.create) withresponse_formatfixed it. - Spotify was returning the same song from multiple albums (e.g., "Could You Be Loved" on both its original album and a greatest-hits compilation). Deduplication on
(title.lower(), artist.lower())resolved the duplicate results.
Testing the AI components with mocks (injecting a fake OpenAI client that returns controlled JSON) is essential. It makes the extraction logic independently verifiable without API costs or network flakiness, and it forces you to define exactly what the AI contract should return.
Building MoodMatch made abstract AI concepts concrete. Retrieval-Augmented Generation stopped being a buzzword and became an obvious solution to a real problem: the model doesn't know what's on Spotify today, so you have to retrieve that data first. Structured output (forcing the model to return valid JSON with a defined schema) was the difference between a reliable pipeline and one that broke silently.
The guardrail feature was a lesson in how easily language models can be pulled off-task. A user typing "give me something random" causes the system to search Spotify for songs literally titled "Random" or "Ransom" β not because the model misunderstood, but because no one told it that search terms should be music signals, not filler words. Careful prompt design and schema constraints fixed that.
The feedback loop (like/dislike/refresh) was the most architecturally interesting feature. The first instinct was to store liked songs in a database. The second instinct was to add new fields to the scoring model. The right answer was much simpler: adjust the target_energy and favorite_genre values already used by the existing scorer, and filter the disliked songs from the pool. No new database, no new model β just better use of what was already there.
That pattern β solve the problem with what you already have before adding new machinery β is one I'll carry forward.
.
βββ src/
β βββ app.py # Streamlit UI, feedback loop, guardrail wiring
β βββ recommender.py # Scoring, explanation, preference extraction
β βββ spotify_client.py # Spotify auth, search, query building
β βββ knowledge_base.py # Genre KB loader and query enrichment
β βββ main.py # CLI runner
βββ tests/
β βββ test_recommender.py # 9 recommender tests
β βββ test_spotify_client.py # 6 Spotify + KB tests
βββ data/
β βββ songs.csv # Local 20-song fallback catalog
β βββ genre_profiles.json # 23 hand-authored genre profiles (KB)
βββ requirements.txt
- Python 3.10+
- Streamlit β UI framework
- OpenAI Python SDK β preference extraction via
gpt-4.1-mini - Spotify Web API β live track search (Client Credentials flow)
- pytest β test suite
- requests β Spotify HTTP calls
