coaching-app / ASSESSMENT.md
kshamaasuresh's picture
Upload 9 files
1acd83f verified
|
Raw
History Blame Contribute Delete
8.94 kB

A newer version of the Streamlit SDK is available: 1.59.1

Upgrade

Apollo MIS β€” LLM Internship Assessment

Candidate: Kshamaa Suresh Date: March 2026 Time Spent: ~5 hours

Task Time
Data exploration + DB setup 30 min
Retrieval (BM25) 45 min
Re-ranking (Claude API) 1 hr
Streamlit frontend 1 hr
System design write-up 1 hr
Onboarding proposal 45 min

1. Data Setup

What's in the dataset

60 exercises with the following fields: id, title, description, tags, body_part, difficulty, equipment, injury_focus, intensity.

One trailing empty column (Unnamed: 9) was dropped. One bad value in difficulty ("body") was noted β€” treated as-is since it only affects one row and doesn't break any logic.

Why SQLite instead of PostgreSQL

The assessment suggests PostgreSQL, but SQLite is a better fit here for a few practical reasons:

  • No server to spin up β€” the database is a single file, which means the app works the same locally and on HuggingFace Spaces without any additional setup
  • 60 rows is well within SQLite's range; it handles hundreds of thousands of rows without issue
  • All queries are standard SQL, so migrating to PostgreSQL later would just be a connection string change

Schema decision

I added a search_text column that pre-concatenates all the text fields used in retrieval (title, description, tags, body_part, equipment, injury_focus, intensity, difficulty). This avoids rebuilding the string at query time and makes the BM25 index straightforward to build.

# setup_db.py β€” key logic
search_text = " ".join([title, description, tags, body_part,
                        equipment, injury_focus, intensity, difficulty]).lower()

2. Query β†’ Recommendations Pipeline

The pipeline has two clearly separated stages:

User query
    β”‚
    β–Ό
Stage 1: BM25 Retrieval
    β”‚  Tokenises the query and scores all 60 exercises
    β”‚  Returns top 15 candidates with score > 0
    β”‚  (falls back to top 15 regardless if fewer than 3 match)
    β–Ό
Stage 2: Claude Re-ranking
    β”‚  Sends query + 15 candidates to Claude
    β”‚  Claude scores and orders by relevance, returns JSON
    β”‚  Returns top 5 with a one-sentence reason each
    β–Ό
Top 3–5 recommendations shown in UI

Why two stages?

Sending all 60 exercises to Claude on every query would work at this size, but it's the wrong pattern:

  • At 100k+ exercises, sending everything to an LLM is not feasible
  • BM25 quickly filters to a relevant subset β€” Claude only needs to reason over 15 candidates, not the full corpus
  • This separation also makes each stage independently testable

Stage 1 β€” BM25 Retrieval

BM25 (Best Match 25) is a standard keyword ranking algorithm. It scores each exercise by how well its text matches the query tokens, accounting for term frequency and document length.

Why BM25 and not embeddings?

  • No model to download or host
  • Works well for domain terms like "knee rehab", "plyometric", "no weights"
  • Fast β€” scores all 60 docs in milliseconds
  • At larger scale, a hybrid approach (BM25 + vector search) would be better
# retrieval.py β€” core logic
corpus = [tokenize(ex["search_text"]) for ex in exercises]
bm25 = BM25Okapi(corpus)
scores = bm25.get_scores(tokenize(query))

Stage 2 β€” LLM Re-ranking (Mistral-7B via HuggingFace free Inference API)

The LLM receives the query and the 15 BM25 candidates and is asked to return a ranked JSON array of the top 5 with a one-sentence reason for each.

Why re-rank with an LLM?

  • BM25 is purely lexical β€” it can miss a great match if the words don't overlap. An LLM understands intent: "low-impact" implies avoiding jumps and heavy loading even if those words don't appear in an exercise title.
  • The LLM can reason about constraints: "no weights" should filter out barbell and dumbbell exercises even if they ranked highly on BM25.

Why Mistral-7B via HuggingFace Inference API?

  • Completely free β€” requires only a free HuggingFace account token
  • Strong instruction-following, handles JSON output reliably
  • No local GPU or model download needed β€” the model runs on HF's servers
  • Works natively on HuggingFace Spaces (the token is available as a built-in secret HF_TOKEN)

