Upload a PDF and read it in the app. Turn it into AI chat, summaries, concept explanations, flashcards and quizzes. Track your progress as you go. This is a MERN stack app (MongoDB, Express, React, Node) with an LLM layer for the AI features.
Live demo. The frontend runs on Vercel. The API runs on Render's free tier, which spins down after a while with no traffic. The first request can take 30 to 50 seconds to wake it back up. After that it's fast.
- Auth: Register and log in with JWT. Routes are protected, and you can change your password. Password reset and email verification use single-use tokens that expire. Token refresh is protected with CSRF and rotates on every use, with reuse detection. The Profile page lists your active sessions so you can sign other devices out.
- Bring your own OpenAI key: Save your own key on the Profile page. It gets verified against OpenAI and encrypted at rest, and your AI usage then bills to you instead of the deployer. The deployer's own key, if they set one, only acts as a capped fallback for users without a key of their own. See Deployment below.
- Try it without signing up: the login page has a demo button that opens a
shared, seeded, read-only account. Every write is refused for it, so it can
never spend on your OpenAI key. On by default outside production. Set
DEMO_MODE=trueto turn it on for a deploy. - Documents: Drag and drop a PDF to upload it (10MB limit). Text gets extracted automatically, with OCR as a fallback for scanned or image-only PDFs. View the PDF right in the app.
- AI Chat: Ask questions about a document. Answers stream in word by word (with a Stop button), come back as markdown with code highlighting, and cite the exact page they came from. Click a citation to open the PDF at that page. Retrieval is hybrid (BM25 + vector + rank fusion + rerank) over the whole document, and a second pass flags claims the excerpts don't support.
- AI Actions: Generate a summary in one click, or ask for a concept explanation on demand.
- Flashcards: AI generates flashcard sets. Flip through them with a card viewer and the keyboard. Each card tracks its own review progress.
- Quizzes: AI generates multiple-choice quizzes. Grading happens on the server, and you get a detailed results page.
- Practice what you miss: The dashboard ranks your weak concepts (Bayesian knowledge tracing over your quiz answers). One click generates flashcards or a quiz on just that concept, from the passages that cover it.
- Search and shortcuts: Ctrl/⌘+K opens a command palette that jumps to any
page or searches your documents, decks, quizzes and chats.
?lists every shortcut. - Export: Download a deck as CSV or as Anki's text-import format, and a finished quiz as Markdown.
- Dashboard: See your document, flashcard and quiz counts, plus recent activity.
- Open network (Beckn v2.0): The app runs a consumer node that searches other providers for material on whatever concept you're weak on, and a provider node that publishes its own generated decks, quizzes and courses for other applications to discover. Every request is ed25519-signed and verified against a registry lookup. See docs/beckn.md.
Frontend: React 19, Vite, React Router v7, Tailwind CSS v4, axios, lucide-react, react-hot-toast, moment, react-markdown + remark-gfm + react-syntax-highlighter.
Backend: Node.js, Express 5, MongoDB with Mongoose, JWT auth, argon2id password hashing (bcryptjs is only kept to verify pre-migration hashes). Also multer for uploads, pdf-parse for text extraction, tesseract.js for OCR on scanned PDFs, the OpenAI API for AI generation, and helmet, compression and express-rate-limit for hardening.
The original plan targeted Google Gemini. This build uses the OpenAI API instead. The AI layer (
backend/utils/aiClient.js) is a single thin wrapper. Swapping providers again only means changing that one file.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
backend/
├── config/ db.js
├── controllers/ auth, document, ai, flashcard, quiz, dashboard, admin, review, mastery
├── middlewares/ auth, upload, error, rate limiter, CSRF, AI key context
├── models/ User, Document, Flashcard, Quiz, QuizAttempt, ChatHistory,
│ ReviewLog, Mastery, LlmCall, RefreshToken, and the token/cache models
├── routes/ one router per resource, mounted under /api/*
├── utils/ generateToken, aiClient, prompts, getOwnedDocument, fsrs, sm2, bkt
├── beckn/ core/ (signing, context, ack), registry/, bap/, bpp/, network.js, demo.js
├── uploads/ (gitignored) stored PDFs
└── server.js
frontend/ai-learning-assistant/src/
├── components/ layout/, documents/, flashcards/, quizzes/, dashboard/, profile/, ui/, auth/
├── context/ AuthContext.jsx
├── hooks/ useAuth.js
├── pages/ Auth/, Dashboard/, Documents/, Flashcards/, Quizzes/, Review/, Profile/, Admin/
├── services/ one module per API resource
├── utils/ axiosInstance, apiPaths, constants, helpers
└── App.jsx
- Node.js 22.12+
- A MongoDB instance (local or Atlas)
- An OpenAI API key (for the AI features)
cd backend
npm install
cp .env.example .env # fill in the values below
npm run dev # http://localhost:8000backend/.env:
PORT=8000
MONGO_URI=<your MongoDB connection string>
JWT_SECRET=<long random string>
CLIENT_URL=http://localhost:5173
# Optional in dev: without it, AI features only work for a user who's saved
# their own key on the Profile page. With it, it's used as a fallback for
# everyone else (see "Deployment" below for why that matters in production).
OPENAI_API_KEY=<your OpenAI API key>
OPENAI_MODEL=gpt-4o-mini # default model; quiz generation is routed to gpt-4o regardless
Check backend/.env.example for the optional variables and their defaults.
That covers access and refresh token lifetimes, account lockout thresholds,
the AI budget cap, OPENAI_QUIZ_MODEL (overrides the quiz model), ENCRYPTION_KEY for saved user API keys, and
COOKIE_SAME_SITE for cross-domain deploys.
cd frontend/ai-learning-assistant
npm install
cp .env.example .env # fill in the value below
npm run dev # http://localhost:5173frontend/ai-learning-assistant/.env:
VITE_API_BASE_URL=http://localhost:8000
Then open http://localhost:5173. Register an account and upload a PDF.
| Location | Script | Purpose |
|---|---|---|
backend/ |
npm run dev |
Start the API with nodemon (auto-restart) |
backend/ |
npm start |
Start the API with plain node |
frontend/ai-learning-assistant/ |
npm run dev |
Start the Vite dev server |
frontend/ai-learning-assistant/ |
npm run build |
Production build to dist/ |
frontend/ai-learning-assistant/ |
npm run lint |
Run ESLint |
backend/ |
npm run compare-schedulers |
Regenerate docs/scheduler-comparison.md (SM-2 vs. FSRS) |
backend/ |
npm run beckn:demo |
Start a local Beckn network and walk discover → confirm |
backend/ |
npm run beckn:conform |
The Beckn protocol conformance suite |
Flashcard review used to be a simple "reviewed" flag. Now it uses real
scheduling. Grade a card Again, Hard, Good or Easy (keys 1 to 4) and
backend/utils/fsrs.js works out its next due date
from a stability and difficulty model, not a fixed multiplier. Every grade
also gets written to an immutable ReviewLog
(backend/models/ReviewLog.js).
backend/utils/sm2.js implements the older SM-2
algorithm too. It's the documented baseline that the comparison below
measures FSRS against. GET /api/review/due is the unified due queue. It
pulls everything due today across every document into one interleaved
session at /review.
docs/scheduler-comparison.md has the
numbers from a synthetic-learner simulation. The app has no real review
history to replay yet, so read the file for what the simulation does and
doesn't prove. Regenerate it with npm run compare-schedulers.
The app is a network participant, on both sides.
As a consumer node it takes the weak concepts the dashboard already ranks and searches an open network for anything that teaches them — so the answer to "you keep missing shortest-path questions" is no longer limited to material generated from documents you already uploaded. As a provider node it publishes its own generated decks, quizzes and micro-courses, tagged by concept, for other applications to find.
cd backend
npm run beckn:demo # start a whole network and walk discover -> confirm
npm run beckn:conform # the protocol conformance suitenpm run beckn:demo starts a registry, a consumer node and two provider nodes
on loopback, then narrates a real journey across them: a learner weak on
dijkstra, a fan-out search answered by both providers, and a
select → init → confirm → status enrolment. Every request is ed25519-signed
and verified against a registry lookup.
This targets the v2.0.0 LTS line, not v1.x — named endpoints per action,
senderId/receiverId instead of bapId/bppId, consumer/provider node
naming, and DeDi-style registry records.
docs/beckn.md covers the architecture, the asynchronous
ACK-then-callback shape, the signing details that are easy to get wrong, how
the education domain is mapped, what is deliberately left out of the MVP, and
the steps to move from the local registry to the live NFH fabric.
If you deploy this with your own OPENAI_API_KEY set, every request against
it gets billed to you. This app's answer is bring-your-own-key. Each
user can save their own OpenAI key on the Profile page. It gets verified
live against OpenAI and encrypted at rest (AES-256-GCM, see
backend/utils/encryption.js). That key is
then used for every AI call the user makes: chat, summaries, flashcards,
quizzes, and the embeddings generated when they upload a document. The key
is never shown again after saving. The Profile page only shows it masked,
as "ending in ...ABCD".
The deployer's own OPENAI_API_KEY, if set, is only ever a fallback for
a user who hasn't saved their own key. Only that fallback path is subject
to MONTHLY_AI_BUDGET_USD and shows up on the /admin/costs dashboard. A
user's own key is their own money. It's uncapped and never logged. This
gives you three deployment shapes, depending on what you set:
OPENAI_API_KEY set? |
MONTHLY_AI_BUDGET_USD set? |
Result |
|---|---|---|
| No | - | BYOK-only. Nobody's usage ever costs you anything; AI features stay off for a user until they add their own key. |
| Yes | No | Anyone can use AI features funded by your key, uncapped. Only reasonable for a private/invite-only deploy. |
| Yes | Yes | Free tier funded by your key up to the per-user monthly cap, then AI features 429 until the user adds their own key (or the month resets). |
Whichever shape you pick, also set a hard spend limit in your OpenAI
dashboard (platform.openai.com, under Settings then Limits) as a backstop.
MONTHLY_AI_BUDGET_USD only throttles calls this app makes. It can't
protect you against a bug, or a key that leaks some other way.
The refresh token and CSRF token live in httpOnly cookies scoped to
/api/auth. Problems show up if your frontend and backend end up on
genuinely different registrable domains. That's the common free-tier setup:
a Vercel frontend plus a Render, Railway or Fly backend on its own
*.vercel.app or *.onrender.com domain. The default SameSite=Lax means
browsers silently drop those cookies on cross-site requests. Users then get
logged out the moment their 15-minute access token expires. Fix it with one
env var:
COOKIE_SAME_SITE=none
This needs HTTPS, which every mainstream host gives you by default. If you
instead put the frontend and backend on subdomains of the same registrable
domain, like app.example.com and api.example.com, the default lax is
fine as is. That still counts as "same-site" as far as cookies are
concerned.
render.yaml is a Render Blueprint.
Connect this repo from the Render dashboard through New + then
Blueprint, and it provisions the API as a Docker web service built from
backend/Dockerfile. It comes with
NODE_ENV=production, COOKIE_SAME_SITE=none and OPENAI_MODEL=gpt-4o-mini
preset, and GET /health wired up as the health check. Anything secret or
deployment-specific is marked sync: false: MONGO_URI, JWT_SECRET,
ENCRYPTION_KEY, CLIENT_URL, OPENAI_API_KEY, MONTHLY_AI_BUDGET_USD and
ADMIN_EMAILS. Render prompts you for each one once in its dashboard
instead of storing it in the file or in git history. The blueprint only
provisions the API. Build and host the frontend separately, on Vercel or
Netlify for example, or with docker-compose.yml below, and point
CLIENT_URL and VITE_API_BASE_URL at each other.
- Set
NODE_ENV=production. It turns ontrust proxy(needed for accurate rate limiting and lockout behind any platform's reverse proxy),Securecookies, and CORS restricted toCLIENT_URL. - Set
ENCRYPTION_KEYexplicitly (seebackend/.env.example) rather than relying on theJWT_SECRET-derived dev fallback. - Uploaded PDFs are still stored on local disk (
backend/uploads/, or whereverUPLOAD_DIRpoints, for example a mounted volume). Most PaaS hosts wipe that on every redeploy. The app handles this gracefully: a document whose file was wiped shows a clear banner instead of a broken viewer, and chat, flashcards and quizzes keep working since the extracted text lives in MongoDB, not on disk. The PDF itself is gone until you re-upload it. Swap in S3 or R2 for real persistence if that matters for your deploy. docker-compose.ymlruns the whole stack (Mongo, the API, and the built frontend behind nginx) in one command for a self-hosted deploy. See the file for which env vars it forwards.
- Metrics:
GET /metrics(Prometheus text). Per-route latency, LLM calls, tokens and estimated dollars by feature, cache hits, and whether the circuit breaker is open. SetMETRICS_TOKENand scrape with a bearer token. Without it,/metricsis open in dev and returns 404 in production. - Health:
GET /healthis liveness.GET /readychecks MongoDB, reports the LLM breaker asokordegraded(informational: AI is down, the rest works), and returns 503 while the server is draining for shutdown. - Graceful shutdown: on SIGTERM the server stops accepting connections, lets in-flight requests (including a half-finished AI generation) complete, closes MongoDB, then exits, with a 15 second cap.
- Circuit breaker: after repeated provider failures, AI calls fail fast with
a 503 and a retry hint instead of piling up behind an outage. A user's own bad
key never trips it. See
CIRCUIT_*inbackend/.env.example. - Idempotency: send an
Idempotency-Keyheader onPOST /api/ai/chat,/generate-flashcardsand/generate-quiz. A retry with the same key replays the first response instead of paying for and saving a second generation. The frontend does this for you. - Audit log: logins, failed logins, lockouts, refresh-token reuse, password
changes and resets, session revocations and API-key changes are recorded
(never with secrets). Admins read them at
GET /api/admin/audit. - Content Security Policy: the API sends a deny-all policy (it only returns
JSON and PDFs), with framing of
/uploads/*allowed for the frontend origin only. The production frontend bundle ships a strict CSP<meta>with no inline scripts, built fromVITE_API_BASE_URL.
| Command | What it covers |
|---|---|
cd backend && npm test |
530+ unit and integration tests against an in-memory MongoDB |
cd frontend/ai-learning-assistant && npm test |
160+ component and unit tests |
cd e2e && npm test |
Playwright smoke tests (register, upload) |
cd e2e && npm run test:full |
The whole product: an API journey and two real-browser journeys (dev server and the production build under its CSP), against a fake OpenAI server |
cd backend && npm run beckn:conform |
Beckn protocol conformance: signatures, tampering, expiry, malformed contexts and replayed messageIds |
cd e2e && npm run eval:check |
RAG evals: retrieval, citation and answer scores over a 100-question gold set, failing on a regression. See e2e/evals/README.md for what it does and doesn't show |
- AI routes are rate limited to 30 requests per 15 minutes per user. They
also block documents with no extractable text at all. A scanned or
image-only PDF falls back to OCR at upload time (
OCR_MAX_PAGES, default 25 pages, since OCR is synchronous and runs during the upload request). So AI features only get blocked if OCR itself finds nothing to read. - Every AI call is logged with its token usage and an estimated cost. Spend
against the deployer's own shared
OPENAI_API_KEYcan be capped per user per month withMONTHLY_AI_BUDGET_USD(leave it unset for no cap). This never applies to a user's own saved key. See Deployment. - Quiz answer keys are never sent to the client until a quiz is submitted. Grading happens on the server.
- Login, register and refresh are rate limited to 20 requests per 15 minutes per IP. An account locks itself out for 15 minutes after 5 consecutive wrong passwords. Together these blunt both email enumeration and credential stuffing. A locked-out login and a login for an email that doesn't exist return the identical "Invalid email or password" response, so neither one leaks which emails are registered.
- Auth uses a short-lived access token (15 minutes), returned in the
response body, plus a 7-day refresh token in an httpOnly cookie.
POST /api/auth/refreshrotates both and checks a server-side record (models/RefreshToken.js) for reuse. Replaying a token that already got rotated away revokes every token descended from that login, not just the one that got reused. Logout revokes it too, not just the browser cookie. A stolen access token is only useful for a few minutes. The refresh token never touches JavaScript-readable storage. - Uploaded files are stored on local disk under
backend/uploads/. For a production deploy with an ephemeral filesystem, swap in S3 or Cloudinary. Until then, a document whose file was wiped by a redeploy shows a "file no longer available" banner instead of a broken viewer. Chat, flashcards and quizzes still work since the extracted text is stored in MongoDB, not on disk. GET /healthis a liveness check.GET /readyalso verifies MongoDB is connected. Point an orchestrator's readiness probe at/ready.- Set
ADMIN_EMAILS(comma-separated) to unlock a read-only cost dashboard at/admin/costs. It shows spend and token usage per user per day, from theLlmCallledger. Access is an allowlist check on every request, not a stored role, so granting or revoking it is just an env var change. - CI runs backend and frontend tests,
npm audit --audit-level=highon both, CodeQL static analysis, and the Playwright E2E suite against a real backend with an ephemeral in-memory MongoDB. See .github/workflows/ci.yml.






