This document describes what the prototype code does (backend + recommender + logging + metrics) and how it was tested end-to-end.
The project is a recommender system prototype that exposes a REST API. It can:
- load and store a public dataset (MovieLens 100K) into SQLite
- generate recommendations using:
- a baseline heuristic recommender
- a Neural Network classifier recommender (optional, if model is trained/loaded)
- log what was shown to the user (impressions) and what the user did (engagements)
- compute PaC (Precision at Curation) from logged impressions + engagements
The emphasis is on having a complete systems pipeline (serve → log → evaluate), not on maximum ML performance.
- FastAPI: REST API + Swagger UI (
/docs) - SQLite: local database (
data/app.db) - PyTorch: NN model training + inference (optional; baseline works without it)
- Makefile: convenience targets for setup, seeding, training
MovieLens files are fetched and seeded into SQLite:
- users →
users - items →
items - ratings →
interactions
Client calls:
GET /recommendations?user_id=...&k=...&engine=auto|baseline|nn
API:
- validates
user_id - generates top-K items
- logs an impression row containing:
recset_id(UUID)user_idengineuseditem_ids_json(ranked list of recommended item IDs)
- returns JSON:
recset_id+ list of items
Client logs actions (click/like/watch) using:
POST /events/engagement
This creates a row in engagements tied to a recset_id.
PaC is computed using:
GET /metrics/pac
It checks: for each impression, how many of the top-K items were engaged within a time window.
- demographic metadata (MovieLens users)
- movies metadata + genres
- MovieLens ratings (event_type
"rating", rating 1..5, ts)
Logs each recommendation list served.
recset_id(PK)user_idengine(baselineornn)item_ids_json(ranked list)ts
Logs user actions for items shown in an impression.
recset_id(FK)user_iditem_idaction_type(e.g.,click)ts
Implemented as a weighted scoring heuristic:
- Candidate pool: globally popular items (by interaction count)
- Filter out items already seen by the user
- Score each candidate by:
- popularity (log-normalized)
- global average rating
- genre match to user profile (derived from ratings >= 4)
Final score:
0.55 * popularity + 0.25 * avg_rating + 0.20 * genre_match
Output: top-K unseen items.
Train an NN classifier:
- input:
(user_id, item_id) - output:
P(like)(sigmoid probability) - label rule:
rating >= 4 => 1 else 0 - model: user embedding + item embedding + small MLP
Inference:
- candidate pool: popular items
- filter seen items
- score candidates with NN
- return top-K by
p_like
Engine selection:
engine=baseline→ baseline onlyengine=nn→ NN only (requires trained model loaded)engine=auto→ use NN if model loaded and user has enough history; otherwise baseline
PaC definition used in this prototype:
For each impression:
- take top-K recommended items
- count engaged items within
window_hours - PaC(impression) = hits / K
API returns:
pac_mean: mean of PaC over impressionspac_micro: total_hits / total_recommended- counts: impressions, total_hits, total_recommended
Filtering:
- by
engine(baselineornn) - by
platform(web/mobile) - by
action_types(e.g., click)
- Create venv + install deps:
make install- Seed database (MovieLens → SQLite):
make seed-ml100k- Run API:
make devOpen Swagger UI:
Call:
GET /debug/usersGET /debug/itemsGET /debug/interactions?user_id=1
- Call:
GET /recommendations?user_id=1&k=5&engine=baseline
- Copy:
recset_id- one
item_idfrom returned list
- Verify impression exists:
GET /debug/impressions?user_id=1
Expected: entries with engine="baseline".
- Post engagement in Swagger:
POST /events/engagement
Body (example):
{
"recset_id": "PASTE_RECSET_ID",
"user_id": 1,
"item_id": 313,
"action_type": "click",
"platform": "web"
}- Verify engagement:
GET /debug/engagements?recset_id=PASTE_RECSET_ID
Expected: row contains action_type="click" and a valid timestamp.
Important testing note: do not leave Swagger defaults like "string" or ts=0, otherwise hits won’t count for PaC.
Compute PaC over all data:
GET /metrics/pac?start_ts=0&k=5&window_hours=168&action_types=click
Expected:
impressions > 0total_hits > 0after at least one correct clickpac_microroughlyhits / (impressions * K)for this test
Compare engines:
GET /metrics/pac?start_ts=0&k=5&window_hours=168&engine=baseline&action_types=click
GET /metrics/pac?start_ts=0&k=5&window_hours=168&engine=nn&action_types=click
Train and save model:
make train-nnSmoke inference:
make smoke-nnThen restart API (model loads on startup):
make devCheck model loaded:
GET /debug/model→nn_loaded: true
Test NN recommendations:
GET /recommendations?user_id=1&k=5&engine=nnGET /recommendations?user_id=1&k=5&engine=auto
Because new tables reference users/items, reset must delete child tables first:
engagements→rec_impressions→interactions→items→users
MovieLens genre flags are aligned by genre id (includes unknown|0).
Fix: load genres by id and only skip "unknown" when building item genre strings.