Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Adaptive Learning Platform

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.

Table of contents

Architecture

+---------------------------+
|      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.

Tech stack

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

The adaptive mastery engine

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)
  • actual is 1 for correct, 0 for incorrect.
  • expected is that same success probability from step 1.
  • K is 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.

Data model

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)

API endpoints

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)

Project layout

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

Local setup

Backend

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-alpine

Then:

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 --reload

API docs land at http://localhost:8000/docs.

Frontend

cd frontend
npm install
cp .env.example .env.local   # NEXT_PUBLIC_API_URL should point at the backend above
npm run dev

Open http://localhost:3000, log in with demo@example.com / password123 (or register a fresh account), and practice a skill.

Running the mastery engine tests

cd backend
pytest tests/test_mastery_engine.py -v

Deployment

Currently deployed as:

  • Frontend → Vercel, NEXT_PUBLIC_API_URL pointed 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_ORIGINS set as service variables. DATABASE_URL references 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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages