coaching-app / ASSESSMENT.md
kshamaasuresh's picture
Upload 9 files
1acd83f verified
|
Raw
History Blame Contribute Delete
8.94 kB
# 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.
```python
# 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
```python
# 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:
```python
@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:
```sql
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.