Live demo: https://adaptive-learning-platform-dun.vercel.app (demo login:
demo@example.com/password123) API: https://backend-production-e307.up.railway.app/docs Repo: https://github.com/sunnyrishy/adaptive-learning-platform
A personalized practice tool, in the spirit of IXL: students answer skill-based questions and a mastery engine adjusts difficulty after every single answer, aiming to keep each student in their own "challenging but winnable" zone.
This is a portfolio project. The interesting part isn't the CRUD, it's the adaptive mastery engine — read on for exactly how it works.
- Architecture
- Tech stack
- The adaptive mastery engine
- Data model
- API endpoints
- Project layout
- Local setup
- Deployment
+---------------------------+
| Next.js (Vercel) |
| App Router + TypeScript |
| Tailwind + Framer Motion |
+-------------+-------------+
| fetch, JWT bearer token
v
+---------------------------------+
| FastAPI (Railway) |
| async SQLAlchemy + Pydantic |
| auth / skills / practice / |
| progress / leaderboard / |
| teacher routers |
+---------+--------------+--------+
| |
v v
+------------------+ +---------------------------+
| PostgreSQL | | Redis / Valkey |
|--------------------| | (Upstash) |
| users, skills, | | leaderboard cache only, |
| questions, | | cache-aside pattern: |
| attempts, mastery, | | read-through, invalidate |
| mastery_history | | on write |
+------------------+ +---------------------------+
Postgres is the source of truth for everything (accounts, questions, mastery, history). Redis only caches the leaderboard — nothing is ever only in Redis, so it can be wiped anytime without losing data. That's the cache-aside pattern: read through the cache, on a miss go to Postgres and repopulate; on a write, invalidate the cache key instead of trying to keep it in sync by hand.
| Layer | Choice | Why |
|---|---|---|
| Backend | Python, FastAPI, async SQLAlchemy 2.0 | async all the way down, first-class Pydantic validation |
| Auth | JWT via python-jose, passlib/bcrypt |
stateless, no session store needed |
| Database | PostgreSQL | relational integrity for users/attempts/mastery |
| Cache | Redis / Valkey (redis-py async) |
leaderboard cache-aside, purpose-built for this one hot read path |
| Migrations | Alembic | versioned schema changes |
| Frontend | Next.js (App Router) + TypeScript | server/client component split, file-system routing |
| Styling | Tailwind CSS v4 | utility-first, fast iteration |
| Motion | Framer Motion | scroll reveals, hover states, animated mastery counters |
| Charts | Recharts | mastery-over-time area charts |
| Deploy | Vercel (frontend), Railway/Render (API + Postgres), Upstash (Redis) | free tiers, minimal ops |
This is the whole point of the project, so here it is in plain English before you go read backend/app/services/mastery_engine.py.
The setup: every question has a difficulty_score from 0–10. Every student has a mastery_score, also 0–10, starting at 5.0 (dead center — we assume nothing about a new student). Difficulty and mastery live on the same scale on purpose, so they're directly comparable.
Step 1 — estimate the odds. Before showing a question, the engine can estimate the student's chance of getting it right, using a logistic curve borrowed from the Elo rating system:
expected_success = 1 / (1 + 10^((difficulty - mastery) / 3))
If difficulty equals mastery, that's exactly 50%. A question 3 points harder than your mastery drops you to about 24%; a question 3 points easier lifts you to about 76%.
Step 2 — pick a question that targets ~70% success. Not 50/50 (that's a coin flip, demoralizing), not 95% (no growth, boring). Solving the same curve for the difficulty that produces a 70% expected success rate gives a target slightly below the student's current mastery. The engine scans the skill's question bank and serves whichever question sits closest to that target (breaking near-ties randomly so the same student doesn't always see the same question at a given mastery level).
Step 3 — update mastery after the answer. This is the Elo-style part: mastery moves toward what the outcome implies about the student's true skill, scaled by how surprising that outcome was.
new_mastery = mastery + K × (actual − expected)
actualis 1 for correct, 0 for incorrect.expectedis that same success probability from step 1.Kis the step size, and it scales with the question's difficulty (0.5× at difficulty 0, up to 1.5× at difficulty 10) — harder questions carry more signal in either direction, so they move your score more.
Worked example: a student sits at mastery 5.0 and gets served a difficulty-8 question (expected success ≈ 24%).
- If they get it right: surprise is large (1 − 0.24 = 0.76), K is high (harder question), so mastery jumps up noticeably — maybe to ~5.9.
- If they get it wrong: surprise is small (0 − 0.24 = −0.24), so mastery barely dips — maybe to ~5.7.
Now flip it — same student, same mastery, but a difficulty-2 question (expected success ≈ 76%):
- Right answer: barely a surprise, mastery ticks up slightly.
- Wrong answer: a big surprise, mastery drops more noticeably.
That asymmetry is the whole design goal in one sentence: beating a hard question should count for more than beating an easy one, and blowing an easy question should sting more than losing to a hard one.
Every attempt also writes a row to mastery_history, which is what powers the progress charts.
This logic is covered by 10 unit tests in backend/tests/test_mastery_engine.py, run in isolation with no database or API involved — the engine is a pure function of (mastery, difficulty, correct) → new mastery, which is exactly why it's easy to reason about and easy to test.
users id, email, password_hash, role (student|teacher), created_at
skills id, name, subject, description
questions id, skill_id, difficulty_score, question_text, choices[], correct_answer
attempts id, user_id, question_id, correct, answered_at
mastery user_id, skill_id, current_score, updated_at (one row per user+skill)
mastery_history id, user_id, skill_id, score, recorded_at (one row per attempt)
| Method | Path | Description |
|---|---|---|
| POST | /auth/register |
create an account, returns a JWT |
| POST | /auth/login |
log in, returns a JWT |
| GET | /skills |
list skills with the current user's mastery per skill |
| GET | /practice/{skill_id}/next-question |
adaptively-selected next question |
| POST | /practice/answer |
submit an answer, updates mastery, returns feedback |
| GET | /progress/{user_id} |
mastery history per skill, for the progress charts |
| GET | /leaderboard/{skill_id} |
top mastery scores for a skill, Redis cache-aside |
| GET | /teacher/overview |
every student's mastery per skill (teacher role only) |
adaptive-learning-platform/
├── backend/
│ ├── app/
│ │ ├── core/ config, security (JWT/bcrypt), redis client, auth dependency
│ │ ├── db/ SQLAlchemy engine/session
│ │ ├── models/ users, skills, questions, attempts, mastery
│ │ ├── schemas/ Pydantic request/response models
│ │ ├── services/ the mastery engine
│ │ ├── routers/ auth, skills, practice, progress, leaderboard, teacher
│ │ └── main.py
│ ├── alembic/ migrations
│ ├── tests/ mastery engine unit tests
│ ├── seed.py 5 skills × 15 questions + a demo login
│ └── requirements.txt
└── frontend/
├── app/ landing, login, register, dashboard, practice/[skillId], progress, teacher
├── components/ Navbar, MasteryRing, AnimatedCounter, GradientBlobs, RequireAuth
└── lib/ api client, auth context, types
Needs Postgres and Redis (or Valkey) reachable — the quickest way is Docker:
docker run -d --name alp-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=adaptive_learning -p 5432:5432 postgres:16-alpine
docker run -d --name alp-valkey -p 6379:6379 valkey/valkey:8-alpineThen:
cd backend
python -m venv .venv
.venv/Scripts/activate # or: source .venv/bin/activate on macOS/Linux
pip install -r requirements.txt
cp .env.example .env # fill in a real JWT_SECRET_KEY
alembic upgrade head
python seed.py # seeds 5 skills, 75 questions, and demo@example.com / password123
uvicorn app.main:app --reloadAPI docs land at http://localhost:8000/docs.
cd frontend
npm install
cp .env.example .env.local # NEXT_PUBLIC_API_URL should point at the backend above
npm run devOpen http://localhost:3000, log in with demo@example.com / password123 (or register a fresh account), and practice a skill.
cd backend
pytest tests/test_mastery_engine.py -vCurrently deployed as:
- Frontend → Vercel,
NEXT_PUBLIC_API_URLpointed at the Railway backend. - Backend → Railway, deployed via
backend/Procfile(alembic upgrade head && uvicorn ...on every deploy),DATABASE_URL/REDIS_URL/JWT_SECRET_KEY/CORS_ORIGINSset as service variables.DATABASE_URLreferences the Postgres service over Railway's private network. - Postgres → a Railway Postgres service in the same project.
- Redis → Upstash free tier, connected over TLS (
rediss://).
To redeploy after changes:
# backend
cd backend && npx @railway/cli up --service backend --ci
# frontend
cd frontend && npx vercel --prod --yes