The prompt uses Mistral's [INST] format and asks for a JSON array only, which makes parsing reliable. A regex fallback extracts the JSON if the model adds any preamble text.


3. Backend Design

How the backend handles each step

1. User submits query via Streamlit text input
2. retrieve(query, top_k=15) called β†’ BM25 scores exercises β†’ returns list of dicts
3. rerank(query, candidates, top_n=5) called β†’ Claude API call β†’ parses JSON β†’ returns ranked list
4. Streamlit renders results with title, description, difficulty, reason

All of this runs in a single Python process in the Streamlit app. For a production backend, this would be a FastAPI endpoint:

@app.post("/recommend")
async def recommend(query: str):
    candidates = retrieve(query, top_k=15)
    results    = rerank(query, candidates, top_n=5)
    return {"results": results}

How this scales to 100k+ exercises

Problem Solution
BM25 over 100k docs is slow in memory Move to Elasticsearch or a vector store (FAISS/pgvector) with an index
Claude can't receive 100k candidates BM25/vector search still returns only 15–20 candidates for re-ranking β€” this step doesn't change
Multiple simultaneous users FastAPI handles async requests natively; Claude API calls are already I/O-bound so they work well with async/await
DB connection pooling Use SQLAlchemy with a connection pool instead of raw sqlite3
Cold start on DB index Pre-build the BM25 index at startup and cache it in memory instead of rebuilding on every request

4. Frontend

Built with Streamlit. The user can:

  • Type a free-text query
  • Click one of 4 example queries to pre-fill the input
  • See the top 5 results with title, description, difficulty, body part, equipment, intensity, injury focus, and Claude's reason for ranking it

Hosting on HuggingFace Spaces (fully free):

  1. Create a new Space with the Streamlit SDK
  2. Push all project files
  3. HF_TOKEN is automatically available in Spaces as a built-in secret β€” no manual configuration needed for the re-ranking step
  4. HuggingFace handles the rest β€” free tier is sufficient

Cost breakdown: $0

  • SQLite β€” free, no server
  • BM25 β€” free, runs in memory
  • Mistral-7B via HF Inference API β€” free tier
  • Streamlit on HuggingFace Spaces β€” free tier

5. Onboarding Proposal β€” Personalisation

What data to collect

During onboarding I'd ask users 4–5 quick questions:

Question Options Why it matters
What's your main goal? Rehab, strength, endurance, sport performance Determines which exercises to prioritise
Any injuries or areas to avoid? Knee, back, shoulder, groin, none Hard filters retrieval β€” don't recommend knee exercises to someone with knee pain
What equipment do you have? None, bands, dumbbells, barbell, full gym Filters by equipment field
How would you rate your fitness level? Beginner, intermediate, advanced Maps to difficulty field
How intense do you want sessions to be? Low, medium, high Maps to intensity field

How it influences retrieval and ranking

At retrieval time: apply hard filters in the SQL query before BM25. For example, a user who said "no equipment" gets a WHERE clause:

WHERE equipment IN ('none', 'bodyweight', 'band')

This reduces the candidate pool before BM25 even runs.

At re-ranking time: inject the user's profile into the Claude prompt:

User profile: rehab goal, knee injury, no equipment, beginner, low intensity
Query: "strengthen my legs"

Claude can then deprioritise any high-intensity or equipment-dependent exercises even if they scored well on BM25.

Inspiration from Spotify / Netflix

  • Spotify's Discover Weekly builds a taste profile from listening history and uses it to weight recommendations. The equivalent here is tracking which exercises a user completes or skips β€” over time, the system learns their preferences without them having to re-answer onboarding questions.

  • Netflix's contextual recommendations change based on time of day and recently watched content. A coaching app could similarly adapt β€” if a user just logged a hard leg session, the next session recommendation should shift toward upper body or recovery work.

  • Cold start problem: Both Spotify and Netflix use onboarding to handle new users who have no history. The 4–5 question onboarding above serves the same purpose β€” it gives the system enough signal to make useful recommendations from day one.