diff --git a/README.md b/README.md
index a478e66..0431156 100644
--- a/README.md
+++ b/README.md
@@ -1,55 +1,93 @@
# FocusMaster
-
-
-
-
-> A unified dashboard designed to maximize your cognitive potential and maintain flow state.
+
+
+
The Ultimate AI-Powered Productivity Workspace
+
A beautifully designed, unified dashboard engineered to maximize cognitive potential, maintain deep work flow states, and intelligently adapt to your study and work patterns.
+
-[Features](#key-features) • [Stack](#tech-stack) • [Quick Start](#quick-start) • [Docs](./docs)
+
+
+
+
+
+
+
----
+## Key Features
-### Key Features
-- **Pomodoro Timer**: Customizable intervals, visual progress rings, and notification sounds.
-- **Kanban Board**: Drag-and-drop workflow tracking with custom tags and priority levels.
-- **Deep Analytics**: Interactive productivity heatmap, trends, and session logs.
-- **Integrations**: Spotify Premium controller and secure Google OAuth sign-in.
-- **Admin Panel**: User role management (RBAC), audit logs, and system metrics.
+### Intelligent AI Study Coach & Planner
+- **Dynamic Study Planner**: Automatically generates weekly subject schedules based on your stream, exam dates, and available hours.
+- **RAG Notes Assistant**: Upload your PDF study materials and chat instantly with an AI that retrieves precise answers directly from your notes.
+- **Smart Nudges & Preparation Advice**: Daily AI-generated insights analyzing your focus history to give actionable productivity advice.
+- **Floating AI Widget**: A premium, globally accessible AI chat assistant ready to provide coaching and context-aware feedback at any moment.
-### Tech Stack
-| Frontend | Backend | DevOps & Database |
-|---|---|---|
-| React 19 + TypeScript | Node.js + Express 5 | MongoDB (Mongoose ODM) |
-| Tailwind CSS 4 + Framer Motion | JWT Auth & Google OAuth | Vercel (CI/CD Deployments) |
-| Zustand + Shadcn/ui | Jest & Supertest | Vitest + Playwright (E2E) |
+### Advanced Productivity Tools
+- **Pomodoro Timer**: Customizable focus intervals, visual progress rings, and adaptive timer suggestions based on drop-off analysis.
+- **Kanban Task Manager**: Drag-and-drop workflow tracking with custom tags, priority levels, and daily persistence.
+- **Clock In/Out Ecosystem**: Comprehensive daily session tracking with rich, calendar-based activity logs.
+- **Deep Analytics**: Interactive productivity heatmaps, focus trends, task completion rates, and gamified level progressions.
----
+### Seamless Integrations
+- **Spotify Premium Controller**: Manage your deep-work playlists and control playback directly from your dashboard without breaking focus.
+- **Secure Authentication**: Robust JWT-based auth and integrated Google OAuth sign-in.
+- **Admin Panel**: User role management (RBAC), system metrics, and audit logs.
+
+## Tech Stack
-### Quick Start
+| Domain | Technologies |
+|---|---|
+| **Frontend** | React 19, TypeScript, Tailwind CSS 4, Framer Motion, Zustand, Shadcn/ui, Vite |
+| **Backend** | Node.js, Express 5, MongoDB (Mongoose), Google Generative AI (Gemini), Multer |
+| **DevOps & Testing** | Vercel (CI/CD), Jest, Supertest, Vitest, Playwright (E2E) |
-1. **Clone the repository:**
+## Quick Start
+
+### 1. Clone the repository
```bash
-git clone https://github.com/codxbrexx/FocusMaster.git && cd FocusMaster
+git clone https://github.com/codxbrexx/FocusMaster.git
+cd FocusMaster
```
-2. **Start Backend:**
-```bash
-cd backend && npm install && npm run dev
+### 2. Configure Backend
+Create a `.env` file in the `backend/` directory:
+```env
+PORT=5000
+MONGO_URI=your_mongodb_connection_string
+JWT_SECRET=your_super_secret_jwt_key
+GOOGLE_CLIENT_ID=your_google_client_id
+GOOGLE_CLIENT_SECRET=your_google_client_secret
+SPOTIFY_CLIENT_ID=your_spotify_client_id
+SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
+GEMINI_API_KEY=your_google_gemini_api_key
+DEFAULT_LLM_MODEL=gemini-2.0-flash
```
-*Configure `backend/.env` with `PORT`, `MONGO_URI`, `JWT_SECRET`, `GOOGLE_CLIENT_ID`, and Spotify credentials.*
-3. **Start Frontend:**
```bash
-cd frontend && npm install && npm run dev
+cd backend
+npm install
+npm run dev
```
-*Configure `frontend/.env` with `VITE_API_URL` and `VITE_GOOGLE_CLIENT_ID`.*
----
+### 3. Configure Frontend
+Create a `.env` file in the `frontend/` directory:
+```env
+VITE_API_URL=http://localhost:5000/api
+VITE_GOOGLE_CLIENT_ID=your_google_client_id
+```
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
-### Development & Resources
-- **Testing**: Run `npm test` in the respective `frontend` or `backend` folder.
-- **Documentation**: Comprehensive guides and specifications are in the [`docs/`](./docs) folder.
+## Documentation
+Comprehensive architecture guides, API specifications, and development workflows can be found in the [`docs/`](./docs) directory.
---
-**Developer:** [@codxbrexx](https://github.com/codxbrexx) • **License**: MIT
+
+
Designed & Developed by @codxbrexx
+
+
Productivity Workspace | MIT License
+
diff --git a/backend/.env.example b/backend/.env.example
index e1d86fb..cc5139e 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -15,3 +15,10 @@ SPOTIFY_REDIRECT_URI=http://localhost:5000/api/spotify/callback
# Vercel sends this as: Authorization: Bearer
# Generate one with: openssl rand -hex 32
CRON_SECRET=replace_with_a_random_32_character_secret
+
+# AI / LLM Integration
+# Set one of the following depending on which LLM provider you prefer to use.
+# For RAG (Phase 4), GEMINI_API_KEY is strongly recommended as it handles Vector Embeddings natively here.
+GEMINI_API_KEY=your_gemini_api_key_here
+ANTHROPIC_API_KEY=your_anthropic_api_key_here
+DEFAULT_LLM_MODEL=gemini-1.5-flash
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 6419777..554bbbd 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
+ "@google/genai": "^2.13.0",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
@@ -22,7 +23,9 @@
"jsonwebtoken": "^9.0.3",
"mongodb": "^7.0.0",
"mongoose": "^9.0.0",
+ "multer": "^2.2.0",
"node-cron": "^4.2.1",
+ "pdf-parse": "1.1.1",
"xss": "^1.0.15",
"zod": "^4.3.6"
},
@@ -601,6 +604,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@google/genai": {
+ "version": "2.13.0",
+ "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.13.0.tgz",
+ "integrity": "sha512-GM7C8Kaomvjz05x5JEO6+l3d/pciL9LxAG9dUjJLD7nTPZ9X0Cfsf2Z7eET6UjgWyUmxXCHtYnQoQ77F9+ZIOQ==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "google-auth-library": "^10.3.0",
+ "p-retry": "^4.6.2",
+ "protobufjs": "^7.5.4",
+ "ws": "^8.18.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@modelcontextprotocol/sdk": "^1.25.2"
+ },
+ "peerDependenciesMeta": {
+ "@modelcontextprotocol/sdk": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1115,6 +1142,63 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@sinclair/typebox": {
"version": "0.34.47",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz",
@@ -1229,12 +1313,17 @@
"version": "25.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
+ "node_modules/@types/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
+ "license": "MIT"
+ },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -1626,6 +1715,12 @@
"node": ">= 8"
}
},
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+ "license": "MIT"
+ },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -1987,9 +2082,19 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2358,6 +2463,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -4642,6 +4762,12 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@@ -5042,6 +5168,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/multer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
+ "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "concat-stream": "^2.0.0",
+ "type-is": "^1.6.18"
+ },
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/multer/node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/multer/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/multer/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/multer/node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/napi-postinstall": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@@ -5116,6 +5304,12 @@
"node": ">=10.5.0"
}
},
+ "node_modules/node-ensure": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
+ "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
+ "license": "MIT"
+ },
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
@@ -5287,6 +5481,19 @@
"node": ">=8"
}
},
+ "node_modules/p-retry": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
+ "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/retry": "0.12.0",
+ "retry": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -5386,6 +5593,28 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/pdf-parse": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz",
+ "integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.1.0",
+ "node-ensure": "^0.0.0"
+ },
+ "engines": {
+ "node": ">=6.8.1"
+ }
+ },
+ "node_modules/pdf-parse/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -5464,6 +5693,29 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/protobufjs": {
+ "version": "7.6.5",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
+ "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -5562,6 +5814,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -5608,6 +5874,15 @@
"node": ">=8"
}
},
+ "node_modules/retry": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
+ "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
@@ -5921,6 +6196,14 @@
"node": ">= 0.8"
}
},
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/streamx": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
@@ -5933,6 +6216,15 @@
"text-decoder": "^1.1.0"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/string-length": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@@ -6328,6 +6620,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -6339,7 +6637,6 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
- "dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
@@ -6417,6 +6714,12 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -6608,6 +6911,27 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/ws": {
+ "version": "8.21.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/xss": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
diff --git a/backend/package.json b/backend/package.json
index b82b944..0207dd1 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -16,6 +16,7 @@
"license": "ISC",
"description": "",
"dependencies": {
+ "@google/genai": "^2.13.0",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
@@ -29,7 +30,9 @@
"jsonwebtoken": "^9.0.3",
"mongodb": "^7.0.0",
"mongoose": "^9.0.0",
+ "multer": "^2.2.0",
"node-cron": "^4.2.1",
+ "pdf-parse": "1.1.1",
"xss": "^1.0.15",
"zod": "^4.3.6"
},
diff --git a/backend/src/app.js b/backend/src/app.js
index cbdf4e8..236943c 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -17,6 +17,8 @@ const seedRoutes = require("./routes/seedRoutes");
const feedbackRoutes = require("./routes/feedbackRoutes");
const gdprRoutes = require("./routes/gdprRoutes");
const cronRoutes = require("./routes/cronRoutes");
+const aiRoutes = require("./routes/aiRoutes");
+const studyProfileRoutes = require("./routes/studyProfileRoutes");
const app = express();
app.set("trust proxy", 1);
@@ -87,6 +89,8 @@ app.use("/api/admin", require("./admin/routes/adminRoutes"));
// Cron endpoint: called by Vercel Cron in production;
// node-cron handles the same job on traditional/local servers (see server.js).
app.use("/api/cron", cronRoutes);
+app.use("/api/ai", aiRoutes);
+app.use("/api/study-profile", studyProfileRoutes);
app.get(["/favicon.ico", "/favicon.png"], (req, res) => res.status(204).end());
diff --git a/backend/src/controllers/aiController.js b/backend/src/controllers/aiController.js
new file mode 100644
index 0000000..b3dae3c
--- /dev/null
+++ b/backend/src/controllers/aiController.js
@@ -0,0 +1,409 @@
+const asyncHandler = require("express-async-handler");
+const { addWeeks } = require("date-fns");
+const { aggregateUserStats } = require("../services/analytics/aggregator");
+const {
+ calculateProductivityScore,
+} = require("../services/analytics/productivityScore");
+const { generateInsights, parseInsightResponse } = require("../services/ai/insightEngine");
+const { generateStudyPlan } = require("../services/ai/studyPlanner");
+const { getRecommendations } = require("../services/ai/recommender");
+const { analyzeFocusDropoff } = require("../services/analytics/focusDropoff");
+const { processDocument } = require("../services/ai/documentProcessor");
+const { generateEmbedding } = require("../services/ai/vectorSearch");
+const { askQuestion, generateQuiz } = require("../services/ai/ragAssistant");
+const { handleStudyChat } = require("../services/ai/chatAssistant");
+
+// Models (only the controller touches the database)
+const AiInsight = require("../models/AiInsight");
+const User = require("../models/User");
+const StudyPlan = require("../models/StudyPlan");
+const Document = require("../models/Document");
+const DocumentChunk = require("../models/DocumentChunk");
+
+const CACHE_HOURS = 24;
+
+// @desc Get aggregated analytics summary + productivity score
+// @route GET /api/ai/summary
+// @access Private
+const getAnalyticsSummary = asyncHandler(async (req, res) => {
+ const days = parseInt(req.query.days, 10) || 30;
+ const stats = await aggregateUserStats(req.user._id, days);
+ const score = calculateProductivityScore(stats, req.user.settings || {});
+
+ res.json({
+ stats,
+ productivityScore: score.score,
+ scoreBreakdown: score.breakdown,
+ });
+});
+
+// @desc Get AI-generated insights (cached 24h)
+// @route GET /api/ai/insights
+// @access Private
+const getInsights = asyncHandler(async (req, res) => {
+ const userId = req.user._id;
+
+ // Check cache
+ const cached = await AiInsight.findOne({
+ user: userId,
+ expiresAt: { $gt: new Date() },
+ })
+ .sort({ generatedAt: -1 })
+ .lean();
+
+ if (cached) {
+ return res.json({
+ insights: cached.insights,
+ recommendations: cached.recommendations,
+ summary: cached.summary,
+ prepAdvice: cached.prepAdvice || "Keep up your preparation!",
+ productivityScore: cached.productivityScore,
+ scoreBreakdown: cached.scoreBreakdown,
+ stats: cached.stats,
+ generatedAt: cached.generatedAt,
+ fromCache: true,
+ });
+ }
+
+ // Aggregate + Score
+ const stats = await aggregateUserStats(userId, 30);
+ const scoreResult = calculateProductivityScore(stats, req.user.settings || {});
+
+ // Call AI service (pure LLM, no DB)
+ let parsed;
+ try {
+ parsed = await generateInsights(stats, scoreResult);
+ } catch (err) {
+ // LLM failed — try stale cache
+ const stale = await AiInsight.findOne({ user: userId })
+ .sort({ generatedAt: -1 })
+ .lean();
+
+ if (stale) {
+ return res.json({
+ insights: stale.insights,
+ recommendations: stale.recommendations,
+ summary: stale.summary,
+ prepAdvice: stale.prepAdvice || "Keep up your preparation!",
+ productivityScore: scoreResult.score,
+ scoreBreakdown: scoreResult.breakdown,
+ stats,
+ generatedAt: stale.generatedAt,
+ fromCache: true,
+ stale: true,
+ });
+ }
+
+ // No cache at all — return fallback
+ parsed = parseInsightResponse("invalid");
+ }
+
+ // Save to cache
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() + CACHE_HOURS * 60 * 60 * 1000);
+
+ const saved = await AiInsight.findOneAndUpdate(
+ { user: userId },
+ {
+ user: userId,
+ insights: parsed.insights,
+ recommendations: parsed.recommendations,
+ summary: parsed.summary,
+ prepAdvice: parsed.prepAdvice,
+ productivityScore: scoreResult.score,
+ scoreBreakdown: scoreResult.breakdown,
+ stats,
+ generatedAt: now,
+ expiresAt,
+ },
+ { upsert: true, new: true },
+ );
+
+ res.json({
+ insights: saved.insights,
+ recommendations: saved.recommendations,
+ summary: saved.summary,
+ prepAdvice: saved.prepAdvice || parsed.prepAdvice,
+ productivityScore: scoreResult.score,
+ scoreBreakdown: scoreResult.breakdown,
+ stats,
+ generatedAt: saved.generatedAt,
+ fromCache: false,
+ });
+});
+
+// @desc Get or generate AI study plan
+// @route GET /api/ai/study-plan
+// @access Private
+const getStudyPlan = asyncHandler(async (req, res) => {
+ const userId = req.user._id;
+
+ // Check for existing plan
+ const existing = await StudyPlan.findOne({ user: userId })
+ .sort({ generatedAt: -1 })
+ .lean();
+
+ if (existing) {
+ return res.json({ plan: existing, fromCache: true });
+ }
+
+ // Fetch profile and stats, then call AI service
+ const user = await User.findById(userId).select("studyProfile settings");
+ const profile = user.studyProfile || {};
+ const stats = await aggregateUserStats(userId, 30);
+
+ const result = await generateStudyPlan(profile, stats);
+
+ if (result.error) {
+ return res.status(result.weeks ? 200 : 400).json({ plan: null, error: result.error });
+ }
+
+ // Add dates and save
+ const saved = await _saveStudyPlan(userId, result.weeks, profile);
+ res.json({ plan: saved, fromCache: false });
+});
+
+// @desc Regenerate AI study plan
+// @route POST /api/ai/study-plan
+// @access Private
+const regenerateStudyPlan = asyncHandler(async (req, res) => {
+ const userId = req.user._id;
+
+ const user = await User.findById(userId).select("studyProfile settings");
+ const profile = user.studyProfile || {};
+ const stats = await aggregateUserStats(userId, 30);
+
+ let result;
+ try {
+ result = await generateStudyPlan(profile, stats);
+ } catch (err) {
+ // LLM failed — return existing plan or error
+ const fallback = await StudyPlan.findOne({ user: userId })
+ .sort({ generatedAt: -1 })
+ .lean();
+
+ if (fallback) {
+ return res.json({ plan: fallback, fromCache: true, stale: true });
+ }
+ return res.status(500).json({ plan: null, error: "Failed to generate study plan. Please try again later." });
+ }
+
+ if (result.error) {
+ return res.status(result.weeks ? 200 : 400).json({ plan: null, error: result.error });
+ }
+
+ const saved = await _saveStudyPlan(userId, result.weeks, profile);
+ res.json({ plan: saved, fromCache: false });
+});
+
+/**
+ * Internal helper to date-stamp and persist a study plan.
+ */
+async function _saveStudyPlan(userId, weeks, profile) {
+ const now = new Date();
+ const weeksWithDates = weeks.map((week, i) => ({
+ ...week,
+ weekNumber: i + 1,
+ startDate: addWeeks(now, i),
+ endDate: addWeeks(now, i + 1),
+ }));
+
+ return StudyPlan.findOneAndUpdate(
+ { user: userId },
+ {
+ user: userId,
+ weeks: weeksWithDates,
+ examDate: profile.examDate,
+ totalWeeks: weeks.length,
+ stream: profile.stream,
+ subjects: (profile.subjects || []).map((s) => s.name),
+ generatedAt: now,
+ },
+ { upsert: true, new: true },
+ );
+}
+
+// @desc Get rule-based recommendations (no LLM)
+// @route GET /api/ai/recommendations
+// @access Private
+const getRecommendationsHandler = asyncHandler(async (req, res) => {
+ const stats = await aggregateUserStats(req.user._id, 30);
+ const result = getRecommendations(stats, req.user.settings || {});
+ res.json(result);
+});
+
+// @desc Get adaptive timer suggestions based on session history
+// @route GET /api/ai/adaptive-timer
+// @access Private
+const getAdaptiveTimer = asyncHandler(async (req, res) => {
+ const result = await analyzeFocusDropoff(req.user._id);
+ res.json(result);
+});
+
+// @desc Upload document for RAG
+// @route POST /api/ai/documents
+// @access Private
+const uploadDocument = asyncHandler(async (req, res) => {
+ if (!req.file) {
+ res.status(400);
+ throw new Error("No file uploaded");
+ }
+
+ const userId = req.user._id;
+
+ // 1. Parse PDF and chunk (pure function, no DB)
+ const { chunks, pageCount } = await processDocument(req.file.buffer);
+
+ // 2. Create Document record
+ const doc = await Document.create({
+ user: userId,
+ title: req.file.originalname.replace(/\.[^/.]+$/, ""),
+ filename: req.file.originalname,
+ size: req.file.size,
+ pageCount,
+ });
+
+ // 3. Generate embeddings and save chunks (batched)
+ const batchSize = 10;
+ let chunkIndex = 0;
+
+ for (let i = 0; i < chunks.length; i += batchSize) {
+ const batch = chunks.slice(i, i + batchSize);
+
+ const chunkDocs = await Promise.all(
+ batch.map(async (content) => {
+ const embedding = await generateEmbedding(content);
+ return {
+ document: doc._id,
+ user: userId,
+ chunkIndex: chunkIndex++,
+ content,
+ embedding,
+ };
+ })
+ );
+
+ await DocumentChunk.insertMany(chunkDocs);
+ }
+
+ res.status(201).json({
+ documentId: doc._id,
+ title: doc.title,
+ chunksProcessed: chunkIndex,
+ });
+});
+
+// @desc Get all user documents
+// @route GET /api/ai/documents
+// @access Private
+const getDocuments = asyncHandler(async (req, res) => {
+ const docs = await Document.find({ user: req.user._id }).sort({ uploadedAt: -1 });
+ res.json({ documents: docs });
+});
+
+/**
+ * Internal helper to perform vector search via MongoDB Atlas.
+ */
+async function _searchSimilarChunks(query, userId, topK = 5) {
+ const queryEmbedding = await generateEmbedding(query);
+
+ try {
+ // NOTE: This requires a search index named "vector_index" created in Atlas
+ const results = await DocumentChunk.aggregate([
+ {
+ $vectorSearch: {
+ index: "vector_index",
+ path: "embedding",
+ queryVector: queryEmbedding,
+ numCandidates: topK * 10,
+ limit: topK,
+ filter: { user: { $eq: userId } },
+ }
+ },
+ {
+ $project: {
+ content: 1,
+ score: { $meta: "vectorSearchScore" }
+ }
+ }
+ ]);
+
+ return results;
+ } catch (err) {
+ console.error("Atlas Vector Search failed. Is it enabled on this cluster?", err.message);
+ return [];
+ }
+}
+
+// @desc Ask a question based on uploaded documents
+// @route POST /api/ai/rag/query
+// @access Private
+const queryRag = asyncHandler(async (req, res) => {
+ const { query } = req.body;
+ if (!query) {
+ res.status(400);
+ throw new Error("Query is required");
+ }
+
+ // Fetch chunks from DB, then pass to pure AI function
+ const chunks = await _searchSimilarChunks(query, req.user._id, 5);
+ const result = await askQuestion(query, chunks);
+ res.json(result);
+});
+
+// @desc Generate a quiz based on uploaded documents
+// @route POST /api/ai/rag/quiz
+// @access Private
+const getQuiz = asyncHandler(async (req, res) => {
+ const { topic } = req.body;
+
+ // Fetch chunks from DB, then pass to pure AI function
+ const searchQuery = topic || "key concepts overview summary";
+ const chunks = await _searchSimilarChunks(searchQuery, req.user._id, 5);
+ const result = await generateQuiz(chunks);
+ res.json(result);
+});
+
+// @desc General chat about study preparation and analytics
+// @route POST /api/ai/chat
+// @access Private
+const studyChat = asyncHandler(async (req, res) => {
+ const { message, history } = req.body;
+ if (!message) {
+ res.status(400);
+ throw new Error("Message is required");
+ }
+
+ const userId = req.user._id;
+
+ // Fetch all context from DB
+ const [stats, user, studyPlan] = await Promise.all([
+ aggregateUserStats(userId, 30),
+ User.findById(userId).select("studyProfile").lean(),
+ StudyPlan.findOne({ user: userId }).sort({ generatedAt: -1 }).lean(),
+ ]);
+
+ // Pass structured context to pure AI function
+ const contextData = {
+ stats,
+ studyProfile: user?.studyProfile || null,
+ studyPlan,
+ };
+
+ const result = await handleStudyChat(message, history || [], contextData);
+ res.json(result);
+});
+
+module.exports = {
+ getAnalyticsSummary,
+ getInsights,
+ getStudyPlan,
+ regenerateStudyPlan,
+ getRecommendationsHandler,
+ getAdaptiveTimer,
+ uploadDocument,
+ getDocuments,
+ queryRag,
+ getQuiz,
+ studyChat,
+};
diff --git a/backend/src/controllers/studyProfileController.js b/backend/src/controllers/studyProfileController.js
new file mode 100644
index 0000000..1553c34
--- /dev/null
+++ b/backend/src/controllers/studyProfileController.js
@@ -0,0 +1,51 @@
+const asyncHandler = require("express-async-handler");
+const User = require("../models/User");
+
+// @desc Get current user's study profile
+// @route GET /api/study-profile
+// @access Private
+const getStudyProfile = asyncHandler(async (req, res) => {
+ const user = await User.findById(req.user._id).select("studyProfile");
+
+ res.json({
+ studyProfile: user.studyProfile || {
+ stream: null,
+ customStreamName: "",
+ subjects: [],
+ examDate: null,
+ weeklyGoalHours: 20,
+ availableHoursPerDay: 4,
+ },
+ });
+});
+
+// @desc Update current user's study profile
+// @route PUT /api/study-profile
+// @access Private
+const updateStudyProfile = asyncHandler(async (req, res) => {
+ const allowedFields = [
+ "stream",
+ "customStreamName",
+ "subjects",
+ "examDate",
+ "weeklyGoalHours",
+ "availableHoursPerDay",
+ ];
+
+ const updates = {};
+ for (const field of allowedFields) {
+ if (req.body[field] !== undefined) {
+ updates[`studyProfile.${field}`] = req.body[field];
+ }
+ }
+
+ const user = await User.findByIdAndUpdate(
+ req.user._id,
+ { $set: updates },
+ { new: true, runValidators: true },
+ ).select("studyProfile");
+
+ res.json({ studyProfile: user.studyProfile });
+});
+
+module.exports = { getStudyProfile, updateStudyProfile };
diff --git a/backend/src/models/AiInsight.js b/backend/src/models/AiInsight.js
new file mode 100644
index 0000000..920c45a
--- /dev/null
+++ b/backend/src/models/AiInsight.js
@@ -0,0 +1,51 @@
+const mongoose = require("mongoose");
+
+const aiInsightSchema = mongoose.Schema({
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ required: true,
+ ref: "User",
+ index: true,
+ },
+ insights: {
+ type: [String],
+ default: [],
+ },
+ recommendations: {
+ type: [String],
+ default: [],
+ },
+ summary: {
+ type: String,
+ default: "",
+ },
+ prepAdvice: {
+ type: String,
+ default: "",
+ },
+ productivityScore: {
+ type: Number,
+ default: 0,
+ },
+ scoreBreakdown: {
+ type: Object,
+ default: {},
+ },
+ stats: {
+ type: Object,
+ default: {},
+ },
+ generatedAt: {
+ type: Date,
+ default: Date.now,
+ },
+ expiresAt: {
+ type: Date,
+ required: true,
+ index: { expires: 0 }, // TTL index — MongoDB auto-deletes when expiresAt passes
+ },
+});
+
+const AiInsight = mongoose.model("AiInsight", aiInsightSchema);
+
+module.exports = AiInsight;
diff --git a/backend/src/models/Document.js b/backend/src/models/Document.js
new file mode 100644
index 0000000..6814a3b
--- /dev/null
+++ b/backend/src/models/Document.js
@@ -0,0 +1,24 @@
+const mongoose = require("mongoose");
+
+const documentSchema = mongoose.Schema(
+ {
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ required: true,
+ ref: "User",
+ index: true,
+ },
+ title: { type: String, required: true },
+ filename: { type: String, required: true },
+ size: { type: Number, required: true },
+ pageCount: { type: Number, default: 0 },
+ uploadedAt: { type: Date, default: Date.now },
+ },
+ {
+ timestamps: true,
+ },
+);
+
+const Document = mongoose.model("Document", documentSchema);
+
+module.exports = Document;
diff --git a/backend/src/models/DocumentChunk.js b/backend/src/models/DocumentChunk.js
new file mode 100644
index 0000000..2f52aba
--- /dev/null
+++ b/backend/src/models/DocumentChunk.js
@@ -0,0 +1,44 @@
+const mongoose = require("mongoose");
+
+const documentChunkSchema = mongoose.Schema(
+ {
+ document: {
+ type: mongoose.Schema.Types.ObjectId,
+ required: true,
+ ref: "Document",
+ index: true,
+ },
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ required: true,
+ ref: "User",
+ index: true,
+ },
+ chunkIndex: { type: Number, required: true },
+ content: { type: String, required: true },
+ // 1536 is standard for text-embedding-ada-002, adjust if using other models like Google embeddings
+ embedding: { type: [Number] },
+ },
+ {
+ timestamps: true,
+ },
+);
+
+// We won't create the Atlas Vector Search index via Mongoose (it's managed via MongoDB Atlas UI or API).
+// The index typically looks like:
+// {
+// "mappings": {
+// "dynamic": true,
+// "fields": {
+// "embedding": {
+// "dimensions": 1536,
+// "similarity": "cosine",
+// "type": "knnVector"
+// }
+// }
+// }
+// }
+
+const DocumentChunk = mongoose.model("DocumentChunk", documentChunkSchema);
+
+module.exports = DocumentChunk;
diff --git a/backend/src/models/StudyPlan.js b/backend/src/models/StudyPlan.js
new file mode 100644
index 0000000..9bc3806
--- /dev/null
+++ b/backend/src/models/StudyPlan.js
@@ -0,0 +1,52 @@
+const mongoose = require("mongoose");
+
+const dailySubjectSchema = mongoose.Schema(
+ {
+ name: { type: String, required: true },
+ hours: { type: Number, required: true },
+ activity: { type: String, default: "Study" },
+ },
+ { _id: false },
+);
+
+const dailyPlanSchema = mongoose.Schema(
+ {
+ day: { type: String, required: true },
+ subjects: [dailySubjectSchema],
+ },
+ { _id: false },
+);
+
+const weekPlanSchema = mongoose.Schema(
+ {
+ weekNumber: { type: Number, required: true },
+ startDate: { type: Date },
+ endDate: { type: Date },
+ dailyPlans: [dailyPlanSchema],
+ },
+ { _id: false },
+);
+
+const studyPlanSchema = mongoose.Schema(
+ {
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ required: true,
+ ref: "User",
+ index: true,
+ },
+ weeks: [weekPlanSchema],
+ examDate: { type: Date },
+ totalWeeks: { type: Number, default: 0 },
+ stream: { type: String },
+ subjects: [{ type: String }],
+ generatedAt: { type: Date, default: Date.now },
+ },
+ {
+ timestamps: true,
+ },
+);
+
+const StudyPlan = mongoose.model("StudyPlan", studyPlanSchema);
+
+module.exports = StudyPlan;
diff --git a/backend/src/models/User.js b/backend/src/models/User.js
index b87f8cd..ce75585 100644
--- a/backend/src/models/User.js
+++ b/backend/src/models/User.js
@@ -78,6 +78,27 @@ const userSchema = mongoose.Schema(
type: String,
select: false,
},
+ studyProfile: {
+ stream: {
+ type: String,
+ enum: ["engineering", "medical", "commerce", "competitive", "custom"],
+ default: null,
+ },
+ customStreamName: { type: String, default: "" },
+ subjects: [
+ {
+ name: { type: String, required: true },
+ difficulty: {
+ type: String,
+ enum: ["easy", "medium", "hard"],
+ default: "medium",
+ },
+ },
+ ],
+ examDate: { type: Date, default: null },
+ weeklyGoalHours: { type: Number, default: 20 },
+ availableHoursPerDay: { type: Number, default: 4 },
+ },
},
{
timestamps: true,
diff --git a/backend/src/routes/aiRoutes.js b/backend/src/routes/aiRoutes.js
new file mode 100644
index 0000000..7da3543
--- /dev/null
+++ b/backend/src/routes/aiRoutes.js
@@ -0,0 +1,43 @@
+const express = require("express");
+const multer = require("multer");
+const { protect } = require("../middleware/authMiddleware");
+const { apiLimiter } = require("../middleware/rateLimitMiddleware");
+const {
+ getAnalyticsSummary,
+ getInsights,
+ getStudyPlan,
+ regenerateStudyPlan,
+ getRecommendationsHandler,
+ getAdaptiveTimer,
+ uploadDocument,
+ getDocuments,
+ queryRag,
+ getQuiz,
+ studyChat,
+} = require("../controllers/aiController");
+
+const router = express.Router();
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit
+});
+
+// All AI routes require authentication + rate limiting
+router.get("/summary", protect, apiLimiter, getAnalyticsSummary);
+router.get("/insights", protect, apiLimiter, getInsights);
+router.get("/study-plan", protect, apiLimiter, getStudyPlan);
+router.post("/study-plan", protect, apiLimiter, regenerateStudyPlan);
+router.get("/recommendations", protect, apiLimiter, getRecommendationsHandler);
+router.get("/adaptive-timer", protect, apiLimiter, getAdaptiveTimer);
+
+// RAG Routes
+router.post("/documents", protect, apiLimiter, upload.single("file"), uploadDocument);
+router.get("/documents", protect, apiLimiter, getDocuments);
+router.post("/rag/query", protect, apiLimiter, queryRag);
+router.post("/rag/quiz", protect, apiLimiter, getQuiz);
+router.post("/chat", protect, apiLimiter, studyChat);
+
+module.exports = router;
+
+
+
diff --git a/backend/src/routes/studyProfileRoutes.js b/backend/src/routes/studyProfileRoutes.js
new file mode 100644
index 0000000..0ad1c8c
--- /dev/null
+++ b/backend/src/routes/studyProfileRoutes.js
@@ -0,0 +1,22 @@
+const express = require("express");
+const { protect } = require("../middleware/authMiddleware");
+const { apiLimiter } = require("../middleware/rateLimitMiddleware");
+const { validate } = require("../middleware/validateMiddleware");
+const { studyProfileBodySchema } = require("../validation/schemas");
+const {
+ getStudyProfile,
+ updateStudyProfile,
+} = require("../controllers/studyProfileController");
+
+const router = express.Router();
+
+router.get("/", protect, apiLimiter, getStudyProfile);
+router.put(
+ "/",
+ protect,
+ apiLimiter,
+ validate({ body: studyProfileBodySchema }),
+ updateStudyProfile,
+);
+
+module.exports = router;
diff --git a/backend/src/services/ai/chatAssistant.js b/backend/src/services/ai/chatAssistant.js
new file mode 100644
index 0000000..0ff8e2a
--- /dev/null
+++ b/backend/src/services/ai/chatAssistant.js
@@ -0,0 +1,73 @@
+const { generate } = require("../llmService");
+
+/**
+ * Handle a general study chat query from the user, providing LLM with context
+ * about their study profile, plan, and recent focus stats.
+ *
+ * This function is a pure data-transformation layer. It does NOT access
+ * the database. The caller (controller) must fetch stats, profile, and
+ * plan from the DB and pass them in via the `contextData` argument.
+ *
+ * @param {string} message
+ * @param {Array<{role: string, text: string}>} history
+ * @param {Object} contextData
+ * @param {Object} [contextData.stats]
+ * @param {Object} [contextData.studyProfile]
+ * @param {Object} [contextData.studyPlan]
+ */
+async function handleStudyChat(message, history = [], contextData = {}) {
+ const { stats, studyProfile, studyPlan } = contextData;
+
+ // Format context for LLM
+ let contextStr = "User Study Context:\n";
+ if (studyProfile && studyProfile.stream) {
+ contextStr += `- Stream/Goal: ${studyProfile.customStreamName || studyProfile.stream}\n`;
+ contextStr += `- Subjects: ${(studyProfile.subjects || []).map(s => s.name).join(", ")}\n`;
+ if (studyProfile.examDate) {
+ contextStr += `- Exam Date: ${new Date(studyProfile.examDate).toLocaleDateString()}\n`;
+ }
+ } else {
+ contextStr += `- Study Profile: Not completely set up yet.\n`;
+ }
+
+ if (stats) {
+ contextStr += `- Recent Focus Stats (last 30 days): ${stats.focus.totalSessions} sessions, ${stats.focus.totalMinutes} total minutes focused.\n`;
+ contextStr += `- Task Completion Rate: ${Math.round(stats.tasks.completionRate)}%\n`;
+ contextStr += `- Current Streak: ${stats.patterns.currentStreak} days\n`;
+ }
+
+ if (studyPlan && studyPlan.weeks && studyPlan.weeks.length > 0) {
+ const currentWeek = studyPlan.weeks[0];
+ contextStr += `- Current Study Plan Week: ${currentWeek.weekNumber} (${currentWeek.theme})\n`;
+ }
+
+ // Build the system prompt
+ let prompt = `You are an expert, encouraging AI study coach and preparation analyzer.
+You are helping a student prepare and analyze their study progress based on their personalized data.
+
+${contextStr}
+
+Be concise, supportive, and highly actionable. Answer the user's latest message based on this context. Do not use Markdown headings like # or ## if possible, just use bold text and lists for clean chat rendering.
+
+Chat History:
+`;
+
+ // Append history (limit to last 6 messages to save tokens)
+ const recentHistory = history.slice(-6);
+ recentHistory.forEach(msg => {
+ prompt += `${msg.role === 'user' ? 'Student' : 'Coach'}: ${msg.text}\n`;
+ });
+
+ prompt += `Student: ${message}\nCoach:`;
+
+ const answer = await generate(prompt, null, {
+ temperature: 0.7,
+ max_tokens: 600,
+ });
+
+ return { answer: answer.trim() };
+}
+
+module.exports = {
+ handleStudyChat,
+};
diff --git a/backend/src/services/ai/documentProcessor.js b/backend/src/services/ai/documentProcessor.js
new file mode 100644
index 0000000..6617558
--- /dev/null
+++ b/backend/src/services/ai/documentProcessor.js
@@ -0,0 +1,73 @@
+const pdfParse = require("pdf-parse");
+
+/**
+ * Splits text into chunks of ~1000 characters with 100 character overlap.
+ *
+ * @param {string} text
+ * @returns {string[]}
+ */
+function chunkText(text) {
+ const chunkSize = 1000;
+ const overlap = 100;
+ const chunks = [];
+
+ let index = 0;
+ while (index < text.length) {
+ let end = index + chunkSize;
+
+ // If not at the end of text, try to find a natural break point (newline or period)
+ if (end < text.length) {
+ const nextNewline = text.indexOf('\n', end);
+ const nextPeriod = text.indexOf('. ', end);
+
+ // If we can find a natural break within 200 chars, use it
+ if (nextNewline !== -1 && nextNewline - end < 200) {
+ end = nextNewline + 1;
+ } else if (nextPeriod !== -1 && nextPeriod - end < 200) {
+ end = nextPeriod + 2;
+ }
+ } else {
+ end = text.length;
+ }
+
+ const chunk = text.slice(index, end).trim();
+ if (chunk.length > 50) { // Ignore tiny chunks
+ chunks.push(chunk);
+ }
+
+ index = end - overlap;
+
+ // Ensure we move forward
+ if (index <= index - (end - index)) {
+ index = end;
+ }
+ }
+
+ return chunks;
+}
+
+/**
+ * Parses a PDF buffer and returns chunked text strings.
+ *
+ * This function is a pure data-transformation layer. It does NOT access
+ * the database. The caller (controller) is responsible for creating the
+ * Document record, generating embeddings, and saving DocumentChunk records.
+ *
+ * @param {Buffer} fileBuffer - Raw PDF file buffer
+ * @returns {Promise<{ text: string, chunks: string[], pageCount: number }>}
+ */
+async function processDocument(fileBuffer) {
+ // 1. Parse PDF
+ const data = await pdfParse(fileBuffer);
+
+ // 2. Chunk text
+ const chunks = chunkText(data.text);
+
+ return {
+ text: data.text,
+ chunks,
+ pageCount: data.numpages || 0,
+ };
+}
+
+module.exports = { processDocument, chunkText };
diff --git a/backend/src/services/ai/insightEngine.js b/backend/src/services/ai/insightEngine.js
new file mode 100644
index 0000000..76bbca2
--- /dev/null
+++ b/backend/src/services/ai/insightEngine.js
@@ -0,0 +1,110 @@
+const { generateJSON } = require("../llmService");
+
+const INSIGHT_SYSTEM_INSTRUCTION = `You are a productivity coach analyzing a student's study data.
+
+Rules:
+- Insights should be specific observations about the user's patterns (e.g., "You focus best between 7–10 PM")
+- Recommendations should be actionable changes (e.g., "Try 35-minute sessions instead of 25")
+- Summary should be encouraging and personalized
+- Keep each item under 100 characters
+- Be conversational, not robotic`;
+
+/**
+ * Build a compact prompt from pre-aggregated stats.
+ * Follows the architecture doc's prompt strategy: never send raw sessions.
+ */
+function buildInsightPrompt(stats, scoreResult) {
+ const peakHoursFormatted = (stats.patterns.peakHours || [])
+ .map((h) => {
+ const hour12 = h % 12 || 12;
+ const ampm = h < 12 ? "AM" : "PM";
+ return `${hour12} ${ampm}`;
+ })
+ .join(", ");
+
+ return `User Statistics:
+- Average focus session: ${stats.focus.avgDurationMin} minutes
+- Session completion rate: ${stats.focus.completionRate}%
+- Total focus this week: ${stats.focus.weeklyMinutes} minutes
+- Current streak: ${stats.patterns.currentStreak} days
+- Most productive hours: ${peakHoursFormatted || "not enough data"}
+- Break frequency: ${stats.patterns.breakFrequency} breaks per focus session
+- Task completion rate: ${stats.tasks.completionRate}%
+- Productivity score: ${scoreResult.score}/100
+
+Generate a JSON response with exactly this structure:
+{
+ "insights": ["insight1", "insight2", "insight3"],
+ "recommendations": ["recommendation1", "recommendation2"],
+ "summary": "one motivational sentence",
+ "prepAdvice": "A specific piece of advice on their exam/subject preparation based on their streak and focus."
+}`;
+}
+
+/**
+ * Validate and clean the parsed LLM JSON response.
+ */
+function parseInsightResponse(parsed) {
+ if (!parsed) parsed = {};
+ try {
+ return {
+ insights: Array.isArray(parsed.insights)
+ ? parsed.insights.slice(0, 3)
+ : [],
+ recommendations: Array.isArray(parsed.recommendations)
+ ? parsed.recommendations.slice(0, 2)
+ : [],
+ summary:
+ typeof parsed.summary === "string"
+ ? parsed.summary
+ : "Keep up the great work!",
+ prepAdvice:
+ typeof parsed.prepAdvice === "string"
+ ? parsed.prepAdvice
+ : "Stay consistent with your preparation!",
+ };
+ } catch {
+ // Fallback if parsing fails
+ return {
+ insights: ["We're still analyzing your patterns — check back soon."],
+ recommendations: [
+ "Complete a few more focus sessions for personalized tips.",
+ ],
+ summary: "Every session counts — keep going!",
+ prepAdvice: "Consistency is key to mastering your subjects.",
+ };
+ }
+}
+
+/**
+ * Generate AI insights from pre-aggregated stats and a pre-computed score.
+ *
+ * This function is a pure data-transformation layer:
+ * 1. Build prompt from stats + score
+ * 2. Call LLM
+ * 3. Parse response
+ * 4. Return structured result
+ *
+ * It does NOT access the database. The caller (controller) is responsible
+ * for cache checks, cache writes, and fetching stats/score.
+ *
+ * @param {Object} stats
+ * @param {Object} scoreResult
+ * @returns {Promise}
+ */
+async function generateInsights(stats, scoreResult) {
+ const prompt = buildInsightPrompt(stats, scoreResult);
+ try {
+ const llmResponse = await generateJSON(prompt, {
+ max_tokens: 400,
+ temperature: 0.4,
+ systemInstruction: INSIGHT_SYSTEM_INSTRUCTION,
+ });
+ return parseInsightResponse(llmResponse);
+ } catch (error) {
+ console.error("Error generating insights:", error);
+ return parseInsightResponse(null);
+ }
+}
+
+module.exports = { generateInsights, buildInsightPrompt, parseInsightResponse };
diff --git a/backend/src/services/ai/ragAssistant.js b/backend/src/services/ai/ragAssistant.js
new file mode 100644
index 0000000..1e49a09
--- /dev/null
+++ b/backend/src/services/ai/ragAssistant.js
@@ -0,0 +1,111 @@
+const { generate } = require("../llmService");
+
+/**
+ * Ask a question based on pre-fetched document chunks.
+ *
+ * This function is a pure data-transformation layer. It does NOT access
+ * the database. The caller (controller) performs the vector search and
+ * passes the relevant chunks in.
+ *
+ * @param {string} query
+ * @param {Array<{ content: string }>} chunks
+ * @returns {Promise}
+ */
+async function askQuestion(query, chunks) {
+ if (!chunks || chunks.length === 0) {
+ return {
+ answer: "I couldn't find any relevant information in your uploaded notes. Please try rephrasing or upload more documents.",
+ context: [],
+ };
+ }
+
+ // Build context
+ const contextText = chunks.map((c, i) => `[Source ${i + 1}]:\n${c.content}`).join("\n\n");
+
+ // Build prompt
+ const prompt = `You are an intelligent study assistant. Answer the student's question using ONLY the provided context from their notes.
+If the answer is not contained in the context, politely state that you don't know based on the provided notes.
+
+Context:
+${contextText}
+
+Question: ${query}
+
+Answer in a clear, educational tone.`;
+
+ // Generate answer
+ try {
+ const answer = await generate(prompt, null, {
+ temperature: 0.2, // Low temp for factual answers
+ max_tokens: 500,
+ });
+
+ return {
+ answer,
+ context: chunks.map(c => c.content.substring(0, 100) + "..."),
+ };
+ } catch (error) {
+ console.error("LLM failed in askQuestion:", error);
+ return {
+ error: "Failed to generate an answer. Please try again later."
+ };
+ }
+}
+
+/**
+ * Generates a multiple-choice quiz from pre-fetched document chunks.
+ *
+ * This function is a pure data-transformation layer. It does NOT access
+ * the database. The caller (controller) performs the vector search and
+ * passes the relevant chunks in.
+ *
+ * @param {Array<{ content: string }>} chunks - Pre-fetched relevant chunks
+ * @returns {Promise}
+ */
+async function generateQuiz(chunks) {
+ if (!chunks || chunks.length === 0) {
+ return {
+ error: "Not enough document content to generate a quiz. Upload notes first."
+ };
+ }
+
+ const contextText = chunks.map(c => c.content).join("\n\n");
+
+ const prompt = `You are a strict teacher. Based on the following study notes, generate a 5-question multiple choice quiz.
+Each question must have exactly 4 options and 1 correct answer.
+Return the result strictly as a JSON object matching this schema (no markdown fences):
+{
+ "title": "Quiz Title",
+ "questions": [
+ {
+ "question": "Question text",
+ "options": ["Option A", "Option B", "Option C", "Option D"],
+ "correctAnswerIndex": 0,
+ "explanation": "Brief explanation of why this is correct"
+ }
+ ]
+}
+
+Study Notes:
+${contextText}`;
+
+ try {
+ let response = await generate(prompt, null, {
+ temperature: 0.3,
+ max_tokens: 1500,
+ });
+
+ let cleaned = response.trim();
+ if (cleaned.startsWith("```")) {
+ cleaned = cleaned.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
+ }
+
+ const quiz = JSON.parse(cleaned);
+ return { quiz };
+ } catch (error) {
+ console.error("Failed to generate quiz:", error);
+ return { error: "Failed to generate quiz. Please try again." };
+ }
+}
+
+module.exports = { askQuestion, generateQuiz };
diff --git a/backend/src/services/ai/recommender.js b/backend/src/services/ai/recommender.js
new file mode 100644
index 0000000..bf84b3d
--- /dev/null
+++ b/backend/src/services/ai/recommender.js
@@ -0,0 +1,184 @@
+/**
+ * Rule-Based Recommender
+ *
+ * Generates 2–3 actionable nudges per day from aggregated stats.
+ * No LLM call needed — pure threshold/rule logic.
+ *
+ * Each rule checks a condition and returns a recommendation object
+ * with { type, message, priority }.
+ */
+
+const RULES = [
+ // ── Focus Duration ────────────────────────────────────────────
+ {
+ id: "shorter-sessions",
+ check: (stats) =>
+ stats.focus.completionRate < 70 && stats.focus.avgDurationMin > 35,
+ result: () => ({
+ type: "focus",
+ message:
+ "Your completion rate drops with longer sessions — try 25–30 minute Pomodoros instead.",
+ priority: "high",
+ }),
+ },
+ {
+ id: "longer-sessions",
+ check: (stats) =>
+ stats.focus.completionRate > 90 && stats.focus.avgDurationMin < 30,
+ result: (stats) => ({
+ type: "focus",
+ message: `You complete ${stats.focus.completionRate}% of sessions — you might handle 35–40 minute sessions well.`,
+ priority: "medium",
+ }),
+ },
+
+ // ── Consistency ───────────────────────────────────────────────
+ {
+ id: "streak-at-risk",
+ check: (stats) =>
+ stats.patterns.currentStreak > 0 && stats.patterns.currentStreak <= 2,
+ result: (stats) => ({
+ type: "streak",
+ message: `Your ${stats.patterns.currentStreak}-day streak is just getting started — keep it alive today!`,
+ priority: "high",
+ }),
+ },
+ {
+ id: "streak-momentum",
+ check: (stats) => stats.patterns.currentStreak >= 7,
+ result: (stats) => ({
+ type: "streak",
+ message: `${stats.patterns.currentStreak}-day streak! You've built solid momentum — don't break the chain.`,
+ priority: "low",
+ }),
+ },
+ {
+ id: "no-streak",
+ check: (stats) =>
+ stats.patterns.currentStreak === 0 && stats.focus.totalSessions > 0,
+ result: () => ({
+ type: "streak",
+ message:
+ "Start a new streak today — even one short session counts!",
+ priority: "medium",
+ }),
+ },
+
+ // ── Peak Hours ────────────────────────────────────────────────
+ {
+ id: "peak-hours-reminder",
+ check: (stats) => {
+ const peaks = stats.patterns.peakHours || [];
+ if (peaks.length === 0) return false;
+ const currentHour = new Date().getHours();
+ // Suggest if any peak hour is within the next 2 hours
+ return peaks.some((h) => h >= currentHour && h <= currentHour + 2);
+ },
+ result: (stats) => {
+ const peakFormatted = (stats.patterns.peakHours || [])
+ .map((h) => {
+ const h12 = h % 12 || 12;
+ return `${h12} ${h < 12 ? "AM" : "PM"}`;
+ })
+ .join(", ");
+ return {
+ type: "timing",
+ message: `Your peak focus time is coming up (${peakFormatted}) — schedule a session now.`,
+ priority: "high",
+ };
+ },
+ },
+
+ // ── Break Frequency ───────────────────────────────────────────
+ {
+ id: "too-few-breaks",
+ check: (stats) =>
+ stats.patterns.breakFrequency < 0.5 && stats.focus.totalSessions >= 5,
+ result: () => ({
+ type: "wellness",
+ message:
+ "You're not taking enough breaks — regular breaks boost long-term focus.",
+ priority: "medium",
+ }),
+ },
+
+ // ── Task Completion ───────────────────────────────────────────
+ {
+ id: "low-task-completion",
+ check: (stats) =>
+ stats.tasks.total > 3 && stats.tasks.completionRate < 50,
+ result: () => ({
+ type: "tasks",
+ message:
+ "Less than half your tasks are done — break large tasks into smaller, completable pieces.",
+ priority: "medium",
+ }),
+ },
+
+ // ── Weekly Volume ─────────────────────────────────────────────
+ {
+ id: "low-weekly-volume",
+ check: (stats) =>
+ stats.focus.weeklyMinutes < 60 && stats.focus.totalSessions > 0,
+ result: () => ({
+ type: "volume",
+ message:
+ "Under 1 hour of focus this week — aim for at least 2–3 sessions today.",
+ priority: "high",
+ }),
+ },
+ {
+ id: "great-weekly-volume",
+ check: (stats) => stats.focus.weeklyMinutes > 600,
+ result: (stats) => ({
+ type: "volume",
+ message: `${Math.round(stats.focus.weeklyMinutes / 60)} hours of focus this week — excellent! Make sure to rest too.`,
+ priority: "low",
+ }),
+ },
+];
+
+/**
+ * Get personalized recommendations for a user.
+ *
+ * @param {Object} stats - Output from aggregateUserStats()
+ * @param {Object} [userSettings={}] - User.settings
+ * @returns {{ recommendations: Array<{ type: string, message: string, priority: string }> }}
+ */
+function getRecommendations(stats, userSettings = {}) {
+ if (!stats || stats.focus.totalSessions === 0) {
+ return {
+ recommendations: [
+ {
+ type: "onboarding",
+ message:
+ "Complete your first focus session to get personalized recommendations!",
+ priority: "medium",
+ },
+ ],
+ };
+ }
+
+ const triggered = [];
+
+ for (const rule of RULES) {
+ try {
+ if (rule.check(stats, userSettings)) {
+ triggered.push({ id: rule.id, ...rule.result(stats, userSettings) });
+ }
+ } catch {
+ // Skip broken rules silently
+ }
+ }
+
+ // Sort by priority and return top 3
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
+ triggered.sort(
+ (a, b) =>
+ (priorityOrder[a.priority] ?? 1) - (priorityOrder[b.priority] ?? 1),
+ );
+
+ return { recommendations: triggered.slice(0, 3) };
+}
+
+module.exports = { getRecommendations, RULES };
diff --git a/backend/src/services/ai/studyPlanner.js b/backend/src/services/ai/studyPlanner.js
new file mode 100644
index 0000000..d17a99f
--- /dev/null
+++ b/backend/src/services/ai/studyPlanner.js
@@ -0,0 +1,134 @@
+const { generate } = require("../llmService");
+const { differenceInWeeks, format } = require("date-fns");
+
+/**
+ * Build a compact prompt for study plan generation.
+ */
+function buildPlannerPrompt(profile, stats) {
+ const subjectList = (profile.subjects || [])
+ .map((s) => `${s.name} (${s.difficulty || "medium"})`)
+ .join(", ");
+
+ const peakHoursFormatted = (stats.patterns.peakHours || [])
+ .map((h) => {
+ const hour12 = h % 12 || 12;
+ const ampm = h < 12 ? "AM" : "PM";
+ return `${hour12} ${ampm}`;
+ })
+ .join(", ");
+
+ const examDate = profile.examDate
+ ? format(new Date(profile.examDate), "yyyy-MM-dd")
+ : "not set";
+
+ const weeksUntilExam = profile.examDate
+ ? Math.max(differenceInWeeks(new Date(profile.examDate), new Date()), 1)
+ : 4;
+
+ return `You are a study planner for a ${profile.stream || "general"} student.
+
+Student Profile:
+- Stream: ${profile.stream || "general"}${profile.customStreamName ? ` (${profile.customStreamName})` : ""}
+- Subjects: ${subjectList || "not specified"}
+- Exam date: ${examDate}
+- Weeks until exam: ${weeksUntilExam}
+- Available hours/day: ${profile.availableHoursPerDay || 4}
+- Weekly goal: ${profile.weeklyGoalHours || 20} hours
+
+Productivity Data:
+- Best study hours: ${peakHoursFormatted || "not enough data"}
+- Average focus duration: ${stats.focus.avgDurationMin} minutes
+- Session completion rate: ${stats.focus.completionRate}%
+
+Generate a study plan for ${Math.min(weeksUntilExam, 8)} weeks as JSON (no markdown fences):
+{
+ "weeks": [
+ {
+ "weekNumber": 1,
+ "theme": "Foundation concepts",
+ "dailyPlans": [
+ {
+ "day": "Monday",
+ "subjects": [
+ { "name": "Subject Name", "hours": 2, "activity": "Study" }
+ ]
+ }
+ ]
+ }
+ ]
+}
+
+Rules:
+- Spread subjects across the week, harder subjects during peak hours
+- Include revision and practice days
+- Total daily hours must not exceed ${profile.availableHoursPerDay || 4}
+- Last 1-2 weeks should focus on revision and mock tests
+- Activity types: Study, Revision, Practice, Mock Test
+- Include all 7 days (Monday to Sunday) with lighter loads on weekends
+- Keep it realistic and achievable`;
+}
+
+/**
+ * Parse the LLM study plan response.
+ */
+function parsePlanResponse(text, weeksCount) {
+ try {
+ let cleaned = text.trim();
+ if (cleaned.startsWith("```")) {
+ cleaned = cleaned.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
+ }
+ const parsed = JSON.parse(cleaned);
+
+ if (Array.isArray(parsed.weeks) && parsed.weeks.length > 0) {
+ return parsed.weeks.slice(0, weeksCount);
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Generate a study plan from a user's profile and aggregated stats.
+ *
+ * This function is a pure data-transformation layer:
+ * 1. Build prompt from profile + stats
+ * 2. Call LLM
+ * 3. Parse response into structured weeks
+ * 4. Return the parsed weeks array (or null on failure)
+ *
+ * It does NOT access the database. The caller (controller) is responsible
+ * for fetching the profile/stats, checking for existing plans, and saving.
+ *
+ * @param {Object} profile
+ * @param {Object} stats
+ * @returns {Promise<{ weeks: Array|null, error: string|null }>}
+ */
+async function generateStudyPlan(profile, stats) {
+ if (!profile.stream && (!profile.subjects || profile.subjects.length === 0)) {
+ return {
+ weeks: null,
+ error: "Please set up your study profile first (stream and subjects).",
+ };
+ }
+
+ const weeksUntilExam = profile.examDate
+ ? Math.max(differenceInWeeks(new Date(profile.examDate), new Date()), 1)
+ : 4;
+ const planWeeks = Math.min(weeksUntilExam, 8);
+
+ const prompt = buildPlannerPrompt(profile, stats);
+ const llmResponse = await generate(prompt, null, {
+ max_tokens: 2000,
+ temperature: 0.3,
+ });
+ const weeks = parsePlanResponse(llmResponse, planWeeks);
+
+ if (!weeks) {
+ return { weeks: null, error: "Could not parse AI response. Please try again." };
+ }
+
+ return { weeks, error: null };
+}
+
+module.exports = { generateStudyPlan, buildPlannerPrompt, parsePlanResponse };
diff --git a/backend/src/services/ai/vectorSearch.js b/backend/src/services/ai/vectorSearch.js
new file mode 100644
index 0000000..0f07ad1
--- /dev/null
+++ b/backend/src/services/ai/vectorSearch.js
@@ -0,0 +1,30 @@
+const { generateEmbedding: llmGenerateEmbedding } = require("../llmService");
+
+/**
+ * Generates a vector embedding for a given text.
+ *
+ * This is the only function in this module. The actual vector search
+ * ($vectorSearch aggregation) is handled by the controller, which owns
+ * all database access.
+ *
+ * @param {string} text
+ * @returns {Promise}
+ */
+async function generateEmbedding(text) {
+ if (!process.env.GEMINI_API_KEY) {
+ // Return mock 768-dimensional vector if no API key
+ console.warn("No GEMINI_API_KEY found, using mock embedding.");
+ return Array.from({ length: 768 }, () => Math.random() * 2 - 1);
+ }
+
+ try {
+ // Uses llmService which inherently uses the @google/genai SDK
+ return await llmGenerateEmbedding(text);
+ } catch (error) {
+ console.error("Error generating embedding:", error);
+ // Fallback mock
+ return Array.from({ length: 768 }, () => Math.random() * 2 - 1);
+ }
+}
+
+module.exports = { generateEmbedding };
diff --git a/backend/src/services/analytics/aggregator.js b/backend/src/services/analytics/aggregator.js
new file mode 100644
index 0000000..35d7376
--- /dev/null
+++ b/backend/src/services/analytics/aggregator.js
@@ -0,0 +1,154 @@
+const Session = require("../../models/Session");
+const Task = require("../../models/Task");
+const {
+ subDays,
+ format,
+ getHours,
+ differenceInCalendarDays,
+} = require("date-fns");
+
+/**
+ * Aggregate productivity statistics for a user.
+ *
+ * Returns pre-computed metrics that downstream services (insight engine,
+ * recommender, productivity score) consume. This function is the single
+ * source of truth for user analytics — AI services never query the
+ * database directly.
+ *
+ * @param {string} userId
+ * @param {number} [days=30]
+ * @returns {Promise}
+ */
+async function aggregateUserStats(userId, days = 30) {
+ const since = subDays(new Date(), days);
+
+ // Fetch raw data
+ const [sessions, tasks] = await Promise.all([
+ Session.find({ user: userId, startTime: { $gte: since } }).lean(),
+ Task.find({ user: userId }).lean(),
+ ]);
+
+ const focusSessions = sessions.filter((s) => s.type === "focus");
+ const breakSessions = sessions.filter((s) => s.type !== "focus");
+
+ // Basic counters
+ const totalFocusSessions = focusSessions.length;
+ const totalFocusSeconds = focusSessions.reduce(
+ (sum, s) => sum + (s.duration || 0),
+ 0,
+ );
+ const avgFocusDurationMin =
+ totalFocusSessions > 0
+ ? Math.round(totalFocusSeconds / totalFocusSessions / 60)
+ : 0;
+
+ // Completion rate
+ const completedFocus = focusSessions.filter((s) => s.completed).length;
+ const completionRate =
+ totalFocusSessions > 0
+ ? Math.round((completedFocus / totalFocusSessions) * 100)
+ : 0;
+
+ // Peak productive hours (hour-of-day histogram, top 3)
+ const hourCounts = new Array(24).fill(0);
+ focusSessions.forEach((s) => {
+ if (s.startTime) {
+ hourCounts[getHours(new Date(s.startTime))] += 1;
+ }
+ });
+ const peakHours = hourCounts
+ .map((count, hour) => ({ hour, count }))
+ .sort((a, b) => b.count - a.count)
+ .filter((h) => h.count > 0)
+ .slice(0, 3)
+ .map((h) => h.hour);
+
+ // Break frequency
+ const breakFrequency =
+ totalFocusSessions > 0
+ ? Math.round((breakSessions.length / totalFocusSessions) * 100) / 100
+ : 0;
+
+ // Current streak (consecutive days with ≥1 focus session)
+ const focusDates = [
+ ...new Set(
+ focusSessions
+ .filter((s) => s.startTime)
+ .map((s) => format(new Date(s.startTime), "yyyy-MM-dd")),
+ ),
+ ].sort();
+
+ let currentStreak = 0;
+ if (focusDates.length > 0) {
+ const today = format(new Date(), "yyyy-MM-dd");
+ const yesterday = format(subDays(new Date(), 1), "yyyy-MM-dd");
+ const lastDate = focusDates[focusDates.length - 1];
+
+ if (lastDate === today || lastDate === yesterday) {
+ currentStreak = 1;
+ for (let i = focusDates.length - 2; i >= 0; i--) {
+ const diff = differenceInCalendarDays(
+ new Date(focusDates[i + 1]),
+ new Date(focusDates[i]),
+ );
+ if (diff === 1) {
+ currentStreak += 1;
+ } else {
+ break;
+ }
+ }
+ }
+ }
+
+ // Weekly / monthly totals
+ const weekAgo = subDays(new Date(), 7);
+ const thisWeekSessions = focusSessions.filter(
+ (s) => s.startTime && new Date(s.startTime) >= weekAgo,
+ );
+ const weeklyFocusMin = Math.round(
+ thisWeekSessions.reduce((sum, s) => sum + (s.duration || 0), 0) / 60,
+ );
+ const monthlyFocusMin = Math.round(totalFocusSeconds / 60);
+
+ // Task stats
+ const totalTasks = tasks.length;
+ const completedTasks = tasks.filter((t) => t.isCompleted).length;
+ const taskCompletionRate =
+ totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
+
+ // Mood distribution
+ const moodCounts = {};
+ focusSessions.forEach((s) => {
+ if (s.mood) {
+ moodCounts[s.mood] = (moodCounts[s.mood] || 0) + 1;
+ }
+ });
+
+ return {
+ period: { days, since: since.toISOString() },
+ focus: {
+ totalSessions: totalFocusSessions,
+ totalMinutes: monthlyFocusMin,
+ avgDurationMin: avgFocusDurationMin,
+ completionRate,
+ weeklyMinutes: weeklyFocusMin,
+ },
+ patterns: {
+ peakHours,
+ breakFrequency,
+ currentStreak,
+ moodDistribution: moodCounts,
+ },
+ tasks: {
+ total: totalTasks,
+ completed: completedTasks,
+ completionRate: taskCompletionRate,
+ },
+ _meta: {
+ generatedAt: new Date().toISOString(),
+ sessionCount: sessions.length,
+ },
+ };
+}
+
+module.exports = { aggregateUserStats };
diff --git a/backend/src/services/analytics/focusDropoff.js b/backend/src/services/analytics/focusDropoff.js
new file mode 100644
index 0000000..4782d61
--- /dev/null
+++ b/backend/src/services/analytics/focusDropoff.js
@@ -0,0 +1,101 @@
+const Session = require("../../models/Session");
+
+/**
+ * Analyzes the user's session history to find their personal optimal focus duration.
+ * It groups completed focus sessions by duration and calculates the completion rate
+ * to suggest ideal focus and break durations.
+ *
+ * @param {string} userId - Mongoose ObjectId string
+ * @returns {Promise} Adaptive timer suggestion object
+ */
+async function analyzeFocusDropoff(userId) {
+ // Fetch recent focus sessions
+ const sessions = await Session.find({
+ user: userId,
+ type: "focus",
+ }).sort({ startTime: -1 }).limit(100).lean();
+
+ if (sessions.length < 10) {
+ return {
+ hasEnoughData: false,
+ message: "Not enough data yet for adaptive suggestions.",
+ };
+ }
+
+ // Group by duration buckets (e.g., 25, 30, 35, 40, etc.)
+ const durationBuckets = {};
+
+ sessions.forEach((s) => {
+ // If the session has a targeted duration, we could use that.
+ // If we only have actual duration and completed flag, we'll try to guess the intended duration
+ // from actual duration if it's completed, but normally we'd need intended duration.
+ // Assuming `duration` is the intended duration in seconds and `completed` indicates if they made it.
+
+ // Convert duration to minutes and round to nearest 5
+ const durationMin = Math.round(s.duration / 60 / 5) * 5;
+
+ // Ignore unusually short or long sessions (< 10 min or > 120 min)
+ if (durationMin < 10 || durationMin > 120) return;
+
+ if (!durationBuckets[durationMin]) {
+ durationBuckets[durationMin] = { total: 0, completed: 0 };
+ }
+
+ durationBuckets[durationMin].total += 1;
+ if (s.completed) {
+ durationBuckets[durationMin].completed += 1;
+ }
+ });
+
+ const dataPoints = Object.keys(durationBuckets).map((d) => {
+ const bucket = durationBuckets[d];
+ return {
+ duration: parseInt(d, 10),
+ total: bucket.total,
+ completed: bucket.completed,
+ completionRate: bucket.total > 0 ? bucket.completed / bucket.total : 0,
+ };
+ }).filter(dp => dp.total >= 3); // Only consider buckets with at least 3 sessions
+
+ if (dataPoints.length === 0) {
+ return {
+ hasEnoughData: false,
+ message: "Not enough grouped data for reliable adaptive suggestions.",
+ };
+ }
+
+ // Find the highest duration that has a completion rate > 75%
+ dataPoints.sort((a, b) => b.duration - a.duration);
+
+ let suggestedFocusDuration = 25; // Default fallback
+
+ for (const dp of dataPoints) {
+ if (dp.completionRate >= 0.75) {
+ suggestedFocusDuration = dp.duration;
+ break;
+ }
+ }
+
+ // If no duration had > 75% completion, find the highest completion rate
+ if (suggestedFocusDuration === 25) {
+ dataPoints.sort((a, b) => b.completionRate - a.completionRate);
+ if (dataPoints.length > 0 && dataPoints[0].completionRate > 0) {
+ suggestedFocusDuration = dataPoints[0].duration;
+ }
+ }
+
+ // Calculate recommended breaks based on focus duration
+ const suggestedShortBreak = suggestedFocusDuration <= 30 ? 5 : 10;
+ const suggestedLongBreak = suggestedFocusDuration <= 30 ? 15 : 20;
+
+ return {
+ hasEnoughData: true,
+ suggestedFocusDuration,
+ suggestedShortBreak,
+ suggestedLongBreak,
+ confidence: dataPoints.length > 3 ? "high" : "medium",
+ dataPoints,
+ };
+}
+
+module.exports = { analyzeFocusDropoff };
diff --git a/backend/src/services/analytics/productivityScore.js b/backend/src/services/analytics/productivityScore.js
new file mode 100644
index 0000000..d0a7230
--- /dev/null
+++ b/backend/src/services/analytics/productivityScore.js
@@ -0,0 +1,76 @@
+/**
+ * Productivity Score Calculator
+ *
+ * Computes a 0–100 score from aggregated stats:
+ * - Consistency (streak): 30%
+ * - Completion rate: 25%
+ * - Focus quality (duration): 25%
+ * - Time management (peak hrs): 20%
+ *
+ * @param {Object} stats - Output of aggregateUserStats()
+ * @param {Object} userSettings - User.settings subdocument
+ * @returns {{ score: number, breakdown: Object }}
+ */
+function calculateProductivityScore(stats, userSettings = {}) {
+ const configuredFocus = userSettings.focusDuration || 25; // minutes
+
+ // Consistency (30%) — streak capped at 30 days
+ const streakMax = 30;
+ const streakRaw = Math.min(stats.patterns.currentStreak, streakMax);
+ const consistencyScore = Math.round((streakRaw / streakMax) * 100);
+
+ // Completion rate (25%) — direct percentage
+ const completionScore = stats.focus.completionRate;
+
+ // ── Focus quality (25%) — how close avg duration is to configured ─
+ // If avg >= configured → 100. If avg is 0 → 0. Linear in between.
+ const avgMin = stats.focus.avgDurationMin;
+ const focusQuality =
+ avgMin >= configuredFocus
+ ? 100
+ : configuredFocus > 0
+ ? Math.round((avgMin / configuredFocus) * 100)
+ : 0;
+
+ // ── Time management (20%) — % of sessions in personal peak hours ──
+ // We can't re-query sessions here (aggregator already computed peaks),
+ // so we use a heuristic: if the user has identified peak hours AND
+ // has been doing sessions, award points based on session volume.
+ // A more precise version would require the aggregator to also return
+ // "sessions in peak hours count", which we can add later.
+ // For now: having ≥3 peak hours identified = good time awareness.
+ const peakHourCount = (stats.patterns.peakHours || []).length;
+ const hasEnoughData = stats.focus.totalSessions >= 5;
+ const timeManagement = hasEnoughData
+ ? Math.min(Math.round((peakHourCount / 3) * 100), 100)
+ : 0;
+
+ // Weighted total
+ const score = Math.round(
+ consistencyScore * 0.3 +
+ completionScore * 0.25 +
+ focusQuality * 0.25 +
+ timeManagement * 0.2,
+ );
+
+ return {
+ score: Math.min(score, 100),
+ breakdown: {
+ consistency: { score: consistencyScore, weight: 30, streak: streakRaw },
+ completion: { score: completionScore, weight: 25 },
+ focusQuality: {
+ score: focusQuality,
+ weight: 25,
+ avgMin,
+ targetMin: configuredFocus,
+ },
+ timeManagement: {
+ score: timeManagement,
+ weight: 20,
+ peakHours: stats.patterns.peakHours,
+ },
+ },
+ };
+}
+
+module.exports = { calculateProductivityScore };
diff --git a/backend/src/services/llmService.js b/backend/src/services/llmService.js
index 4b56cc2..522786f 100644
--- a/backend/src/services/llmService.js
+++ b/backend/src/services/llmService.js
@@ -1,46 +1,191 @@
-const axios = require("axios");
+/**
+ * LLM Service — Professional Gemini API Integration
+ *
+ * Features:
+ * • @google/genai SDK (GA, replaces deprecated @google/generative-ai)
+ * • Singleton client — one instance for the app lifecycle
+ * • JSON mode — `responseMimeType: "application/json"` for structured outputs
+ * • System instructions — separated from user prompts (lower token cost)
+ * • Safety settings — prevents unnecessary content blocks for educational content
+ * • Retry with exponential backoff — handles 429 / 503 gracefully
+ * • Token usage logging — cost visibility per request
+ * • Embedding support — shared client for embedding calls
+ */
-const ANTHROPIC_ENDPOINT = "https://api.anthropic.com/v1/complete";
+const { GoogleGenAI } = require("@google/genai");
-async function generate(prompt, model, options = {}) {
- const chosenModel =
- model || process.env.DEFAULT_LLM_MODEL || "claude-haiku-4.5";
+// Singleton Client
+let _client = null;
- if (!process.env.ANTHROPIC_API_KEY) {
- throw new Error(
- "No LLM provider configured. Set ANTHROPIC_API_KEY to enable Claude requests.",
+function getClient() {
+ if (!_client) {
+ const apiKey = process.env.GEMINI_API_KEY;
+ if (!apiKey) {
+ throw new Error(
+ "GEMINI_API_KEY is not set. Add it to your .env file.",
+ );
+ }
+ _client = new GoogleGenAI({ apiKey });
+ }
+ return _client;
+}
+
+// Default Configuration
+const DEFAULT_MODEL = process.env.DEFAULT_LLM_MODEL || "gemini-2.0-flash";
+const DEFAULT_EMBEDDING_MODEL = process.env.DEFAULT_EMBEDDING_MODEL || "gemini-embedding-001";
+
+const MAX_RETRIES = 3;
+const BASE_DELAY_MS = 1000;
+
+// Safety settings — tuned for educational/productivity content
+const SAFETY_SETTINGS = [
+ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_ONLY_HIGH" },
+ { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
+ { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_ONLY_HIGH" },
+ { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_ONLY_HIGH" },
+];
+
+// Retry Helper
+async function withRetry(fn, retries = MAX_RETRIES) {
+ for (let attempt = 0; attempt <= retries; attempt++) {
+ try {
+ return await fn();
+ } catch (err) {
+ const isRetryable =
+ err.status === 429 ||
+ err.status === 503 ||
+ err.message?.includes("RESOURCE_EXHAUSTED") ||
+ err.message?.includes("UNAVAILABLE");
+
+ if (!isRetryable || attempt === retries) {
+ throw err;
+ }
+
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 500;
+ console.warn(
+ `[LLM] Retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${retries}) — ${err.message}`,
+ );
+ await new Promise((r) => setTimeout(r, delay));
+ }
+ }
+}
+
+// Token Usage Logger
+function logUsage(label, response) {
+ const usage = response?.usageMetadata;
+ if (usage) {
+ console.log(
+ `[LLM:${label}] Tokens — prompt: ${usage.promptTokenCount ?? "?"}, ` +
+ `completion: ${usage.candidatesTokenCount ?? "?"}, ` +
+ `total: ${usage.totalTokenCount ?? "?"}`,
);
}
+}
- const max_tokens = options.max_tokens || 512;
+// Generate (Text Output)
+/**
+ * Generate text from a prompt.
+ *
+ * @param {string} prompt
+ * @param {Object} [options]
+ * @param {string} [options.model]
+ * @param {string} [options.systemInstruction]
+ * @param {number} [options.maxOutputTokens]
+ * @param {number} [options.temperature]
+ * @param {string} [options.label]
+ * @returns {Promise}
+ */
+async function generate(prompt, options = {}) {
+ const client = getClient();
+ const model = options.model || DEFAULT_MODEL;
- const payload = {
- model: chosenModel,
- prompt,
- max_tokens,
- temperature: options.temperature ?? 0.2,
+ const config = {
+ maxOutputTokens: options.maxOutputTokens || options.max_tokens || 512,
+ temperature: options.temperature ?? 0.4,
+ safetySettings: SAFETY_SETTINGS,
};
- const headers = {
- Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}`,
- "Content-Type": "application/json",
+ if (options.systemInstruction) {
+ config.systemInstruction = options.systemInstruction;
+ }
+
+ return withRetry(async () => {
+ const response = await client.models.generateContent({
+ model,
+ contents: prompt,
+ config,
+ });
+
+ logUsage(options.label || model, response);
+ return response.text;
+ });
+}
+
+// Generate JSON (Structured Output)
+/**
+ * Generate structured JSON from a prompt.
+ * Uses Gemini's native JSON mode — guarantees valid JSON output,
+ * eliminating the need for "no markdown fences" hacks and fragile parsing.
+ *
+ * @param {string} prompt - User/data prompt
+ * @param {Object} [options] - Generation options (same as generate)
+ * @returns {Promise} Parsed JSON object
+ */
+async function generateJSON(prompt, options = {}) {
+ const client = getClient();
+ const model = options.model || DEFAULT_MODEL;
+
+ const config = {
+ maxOutputTokens: options.maxOutputTokens || options.max_tokens || 512,
+ temperature: options.temperature ?? 0.3,
+ responseMimeType: "application/json",
+ safetySettings: SAFETY_SETTINGS,
};
- try {
- const res = await axios.post(ANTHROPIC_ENDPOINT, payload, { headers });
+ if (options.systemInstruction) {
+ config.systemInstruction = options.systemInstruction;
+ }
- if (res.data && (res.data.output || res.data.completion || res.data.text)) {
- return res.data.output || res.data.completion || res.data.text;
+ return withRetry(async () => {
+ const response = await client.models.generateContent({
+ model,
+ contents: prompt,
+ config,
+ });
+
+ logUsage(options.label || `${model}:json`, response);
+
+ const text = response.text;
+
+ try {
+ return JSON.parse(text);
+ } catch {
+ // Shouldn't happen with JSON mode, but safety net
+ console.error("[LLM] JSON mode returned unparseable text:", text?.substring(0, 200));
+ throw new Error("LLM returned invalid JSON despite JSON mode.");
}
+ });
+}
- return JSON.stringify(res.data);
- } catch (err) {
- const message =
- err.response && err.response.data
- ? JSON.stringify(err.response.data)
- : err.message;
- throw new Error(`LLM request failed: ${message}`);
- }
+// Generate Embedding
+/**
+ * Generate a vector embedding for text.
+ *
+ * @param {string} text - Text to embed
+ * @param {string} [model] - Embedding model name override
+ * @returns {Promise} Embedding vector
+ */
+async function generateEmbedding(text, model) {
+ const client = getClient();
+ const embeddingModel = model || DEFAULT_EMBEDDING_MODEL;
+
+ return withRetry(async () => {
+ const response = await client.models.embedContent({
+ model: embeddingModel,
+ contents: text,
+ });
+ return response.embeddings[0].values;
+ });
}
-module.exports = { generate };
+module.exports = { generate, generateJSON, generateEmbedding, getClient };
diff --git a/backend/src/validation/schemas.js b/backend/src/validation/schemas.js
index c0fce29..31eb66e 100644
--- a/backend/src/validation/schemas.js
+++ b/backend/src/validation/schemas.js
@@ -200,6 +200,27 @@ const adminFeedbackStatusBodySchema = z
})
.strict();
+const studyProfileBodySchema = z
+ .object({
+ stream: z
+ .enum(["engineering", "medical", "commerce", "competitive", "custom"])
+ .optional(),
+ customStreamName: optionalTrimmedString(100),
+ subjects: z
+ .array(
+ z.object({
+ name: safeString("Subject name", 100),
+ difficulty: z.enum(["easy", "medium", "hard"]).optional(),
+ }),
+ )
+ .max(20)
+ .optional(),
+ examDate: isoDate.optional().nullable(),
+ weeklyGoalHours: z.coerce.number().min(1).max(100).optional(),
+ availableHoursPerDay: z.coerce.number().min(0.5).max(16).optional(),
+ })
+ .strict();
+
module.exports = {
adminFeedbackStatusBodySchema,
adminUserStatusBodySchema,
@@ -212,6 +233,7 @@ module.exports = {
sessionQuerySchema,
sessionUpdateBodySchema,
spotifyCallbackSchema,
+ studyProfileBodySchema,
taskBodySchema,
taskUpdateBodySchema,
workLogStopSchema,
diff --git a/frontend/index.html b/frontend/index.html
index 4b8f7e6..a0a80a7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -30,7 +30,7 @@
/>
-
+
@@ -43,14 +43,14 @@
name="twitter:description"
content="Boost your productivity with FocusMaster. Built-in Pomodoro timer, smart task manager, focus analytics, and Spotify integration."
/>
-
+
-
-
+
+
@@ -80,7 +80,7 @@
"browserRequirements": "Requires a modern browser with JavaScript enabled",
"description": "FocusMaster is a productivity web app featuring a Pomodoro timer, task manager, analytics dashboard, calendar, clock-in/out tracker, and Spotify integration to help you stay focused and productive.",
"url": "https://focusmaster-tau.vercel.app",
- "image": "https://focusmaster-tau.vercel.app/fmasterlogo.png",
+ "image": "https://focusmaster-tau.vercel.app/FM_logo.png",
"screenshot": "https://focusmaster-tau.vercel.app/dash_admin.png",
"softwareVersion": "1.0.0",
"offers": {
@@ -117,15 +117,5 @@
-