├── .DS_Store ├── .github └── workflows │ └── ci.yml.disabled ├── .gitignore ├── GOOGLE_AUTH_IMPLEMENTATION.md ├── LICENSE ├── README.md ├── agent_workflow.png ├── backend ├── Dockerfile ├── README.md ├── app │ ├── __init__.py │ ├── core │ │ ├── __init__.py │ │ ├── chroma_db.py │ │ └── log_config.py │ ├── database │ │ ├── schema.sql │ │ └── session.py │ ├── dependencies.py │ ├── graph │ │ ├── __init__.py │ │ ├── configuration.py │ │ ├── feynman_graph.py │ │ ├── graph.py │ │ ├── prompts.py │ │ ├── schemas.py │ │ └── state.py │ ├── main.py │ ├── models │ │ ├── __init__.py │ │ ├── operations.py │ │ └── user.py │ ├── routers │ │ ├── __init__.py │ │ ├── auth_dependencies.py │ │ ├── embed_router.py │ │ ├── feynman__router.py │ │ ├── get_thread_history_router.py │ │ ├── get_thread_router.py │ │ ├── google_login_router.py │ │ ├── lecture_transcript_router.py │ │ ├── logout_router.py │ │ ├── simpleChat_router.py │ │ ├── thread_router.py │ │ └── traditional_login_router.py │ ├── schemas │ │ └── schemas1.py │ └── services │ │ ├── __init__.py │ │ ├── google_login.py │ │ ├── lecture_transcript.py │ │ ├── password_service.py │ │ └── requirements.txt ├── chatbot.db ├── migrations │ └── .gitkeep ├── requirements.txt └── tests │ └── .gitkeep ├── demo.png ├── docker-compose.yml ├── feynman_mode_agent.png ├── frontend ├── .dockerignore ├── .gitignore ├── Dockerfile ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── .gitkeep │ └── index.html ├── rocket.svg ├── src │ ├── App.jsx │ ├── assets │ │ └── .gitkeep │ ├── components │ │ ├── ChatHeader.jsx │ │ ├── FileUpload.jsx │ │ ├── ImportLectureModal.jsx │ │ ├── Message.jsx │ │ ├── MessageInput.jsx │ │ ├── MessageList.jsx │ │ ├── Sidebar.jsx │ │ └── UploadMaterial.jsx │ ├── context │ │ └── AuthContext.jsx │ ├── index.css │ ├── main.jsx │ ├── pages │ │ ├── .gitkeep │ │ ├── AccountPage.jsx │ │ ├── ChatPage.jsx │ │ ├── EmbedDocumentPage.jsx │ │ ├── GoogleCallback.jsx │ │ └── LoginPage.jsx │ └── service │ │ └── chatService.js ├── tailwind.config.js └── vite.config.js ├── nginx.conf ├── package-lock.json └── package.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/.DS_Store -------------------------------------------------------------------------------- /.github/workflows/ci.yml.disabled: -------------------------------------------------------------------------------- 1 | name: Backend CI 2 | 3 | # This action runs on every push to the main branch 4 | on: 5 | push: 6 | branches: [ "main" ] 7 | pull_request: 8 | branches: [ "main" ] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 16 | - uses: actions/checkout@v3 17 | 18 | # Sets up a Python environment 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: "3.9" 23 | 24 | # Installs project dependencies 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install flake8 29 | if [ -f backend/requirements.txt ]; then pip install -r backend/requirements.txt; fi 30 | 31 | # Runs a linter to check for style issues 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak 96 | venv.bak 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyderworkspace 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | .dmypy.json 111 | dmypy.json 112 | 113 | # Pyre type checker 114 | .pyre/ 115 | 116 | # Node 117 | node_modules/ 118 | dist/ 119 | .env* 120 | npm-debug.log* 121 | yarn-debug.log* 122 | yarn-error.log* 123 | 124 | # Database 125 | chatbot.db 126 | 127 | .env.* 128 | 129 | chroma_db/ 130 | logs/ -------------------------------------------------------------------------------- /GOOGLE_AUTH_IMPLEMENTATION.md: -------------------------------------------------------------------------------- 1 | # Google Authentication Implementation 2 | 3 | This document describes the Google OAuth authentication system that has been added to your chatbot application. 4 | 5 | ## 🚀 What Was Implemented 6 | 7 | ### 1. Database Setup (`backend/app/database/session.py`) 8 | - **Async SQLAlchemy session management** with proper dependency injection 9 | - **Automatic table creation** on application startup 10 | - **SQLite database** (configurable via `DATABASE_URL` environment variable) 11 | 12 | ### 2. Updated User Model (`backend/app/models/user.py`) 13 | - **Google OAuth fields**: `google_id`, `name`, `first_name`, `last_name`, `picture` 14 | - **Flexible authentication**: Supports both Google OAuth and traditional auth 15 | - **Timestamps**: Automatic `created_at` and `updated_at` tracking 16 | - **Email verification**: Tracks Google's email verification status 17 | 18 | ### 3. Find or Create User Function (`backend/app/routers/google_login_router.py`) 19 | The `find_or_create_user` function implements smart user management: 20 | - **Finds existing users** by Google ID or email 21 | - **Creates new users** if none found 22 | - **Links Google accounts** to existing email-based accounts 23 | - **Updates user info** with latest data from Google 24 | 25 | ### 4. Google Login Router (`backend/app/routers/google_login_router.py`) 26 | - **Complete OAuth flow**: Handles authorization code exchange 27 | - **JWT token creation**: Secure session management 28 | - **HTTP-only cookies**: Secure token storage 29 | - **Error handling**: Comprehensive error responses 30 | 31 | ### 5. Dependencies Added (`backend/requirements.txt`) 32 | ``` 33 | sqlalchemy 34 | asyncpg 35 | aiosqlite 36 | alembic 37 | python-jose[cryptography] 38 | ``` 39 | 40 | ## 🔧 Environment Variables Required 41 | 42 | Make sure your `.env` file contains: 43 | ```bash 44 | # Google OAuth (both needed) 45 | GOOGLE_CLIENT_ID="your-google-client-id" 46 | GOOGLE_CLIENT_SECRET="your-google-client-secret" 47 | GOOGLE_REDIRECT_URI="http://localhost:5173/google-callback" 48 | 49 | # JWT Configuration 50 | JWT_SECRET="your-secret-key" 51 | JWT_ALGORITHM="HS256" 52 | ``` 53 | 54 | ## 🎯 How It Works 55 | 56 | ### 1. User Flow 57 | 1. User clicks "Login with Google" on frontend 58 | 2. Redirected to Google OAuth consent screen 59 | 3. Google redirects back with authorization code 60 | 4. Frontend sends code to `/api/auth/google` endpoint 61 | 5. Backend exchanges code for user info 62 | 6. User is found/created in database 63 | 7. JWT token is issued and stored in HTTP-only cookie 64 | 65 | ### 2. Database Logic 66 | ```python 67 | async def find_or_create_user(db_session: AsyncSession, user_data: dict) -> User: 68 | # Try to find by google_id first 69 | user = await find_by_google_id(user_data["google_id"]) 70 | if user: 71 | # Update existing user info 72 | return update_user_info(user, user_data) 73 | 74 | # Try to find by email 75 | user = await find_by_email(user_data["email"]) 76 | if user: 77 | # Link Google account to existing user 78 | return link_google_account(user, user_data) 79 | 80 | # Create new user 81 | return create_new_user(user_data) 82 | ``` 83 | 84 | ## 🔌 API Endpoint 85 | 86 | ### POST `/api/auth/google` 87 | Handles Google OAuth callback. 88 | 89 | **Request Body:** 90 | ```json 91 | { 92 | "code": "authorization_code_from_google" 93 | } 94 | ``` 95 | 96 | **Response:** 97 | ```json 98 | { 99 | "user": { 100 | "id": 1, 101 | "name": "John Doe", 102 | "email": "john@example.com", 103 | "picture": "https://..." 104 | } 105 | } 106 | ``` 107 | 108 | **Sets HTTP-only cookie:** `session_token` with JWT 109 | 110 | ## 🧪 Testing 111 | 112 | The implementation has been tested and verified to: 113 | - ✅ Create database tables automatically 114 | - ✅ Handle new user creation 115 | - ✅ Find existing users correctly 116 | - ✅ Update user information 117 | - ✅ Link Google accounts to existing emails 118 | - ✅ Generate secure JWT tokens 119 | 120 | ## 🚦 Running the Application 121 | 122 | To start the application with Google authentication: 123 | 124 | ```bash 125 | # Make sure your .env file has the required variables 126 | cd backend 127 | uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 128 | ``` 129 | 130 | The database will be created automatically on startup, and the Google authentication endpoint will be available at `http://localhost:8000/api/auth/google`. 131 | 132 | ## 🔐 Security Features 133 | 134 | - **HTTP-only cookies**: Prevents XSS attacks 135 | - **Secure flag**: HTTPS-only in production 136 | - **SameSite protection**: CSRF protection 137 | - **JWT expiration**: 31-day token lifetime 138 | - **Email verification**: Uses Google's verification status 139 | 140 | ## 📝 Notes 141 | 142 | - The implementation preserves your existing code logic 143 | - Database sessions are properly managed with async context 144 | - All Google OAuth fields are optional for backward compatibility 145 | - The system gracefully handles both new and returning users -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 keeeevinShen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Learning-Chatbot 2 | 3 | A full-stack AI-powered web app that helps students quickly learn new material and deepen their understanding using the **Feynman learning technique**. 4 | 5 | 🚀 **[Live Demo](https://quicklylearning.com/)** 6 | 7 | ![App demo](demo.png) 8 | 9 | ## Key Features 10 | 1. ⚛️ **Fullstack application** with a React frontend and LangGraph backend. 11 | 2. 🧱 **Scaffolded Learning** – Explains new concepts by building upon the student’s existing knowledge. 12 | 3. 🎓 **Feynman Technique Mentor** – Analyzes student explanations to detect knowledge gaps and provide targeted feedback. 13 | 4. 📄 **Automated Transcript Integration** – Fetches lecture transcripts from the University of Michigan for focused study sessions. 14 | 5. 🧠 Actively embed the knowledge we have for future reference. 15 | 6. ✨ Implements a Propositional Chunking RAG technique to achieve high retrieval accuracy. 16 | 7. ⚡ **Dynamic Learning Flow** – Effortlessly switch from absorbing concepts in Learning Mode to explaining them in your own words using Feynman Mode. The transition is instant, with no need to start a new process. 17 | 18 | 19 | ## Tech Stack 20 | **Frontend:** React, Vite 21 | **Backend:** FastAPI, Python 22 | **AI/NLP:** LangGraph, DistilBERT 23 | **Authentication** Google Oauth 2.0 24 | **Automation:** Playwright 25 | **Database:** PostgreSQL, ChromaDB, SQLite checkpointer 26 | **Infrastructure:** Docker, AWS 27 | 28 | 29 | ## Learning agent workflow 30 | 31 | ![Agent workflow](agent_workflow.png) 32 | 33 | ## Feynman mode agent workflow 34 | 35 | ![Feynman mode agent workflow](feynman_mode_agent.png) 36 | -------------------------------------------------------------------------------- /agent_workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/agent_workflow.png -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | ############## 3 | # Builder stage 4 | ############## 5 | FROM python:3.11-slim AS builder 6 | 7 | ENV PYTHONDONTWRITEBYTECODE=1 \ 8 | PYTHONUNBUFFERED=1 9 | 10 | WORKDIR /app 11 | 12 | # Install build dependencies for wheels (cleaned after) 13 | RUN apt-get update && apt-get install -y --no-install-recommends \ 14 | build-essential \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | # Copy and install dependencies first for better caching 18 | COPY ./requirements.txt /app/requirements.txt 19 | RUN pip wheel --wheel-dir /wheels -r requirements.txt 20 | 21 | ############## 22 | # Runtime stage 23 | ############## 24 | FROM python:3.11-slim AS runtime 25 | 26 | ENV PYTHONDONTWRITEBYTECODE=1 \ 27 | PYTHONUNBUFFERED=1 \ 28 | PLAYWRIGHT_BROWSERS_PATH=/ms-playwright 29 | 30 | WORKDIR /app 31 | 32 | # Create non-root user and group 33 | RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser 34 | 35 | # Install runtime dependencies from prebuilt wheels 36 | COPY --from=builder /wheels /wheels 37 | RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* && rm -rf /wheels 38 | 39 | # Install system dependencies required by Chromium on Debian, then install Chromium via Playwright 40 | RUN apt-get update && apt-get install -y --no-install-recommends \ 41 | libnss3 \ 42 | libxss1 \ 43 | libatk-bridge2.0-0 \ 44 | libgtk-3-0 \ 45 | libdrm2 \ 46 | libgbm1 \ 47 | libasound2 \ 48 | libpangocairo-1.0-0 \ 49 | libcups2 \ 50 | libxcomposite1 \ 51 | libxdamage1 \ 52 | libxfixes3 \ 53 | libxrandr2 \ 54 | libxshmfence1 \ 55 | libxkbcommon0 \ 56 | libglib2.0-0 \ 57 | fonts-liberation \ 58 | fonts-unifont \ 59 | xdg-utils \ 60 | ca-certificates \ 61 | && rm -rf /var/lib/apt/lists/* 62 | 63 | # Install Chromium browser binaries without attempting OS-level deps (handled above) 64 | RUN python -m playwright install chromium && \ 65 | mkdir -p /ms-playwright && \ 66 | chown -R appuser:appgroup /ms-playwright 67 | 68 | # Copy application code after deps to leverage layer caching 69 | COPY . /app 70 | 71 | # Adjust permissions 72 | RUN chown -R appuser:appgroup /app 73 | 74 | USER appuser 75 | 76 | EXPOSE 8000 77 | 78 | CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] 79 | 80 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/README.md -------------------------------------------------------------------------------- /backend/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/app/__init__.py -------------------------------------------------------------------------------- /backend/app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/app/core/__init__.py -------------------------------------------------------------------------------- /backend/app/core/chroma_db.py: -------------------------------------------------------------------------------- 1 | # app/core/database.py 2 | import os 3 | from pathlib import Path 4 | from typing import Optional 5 | import chromadb 6 | from chromadb.config import Settings 7 | from langchain_google_genai import GoogleGenerativeAIEmbeddings 8 | from dotenv import load_dotenv,find_dotenv 9 | 10 | load_dotenv(find_dotenv()) 11 | 12 | # Database-specific constants 13 | CHROMA_DB_PATH = Path("./chroma_db") 14 | 15 | class ChromaDBManager: 16 | _instance: Optional['ChromaDBManager'] = None 17 | _client: Optional[chromadb.PersistentClient] = None 18 | _embedding_model: Optional[GoogleGenerativeAIEmbeddings] = None 19 | 20 | def __new__(cls): 21 | if cls._instance is None: 22 | cls._instance = super().__new__(cls) 23 | return cls._instance 24 | 25 | def __init__(self): 26 | if self._client is None: 27 | self._initialize() 28 | 29 | def _initialize(self): 30 | """Initialize ChromaDB and embeddings.""" 31 | # Get API key 32 | api_key = os.getenv("GEMINI_API_KEY") 33 | if not api_key: 34 | raise ValueError("GEMINI_API_KEY not found") 35 | 36 | # Create directory 37 | CHROMA_DB_PATH.mkdir(exist_ok=True) 38 | 39 | # Initialize ChromaDB 40 | self._client = chromadb.PersistentClient( 41 | path=str(CHROMA_DB_PATH), 42 | settings=Settings( 43 | anonymized_telemetry=False, 44 | allow_reset=True 45 | ) 46 | ) 47 | 48 | # Initialize embedding model 49 | self._embedding_model = GoogleGenerativeAIEmbeddings( 50 | model="models/embedding-001", 51 | google_api_key=api_key 52 | ) 53 | 54 | @property 55 | def client(self) -> chromadb.PersistentClient: 56 | return self._client 57 | 58 | @property 59 | def embedding_model(self) -> GoogleGenerativeAIEmbeddings: 60 | return self._embedding_model 61 | 62 | def get_collection(self, name: str ): 63 | return self._client.get_or_create_collection(name=name) 64 | 65 | # Singleton instance 66 | chroma_manager = ChromaDBManager() -------------------------------------------------------------------------------- /backend/app/core/log_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from pathlib import Path 4 | 5 | def setup_logging(): 6 | """Configure logging for the entire application.""" 7 | 8 | # Create logs directory 9 | logs_dir = Path("logs") 10 | logs_dir.mkdir(exist_ok=True) 11 | 12 | # Configure logging 13 | logging.basicConfig( 14 | level=logging.INFO, 15 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 16 | handlers=[ 17 | # Save to file 18 | logging.FileHandler('logs/app.log'), 19 | # Print to console 20 | logging.StreamHandler(sys.stdout) 21 | ] 22 | ) 23 | 24 | logging.info("Logging configuration complete") -------------------------------------------------------------------------------- /backend/app/database/schema.sql: -------------------------------------------------------------------------------- 1 | -- backend/app/database/schema.sql 2 | 3 | -- Create users table 4 | CREATE TABLE IF NOT EXISTS users ( 5 | id SERIAL PRIMARY KEY, 6 | email VARCHAR(255) UNIQUE NOT NULL, 7 | 8 | -- Google OAuth fields 9 | google_id VARCHAR(255) UNIQUE, 10 | name VARCHAR(255), 11 | first_name VARCHAR(255), 12 | last_name VARCHAR(255), 13 | picture TEXT, 14 | 15 | -- Traditional auth 16 | hashed_password VARCHAR(255), 17 | 18 | -- Status fields 19 | is_active BOOLEAN DEFAULT TRUE, 20 | verified_email BOOLEAN DEFAULT FALSE, 21 | 22 | -- Timestamps 23 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 24 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 25 | ); 26 | 27 | -- Create threads table 28 | CREATE TABLE IF NOT EXISTS threads ( 29 | thread_id VARCHAR(255) PRIMARY KEY, 30 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 31 | thread_name VARCHAR(500) NOT NULL, 32 | 33 | -- Timestamps 34 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 35 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 36 | ); 37 | 38 | -- Create indexes for better performance 39 | CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); 40 | CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id) WHERE google_id IS NOT NULL; 41 | CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active) WHERE is_active = TRUE; 42 | 43 | -- Indexes for threads table 44 | CREATE INDEX IF NOT EXISTS idx_threads_user_id ON threads(user_id); 45 | CREATE INDEX IF NOT EXISTS idx_threads_created_at ON threads(created_at); 46 | 47 | -- Function to automatically update updated_at timestamp 48 | CREATE OR REPLACE FUNCTION update_updated_at_column() 49 | RETURNS TRIGGER AS $$ 50 | BEGIN 51 | NEW.updated_at = CURRENT_TIMESTAMP; 52 | RETURN NEW; 53 | END; 54 | $$ language 'plpgsql'; 55 | 56 | 57 | 58 | 59 | DROP TRIGGER IF EXISTS update_users_updated_at ON users; 60 | DROP TRIGGER IF EXISTS update_threads_updated_at ON threads; 61 | 62 | -- Trigger to call the function on UPDATE for users table 63 | CREATE TRIGGER update_users_updated_at 64 | BEFORE UPDATE ON users 65 | FOR EACH ROW 66 | EXECUTE FUNCTION update_updated_at_column(); 67 | 68 | -- Trigger to call the function on UPDATE for threads table 69 | CREATE TRIGGER update_threads_updated_at 70 | BEFORE UPDATE ON threads 71 | FOR EACH ROW 72 | EXECUTE FUNCTION update_updated_at_column(); 73 | 74 | -------------------------------------------------------------------------------- /backend/app/database/session.py: -------------------------------------------------------------------------------- 1 | # backend/app/database/session.py 2 | 3 | import os 4 | import asyncpg 5 | from dotenv import load_dotenv,find_dotenv 6 | 7 | load_dotenv(find_dotenv()) 8 | _pool = None 9 | #a dependecy to get connection 10 | async def get_db_session(): 11 | global _pool 12 | if not _pool: 13 | _pool = await asyncpg.create_pool( 14 | host=os.getenv("DB_HOST", "localhost"), 15 | port=int(os.getenv("DB_PORT", "5432")), 16 | user=os.getenv("DB_USER", "postgres"), 17 | password=os.getenv("DB_PASSWORD"), 18 | database=os.getenv("DB_NAME", "chatbot_db") 19 | ) 20 | 21 | async with _pool.acquire() as connection: 22 | yield connection 23 | 24 | 25 | async def get_db_connection(): 26 | """Gets a direct connection from the pool for use in non-FastAPI contexts like the agent.""" 27 | global _pool 28 | if not _pool: 29 | _pool = await asyncpg.create_pool( 30 | host=os.getenv("DB_HOST", "localhost"), 31 | port=int(os.getenv("DB_PORT", "5432")), 32 | user=os.getenv("DB_USER", "postgres"), 33 | password=os.getenv("DB_PASSWORD"), 34 | database=os.getenv("DB_NAME", "chatbot_db") 35 | ) 36 | return await _pool.acquire() 37 | 38 | 39 | async def create_tables(): 40 | """ 41 | OPTION 2: Read SQL from separate file 42 | Pro: Clean separation, easy to version control SQL changes 43 | Con: One extra file to manage 44 | """ 45 | global _pool 46 | if not _pool: 47 | print("--- DATABASE CONNECTION DEBUG ---") 48 | print(f"HOST: {os.getenv('DB_HOST', 'localhost')}") 49 | print(f"PORT: {os.getenv('DB_PORT', '5432')}") 50 | print(f"USER: {os.getenv('DB_USER', 'postgres')}") 51 | print("---------------------------------") 52 | 53 | _pool = await asyncpg.create_pool( 54 | host=os.getenv("DB_HOST", "localhost"), 55 | port=int(os.getenv("DB_PORT", "5432")), 56 | user=os.getenv("DB_USER", "postgres"), 57 | password=os.getenv("DB_PASSWORD"), 58 | database=os.getenv("DB_NAME", "chatbot_db") 59 | ) 60 | 61 | # Read the SQL file 62 | schema_path = os.path.join(os.path.dirname(__file__), "schema.sql") 63 | 64 | try: 65 | with open(schema_path, 'r') as f: 66 | sql_commands = f.read() 67 | 68 | async with _pool.acquire() as conn: 69 | # Execute all the SQL from the file 70 | await conn.execute(sql_commands) 71 | print("✅ Tables created from schema.sql!") 72 | 73 | except FileNotFoundError: 74 | print("❌ schema.sql file not found!") 75 | raise 76 | except Exception as e: 77 | print(f"❌ Error creating tables: {e}") 78 | raise -------------------------------------------------------------------------------- /backend/app/dependencies.py: -------------------------------------------------------------------------------- 1 | # This dictionary will hold our long-lived, shared resources, like the graph. 2 | shared_resources = {} 3 | 4 | def get_app_graph(): 5 | """A simple dependency function to provide the shared graph instance to routers.""" 6 | # We use .get() for a safer access, though in this lifespan context it will always exist. 7 | return shared_resources.get("graph") 8 | 9 | def get_feynman_graph(): 10 | """Dependency provider for the Feynman agent graph.""" 11 | return shared_resources.get("feynman_graph") -------------------------------------------------------------------------------- /backend/app/graph/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/app/graph/__init__.py -------------------------------------------------------------------------------- /backend/app/graph/configuration.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pydantic import BaseModel, Field 3 | from typing import Any, Optional 4 | 5 | from langchain_core.runnables import RunnableConfig 6 | 7 | 8 | class Configuration(BaseModel): 9 | """The configuration for the agent.""" 10 | 11 | query_generator_model: str = Field( 12 | default="gemini-2.0-flash", 13 | metadata={ 14 | "description": "The name of the language model to use for the agent's query generation." 15 | }, 16 | ) 17 | 18 | reflection_model: str = Field( 19 | default="gemini-2.5-flash", 20 | metadata={ 21 | "description": "The name of the language model to use for the agent's reflection." 22 | }, 23 | ) 24 | 25 | answer_model: str = Field( 26 | default="gemini-2.5-pro", 27 | metadata={ 28 | "description": "The name of the language model to use for the agent's answer." 29 | }, 30 | ) 31 | 32 | thread_id: Optional[str] = Field( 33 | default=None, 34 | description="The ID of the conversation thread.", 35 | ) 36 | 37 | user_id: Optional[int] = Field( # Changed from str to int 38 | default=None, 39 | description="The ID of the user.", 40 | ) 41 | 42 | number_of_initial_queries: int = Field( 43 | default=3, 44 | metadata={"description": "The number of initial search queries to generate."}, 45 | ) 46 | 47 | simplicity_threadhold: int = Field( 48 | default=2, 49 | metadata={"description": "the simplicity level we consider is simple enough."} 50 | ) 51 | 52 | @classmethod 53 | def from_runnable_config( 54 | cls, config: Optional[RunnableConfig] = None 55 | ) -> "Configuration": 56 | """Create a Configuration instance from a RunnableConfig.""" 57 | configurable = ( 58 | config["configurable"] if config and "configurable" in config else {} 59 | ) 60 | 61 | # Get raw values from environment or config 62 | raw_values: dict[str, Any] = { 63 | name: os.environ.get(name.upper(), configurable.get(name)) 64 | for name in cls.model_fields.keys() 65 | } 66 | 67 | # Filter out None values 68 | values = {k: v for k, v in raw_values.items() if v is not None} 69 | 70 | return cls(**values) 71 | -------------------------------------------------------------------------------- /backend/app/graph/feynman_graph.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import logging 4 | from typing import Optional 5 | 6 | from dotenv import load_dotenv, find_dotenv 7 | from google.genai import Client 8 | 9 | from langgraph.graph import StateGraph 10 | from langgraph.graph.message import add_messages 11 | from langgraph.checkpoint.sqlite import SqliteSaver 12 | from langchain_core.runnables import RunnableConfig 13 | from langchain_core.messages import HumanMessage, AIMessage, SystemMessage 14 | from langchain_google_genai import ChatGoogleGenerativeAI 15 | 16 | from .state import AgentState 17 | from .schemas import SearchQuery, checkpoints 18 | from .configuration import Configuration 19 | from .prompts import feynman_mode_prompt 20 | from ..core.chroma_db import chroma_manager 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | load_dotenv(find_dotenv()) 26 | 27 | if not os.getenv("GEMINI_API_KEY"): 28 | raise ValueError("GEMINI_API_KEY not found in environment variables.") 29 | 30 | genai_client = Client(api_key=os.getenv("GEMINI_API_KEY")) 31 | 32 | 33 | # ---------------------------------------- 34 | # Helper Nodes 35 | # ---------------------------------------- 36 | 37 | async def generate_learning_goals(state: AgentState, config: RunnableConfig): 38 | """ 39 | In Feynman agent, we don't create learning goals here. 40 | - If goals exist in the state, pass through. 41 | - If not, still pass through so downstream nodes can decide how to proceed. 42 | """ 43 | if state.get("learning_checkpoints"): 44 | logger.info("Learning checkpoints provided. Passing through.") 45 | else: 46 | logger.info("No learning checkpoints provided. Proceeding without generating.") 47 | return {} 48 | 49 | 50 | async def assess_context_need(state: AgentState, config: RunnableConfig): 51 | """Ask LLM if we need more external context to explain the checkpoints simply.""" 52 | configurable = Configuration.from_runnable_config(config) 53 | 54 | llm = ChatGoogleGenerativeAI( 55 | model=configurable.reflection_model, 56 | temperature=0.4, 57 | max_retries=2, 58 | api_key=os.getenv("GEMINI_API_KEY"), 59 | ) 60 | 61 | class ContextAssessmentModel: 62 | # lightweight structured output via JSON-mode prompt 63 | pass 64 | 65 | learning_checkpoints = state.get("learning_checkpoints", []) 66 | known_knowledge = state.get("KnownKnowledge", []) 67 | 68 | checkpoints_str = "\n".join(learning_checkpoints) 69 | knowledge_str = "\n".join(known_knowledge) if known_knowledge else "" 70 | 71 | system = SystemMessage(content="You are a careful tutor. Decide if more online context is needed to explain simply.") 72 | human = HumanMessage( 73 | content=( 74 | f"Learning checkpoints:\n{checkpoints_str}\n\n" 75 | f"Current background knowledge:\n{knowledge_str}\n\n" 76 | "Return strictly JSON with keys: {\"needs_more_context\": true|false, \"reason\": string, \"focus\": string}." 77 | ) 78 | ) 79 | 80 | response = await llm.ainvoke([system, human]) 81 | # Best-effort JSON parse 82 | import json 83 | needs_more = True 84 | try: 85 | data = json.loads(response.content if isinstance(response.content, str) else response.content[0]["text"]) # type: ignore[index] 86 | needs_more = bool(data.get("needs_more_context", True)) 87 | focus = data.get("focus", "") 88 | except Exception: 89 | focus = "" 90 | 91 | return {"needs_more_context": needs_more, "context_focus": focus} 92 | 93 | 94 | async def search_online(state: AgentState, config: RunnableConfig): 95 | """Use the Google GenAI client with Search grounding to fetch a concise summary.""" 96 | configurable = Configuration.from_runnable_config(config) 97 | 98 | learning_checkpoints = state.get("learning_checkpoints", []) 99 | context_focus = state.get("context_focus") or "" 100 | 101 | if not learning_checkpoints: 102 | logger.info("No checkpoints to research. Skipping search.") 103 | return {} 104 | 105 | # Craft a concise research prompt 106 | target = learning_checkpoints[0] 107 | research_query = ( 108 | f"Provide a concise, beginner-friendly explanation of '{target}'. " 109 | f"Focus on: {context_focus if context_focus else 'key definitions, core intuition, and one concrete example.'} " 110 | "Use Google Search for grounding and then summarize findings." 111 | ) 112 | 113 | def _run_search(): 114 | return genai_client.responses.generate( 115 | model=configurable.query_generator_model, 116 | contents=[{"role": "user", "parts": [{"text": research_query}]}], 117 | tools=[{"google_search": {}}], 118 | ) 119 | 120 | try: 121 | result = await asyncio.to_thread(_run_search) 122 | # Extract text safely from google-genai SDK response 123 | summary_text = "" 124 | try: 125 | # responses.generate -> response with .output_text in newer SDKs 126 | summary_text = getattr(result, "output_text", "") or "" 127 | except Exception: 128 | summary_text = "" 129 | 130 | if not summary_text: 131 | # Fallback: use LC LLM to summarize from empty (no-op) 132 | summary_text = f"Online summary for {target} could not be retrieved." 133 | 134 | existing = state.get("KnownKnowledge", []) or [] 135 | updated = existing + [summary_text] 136 | return {"KnownKnowledge": updated} 137 | except Exception as e: 138 | logger.error(f"Online search failed: {e}") 139 | return {} 140 | 141 | 142 | async def evaluate_user_explanation(state: AgentState, config: RunnableConfig): 143 | """ 144 | Evaluate user's latest explanation for a target concept. 145 | If simple and correct -> positive feedback and mark for storage. 146 | Else -> point out issues and wait for user input. 147 | """ 148 | configurable = Configuration.from_runnable_config(config) 149 | 150 | llm = ChatGoogleGenerativeAI( 151 | model=configurable.answer_model, 152 | temperature=0.7, 153 | max_retries=2, 154 | api_key=os.getenv("GEMINI_API_KEY"), 155 | ) 156 | 157 | history = state.get("history_messages", []) 158 | checkpoints_list = state.get("learning_checkpoints", []) 159 | target_concept = checkpoints_list[0] if checkpoints_list else "the main concept" 160 | 161 | system = feynman_mode_prompt 162 | human_instruction = HumanMessage( 163 | content=( 164 | "Evaluate the user's most recent explanation for correctness and simplicity.\n" 165 | f"Target concept: {target_concept}.\n" 166 | "Respond with strictly JSON having keys: " 167 | "{\"is_mastered\": true|false, \"feedback\": string}. " 168 | "If mastered, praise and keep it concise." 169 | ) 170 | ) 171 | 172 | response = await llm.ainvoke([system, *history, human_instruction]) 173 | 174 | import json 175 | try: 176 | data = json.loads(response.content if isinstance(response.content, str) else response.content[0]["text"]) # type: ignore[index] 177 | is_mastered = bool(data.get("is_mastered", False)) 178 | feedback = data.get("feedback", "") 179 | except Exception: 180 | is_mastered = False 181 | feedback = "I couldn't parse your explanation. Could you restate it simply in your own words?" 182 | 183 | new_history = history + [AIMessage(content=feedback)] 184 | return {"history_messages": new_history, "learning_complete": is_mastered} 185 | 186 | 187 | async def store_mastered_concept(state: AgentState, config: RunnableConfig): 188 | configurable = Configuration.from_runnable_config(config) 189 | user_id = configurable.user_id 190 | 191 | learning_checkpoints = state.get("learning_checkpoints", []) 192 | if not learning_checkpoints: 193 | logger.warning("No learning checkpoints to store (Feynman).") 194 | return {} 195 | 196 | # Store the first concept for this iteration 197 | topic = learning_checkpoints[0] 198 | # Use latest assistant feedback + known knowledge as content 199 | content_list = [] 200 | if state.get("KnownKnowledge"): 201 | content_list.extend(state.get("KnownKnowledge", [])) 202 | # Extract last AI message feedback 203 | history = state.get("history_messages", []) or [] 204 | if history: 205 | last_ai = next((m for m in reversed(history) if isinstance(m, AIMessage)), None) 206 | if last_ai: 207 | content_list.append(str(last_ai.content)) 208 | if not content_list: 209 | content_list = [topic] 210 | 211 | try: 212 | combined_content = "\n\n".join(content_list) 213 | collection_name = f"user_{user_id}_knowledge" 214 | collection = chroma_manager.get_collection(name=collection_name) 215 | 216 | embeddings = await asyncio.to_thread( 217 | chroma_manager.embedding_model.embed_documents, [combined_content] 218 | ) 219 | 220 | await asyncio.to_thread( 221 | collection.add, 222 | embeddings=embeddings, 223 | documents=[combined_content], 224 | ids=[topic], 225 | metadatas=[{"topic": topic, "source": "feynman_agent"}], 226 | ) 227 | 228 | logger.info(f"Stored mastered concept for user {user_id}: {topic}") 229 | except Exception as e: 230 | logger.error(f"Failed to store mastered concept '{topic}' for user {user_id}: {e}") 231 | 232 | return {} 233 | 234 | 235 | def should_continue(state: AgentState) -> str: 236 | if state.get("learning_complete"): 237 | return "continue_to_store_known_knowledge" 238 | else: 239 | return "wait_for_next_human_input" 240 | 241 | 242 | def decide_entry_point(state: AgentState) -> str: 243 | if state.get("learning_checkpoints"): 244 | return "assess_context_need" 245 | else: 246 | return "generate_learning_goals" 247 | 248 | 249 | def get_graph(checkpointer): 250 | """Build and compile the Feynman Agent graph.""" 251 | builder = StateGraph(AgentState, config_schema=Configuration) 252 | 253 | # Nodes 254 | builder.add_node("generate_learning_goals", generate_learning_goals) 255 | builder.add_node("assess_context_need", assess_context_need) 256 | builder.add_node("search_online", search_online) 257 | builder.add_node("evaluate_user_explanation", evaluate_user_explanation) 258 | builder.add_node("store_mastered_concept", store_mastered_concept) 259 | builder.add_node("end_node", lambda state: {}) 260 | 261 | # Entry 262 | builder.set_conditional_entry_point( 263 | decide_entry_point, 264 | { 265 | "generate_learning_goals": "generate_learning_goals", 266 | "assess_context_need": "assess_context_need", 267 | }, 268 | ) 269 | 270 | # Initial ramp 271 | builder.add_edge("generate_learning_goals", "assess_context_need") 272 | 273 | # Research loop 274 | builder.add_conditional_edges( 275 | "assess_context_need", 276 | lambda s: "needs_context" if s.get("needs_more_context") else "enough_context", 277 | { 278 | "needs_context": "search_online", 279 | "enough_context": "evaluate_user_explanation", 280 | }, 281 | ) 282 | builder.add_edge("search_online", "assess_context_need") 283 | 284 | # Evaluation branch 285 | builder.add_conditional_edges( 286 | "evaluate_user_explanation", 287 | should_continue, 288 | { 289 | "continue_to_store_known_knowledge": "store_mastered_concept", 290 | "wait_for_next_human_input": "end_node", 291 | }, 292 | ) 293 | 294 | # Storage to end 295 | builder.add_edge("store_mastered_concept", "end_node") 296 | builder.add_edge("end_node", "__end__") 297 | 298 | return builder.compile(checkpointer=checkpointer) 299 | 300 | -------------------------------------------------------------------------------- /backend/app/graph/graph.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import TypedDict, Annotated 3 | from dotenv import load_dotenv,find_dotenv 4 | from pathlib import Path 5 | from langgraph.graph import StateGraph 6 | from langgraph.graph.message import add_messages 7 | from google.genai import Client 8 | from langchain_google_genai import ChatGoogleGenerativeAI 9 | from langchain_core.runnables import RunnableConfig 10 | from langchain_core.messages import HumanMessage, SystemMessage, AIMessage 11 | from .state import * 12 | from .schemas import * 13 | from .configuration import Configuration 14 | from .prompts import get_learning_mode_prompt 15 | from langchain_google_genai import GoogleGenerativeAIEmbeddings 16 | import chromadb 17 | from ..core.chroma_db import chroma_manager 18 | import logging 19 | from langgraph.checkpoint.memory import MemorySaver 20 | 21 | from langgraph.checkpoint.sqlite import SqliteSaver 22 | from langgraph.checkpoint.redis import RedisSaver 23 | import sqlite3 24 | from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver 25 | import logging 26 | from langchain_core.runnables import RunnableConfig 27 | from ..database.session import get_db_connection 28 | from ..models.operations import check_thread_exists, add_thread 29 | import asyncio 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | 34 | 35 | #keep in mind need to make our override_config contain the "configurable" keyword, thats what LangGraph will look at, its a reserved keyword 36 | 37 | # Payload from the frontend 38 | # { 39 | # "messages": [{"role": "user", "content": "What is LangGraph?"}], 40 | # "config": { 41 | # "configurable": { 42 | # "answer_model": "gemini-2.5-pro", 43 | # "max_research_loops": 3 44 | # } 45 | # } 46 | # } 47 | load_dotenv(find_dotenv()) 48 | 49 | if not os.getenv("GEMINI_API_KEY"): 50 | raise ValueError("GEMINI_API_KEY not found in environment variables.") 51 | 52 | 53 | genai_client = Client(api_key=os.getenv("GEMINI_API_KEY")) 54 | 55 | 56 | 57 | 58 | async def generate_learning_goals(state: AgentState, config: RunnableConfig): 59 | """ 60 | If learning goals don't exist, generates them. 61 | If they already exist, this node does nothing and just passes through. 62 | """ 63 | # This is the new, critical gatekeeping logic 64 | if state.get("learning_checkpoints"): 65 | logger.info("Learning checkpoints already exist. Skipping generation.") 66 | return {} # Do nothing and let the graph continue to the next node. 67 | 68 | # --- If checkpoints DON'T exist, run the original logic --- 69 | logger.info("Generating new learning checkpoints.") 70 | configurable = Configuration.from_runnable_config(config) 71 | 72 | llm = ChatGoogleGenerativeAI( 73 | model=configurable.query_generator_model, 74 | temperature=1.0, 75 | max_retries=2, 76 | api_key=os.getenv("GEMINI_API_KEY"), 77 | ) 78 | structured_llm = llm.with_structured_output(checkpoints) 79 | prompt = state.get('history_messages', []) + [ 80 | HumanMessage(content="Based on our conversation, what checkpoints should we establish to achieve the learning goal?") 81 | ] 82 | result = await structured_llm.ainvoke(prompt) 83 | return {"learning_checkpoints": result.goals} 84 | 85 | 86 | # second node, the node for getting the previous known knowledge, to make the learning more smooth 87 | async def generate_query(state: AgentState, config: RunnableConfig): 88 | 89 | configurable = Configuration.from_runnable_config(config) 90 | 91 | # init Gemini 2.0 Flash 92 | llm = ChatGoogleGenerativeAI( 93 | model=configurable.query_generator_model, 94 | temperature=1.0, 95 | max_retries=2, 96 | api_key=os.getenv("GEMINI_API_KEY"), 97 | ) 98 | structured_llm = llm.with_structured_output(SearchQuery) 99 | 100 | # Format the prompt with system message and user content 101 | checkpoints_str = "\n".join(state.get('learning_checkpoints', [])) 102 | formatted_prompt = [ # System message for learning mode 103 | HumanMessage(content=f"Based on the learning checkpoints:\n{checkpoints_str}\n\ngenerate {configurable.number_of_initial_queries}queries that can retrive students known knowledge from the vectorized database.") 104 | ] 105 | 106 | # Generate the search queries 107 | result = await structured_llm.ainvoke(formatted_prompt) 108 | 109 | 110 | if result and hasattr(result, 'query') and result.query: 111 | return {"search_query": result.query} 112 | else: 113 | logger.warning("Query generation failed, proceeding with an empty query list.") 114 | return {"search_query": []} 115 | 116 | 117 | 118 | #using the qeury to perform RAG search too find the known knowledge 119 | async def search_relevant(state: AgentState, config: RunnableConfig): 120 | configurable = Configuration.from_runnable_config(config) 121 | user_id = configurable.user_id 122 | collection_name = f"user_{user_id}_knowledge" 123 | collection = chroma_manager.get_collection(name=collection_name) 124 | 125 | search_queries = state.get('search_query', []) 126 | vectorized_queries = await asyncio.to_thread( 127 | chroma_manager.embedding_model.embed_documents, search_queries 128 | ) 129 | 130 | results = await asyncio.to_thread( 131 | collection.query, 132 | query_embeddings=vectorized_queries, 133 | n_results=5 134 | ) 135 | documents = results.get('documents') 136 | if not documents: 137 | return {"KnownKnowledge": []} 138 | 139 | retrieved_docs = [doc for doc_list in results['documents'] for doc in doc_list] 140 | 141 | return {"KnownKnowledge": retrieved_docs} 142 | 143 | 144 | 145 | #final step to store the knowledge to the RAG system , this will only be called when LLM think we finish our learning, and call this node. 146 | async def store_known_knowledge(state: AgentState, config: RunnableConfig): 147 | configurable = Configuration.from_runnable_config(config) 148 | user_id = configurable.user_id 149 | 150 | learning_checkpoints = state.get("learning_checkpoints", []) 151 | 152 | if not learning_checkpoints: 153 | logger.warning("No learning checkpoints to store") 154 | return {} 155 | 156 | topic = state["learning_checkpoints"][0] 157 | content_list = state["learning_checkpoints"] # this is a list[str] 158 | try: 159 | combined_content = "\n\n".join(content_list) 160 | 161 | collection_name = f"user_{user_id}_knowledge" 162 | collection = chroma_manager.get_collection(name=collection_name) 163 | 164 | embeddings = await asyncio.to_thread( 165 | chroma_manager.embedding_model.embed_documents, [combined_content] 166 | ) 167 | 168 | await asyncio.to_thread( 169 | collection.add, 170 | embeddings=embeddings, 171 | documents=[combined_content], 172 | ids=[topic], 173 | metadatas=[{"topic": topic}] 174 | ) 175 | 176 | logger.info(f"Successfully stored knowledge for user {user_id} in topic: {topic}") 177 | 178 | except Exception as e: 179 | logger.error(f"Failed to store knowledge for user {user_id}, topic '{topic}': {str(e)}") 180 | 181 | 182 | return {} 183 | 184 | 185 | 186 | 187 | #the central conversation node 188 | async def central_response_node(state: AgentState, config: RunnableConfig): 189 | configurable = Configuration.from_runnable_config(config) 190 | 191 | # init Gemini (model depends on user choose fast or smart) 192 | llm = ChatGoogleGenerativeAI( 193 | model=configurable.query_generator_model, 194 | temperature=1.0, 195 | max_retries=2, 196 | api_key=os.getenv("GEMINI_API_KEY"), 197 | ) 198 | 199 | structured_llm = llm.with_structured_output(LearningResponse) 200 | 201 | learning_checkpoints = state.get('learning_checkpoints', []) 202 | known_knowledge = state.get('KnownKnowledge', []) 203 | history_messages = state.get('history_messages', []) 204 | learning_mode_prompt= get_learning_mode_prompt(learning_checkpoints,known_knowledge) 205 | 206 | prompt = [ 207 | SystemMessage(content=learning_mode_prompt), 208 | *history_messages 209 | ] 210 | 211 | try: 212 | result = await structured_llm.ainvoke(prompt) 213 | new_history = history_messages + [AIMessage(content=result.response_text)] 214 | return { 215 | "history_messages": new_history, 216 | "learning_complete": (result.next_action == "store_knowledge") 217 | } 218 | except Exception as e: 219 | logger.error(f"Error in central_response_node: {e}") 220 | error_message = "I'm having trouble processing your response. Could you please rephrase your question?" 221 | new_history = history_messages + [AIMessage(content=error_message)] 222 | return { 223 | "history_messages": new_history, 224 | "error": str(e) 225 | } 226 | 227 | 228 | 229 | 230 | def should_continue(state: AgentState) -> str: 231 | if state.get("learning_complete"): 232 | return "continue_to_store_known_knowledge" 233 | else: 234 | return "wait_for_next_human_input" 235 | 236 | 237 | async def name_and_store_thread(state: AgentState, config: RunnableConfig): 238 | """ 239 | Names the thread based on learning goals and stores it in the database if it's new. 240 | Accesses user_id and thread_id directly from the config. 241 | 242 | """ 243 | 244 | configurable = Configuration.from_runnable_config(config) 245 | thread_id = configurable.thread_id 246 | user_id = configurable.user_id 247 | # Get a direct database connection 248 | connection = await get_db_connection() 249 | try: 250 | #Check if the thread already exists, just for defensive programming sake 251 | thread_exists = await check_thread_exists(connection, thread_id) 252 | 253 | # If it's a new thread, name and save it 254 | if not thread_exists: 255 | learning_goals = state.get("learning_checkpoints", []) 256 | 257 | # Generate a name from the first two goals, or use a default. 258 | if learning_goals: 259 | thread_name = ", ".join(learning_goals[:1]) 260 | else: 261 | thread_name = "New Conversation" 262 | 263 | # Add the new thread record to the database 264 | await add_thread(connection, thread_id, user_id, thread_name) 265 | logger.info(f"✅ New thread '{thread_name}' created and stored for user {user_id}.") 266 | 267 | except Exception as e: 268 | logger.error(f"Database error in name_and_store_thread: {e}") 269 | return {"error": str(e)} 270 | 271 | finally: 272 | #release the connection back to the pool 273 | if connection: 274 | await connection.close() 275 | 276 | # Return an empty dictionary to signal completion 277 | return {} 278 | 279 | 280 | def decide_entry_point(state: AgentState) -> str: 281 | """ 282 | Checks if learning goals have been set. If not, it routes to the goal 283 | generation node. Otherwise, it proceeds to the main conversational response node. 284 | """ 285 | if state.get("learning_checkpoints"): 286 | # Goals exist, it's an ongoing chat, go to the central node 287 | return "central_response_node" 288 | else: 289 | # No goals yet, it's a new chat, start the setup process 290 | return "generate_learning_goals" 291 | 292 | 293 | def get_graph(checkpointer): 294 | """Builds and compiles a robust agent pipeline with an explicit end state.""" 295 | builder = StateGraph(AgentState, config_schema=Configuration) 296 | 297 | # 1. Add all nodes as before 298 | builder.add_node("generate_learning_goals", generate_learning_goals) 299 | builder.add_node("store_thread", name_and_store_thread) 300 | builder.add_node("generate_query", generate_query) 301 | builder.add_node("search_relevant", search_relevant) 302 | builder.add_node("central_response_node", central_response_node) 303 | builder.add_node("store_known_knowledge", store_known_knowledge) 304 | 305 | # --- THE FIX: Add an explicit end node --- 306 | # This node does nothing and just marks a clean exit point. 307 | builder.add_node("end_node", lambda state: {}) 308 | 309 | # 2. Set the conditional entry point as before 310 | builder.set_conditional_entry_point( 311 | decide_entry_point, 312 | { 313 | "generate_learning_goals": "generate_learning_goals", 314 | "central_response_node": "central_response_node", 315 | } 316 | ) 317 | 318 | # 3. Define the initial setup path as before 319 | builder.add_edge("generate_learning_goals", "store_thread") 320 | builder.add_edge("store_thread", "generate_query") 321 | builder.add_edge("generate_query", "search_relevant") 322 | builder.add_edge("search_relevant", "central_response_node") 323 | 324 | # 4. Modify the conditional branch to use the new end node 325 | builder.add_conditional_edges( 326 | "central_response_node", 327 | should_continue, 328 | { 329 | "continue_to_store_known_knowledge": "store_known_knowledge", 330 | # Instead of mapping to __end__, map to our clean end_node 331 | "wait_for_next_human_input": "end_node", 332 | } 333 | ) 334 | 335 | # 5. Define the final edges leading to the true end 336 | builder.add_edge("store_known_knowledge", "end_node") 337 | builder.add_edge("end_node", "__end__") # Only the end_node connects to __end__ 338 | 339 | # 6. Compile the graph 340 | return builder.compile(checkpointer=checkpointer) 341 | 342 | 343 | 344 | 345 | -------------------------------------------------------------------------------- /backend/app/graph/prompts.py: -------------------------------------------------------------------------------- 1 | from langchain_core.messages import SystemMessage 2 | 3 | #prompt to make LLM act as an Feynman instructor 4 | feynman_mode_prompt = SystemMessage(content=""" 5 | You are an interactive learning assistant based on the Feynman Technique. Your primary goal is to help students truly understand concepts by encouraging them to explain ideas in simple, clear language. You operate in two main modes: 6 | 7 | **Mode 1: When the user is explaining a concept to you** 8 | - Carefully examine their explanation for clarity and correctness 9 | - Check if they're using unnecessarily complex jargon or technical terms without proper explanation 10 | - Identify any misconceptions, gaps in understanding, or incorrect information 11 | - For any issues you find, don't just point out what's wrong - instead, ask thoughtful Socratic questions that guide them to discover the correct understanding themselves 12 | - Examples of good Socratic questions: 13 | - "What do you think would happen if...?" 14 | - "Can you think of a simpler way to explain this part?" 15 | - "How does this relate to [simpler concept they should know]?" 16 | - "What if we tried to explain this to a 10-year-old?" 17 | - Praise clear, simple explanations and correct understanding 18 | - If they use technical terms, ask them to explain those terms in their own words 19 | 20 | **Mode 2: When the user provides lecture content, textbook material, or background knowledge without their own explanation** 21 | - Analyze the material to identify key concepts that need to be understood 22 | - Generate specific, targeted questions that ask the user to explain these concepts in their own words 23 | - Focus on fundamental principles rather than memorization of facts 24 | - Create questions that build from basic to more complex understanding 25 | - Examples of good prompting questions: 26 | - "Can you explain [concept] as if you were teaching it to a friend?" 27 | - "What's the main idea behind [principle] and why is it important?" 28 | - "How would you describe [process] using everyday analogies?" 29 | - "What connections do you see between [concept A] and [concept B]?" 30 | 31 | **General Guidelines:** 32 | - Always maintain an encouraging, patient, and supportive tone 33 | - Celebrate when students demonstrate clear understanding 34 | - If a student is struggling, break concepts down into smaller, more manageable pieces 35 | - Use analogies and real-world examples to help clarify abstract concepts 36 | - Remember that the goal is deep understanding, not just surface-level memorization 37 | - Guide students to discover answers rather than simply providing them 38 | - Ask follow-up questions to ensure understanding is solid and not just superficial 39 | 40 | Your ultimate objective is to help students achieve the level of understanding where they can explain concepts clearly and simply to others, just as Richard Feynman advocated. 41 | """) 42 | 43 | 44 | def get_learning_mode_prompt(learning_checkpoints, known_knowledge): 45 | Learning_mode_prompt = f""" 46 | You are an AI learning tutor helping a student master these learning checkpoints: 47 | {chr(10).join([f"• {checkpoint}" for checkpoint in learning_checkpoints])} 48 | 49 | Available background knowledge: {known_knowledge[:] if known_knowledge else "No prior knowledge available"} 50 | 51 | **FORMATTING INSTRUCTIONS:** 52 | Always format your responses using markdown for better readability: 53 | - Use ## for main topics and concepts 54 | - Use ### for subtopics and detailed explanations 55 | - Use **bold** for key terms and important concepts 56 | - Use bullet points (-) for lists and steps 57 | - Use `code blocks` for any technical terms or examples 58 | - Use > blockquotes for important notes and key takeaways 59 | - Use --- for section dividers when appropriate 60 | 61 | **TEACHING APPROACH:** 62 | Analyze and Anchor: 63 | You will be provided with the user's background knowledge. Before explaining anything, deeply analyze this background. Your entire explanation must be anchored to this knowledge. Use frequent analogies, metaphors, and direct comparisons to what the user already knows to make new information intuitive and familiar. 64 | 65 | Explain with Clarity: 66 | Use vivid, concrete examples and a clear, coherent logical flow. Break down complex ideas into simple, digestible steps. 67 | 68 | Guide with Socratic Questions: Structure the lesson as a dialogue. After explaining a key point, you must pause and ask a thoughtful, guiding question. 69 | 70 | **Example response format:** 71 | ## Understanding [Topic] 72 | 73 | ### Key Concept 74 | **[Important term]** means... 75 | 76 | ### Connection to What You Know 77 | Think of it like... 78 | 79 | > **Important**: Remember that... 80 | 81 | ### Let's Explore 82 | Now, can you think about how this might relate to...? 83 | 84 | This question should: 85 | - Be a logical extension of the point you just made 86 | - Naturally bridge to the next concept you plan to introduce 87 | - Encourage the user to reason and discover the connections themselves 88 | 89 | Your output should feel less like a lecture and more like a guided discovery, where each concept is connected by an insightful question that sparks curiosity and understanding. 90 | """ 91 | 92 | return Learning_mode_prompt 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /backend/app/graph/schemas.py: -------------------------------------------------------------------------------- 1 | # backend/app/schemas.py 2 | 3 | from pydantic import BaseModel, Field 4 | from typing import List, Optional, Literal 5 | from typing import List, Optional, TypedDict, Annotated 6 | from pydantic import BaseModel, Field 7 | import operator 8 | from langgraph.graph import add_messages 9 | from langchain_core.messages import AnyMessage 10 | 11 | # ============================================================================ 12 | # PYDANTIC MODELS FOR STRUCTURED OUTPUT 13 | # ============================================================================ 14 | 15 | class Attachment(BaseModel): 16 | name: str = Field(description="The name of the attached file.") 17 | size: int = Field(description="The size of the file in bytes.") 18 | type: str = Field(description="The MIME type of the file.") 19 | content: Optional[bytes] = Field(None, description="The file content in bytes.") 20 | 21 | #how llm should response if it wants to use tool 22 | class ToolCall(BaseModel): 23 | tool_name: str = Field(description="The name of the tool that was called.") 24 | parameters: dict = Field({}, description="The parameters passed to the tool.") 25 | result: Optional[str] = Field(None, description="The result returned by the tool.") 26 | 27 | 28 | class ChatResponse(BaseModel): 29 | message_id: str = Field(description="Unique identifier for this specific message.") 30 | response_text: str = Field(description="The main text response from the assistant.") 31 | tool_calls: List[ToolCall] = Field([], description="A list of tools that were executed.") 32 | 33 | 34 | 35 | class SearchQuery(BaseModel): 36 | query: List[str] = Field( 37 | description="A list of queries for searching revelant user known knowledge, since later we need this known knowledge to explain new concepts" 38 | ) 39 | 40 | 41 | class checkpoints(BaseModel): 42 | goals: List[str] = Field(description="the steps we need to take to learn a certain lecture or concepts, this should contains at least 3 checkpoints") 43 | 44 | 45 | class LearningResponse(BaseModel): 46 | """Simple structured output for the central chat node""" 47 | response_text: str = Field(description="Rich markdown-formatted educational response with proper structure, formatting, and interactive elements") 48 | 49 | next_action: Literal["continue_learning", "store_knowledge"] = Field( 50 | description="""continue_learning = send message and wait for user reply, continue_learning is used when you think student havn't finish understanding all the checkpoints yet 51 | store_knowledge = user has mastered all checkpoints 52 | """ 53 | ) 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /backend/app/graph/state.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from typing import List, Optional, TypedDict, Annotated 4 | import operator 5 | from langgraph.graph import add_messages 6 | from langchain_core.messages import AnyMessage 7 | from .schemas import * 8 | 9 | 10 | 11 | class AgentState(TypedDict): 12 | 13 | # Core conversation data 14 | history_messages: Annotated[list[AnyMessage], add_messages] 15 | 16 | 17 | #place we store the search quries generate by LLM 18 | search_query: Annotated[list, operator.add] 19 | 20 | #learning goals, also means what we will know after we finish learning 21 | learning_checkpoints: Annotated[list[str], operator.add] 22 | 23 | #knowledge can used to explain after perform RAG 24 | KnownKnowledge: Annotated[list[str], operator.add] 25 | 26 | learning_complete: bool = False 27 | error: Optional[str] 28 | 29 | # Feynman agent specific transient flags 30 | needs_more_context: bool 31 | context_focus: Optional[str] 32 | 33 | -------------------------------------------------------------------------------- /backend/app/main.py: -------------------------------------------------------------------------------- 1 | # File: app/main.py 2 | 3 | import logging 4 | from contextlib import asynccontextmanager 5 | from fastapi import FastAPI 6 | from fastapi.middleware.cors import CORSMiddleware 7 | 8 | # --- LangGraph Imports --- 9 | from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver 10 | from .graph.graph import get_graph 11 | from .graph.feynman_graph import get_graph as get_feynman_graph 12 | 13 | # --- App Module Imports --- 14 | from app.core.log_config import setup_logging 15 | # CHANGE: Import the shared resources dictionary from the new dependencies file 16 | from .dependencies import shared_resources 17 | from .routers import lecture_transcript_router, google_login_router, logout_router, simpleChat_router, traditional_login_router,get_thread_history_router,get_thread_router, feynman__router 18 | from .database.session import create_tables 19 | 20 | # Run setup functions 21 | setup_logging() 22 | logger = logging.getLogger(__name__) 23 | 24 | # The shared_resources dictionary has been moved to app/dependencies.py 25 | 26 | @asynccontextmanager 27 | async def lifespan(app: FastAPI): 28 | # --- Code here runs ONCE on startup --- 29 | logger.info("Application starting up...") 30 | 31 | # 1. Create database tables if they don't exist 32 | await create_tables() 33 | logger.info("Database tables verified.") 34 | 35 | # 2. Set up the checkpointer's database connection 36 | # The 'async with' handles connection opening and closing 37 | checkpointer = AsyncSqliteSaver.from_conn_string("checkpoints.sqlite") 38 | async with checkpointer as db_checkpoint: 39 | 40 | # 3. Build the graph once using the checkpointer 41 | # and store it in the shared dictionary from the dependencies module 42 | shared_resources["graph"] = get_graph(db_checkpoint) 43 | shared_resources["feynman_graph"] = get_feynman_graph(db_checkpoint) 44 | logger.info("LangGraph agents (default and feynman) have been built and are ready.") 45 | 46 | yield # The app is now running and accepting requests 47 | 48 | # --- Code here runs ONCE on shutdown --- 49 | logger.info("Application shutting down...") 50 | # The 'async with' block ensures the checkpointer connection is closed gracefully 51 | 52 | # Create the FastAPI app instance with our lifespan manager 53 | app = FastAPI(lifespan=lifespan) 54 | 55 | # Add CORS middleware 56 | app.add_middleware( 57 | CORSMiddleware, 58 | allow_origins=[ 59 | "http://localhost:5174", 60 | "http://localhost:5173", 61 | "http://127.0.0.1:5174", 62 | "http://127.0.0.1:5173", 63 | "http://localhost:3000", 64 | "http://127.0.0.1:3000", 65 | ], 66 | allow_credentials=True, 67 | allow_methods=["*"], 68 | allow_headers=["*"], 69 | ) 70 | 71 | # Include your API routers 72 | app.include_router(lecture_transcript_router.router) 73 | app.include_router(google_login_router.router) 74 | app.include_router(logout_router.router) 75 | app.include_router(simpleChat_router.router) 76 | app.include_router(feynman__router.router) 77 | app.include_router(traditional_login_router.router) 78 | app.include_router(get_thread_history_router.router) 79 | app.include_router(get_thread_router.router) 80 | 81 | 82 | # CHANGE: The dependency function has been moved to app/dependencies.py 83 | 84 | @app.get("/") 85 | def read_root(): 86 | """A simple endpoint to check if the API is running.""" 87 | logger.info("Root endpoint was hit.") 88 | return {"status": "API is running"} 89 | -------------------------------------------------------------------------------- /backend/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | 3 | __all__ = ["User"] -------------------------------------------------------------------------------- /backend/app/models/operations.py: -------------------------------------------------------------------------------- 1 | # backend/app/database/operations.py 2 | 3 | import asyncpg 4 | from datetime import datetime 5 | from typing import Optional, Dict, Any 6 | import logging 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | async def find_or_create_user( 12 | connection: asyncpg.Connection, 13 | user_data: Dict[str, Any] 14 | ) -> Dict[str, Any]: 15 | """ 16 | Find an existing user by email or google_id, or create a new one. 17 | This is the main function for handling user authentication. 18 | 19 | Args: 20 | connection: AsyncPG database connection 21 | user_data: Dict containing user information from Google OAuth with keys: 22 | - email: User's email address 23 | - google_id: Google OAuth ID (optional) 24 | - name: Full name (optional) 25 | - first_name: First name (optional) 26 | - last_name: Last name (optional) 27 | - picture: Profile picture URL (optional) 28 | - verified_email: Email verification status (optional) 29 | 30 | Returns: 31 | Dict containing the user's data 32 | """ 33 | # First try to find by google_id if provided 34 | if user_data.get("google_id"): 35 | query = """ 36 | SELECT id, email, name, google_id, first_name, last_name, 37 | picture, is_active, verified_email, created_at, updated_at 38 | FROM users 39 | WHERE google_id = $1 40 | """ 41 | row = await connection.fetchrow(query, user_data.get("google_id")) 42 | 43 | if row: 44 | # Update user info with latest data from Google 45 | update_query = """ 46 | UPDATE users 47 | SET name = $2, first_name = $3, last_name = $4, 48 | picture = $5, verified_email = $6, updated_at = CURRENT_TIMESTAMP 49 | WHERE id = $1 50 | RETURNING id, email, name, google_id, first_name, last_name, 51 | picture, is_active, verified_email, created_at, updated_at 52 | """ 53 | updated_row = await connection.fetchrow( 54 | update_query, 55 | row['id'], 56 | user_data.get("name"), 57 | user_data.get("first_name"), 58 | user_data.get("last_name"), 59 | user_data.get("picture"), 60 | user_data.get("verified_email", False) 61 | ) 62 | logger.info(f"Updated existing user with Google ID: {user_data.get('google_id')}") 63 | return dict(updated_row) 64 | 65 | # If not found by google_id, try to find by email 66 | email_query = """ 67 | SELECT id, email, name, google_id, first_name, last_name, 68 | picture, is_active, verified_email, created_at, updated_at 69 | FROM users 70 | WHERE email = $1 71 | """ 72 | row = await connection.fetchrow(email_query, user_data.get("email")) 73 | 74 | if row: 75 | # Link existing user account to Google 76 | update_query = """ 77 | UPDATE users 78 | SET google_id = $2, name = $3, first_name = $4, last_name = $5, 79 | picture = $6, verified_email = $7, updated_at = CURRENT_TIMESTAMP 80 | WHERE id = $1 81 | RETURNING id, email, name, google_id, first_name, last_name, 82 | picture, is_active, verified_email, created_at, updated_at 83 | """ 84 | updated_row = await connection.fetchrow( 85 | update_query, 86 | row['id'], 87 | user_data.get("google_id"), 88 | user_data.get("name"), 89 | user_data.get("first_name"), 90 | user_data.get("last_name"), 91 | user_data.get("picture"), 92 | user_data.get("verified_email", False) 93 | ) 94 | logger.info(f"Linked Google account to existing user: {user_data.get('email')}") 95 | return dict(updated_row) 96 | 97 | # Create new user 98 | insert_query = """ 99 | INSERT INTO users ( 100 | email, google_id, name, first_name, last_name, 101 | picture, verified_email, is_active 102 | ) 103 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 104 | RETURNING id, email, name, google_id, first_name, last_name, 105 | picture, is_active, verified_email, created_at, updated_at 106 | """ 107 | 108 | new_row = await connection.fetchrow( 109 | insert_query, 110 | user_data.get("email"), 111 | user_data.get("google_id"), 112 | user_data.get("name"), 113 | user_data.get("first_name"), 114 | user_data.get("last_name"), 115 | user_data.get("picture"), 116 | user_data.get("verified_email", False), 117 | True # is_active 118 | ) 119 | 120 | logger.info(f"Created new user: {user_data.get('email')}") 121 | return dict(new_row) 122 | 123 | 124 | async def find_or_create_user_traditional( 125 | connection: asyncpg.Connection, 126 | email: str, 127 | password: str 128 | ) -> Dict[str, Any]: 129 | """ 130 | Find an existing user by email and verify password, or create a new one. 131 | This is the main function for traditional email/password authentication. 132 | 133 | Args: 134 | connection: AsyncPG database connection 135 | email: User's email address 136 | password: Plain text password (will be hashed if creating new user) 137 | 138 | Returns: 139 | Dict containing the user's data if authentication successful 140 | 141 | Raises: 142 | ValueError: If password is incorrect for existing user 143 | """ 144 | from ..services.password_service import get_password_hash, verify_password 145 | 146 | # First try to find user by email 147 | email_query = """ 148 | SELECT id, email, name, google_id, first_name, last_name, 149 | picture, hashed_password, is_active, verified_email, 150 | created_at, updated_at 151 | FROM users 152 | WHERE email = $1 153 | """ 154 | row = await connection.fetchrow(email_query, email) 155 | 156 | if row: 157 | user_data = dict(row) 158 | # User exists - verify password 159 | if not user_data.get('hashed_password'): 160 | raise ValueError("This account uses Google sign-in. Please use 'Sign in with Google'.") 161 | 162 | if not verify_password(password, user_data['hashed_password']): 163 | raise ValueError("Incorrect password") 164 | 165 | logger.info(f"User authenticated: {email}") 166 | return user_data 167 | 168 | # User doesn't exist - create new one 169 | hashed_password = get_password_hash(password) 170 | 171 | insert_query = """ 172 | INSERT INTO users (email, hashed_password, is_active) 173 | VALUES ($1, $2, $3) 174 | RETURNING id, email, name, google_id, first_name, last_name, 175 | picture, is_active, verified_email, created_at, updated_at 176 | """ 177 | 178 | new_row = await connection.fetchrow( 179 | insert_query, 180 | email, 181 | hashed_password, 182 | True # is_active 183 | ) 184 | 185 | logger.info(f"Created new user via traditional auth: {email}") 186 | return dict(new_row) 187 | 188 | 189 | async def add_thread( 190 | connection: asyncpg.Connection, 191 | thread_id: str, 192 | user_id: int, 193 | thread_name: str 194 | ) -> Dict[str, Any]: 195 | """ 196 | Add a new thread to the threads table. 197 | 198 | Args: 199 | connection: AsyncPG database connection 200 | thread_id: Unique thread identifier 201 | user_id: ID of the user who owns the thread 202 | thread_name: Name/title of the thread 203 | 204 | Returns: 205 | Dict containing the created thread's data 206 | 207 | Raises: 208 | asyncpg.UniqueViolationError: If thread_id already exists 209 | asyncpg.ForeignKeyViolationError: If user_id doesn't exist 210 | """ 211 | try: 212 | query = """ 213 | INSERT INTO threads (thread_id, user_id, thread_name) 214 | VALUES ($1, $2, $3) 215 | RETURNING thread_id, user_id, thread_name, created_at, updated_at 216 | """ 217 | 218 | row = await connection.fetchrow(query, thread_id, user_id, thread_name) 219 | 220 | thread_data = dict(row) 221 | logger.info(f"Successfully created thread: {thread_data['thread_id']} for user: {user_id}") 222 | return thread_data 223 | 224 | except asyncpg.UniqueViolationError as e: 225 | logger.error(f"Thread with ID {thread_id} already exists: {e}") 226 | raise 227 | except asyncpg.ForeignKeyViolationError as e: 228 | logger.error(f"User with ID {user_id} does not exist: {e}") 229 | raise 230 | except Exception as e: 231 | logger.error(f"Error creating thread: {e}") 232 | raise 233 | 234 | 235 | # Helper functions for common queries 236 | 237 | async def get_user_by_email( 238 | connection: asyncpg.Connection, 239 | email: str 240 | ) -> Optional[Dict[str, Any]]: 241 | """ 242 | Get a user by their email address. 243 | 244 | Args: 245 | connection: AsyncPG database connection 246 | email: User's email address 247 | 248 | Returns: 249 | User data as dict or None if not found 250 | """ 251 | query = """ 252 | SELECT id, email, name, google_id, first_name, last_name, 253 | picture, hashed_password, is_active, verified_email, 254 | created_at, updated_at 255 | FROM users 256 | WHERE email = $1 257 | """ 258 | 259 | row = await connection.fetchrow(query, email) 260 | return dict(row) if row else None 261 | 262 | 263 | async def get_user_by_google_id( 264 | connection: asyncpg.Connection, 265 | google_id: str 266 | ) -> Optional[Dict[str, Any]]: 267 | """ 268 | Get a user by their Google ID. 269 | 270 | Args: 271 | connection: AsyncPG database connection 272 | google_id: User's Google ID 273 | 274 | Returns: 275 | User data as dict or None if not found 276 | """ 277 | query = """ 278 | SELECT id, email, name, google_id, first_name, last_name, 279 | picture, is_active, verified_email, created_at, updated_at 280 | FROM users 281 | WHERE google_id = $1 282 | """ 283 | 284 | row = await connection.fetchrow(query, google_id) 285 | return dict(row) if row else None 286 | 287 | 288 | async def get_user_by_id( 289 | connection: asyncpg.Connection, 290 | user_id: int 291 | ) -> Optional[Dict[str, Any]]: 292 | """ 293 | Get a user by their ID. 294 | 295 | Args: 296 | connection: AsyncPG database connection 297 | user_id: User's ID 298 | 299 | Returns: 300 | User data as dict or None if not found 301 | """ 302 | query = """ 303 | SELECT id, email, name, google_id, first_name, last_name, 304 | picture, hashed_password, is_active, verified_email, 305 | created_at, updated_at 306 | FROM users 307 | WHERE id = $1 308 | """ 309 | 310 | row = await connection.fetchrow(query, user_id) 311 | return dict(row) if row else None 312 | 313 | 314 | async def get_user_threads( 315 | connection: asyncpg.Connection, 316 | user_id: int, 317 | limit: Optional[int] = None 318 | ) -> list[Dict[str, Any]]: 319 | """ 320 | Get threads for a specific user, optionally limited. 321 | 322 | Args: 323 | connection: AsyncPG database connection 324 | user_id: User's ID 325 | limit: Maximum number of threads to return (None for all) 326 | 327 | Returns: 328 | List of thread data dicts ordered by created_at DESC 329 | """ 330 | query = """ 331 | SELECT thread_id, user_id, thread_name, created_at, updated_at 332 | FROM threads 333 | WHERE user_id = $1 334 | ORDER BY created_at DESC 335 | """ 336 | 337 | if limit is not None: 338 | query += f" LIMIT {limit}" 339 | 340 | rows = await connection.fetch(query, user_id) 341 | return [dict(row) for row in rows] 342 | 343 | 344 | async def check_thread_exists( 345 | connection: asyncpg.Connection, 346 | thread_id: str 347 | ) -> bool: 348 | """ 349 | Check if a thread with the given ID exists. 350 | 351 | Args: 352 | connection: AsyncPG database connection 353 | thread_id: Thread ID to check 354 | 355 | Returns: 356 | True if thread exists, False otherwise 357 | """ 358 | query = "SELECT EXISTS(SELECT 1 FROM threads WHERE thread_id = $1)" 359 | return await connection.fetchval(query, thread_id) 360 | 361 | 362 | # Example: Create user with initial thread using transaction 363 | async def create_user_with_initial_thread( 364 | connection: asyncpg.Connection, 365 | user_data: Dict[str, Any], 366 | thread_id: str, 367 | thread_name: str = "Welcome Thread" 368 | ) -> tuple[Dict[str, Any], Dict[str, Any]]: 369 | """ 370 | Create a user and their initial thread in a single transaction. 371 | Uses find_or_create_user to handle the user creation. 372 | 373 | Args: 374 | connection: AsyncPG database connection 375 | user_data: User data dict (email, google_id, name, etc.) 376 | thread_id: Initial thread ID 377 | thread_name: Name for the initial thread 378 | 379 | Returns: 380 | Tuple of (user_data, thread_data) 381 | """ 382 | async with connection.transaction(): 383 | # Create or find user 384 | user = await find_or_create_user(connection, user_data) 385 | 386 | # Create initial thread 387 | thread_data = await add_thread( 388 | connection, 389 | thread_id, 390 | user['id'], 391 | thread_name 392 | ) 393 | 394 | return user, thread_data -------------------------------------------------------------------------------- /backend/app/models/user.py: -------------------------------------------------------------------------------- 1 | # backend/app/models/user.py 2 | 3 | from pydantic import BaseModel 4 | from typing import Optional 5 | from datetime import datetime 6 | 7 | class User(BaseModel): 8 | """ 9 | Simple type-safe model - just tells Python what fields exist. 10 | The actual database structure is defined in SQL schema. 11 | """ 12 | user_id: str 13 | email: str 14 | 15 | thread_ids: list[str] 16 | thread_names: list[str] 17 | 18 | # Google OAuth fields 19 | google_id: Optional[str] = None 20 | name: Optional[str] = None 21 | first_name: Optional[str] = None 22 | last_name: Optional[str] = None 23 | picture: Optional[str] = None 24 | 25 | # Traditional auth fields 26 | hashed_password: Optional[str] = None 27 | 28 | # Status fields 29 | is_active: bool = True 30 | verified_email: bool = False 31 | 32 | # Timestamps 33 | created_at: datetime 34 | updated_at: datetime 35 | 36 | class Config: 37 | # This lets you create User objects from database rows 38 | from_attributes = True -------------------------------------------------------------------------------- /backend/app/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/app/routers/__init__.py -------------------------------------------------------------------------------- /backend/app/routers/auth_dependencies.py: -------------------------------------------------------------------------------- 1 | # backend/app/auth_dependencies.py 2 | 3 | import os 4 | from dotenv import load_dotenv, find_dotenv 5 | from fastapi import Request, Depends, HTTPException, status 6 | from jose import JWTError, jwt 7 | import asyncpg # Import asyncpg 8 | 9 | # Import your database session function 10 | from ..database.session import get_db_session 11 | 12 | # Load environment variables 13 | load_dotenv(find_dotenv()) 14 | SECRET_KEY = os.getenv("JWT_SECRET") 15 | ALGORITHM = os.getenv("JWT_ALGORITHM") 16 | 17 | credentials_exception = HTTPException( 18 | status_code=status.HTTP_401_UNAUTHORIZED, 19 | detail="Could not validate credentials", 20 | headers={"WWW-Authenticate": "Bearer"}, 21 | ) 22 | 23 | async def get_current_user( 24 | request: Request, 25 | db_connection: asyncpg.Connection = Depends(get_db_session) # Use the asyncpg connection 26 | ) -> dict: # Return a dict instead of a User model 27 | """ 28 | Dependency to get the current user from the JWT in the cookie. 29 | """ 30 | token = request.cookies.get("session_token") 31 | if token is None: 32 | raise credentials_exception 33 | 34 | try: 35 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 36 | user_id: str = payload.get("sub") 37 | if user_id is None: 38 | raise credentials_exception 39 | 40 | except JWTError: 41 | raise credentials_exception 42 | 43 | # Fetch the user from the database using a raw SQL query 44 | user_record = await db_connection.fetchrow( 45 | "SELECT * FROM users WHERE id = $1", int(user_id) 46 | ) 47 | 48 | if user_record is None: 49 | raise credentials_exception 50 | 51 | # Return the user data as a dictionary 52 | return dict(user_record) -------------------------------------------------------------------------------- /backend/app/routers/embed_router.py: -------------------------------------------------------------------------------- 1 | # In your router file (e.g., app/api/routers/embed.py) 2 | from fastapi import FastAPI, HTTPException, Depends, APIRouter, Body 3 | from ..core.chroma_db import chroma_manager 4 | from .auth_dependencies import get_current_user 5 | from langchain.text_splitter import RecursiveCharacterTextSplitter 6 | import logging 7 | import asyncio 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | router = APIRouter( 12 | prefix="/api/knowledge", 13 | tags=["knowledge"] 14 | ) 15 | 16 | @router.post("/embed") 17 | async def embed_known_knowledge( 18 | topic: str, 19 | chunk_size: int = 1000, # ✅ Optional: Define the size of each text chunk 20 | chunk_overlap: int = 200, # ✅ Optional: Define the overlap between chunks 21 | content: str = Body(..., media_type="text/plain"), 22 | current_user: dict = Depends(get_current_user) 23 | ): 24 | """ 25 | Receives raw text, splits it into chunks, embeds each chunk, and 26 | stores them in the user's personal knowledge collection in ChromaDB. 27 | """ 28 | user_id = current_user.get("user_id") 29 | if not user_id: 30 | raise HTTPException(status_code=403, detail="User ID not found in token.") 31 | 32 | if not content or not topic: 33 | raise HTTPException(status_code=400, detail="A 'topic' query parameter and a text body are required.") 34 | 35 | try: 36 | # --- ADDED: Text Splitting Logic --- 37 | text_splitter = RecursiveCharacterTextSplitter( 38 | chunk_size=chunk_size, 39 | chunk_overlap=chunk_overlap, 40 | length_function=len, 41 | ) 42 | # The splitter returns a list of strings (the chunks) 43 | chunks = text_splitter.split_text(content) 44 | 45 | if not chunks: 46 | raise HTTPException(status_code=400, detail="Content could not be split into chunks.") 47 | 48 | # Get the user-specific collection 49 | collection_name = f"user_{user_id}_knowledge" 50 | collection = chroma_manager.get_collection(name=collection_name) 51 | 52 | # Create embeddings for all chunks at once 53 | embeddings = await asyncio.to_thread( 54 | chroma_manager.embedding_model.embed_documents, chunks 55 | ) 56 | 57 | # --- MODIFIED: Prepare data for multiple chunks --- 58 | # Create a unique ID for each chunk (e.g., "my-topic-0", "my-topic-1") 59 | ids = [f"{topic}-{i}" for i in range(len(chunks))] 60 | metadatas = [{"topic": topic, "chunk_index": i} for i in range(len(chunks))] 61 | 62 | # Add all chunks, embeddings, and metadatas to the collection 63 | await asyncio.to_thread( 64 | collection.add, 65 | embeddings=embeddings, 66 | documents=chunks, 67 | ids=ids, 68 | metadatas=metadatas 69 | ) 70 | 71 | logger.info(f"Successfully stored {len(chunks)} chunks for user {user_id} on topic: {topic}") 72 | return {"message": f"Knowledge on topic '{topic}' embedded successfully in {len(chunks)} chunks."} 73 | 74 | except Exception as e: 75 | logger.error(f"Failed to store knowledge for user {user_id}, topic '{topic}': {str(e)}") 76 | raise HTTPException(status_code=500, detail=f"Failed to embed knowledge: {str(e)}") 77 | -------------------------------------------------------------------------------- /backend/app/routers/feynman__router.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from fastapi import APIRouter, Form, HTTPException, Depends 3 | from fastapi.responses import StreamingResponse 4 | from langchain_core.messages import HumanMessage, AIMessage 5 | 6 | from ..dependencies import get_feynman_graph 7 | from .auth_dependencies import * 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | router = APIRouter(prefix="/api") 12 | 13 | 14 | @router.post("/feynman") 15 | async def chat_with_feynman_agent( 16 | message: str = Form(...), 17 | thread_id: str = Form(...), 18 | current_user: dict = Depends(get_current_user), 19 | graph = Depends(get_feynman_graph), 20 | ): 21 | if not current_user.get('is_active', True): 22 | raise HTTPException(status_code=403, detail="Account suspended") 23 | 24 | async def stream_agent_response(): 25 | try: 26 | config = { 27 | "configurable": { 28 | "thread_id": thread_id, 29 | "user_id": int(current_user['id']) 30 | } 31 | } 32 | 33 | input_payload = { 34 | "history_messages": [HumanMessage(content=message)] 35 | } 36 | 37 | logger.info(f"Starting Feynman graph stream for thread: {thread_id}, user: {current_user['id']}") 38 | 39 | async for event in graph.astream_events(input_payload, config, version="v1"): 40 | kind = event["event"] 41 | 42 | if kind == "on_chain_end": 43 | node_name = event["name"] 44 | node_data = (event.get("data") or {}).get("output") or {} 45 | 46 | if not node_data: 47 | continue 48 | 49 | # Feynman-specific node handling 50 | if node_name == "assess_context_need": 51 | needs_more = bool(node_data.get("needs_more_context", False)) 52 | if needs_more: 53 | yield "data: 🔍 I'll search online for more helpful context.\n\n" 54 | else: 55 | yield "data: ✅ We have enough context. Let’s evaluate your explanation.\n\n" 56 | 57 | elif node_name == "search_online": 58 | # Optional: surface that new knowledge was added 59 | knowledge = node_data.get("KnownKnowledge") or [] 60 | if knowledge: 61 | preview = (knowledge[-1] or "").strip().splitlines()[0][:180] 62 | if preview: 63 | yield f"data: 📚 Found extra context: {preview}\n\n" 64 | 65 | elif node_name == "evaluate_user_explanation": 66 | messages = node_data.get("history_messages") or [] 67 | if messages: 68 | latest_message = messages[-1] 69 | if isinstance(latest_message, AIMessage): 70 | response_text = latest_message.content or "" 71 | for line in response_text.splitlines(): 72 | yield f"data: {line}\n" 73 | yield "\n" 74 | 75 | if node_data.get("learning_complete", False): 76 | congrats_text = "🎉 Congratulations! You've mastered this concept." 77 | for line in congrats_text.splitlines(): 78 | yield f"data: {line}\n" 79 | yield "\n" 80 | 81 | elif node_name == "store_mastered_concept": 82 | yield "data: ✅ I've stored your mastered concept into your personal knowledge database.\n\n" 83 | 84 | except Exception: 85 | logger.exception(f"Critical Feynman agent error in thread {thread_id}") 86 | yield "data: ❌ I'm having a critical problem. Please try again in a moment.\n\n" 87 | 88 | return StreamingResponse( 89 | stream_agent_response(), 90 | media_type="text/event-stream" 91 | ) 92 | 93 | 94 | -------------------------------------------------------------------------------- /backend/app/routers/get_thread_history_router.py: -------------------------------------------------------------------------------- 1 | # In your main application file (e.g., app/server.py) 2 | from fastapi import FastAPI, HTTPException, Depends,APIRouter # <-- Add Depends 3 | from ..dependencies import get_app_graph 4 | 5 | 6 | router = APIRouter( 7 | prefix="/api", 8 | tags=["thread"] 9 | ) 10 | 11 | @router.get("/thread_history/{thread_id}") 12 | async def get_thread_history(thread_id: str, graph = Depends(get_app_graph)): 13 | """ 14 | Retrieves the message history for a specific thread without running the agent. 15 | """ 16 | config = {"configurable": {"thread_id": thread_id}} 17 | 18 | try: 19 | # Use get_state() to load the latest saved state from the checkpointer 20 | state_snapshot = await graph.aget_state(config) # Use aget_state for async 21 | 22 | # Extract the messages from the state 23 | history_messages = state_snapshot.values.get("history_messages", []) 24 | 25 | # Return the history to the frontend 26 | return {"messages": history_messages} 27 | 28 | except Exception as e: 29 | raise HTTPException(status_code=404, detail=f"Thread not found or error retrieving state: {e}") -------------------------------------------------------------------------------- /backend/app/routers/get_thread_router.py: -------------------------------------------------------------------------------- 1 | # In your main application file (e.g., app/server.py) 2 | from fastapi import FastAPI, HTTPException, Depends, APIRouter 3 | import asyncpg 4 | from typing import List, Dict, Any 5 | import logging 6 | 7 | from ..dependencies import get_app_graph 8 | from .auth_dependencies import get_current_user 9 | from ..database.session import get_db_session 10 | from ..models.operations import get_user_threads 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | router = APIRouter( 15 | prefix="/api", 16 | tags=["thread"] 17 | ) 18 | 19 | @router.get("/threads") 20 | async def get_user_recent_threads( 21 | current_user: dict = Depends(get_current_user), 22 | db_connection: asyncpg.Connection = Depends(get_db_session) 23 | ) -> List[Dict[str, Any]]: 24 | """ 25 | Get the latest 5 conversations/threads for the current user. 26 | Returns thread_id and thread_name for displaying in the sidebar. 27 | """ 28 | try: 29 | user_id = int(current_user['id']) 30 | logger.info(f"Getting recent threads for user: {user_id}") 31 | 32 | # Get the latest 5 threads for the user 33 | threads = await get_user_threads( 34 | connection=db_connection, 35 | user_id=user_id, 36 | limit=5 37 | ) 38 | 39 | logger.info(f"Found {len(threads)} threads for user: {user_id}") 40 | 41 | # Return only the fields needed by the frontend 42 | return [ 43 | { 44 | "thread_id": thread["thread_id"], 45 | "thread_name": thread["thread_name"], 46 | "created_at": thread["created_at"].isoformat() if thread["created_at"] else None, 47 | "updated_at": thread["updated_at"].isoformat() if thread["updated_at"] else None 48 | } 49 | for thread in threads 50 | ] 51 | 52 | except Exception as e: 53 | logger.error(f"Error retrieving threads for user {current_user.get('id', 'unknown')}: {e}") 54 | raise HTTPException(status_code=500, detail=f"Error retrieving threads: {e}") 55 | 56 | -------------------------------------------------------------------------------- /backend/app/routers/google_login_router.py: -------------------------------------------------------------------------------- 1 | # backend/app/routers/google_login_router.py 2 | from fastapi import APIRouter, HTTPException, Depends, Response 3 | from pydantic import BaseModel 4 | from dotenv import load_dotenv, find_dotenv 5 | import os 6 | from datetime import datetime, timedelta, timezone 7 | from jose import JWTError, jwt 8 | import asyncpg 9 | import logging 10 | 11 | load_dotenv(find_dotenv()) 12 | 13 | # Import the authentication function from services and find_or_create_user from operations 14 | from ..services.google_login import authenticate_google_user 15 | from ..models.operations import find_or_create_user,get_user_threads 16 | from ..database.session import get_db_session 17 | 18 | router = APIRouter( 19 | prefix="/api/auth", 20 | tags=["Authentication"] 21 | ) 22 | 23 | class GoogleAuthPayload(BaseModel): 24 | code: str 25 | 26 | # Load the redirect URI from environment variables 27 | GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI") 28 | 29 | # JWT Configuration 30 | SECRET_KEY = os.getenv("JWT_SECRET") 31 | ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") 32 | ACCESS_TOKEN_EXPIRE_MINUTES = 50000 33 | 34 | 35 | def create_access_token(data: dict): 36 | to_encode = data.copy() 37 | expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) 38 | to_encode.update({"exp": expire}) 39 | 40 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 41 | return encoded_jwt 42 | 43 | 44 | @router.post("/google") 45 | async def google_auth_callback( 46 | payload: GoogleAuthPayload, 47 | response: Response, 48 | db_connection: asyncpg.Connection = Depends(get_db_session) 49 | ): 50 | """ 51 | This endpoint is called by the frontend after it receives the 52 | authorization code from Google. 53 | """ 54 | try: 55 | # 1. Pass the code and the redirect_uri to your service 56 | auth_result = await authenticate_google_user( 57 | authorization_code=payload.code, 58 | redirect_uri=GOOGLE_REDIRECT_URI 59 | ) 60 | 61 | if not auth_result.get("success"): 62 | raise HTTPException( 63 | status_code=401, 64 | detail=f"Authentication failed: {auth_result.get('error', 'Unknown error')}" 65 | ) 66 | 67 | user_data = auth_result.get("user") 68 | 69 | # 2. Find or create the user in your database 70 | user = await find_or_create_user(db_connection, user_data) 71 | 72 | # 3. Get user's latest 10 threads 73 | user_threads = await get_user_threads(db_connection, user['id'], limit=10) 74 | 75 | # 4. Create a session token (JWT) for the user 76 | session_token = create_access_token(data={"sub": str(user['id'])}) 77 | 78 | # Remember to set secure to True when hosting live 79 | response.set_cookie( 80 | key="session_token", 81 | value=session_token, 82 | httponly=True, 83 | secure=False, 84 | samesite='lax', 85 | path='/',# important to set the path to root so cookie is send with all requests 86 | max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60 87 | ) 88 | 89 | # 5. Return the user data and their threads 90 | return { 91 | "user": { 92 | "id": user['id'], 93 | "name": user.get('name'), 94 | "email": user['email'], 95 | "picture": user.get('picture') 96 | }, 97 | "threads": [ 98 | { 99 | "thread_id": thread['thread_id'], 100 | "thread_name": thread['thread_name'], 101 | "created_at": thread['created_at'].isoformat() if thread['created_at'] else None, 102 | "updated_at": thread['updated_at'].isoformat() if thread['updated_at'] else None 103 | } 104 | for thread in user_threads 105 | ] 106 | } 107 | 108 | except Exception as e: 109 | logging.error("Error in google_auth_callback", exc_info=True) 110 | 111 | raise HTTPException( 112 | status_code=500, 113 | detail=f"Authentication failed: {str(e)}" 114 | ) -------------------------------------------------------------------------------- /backend/app/routers/lecture_transcript_router.py: -------------------------------------------------------------------------------- 1 | # File: app/routers/lecture_transcript_router.py 2 | 3 | import logging 4 | from fastapi import APIRouter, HTTPException 5 | from pydantic import BaseModel 6 | from ..services.lecture_transcript import * 7 | 8 | # Request model for JSON body 9 | class LectureRequest(BaseModel): 10 | lecture_url: str 11 | 12 | router = APIRouter( 13 | prefix="/api", 14 | tags=["Lectures"] 15 | ) 16 | 17 | 18 | @router.post("/transcript") 19 | async def get_transcript_from_url(request: LectureRequest): 20 | """ 21 | This endpoint receives a lecture URL, calls the service to scrape 22 | the transcript, and returns success status with filename (no transcript text). 23 | """ 24 | lecture_url = request.lecture_url 25 | logging.info("Router received request for URL: %s", lecture_url) 26 | 27 | 28 | try: 29 | transcript_text = handle_transcript_request(lecture_url) 30 | 31 | if not transcript_text: 32 | # Failed to get transcript 33 | return { 34 | "success": False 35 | } 36 | 37 | # Successfully got transcript, generate filename 38 | lecture_filename = handle_lecture_name(lecture_url) 39 | 40 | return { 41 | "success": True, 42 | "filename": lecture_filename 43 | } 44 | 45 | except Exception as e: 46 | logging.error("Unhandled exception in router for URL %s", lecture_url, exc_info=True) 47 | return { 48 | "success": False 49 | } -------------------------------------------------------------------------------- /backend/app/routers/logout_router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Response, status 2 | 3 | router = APIRouter( 4 | prefix="/api/auth", 5 | tags=["Authentication"] 6 | ) 7 | 8 | @router.post("/logout") 9 | async def logout(response: Response): 10 | """ 11 | This endpoint clears the session_token cookie, effectively logging the user out. 12 | """ 13 | # The key to clearing a cookie is to set it again with the same parameters 14 | # but with a max_age of 0. This tells the browser it has expired. 15 | response.set_cookie( 16 | key="session_token", 17 | value="", # The value does not matter, but empty is conventional 18 | httponly=True, 19 | secure=False, # Should be True in production (HTTPS) 20 | samesite='lax', 21 | path='/', 22 | max_age=0 # Set max_age to 0 to expire the cookie immediately 23 | ) 24 | 25 | # Return a success message 26 | return { 27 | "message": "Successfully logged out" 28 | } -------------------------------------------------------------------------------- /backend/app/routers/simpleChat_router.py: -------------------------------------------------------------------------------- 1 | # File: app/routers/simpleChat_router.py 2 | 3 | import logging 4 | from fastapi import APIRouter, Form, HTTPException, Depends 5 | from fastapi.responses import StreamingResponse 6 | from langchain_core.messages import HumanMessage, AIMessage 7 | 8 | from ..dependencies import get_app_graph 9 | from .auth_dependencies import * 10 | 11 | logger = logging.getLogger(__name__) 12 | router = APIRouter(prefix="/api") 13 | 14 | @router.post("/simplechat") 15 | async def chat_with_agent( 16 | message: str = Form(...), 17 | thread_id: str = Form(...), 18 | current_user: dict = Depends(get_current_user), 19 | graph = Depends(get_app_graph), 20 | ): 21 | if not current_user.get('is_active', True): 22 | raise HTTPException(status_code=403, detail="Account suspended") 23 | 24 | async def stream_agent_response(): 25 | try: 26 | config = { 27 | "configurable": { 28 | "thread_id": thread_id, 29 | "user_id": int(current_user['id'])} 30 | } 31 | 32 | # The input should be a dictionary where keys match the AgentState. 33 | # This is the standard way to pass new data to be added to the state. 34 | input_payload = { 35 | "history_messages": [HumanMessage(content=message)] 36 | } 37 | 38 | logger.info(f"Starting graph stream for thread: {thread_id}, user: {current_user['id']}") 39 | 40 | # Use graph.astream_events for more granular control 41 | async for event in graph.astream_events(input_payload, config, version="v1"): 42 | kind = event["event"] 43 | 44 | # Focus only on events where nodes finish running 45 | if kind == "on_chain_end": 46 | node_name = event["name"] # In v1, the node name is in the 'name' field 47 | node_data = (event.get("data") or {}).get("output") or {} 48 | 49 | # Skip if there's no output data 50 | if not node_data: 51 | continue 52 | 53 | # Now, process the output from each specific node 54 | if node_name == "generate_learning_goals": 55 | goals = node_data.get("learning_checkpoints", []) 56 | if goals: 57 | logger.info("## 📚 Learning Plan") 58 | for i, goal in enumerate(goals, 1): 59 | logger.info(f"**{i}.** {goal}") 60 | logger.info("---") 61 | 62 | elif node_name == "central_response_node": 63 | error = node_data.get("error") 64 | if error: 65 | yield f"data: ❌ **Error:** {error}\n\n" 66 | else: 67 | messages = node_data.get("history_messages") or [] 68 | if messages: 69 | latest_message = messages[-1] 70 | if isinstance(latest_message, AIMessage): 71 | response_text = latest_message.content or "" 72 | # SSE requires each line of data to be prefixed with 'data:' 73 | for line in response_text.splitlines(): 74 | yield f"data: {line}\n" 75 | # End of one SSE message event 76 | yield "\n" 77 | 78 | if node_data.get("learning_complete", False): 79 | congrats_text = "🎉 **Congratulations!** You've mastered all the learning checkpoints!" 80 | for line in congrats_text.splitlines(): 81 | yield f"data: {line}\n" 82 | yield "\n" 83 | 84 | elif node_name == "store_known_knowledge": 85 | yield "data: ✅ I have also stored what you learnt in this conversation into your personal knowledge database, used for future reference.\n\n" 86 | 87 | except Exception as e: 88 | logger.exception(f"Critical agent error in thread {thread_id}") 89 | yield "data: ❌ I'm having a critical problem. Please try again in a moment.\n\n" 90 | 91 | return StreamingResponse( 92 | stream_agent_response(), 93 | media_type="text/event-stream" 94 | ) -------------------------------------------------------------------------------- /backend/app/routers/thread_router.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/app/routers/thread_router.py -------------------------------------------------------------------------------- /backend/app/routers/traditional_login_router.py: -------------------------------------------------------------------------------- 1 | # backend/app/routers/traditional_login_router.py 2 | from fastapi import APIRouter, Depends, HTTPException, Response 3 | from pydantic import BaseModel 4 | import asyncpg 5 | 6 | from ..models.operations import find_or_create_user_traditional,get_user_threads 7 | from ..database.session import get_db_session 8 | from .google_login_router import create_access_token # Reusing your JWT creation function 9 | 10 | router = APIRouter( 11 | prefix="/api/auth", 12 | tags=["Authentication"] 13 | ) 14 | 15 | class TraditionalLoginPayload(BaseModel): 16 | email: str 17 | password: str 18 | 19 | @router.post("/login") 20 | async def traditional_login( 21 | payload: TraditionalLoginPayload, 22 | response: Response, 23 | connection: asyncpg.Connection = Depends(get_db_session) 24 | ): 25 | """ 26 | Traditional email/password login endpoint. 27 | If user exists, verifies password. If not, creates new account. 28 | """ 29 | try: 30 | # Find existing user or create new one 31 | user = await find_or_create_user_traditional( 32 | connection, 33 | payload.email, 34 | payload.password 35 | ) 36 | 37 | # Check if account is active 38 | if not user.get('is_active', True): 39 | raise HTTPException( 40 | status_code=403, 41 | detail="Account is deactivated" 42 | ) 43 | 44 | # Get user's latest 10 threads 45 | user_threads = await get_user_threads(connection, user['id'], limit=10) 46 | 47 | # Create and set the session token 48 | session_token = create_access_token(data={"sub": str(user['id'])}) 49 | response.set_cookie( 50 | key="session_token", 51 | value=session_token, 52 | httponly=True, 53 | secure=False, # Set to True in production with HTTPS 54 | samesite='lax', 55 | path='/', 56 | max_age=50000 * 60 57 | ) 58 | 59 | return { 60 | "user": { 61 | "id": user['id'], 62 | "name": user.get('name'), 63 | "email": user['email'], 64 | "picture": user.get('picture') 65 | }, 66 | "threads": [ 67 | { 68 | "thread_id": thread['thread_id'], 69 | "thread_name": thread['thread_name'], 70 | "created_at": thread['created_at'].isoformat() if thread['created_at'] else None, 71 | "updated_at": thread['updated_at'].isoformat() if thread['updated_at'] else None 72 | } 73 | for thread in user_threads 74 | ] 75 | } 76 | 77 | except ValueError as e: 78 | # This catches password verification errors 79 | raise HTTPException( 80 | status_code=401, 81 | detail=str(e), 82 | headers={"WWW-Authenticate": "Bearer"}, 83 | ) 84 | except Exception as e: 85 | raise HTTPException( 86 | status_code=500, 87 | detail=f"Login failed: {str(e)}" 88 | ) -------------------------------------------------------------------------------- /backend/app/schemas/schemas1.py: -------------------------------------------------------------------------------- 1 | # backend/app/schemas.py 2 | 3 | from pydantic import BaseModel, Field 4 | from typing import List, Optional, Literal 5 | 6 | class Attachment(BaseModel): 7 | name: str = Field(description="The name of the attached file.") 8 | size: int = Field(description="The size of the file in bytes.") 9 | type: str = Field(description="The MIME type of the file.") 10 | content: Optional[bytes] = Field(None, description="The file content in bytes.") 11 | 12 | class ToolCall(BaseModel): 13 | tool_name: str = Field(description="The name of the tool that was called.") 14 | parameters: dict = Field({}, description="The parameters passed to the tool.") 15 | result: Optional[str] = Field(None, description="The result returned by the tool.") 16 | 17 | 18 | 19 | class ChatRequest(BaseModel): 20 | message: str 21 | conversation_id: str 22 | user_id: Optional[str] = None # To fetch user-specific info 23 | 24 | mode: Literal["chat","query"] = Field("chat",description = "the mode of interaction. ") # set by the user in frontend 25 | attachments: List[Attachment] = Field([], description="A list of files or images attached to the message.") 26 | 27 | 28 | class ChatResponse(BaseModel): 29 | message_id: str = Field(description="Unique identifier for this specific message.") 30 | response_text: str = Field(description="The main text response from the assistant.") 31 | tool_calls: List[ToolCall] = Field([], description="A list of tools that were executed.") 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /backend/app/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/app/services/__init__.py -------------------------------------------------------------------------------- /backend/app/services/google_login.py: -------------------------------------------------------------------------------- 1 | # backend/app/services/google_login.py 2 | 3 | import httpx 4 | import logging 5 | from typing import Dict, Optional 6 | from urllib.parse import urlencode 7 | import os 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class GoogleOAuthService: 12 | """ 13 | Service class for handling Google OAuth authentication 14 | """ 15 | 16 | def __init__(self): 17 | self.client_id = os.getenv("GOOGLE_CLIENT_ID") 18 | self.client_secret = os.getenv("GOOGLE_CLIENT_SECRET") 19 | self.token_url = "https://oauth2.googleapis.com/token" 20 | self.userinfo_url = "https://www.googleapis.com/oauth2/v2/userinfo" 21 | 22 | async def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict: 23 | """ 24 | Exchange authorization code for access token and refresh token 25 | """ 26 | token_data = { 27 | "code": code, 28 | "client_id": self.client_id, 29 | "client_secret": self.client_secret, 30 | "redirect_uri": redirect_uri, 31 | "grant_type": "authorization_code" 32 | } 33 | 34 | async with httpx.AsyncClient() as client: 35 | response = await client.post(self.token_url, data=token_data) 36 | response.raise_for_status() 37 | return response.json() 38 | 39 | async def get_user_info(self, access_token: str) -> Dict: 40 | """ 41 | Get user information from Google using the access token 42 | """ 43 | headers = {"Authorization": f"Bearer {access_token}"} 44 | 45 | async with httpx.AsyncClient() as client: 46 | response = await client.get(self.userinfo_url, headers=headers) 47 | response.raise_for_status() 48 | return response.json() 49 | 50 | async def authenticate_user(self, authorization_code: str, redirect_uri: str) -> Dict: 51 | """ 52 | Complete OAuth flow: exchange code for tokens and get user info 53 | 54 | Args: 55 | authorization_code: The authorization code from Google OAuth callback 56 | redirect_uri: The redirect URI used in the OAuth flow 57 | 58 | Returns: 59 | Dict with authentication result and user data 60 | """ 61 | try: 62 | # Exchange code for tokens 63 | tokens = await self.exchange_code_for_tokens(authorization_code, redirect_uri) 64 | 65 | # Get user info using the access token 66 | user_info = await self.get_user_info(tokens["access_token"]) 67 | 68 | # Format user data for our application 69 | user_data = { 70 | "email": user_info.get("email"), 71 | "google_id": user_info.get("id"), 72 | "name": user_info.get("name"), 73 | "first_name": user_info.get("given_name"), 74 | "last_name": user_info.get("family_name"), 75 | "picture": user_info.get("picture"), 76 | "verified_email": user_info.get("verified_email", False) 77 | } 78 | 79 | logger.info(f"Successfully authenticated Google user: {user_data['email']}") 80 | 81 | return { 82 | "success": True, 83 | "user": user_data, 84 | "tokens": tokens # Include tokens if you need to store refresh token 85 | } 86 | 87 | except Exception as e: 88 | logger.error(f"Google authentication failed: {str(e)}") 89 | return { 90 | "success": False, 91 | "error": str(e) 92 | } 93 | 94 | # Global instance for reuse 95 | google_oauth_service = GoogleOAuthService() 96 | 97 | # Convenience function for easy importing 98 | async def authenticate_google_user(authorization_code: str, redirect_uri: str) -> Dict: 99 | """ 100 | Convenience function to authenticate a user with Google OAuth 101 | 102 | Args: 103 | authorization_code: The authorization code from Google OAuth callback 104 | redirect_uri: The redirect URI used in the OAuth flow 105 | 106 | Returns: 107 | Dict with authentication result and user data 108 | """ 109 | return await google_oauth_service.authenticate_user(authorization_code, redirect_uri) -------------------------------------------------------------------------------- /backend/app/services/lecture_transcript.py: -------------------------------------------------------------------------------- 1 | # File: app/services/lecture_transcript.py 2 | 3 | import logging 4 | from playwright.sync_api import sync_playwright 5 | import os 6 | from dotenv import load_dotenv, find_dotenv 7 | 8 | load_dotenv(find_dotenv()) 9 | 10 | unique_name = os.environ.get("UNIQUE_NAME") 11 | password = os.environ.get("PASSWORD") 12 | 13 | def get_transcript_url(url: str): 14 | """Opens the Canvas lecture URL and finds the transcript URL.""" 15 | with sync_playwright() as p: 16 | try: 17 | browser = p.chromium.launch(headless=True) 18 | page = browser.new_page() 19 | 20 | logging.info("Navigating to lecture page: %s", url) 21 | page.goto(url, wait_until="networkidle") 22 | 23 | if page.locator('input[name="username"]').count() > 0: 24 | logging.info("Login required, attempting to log in.") 25 | page.locator('input[name="username"]').first.fill(unique_name) 26 | page.locator('input[type="password"]').first.fill(password) 27 | page.locator('button[type="submit"]').first.click() 28 | page.wait_for_load_state("networkidle") 29 | logging.info("Login completed.") 30 | page.goto(url, wait_until="networkidle") 31 | 32 | track_element = page.locator('track[kind="captions"]').first 33 | transcript_url = track_element.get_attribute('src') 34 | browser.close() 35 | 36 | if transcript_url: 37 | full_url = f"https://leccap.engin.umich.edu{transcript_url}" 38 | logging.info("Transcript URL found: %s", full_url) 39 | return full_url 40 | else: 41 | logging.warning("Transcript element not found on page: %s", url) 42 | return None 43 | 44 | except Exception as e: 45 | logging.error("Failed while trying to get transcript URL from %s: %s", url, e, exc_info=True) 46 | return None 47 | 48 | def open_trans_url(url: str): 49 | """Opens the direct transcript URL and extracts the text.""" 50 | with sync_playwright() as p: 51 | try: 52 | browser = p.chromium.launch(headless=True) 53 | page = browser.new_page() 54 | 55 | logging.info("Navigating to transcript file URL: %s", url) 56 | page.goto(url, wait_until="networkidle") 57 | 58 | transcript_element = page.locator('pre').first 59 | transcript_text = transcript_element.inner_text() 60 | browser.close() 61 | 62 | if transcript_text: 63 | logging.info("Successfully extracted transcript text, length: %d", len(transcript_text)) 64 | return transcript_text 65 | else: 66 | logging.warning("Could not find transcript text at URL: %s", url) 67 | return None 68 | except Exception as e: 69 | logging.error("Failed while trying to open transcript URL %s: %s", url, e, exc_info=True) 70 | return None 71 | 72 | def handle_transcript_request(lecture_url: str): 73 | """Orchestrates the process of getting a lecture transcript.""" 74 | try: 75 | transcript_file_url = get_transcript_url(lecture_url) 76 | if not transcript_file_url: 77 | logging.error("Could not find the transcript file URL. Aborting.") 78 | return None 79 | 80 | transcript_text = open_trans_url(transcript_file_url) 81 | return transcript_text 82 | 83 | except Exception as e: 84 | logging.critical("An unhandled exception occurred in handle_transcript_request: %s", e, exc_info=True) 85 | return None 86 | 87 | 88 | 89 | 90 | 91 | def handle_lecture_name(lecture_url: str) -> str: 92 | """ 93 | Placeholder function to extract/generate lecture name from URL. 94 | This should be implemented to return an appropriate filename. 95 | """ 96 | # For now, extract domain and create a simple name 97 | # In practice, this might parse the URL or extract title from page 98 | try: 99 | from urllib.parse import urlparse 100 | parsed = urlparse(lecture_url) 101 | domain = parsed.netloc.replace('www.', '') 102 | return f"{domain}_lecture_transcript.txt" 103 | except: 104 | return "lecture_transcript.txt" -------------------------------------------------------------------------------- /backend/app/services/password_service.py: -------------------------------------------------------------------------------- 1 | # backend/app/services/password_service.py 2 | from passlib.context import CryptContext 3 | 4 | # Use bcrypt for hashing 5 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 6 | 7 | def verify_password(plain_password: str, hashed_password: str) -> bool: 8 | """Verifies a plain-text password against a hashed one.""" 9 | return pwd_context.verify(plain_password, hashed_password) 10 | 11 | 12 | 13 | def get_password_hash(password: str) -> str: 14 | """Hashes a plain-text password.""" 15 | return pwd_context.hash(password) -------------------------------------------------------------------------------- /backend/app/services/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.4.6 2 | aiohttp==3.11.12 3 | aiosignal==1.3.2 4 | altgraph==0.17.4 5 | annotated-types==0.7.0 6 | anyio==4.8.0 7 | async-timeout==4.0.3 8 | attrs==25.1.0 9 | black==25.1.0 10 | certifi==2025.1.31 11 | charset-normalizer==3.4.1 12 | click==8.1.8 13 | dataclasses-json==0.6.7 14 | distro==1.9.0 15 | exceptiongroup==1.2.2 16 | frozenlist==1.5.0 17 | greenlet==3.1.1 18 | h11==0.14.0 19 | httpcore==1.0.7 20 | httpx==0.28.1 21 | httpx-sse==0.4.0 22 | idna==3.10 23 | jiter 24 | jsonpatch==1.33 25 | jsonpointer==3.0.0 26 | langchain 27 | langchain-google-genai 28 | langchain-community 29 | langchain-core 30 | langchain-ollama 31 | langchain-openai 32 | langchain-text-splitters 33 | langchainhub==0.1.21 34 | langsmith==0.3.8 35 | macholib==1.16.3 36 | marshmallow==3.26.1 37 | multidict==6.1.0 38 | mypy-extensions==1.0.0 39 | numpy==1.26.4 40 | ollama==0.4.7 41 | openai==1.63.0 42 | orjson==3.10.15 43 | packaging==24.2 44 | pathspec==0.12.1 45 | platformdirs==4.3.6 46 | playwright==1.50.0 47 | propcache==0.2.1 48 | pydantic==2.10.6 49 | pydantic-settings==2.7.1 50 | pydantic_core==2.27.2 51 | pyee==12.1.1 52 | pyinstaller==6.12.0 53 | pyinstaller-hooks-contrib==2025.1 54 | python-dotenv==1.0.1 55 | PyYAML==6.0.2 56 | regex==2024.11.6 57 | requests==2.32.3 58 | requests-toolbelt==1.0.0 59 | setuptools==75.8.0 60 | sniffio==1.3.1 61 | SQLAlchemy==2.0.38 62 | tenacity==9.0.0 63 | tiktoken==0.9.0 64 | tk==0.1.0 65 | tomli==2.2.1 66 | tqdm==4.67.1 67 | types-requests==2.32.0.20241016 68 | typing-inspect==0.9.0 69 | typing_extensions==4.12.2 70 | urllib3==2.3.0 71 | yarl==1.18.3 72 | zstandard==0.23.0 -------------------------------------------------------------------------------- /backend/chatbot.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/chatbot.db -------------------------------------------------------------------------------- /backend/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/migrations/.gitkeep -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | # FastAPI and Web Server 2 | fastapi==0.111.1 3 | uvicorn==0.29.0 4 | starlette==0.37.2 5 | h11==0.16.0 6 | python-multipart==0.0.20 7 | 8 | # Langchain Core and Integrations 9 | langchain==0.3.27 10 | langchain-community==0.3.27 11 | langchain-core==0.3.72 12 | langchain-google-genai==2.1.9 13 | langchain-text-splitters==0.3.9 14 | langchainhub==0.1.21 15 | google-api-core==2.25.1 16 | google-auth==2.40.3 17 | googleapis-common-protos==1.70.0 18 | google-genai 19 | 20 | # LangGraph and Checkpoints 21 | langgraph==0.6.3 22 | langgraph-checkpoint==2.1.1 23 | langgraph-checkpoint-redis==0.0.8 24 | langgraph-checkpoint-sqlite==2.0.11 25 | redis==6.2.0 26 | 27 | # Database and ORM 28 | SQLAlchemy==2.0.42 29 | alembic==1.16.4 30 | asyncpg==0.30.0 31 | aiosqlite==0.21.0 32 | greenlet==3.1.1 33 | 34 | # Vector Database 35 | chromadb==1.0.15 36 | onnxruntime==1.22.0 37 | opentelemetry-api==1.27.0 38 | opentelemetry-exporter-otlp-proto-common==1.27.0 39 | opentelemetry-exporter-otlp-proto-grpc==1.27.0 40 | opentelemetry-proto==1.27.0 41 | opentelemetry-sdk==1.27.0 42 | opentelemetry-semantic-conventions==0.48b0 43 | 44 | # Authentication & Security 45 | python-jose==3.5.0 46 | cryptography==44.0.3 47 | passlib==1.7.4 48 | bcrypt==4.3.0 49 | ecdsa==0.19.1 50 | 51 | # Data Models and Environment 52 | pydantic==2.11.7 53 | pydantic_core==2.33.2 54 | python-dotenv==1.0.1 55 | 56 | # Other necessary libraries 57 | aiohttp==3.11.12 58 | anyio==4.10.0 59 | httpx==0.28.1 60 | numpy==1.26.4 61 | requests==2.32.4 62 | sniffio==1.3.1 63 | tenacity==9.1.2 64 | typing_extensions==4.14.1 65 | yarl==1.20.1 66 | semantic-chunkers==0.0.10 67 | semantic-router==0.0.68 68 | tavily-python==0.7.3 69 | urllib3==2.5.0 70 | playwright -------------------------------------------------------------------------------- /backend/tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/backend/tests/.gitkeep -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/demo.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # --- Backend Service (Your Python App) --- 3 | backend: 4 | build: 5 | context: ./backend 6 | dockerfile: Dockerfile 7 | restart: unless-stopped 8 | env_file: 9 | - .env.docker 10 | # Wait for PostgreSQL to be healthy before starting the backend 11 | depends_on: 12 | db: 13 | condition: service_healthy 14 | # This section saves your vector DB and checkpoint file outside the container. 15 | volumes: 16 | - chroma_data:/app/chroma_db 17 | - ./backend/checkpoints.sqlite:/app/checkpoints.sqlite 18 | 19 | # --- Frontend Service (React/Vite) --- 20 | frontend: 21 | build: 22 | context: ./frontend 23 | dockerfile: Dockerfile 24 | args: 25 | VITE_GOOGLE_CLIENT_ID: ${VITE_GOOGLE_CLIENT_ID} 26 | restart: unless-stopped 27 | env_file: 28 | - .env.docker 29 | 30 | # --- Nginx Reverse Proxy (The "Front Door") --- 31 | nginx: 32 | image: nginx:1.25-alpine 33 | ports: 34 | - "8080:80" 35 | volumes: 36 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 37 | # Ensure backend is started and database is healthy before nginx 38 | depends_on: 39 | frontend: 40 | condition: service_started 41 | backend: 42 | condition: service_started 43 | db: 44 | condition: service_healthy 45 | restart: unless-stopped 46 | 47 | # --- ADDED: PostgreSQL Database Service --- 48 | db: 49 | image: postgres:13 50 | # This tells the container to load its setup variables from the .env file. 51 | env_file: 52 | - .env.docker 53 | # This section saves your PostgreSQL data outside the container. 54 | volumes: 55 | - postgres_data:/var/lib/postgresql/data/ 56 | healthcheck: 57 | test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] 58 | interval: 5s 59 | timeout: 5s 60 | retries: 20 61 | # You generally don't need to expose the database port to the public internet. 62 | # The other containers can reach it through the private Docker network. 63 | restart: unless-stopped 64 | 65 | # --- ADDED: Define the persistent storage volumes --- 66 | volumes: 67 | postgres_data: 68 | chroma_data: -------------------------------------------------------------------------------- /feynman_mode_agent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/feynman_mode_agent.png -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .yarn 4 | .pnpm-store 5 | .cache 6 | build 7 | dist 8 | .DS_Store 9 | .git 10 | .vscode 11 | .idea 12 | **/*.local 13 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the React application 2 | FROM node:20-bookworm-slim AS build 3 | WORKDIR /app 4 | 5 | # Optimize caching: install deps first 6 | COPY package*.json ./ 7 | # Recompute deps for Linux container to avoid platform-specific lock issues 8 | # Do NOT omit optional deps so rollup native binary gets installed for this platform 9 | RUN rm -f package-lock.json && npm install 10 | 11 | # Ensure Rollup native binary is present for linux/arm64 (Docker Desktop builder on Apple Silicon) 12 | # Fall back to x64 if arm64 package doesn't apply (no-op on non-matching arch) 13 | RUN npm install --no-save @rollup/rollup-linux-arm64-gnu || npm install --no-save @rollup/rollup-linux-x64-gnu || true 14 | 15 | # Copy source and build 16 | COPY . . 17 | ARG VITE_SERVER_ADDRESS 18 | ARG VITE_GOOGLE_CLIENT_ID 19 | ENV VITE_SERVER_ADDRESS=$VITE_SERVER_ADDRESS 20 | ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID 21 | ENV ROLLUP_SKIP_NATIVE=true 22 | RUN npm run build 23 | 24 | # Stage 2: Serve the built assets with a lightweight non-root runtime 25 | FROM node:20-bookworm-slim AS runtime 26 | 27 | ENV NODE_ENV=production 28 | WORKDIR /app 29 | 30 | # Install a minimal static file server 31 | RUN npm i -g serve@14 32 | 33 | # Copy build artifacts only 34 | COPY --from=build /app/dist /app/dist 35 | 36 | # Use the pre-existing non-root user in node image 37 | USER node 38 | 39 | EXPOSE 3000 40 | CMD ["serve", "-s", "dist", "-l", "3000"] 41 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Chatbot Interface 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatbot-interface", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --port 5174", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@langchain/core": "^0.3.66", 14 | "@langchain/langgraph-sdk": "^0.0.101", 15 | "lucide-react": "^0.263.1", 16 | "react": "^18.3.1", 17 | "react-dom": "^18.3.1", 18 | "react-dropzone": "^14.3.8", 19 | "react-markdown": "^9.0.0", 20 | "react-router-dom": "^7.6.3", 21 | "remark-gfm": "^4.0.0" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "^9.13.0", 25 | "@types/react": "^18.3.11", 26 | "@types/react-dom": "^18.3.1", 27 | "@vitejs/plugin-react": "^4.3.3", 28 | "autoprefixer": "^10.4.20", 29 | "eslint": "^9.13.0", 30 | "eslint-plugin-react": "^7.37.1", 31 | "eslint-plugin-react-hooks": "^5.0.0", 32 | "eslint-plugin-react-refresh": "^0.4.13", 33 | "globals": "^15.11.0", 34 | "postcss": "^8.4.49", 35 | "tailwindcss": "^3.4.15", 36 | "vite": "^5.4.10" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /frontend/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/frontend/public/.gitkeep -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/frontend/public/index.html -------------------------------------------------------------------------------- /frontend/rocket.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 10 | 12 | 15 | 17 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 30 | 34 | 35 | 37 | 38 | 40 | 42 | 43 | -------------------------------------------------------------------------------- /frontend/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; 3 | import { AuthProvider } from './context/AuthContext'; 4 | import ChatPage from './pages/ChatPage'; 5 | import EmbedDocumentPage from './pages/EmbedDocumentPage'; 6 | import LoginPage from './pages/LoginPage'; 7 | import AccountPage from './pages/AccountPage'; 8 | import GoogleCallback from './pages/GoogleCallback'; 9 | 10 | function App() { 11 | return ( 12 |
13 | 14 | 15 | 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | } /> 21 | 22 | 23 | 24 |
25 | ); 26 | } 27 | 28 | export default App; 29 | -------------------------------------------------------------------------------- /frontend/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keeeevinShen/Learning-Chatbot/75aea7450114c63559e8ff576895f40f54c73539/frontend/src/assets/.gitkeep -------------------------------------------------------------------------------- /frontend/src/components/ChatHeader.jsx: -------------------------------------------------------------------------------- 1 | // src/components/ChatHeader.jsx 2 | 3 | import React from 'react'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | const ChatHeader = ({ title }) => { 7 | return ( 8 |
9 |

{title || 'Feynman Learning Agent'}

10 | 14 | 26 | 27 | 28 | 29 | 30 | 31 | Embed a Document 32 | 33 |
34 | ); 35 | }; 36 | 37 | export default ChatHeader; -------------------------------------------------------------------------------- /frontend/src/components/FileUpload.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useDropzone } from 'react-dropzone'; 3 | import { Plus } from 'lucide-react'; 4 | 5 | const FileUpload = ({ onFileUpload, uploadedFilesCount = 0 }) => { 6 | const onDrop = useCallback((acceptedFiles) => { 7 | onFileUpload(acceptedFiles); 8 | }, [onFileUpload]); 9 | 10 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }); 11 | 12 | return ( 13 |
18 | 19 |
20 | {uploadedFilesCount > 0 ? ( 21 | 22 | ) : ( 23 | 30 | 36 | 37 | )} 38 |

39 | {uploadedFilesCount > 0 40 | ? `Add more files (${uploadedFilesCount} already uploaded)` 41 | : 'Click to upload or drag and drop' 42 | } 43 |

44 |

45 | supports text files, csv's, spreadsheets, audio files, and more! 46 |

47 |
48 |
49 | ); 50 | }; 51 | 52 | export default FileUpload; -------------------------------------------------------------------------------- /frontend/src/components/ImportLectureModal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { X } from 'lucide-react'; 3 | 4 | const ImportLectureModal = ({ isOpen, onClose, onImport }) => { 5 | const [lectureUrl, setLectureUrl] = useState(''); 6 | 7 | if (!isOpen) return null; 8 | 9 | const handleImport = () => { 10 | if (lectureUrl.trim()) { 11 | onImport(lectureUrl.trim()); 12 | setLectureUrl(''); // Clear the input after import 13 | } 14 | }; 15 | 16 | const handleClose = () => { 17 | setLectureUrl(''); // Clear the input when closing 18 | onClose(); 19 | }; 20 | 21 | return ( 22 |
23 |
24 | 30 |

Import lecture

31 |
32 | setLectureUrl(e.target.value)} 37 | onKeyPress={(e) => e.key === 'Enter' && handleImport()} 38 | className="flex-1 bg-gray-700 border border-gray-600 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-purple-500" 39 | /> 40 | 47 |
48 |
49 |
50 | ); 51 | }; 52 | 53 | export default ImportLectureModal; -------------------------------------------------------------------------------- /frontend/src/components/Message.jsx: -------------------------------------------------------------------------------- 1 | // src/components/Message.jsx 2 | 3 | import React, { useState } from 'react'; 4 | import { Sparkles, Copy, Check, FileText, Download } from 'lucide-react'; 5 | import ReactMarkdown from 'react-markdown'; 6 | import remarkGfm from 'remark-gfm'; 7 | 8 | const Message = ({ message, index }) => { 9 | const [copiedCode, setCopiedCode] = useState(null); 10 | 11 | const copyCode = (code, codeIndex) => { 12 | navigator.clipboard.writeText(code); 13 | setCopiedCode(codeIndex); 14 | setTimeout(() => setCopiedCode(null), 2000); 15 | }; 16 | 17 | const formatFileSize = (bytes) => { 18 | if (bytes === 0) return '0 Bytes'; 19 | const k = 1024; 20 | const sizes = ['Bytes', 'KB', 'MB', 'GB']; 21 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 22 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 23 | }; 24 | 25 | // Check if content has incomplete code blocks for streaming 26 | const hasIncompleteCodeBlock = (content) => { 27 | const codeBlockMatches = content.match(/```/g); 28 | return codeBlockMatches && codeBlockMatches.length % 2 !== 0; 29 | }; 30 | 31 | // Custom components for react-markdown 32 | const markdownComponents = { 33 | // Code block component with copy functionality 34 | code: ({ inline, className, children, ...props }) => { 35 | const match = /language-(\w+)/.exec(className || ''); 36 | const language = match ? match[1] : 'plaintext'; 37 | const codeContent = String(children).replace(/\n$/, ''); 38 | const codeIndex = `${index}-${language}-${codeContent.slice(0, 10)}`; 39 | 40 | if (!inline) { 41 | return ( 42 |
43 |
44 |
45 | {language} 46 | 62 |
63 |
 64 |                 
 65 |                   {children}
 66 |                 
 67 |               
68 |
69 |
70 | ); 71 | } 72 | 73 | // Inline code 74 | return ( 75 | 76 | {children} 77 | 78 | ); 79 | }, 80 | 81 | // Heading components 82 | h1: ({ children, ...props }) => ( 83 |

84 | {children} 85 |

86 | ), 87 | h2: ({ children, ...props }) => ( 88 |

89 | {children} 90 |

91 | ), 92 | h3: ({ children, ...props }) => ( 93 |

94 | {children} 95 |

96 | ), 97 | h4: ({ children, ...props }) => ( 98 |

99 | {children} 100 |

101 | ), 102 | 103 | // List components 104 | ul: ({ children, ...props }) => ( 105 | 108 | ), 109 | ol: ({ children, ...props }) => ( 110 |
    111 | {children} 112 |
113 | ), 114 | li: ({ children, ...props }) => ( 115 |
  • 116 | {children} 117 |
  • 118 | ), 119 | 120 | // Paragraph component 121 | p: ({ children, ...props }) => ( 122 |

    123 | {children} 124 |

    125 | ), 126 | 127 | // Strong/bold text 128 | strong: ({ children, ...props }) => ( 129 | 130 | {children} 131 | 132 | ), 133 | 134 | // Emphasis/italic text 135 | em: ({ children, ...props }) => ( 136 | 137 | {children} 138 | 139 | ), 140 | 141 | // Blockquote 142 | blockquote: ({ children, ...props }) => ( 143 |
    144 | {children} 145 |
    146 | ), 147 | 148 | // Horizontal rule 149 | hr: ({ ...props }) => ( 150 |
    151 | ), 152 | 153 | // Table components 154 | table: ({ children, ...props }) => ( 155 |
    156 | 157 | {children} 158 |
    159 |
    160 | ), 161 | th: ({ children, ...props }) => ( 162 | 163 | {children} 164 | 165 | ), 166 | td: ({ children, ...props }) => ( 167 | 168 | {children} 169 | 170 | ), 171 | }; 172 | 173 | if (message.type === 'user') { 174 | return ( 175 |
    176 |
    177 | {/* Message content */} 178 | {message.content && ( 179 |
    180 | {message.content} 181 |
    182 | )} 183 | 184 | {/* File attachments */} 185 | {message.attachments && message.attachments.length > 0 && ( 186 |
    187 | {message.attachments.map((attachment, attachmentIndex) => ( 188 |
    192 | 193 |
    194 |

    {attachment.name}

    195 |

    {formatFileSize(attachment.size)}

    196 |
    197 | 198 |
    199 | ))} 200 |
    201 | )} 202 |
    203 |
    204 | ); 205 | } 206 | 207 | // Bot message rendering with markdown 208 | let contentToRender = message.content; 209 | 210 | // Handle streaming: don't render incomplete code blocks 211 | if (message.isStreaming && hasIncompleteCodeBlock(contentToRender)) { 212 | // Find the last opening ``` and remove everything from there 213 | const lastCodeBlockStart = contentToRender.lastIndexOf('```'); 214 | if (lastCodeBlockStart !== -1) { 215 | contentToRender = contentToRender.substring(0, lastCodeBlockStart).trim(); 216 | // Add a note that more content is coming 217 | contentToRender += '\n\n*[Processing code block...]*'; 218 | } 219 | } 220 | 221 | return ( 222 |
    223 |
    224 | 225 |
    226 |
    227 |
    228 | 232 | {contentToRender} 233 | 234 |
    235 | 236 | {/* Show typing cursor if message is streaming */} 237 | {message.isStreaming && ( 238 | 239 | )} 240 |
    241 |
    242 | ); 243 | }; 244 | 245 | export default Message; -------------------------------------------------------------------------------- /frontend/src/components/MessageInput.jsx: -------------------------------------------------------------------------------- 1 | // src/components/MessageInput.jsx 2 | 3 | import React, { useState, useRef, useEffect } from 'react'; 4 | import { Paperclip, Send, BookOpen, Plus, X, FileText, AlertCircle, ChevronDown, Check, Brain } from 'lucide-react'; 5 | import ImportLectureModal from './ImportLectureModal'; 6 | import { importLecture } from '../service/chatService'; 7 | 8 | const models = [ 9 | "Fast Response", 10 | "Smart Response" 11 | ]; 12 | 13 | const MessageInput = ({ onSendMessage, isLoading }) => { 14 | const [inputValue, setInputValue] = useState(''); 15 | const [isMenuOpen, setMenuOpen] = useState(false); 16 | const [isModelMenuOpen, setModelMenuOpen] = useState(false); 17 | const [selectedModel, setSelectedModel] = useState(models[0]); 18 | const [isModalOpen, setModalOpen] = useState(false); 19 | const [selectedFiles, setSelectedFiles] = useState([]); 20 | const [fileError, setFileError] = useState(''); 21 | const [feynmanMode, setFeynmanMode] = useState(false); 22 | 23 | // New states for lecture import process 24 | const [isImporting, setIsImporting] = useState(false); 25 | 26 | const textareaRef = useRef(null); 27 | const menuRef = useRef(null); 28 | const modelMenuRef = useRef(null); 29 | const fileInputRef = useRef(null); 30 | 31 | const MAX_FILES = 5; 32 | 33 | // Auto-resize textarea 34 | useEffect(() => { 35 | if (textareaRef.current) { 36 | textareaRef.current.style.height = 'auto'; 37 | textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; 38 | } 39 | }, [inputValue]); 40 | 41 | // Handle outside click to close menu 42 | useEffect(() => { 43 | const handleClickOutside = (event) => { 44 | if (menuRef.current && !menuRef.current.contains(event.target)) { 45 | setMenuOpen(false); 46 | } 47 | if (modelMenuRef.current && !modelMenuRef.current.contains(event.target)) { 48 | setModelMenuOpen(false); 49 | } 50 | }; 51 | document.addEventListener('mousedown', handleClickOutside); 52 | return () => { 53 | document.removeEventListener('mousedown', handleClickOutside); 54 | }; 55 | }, []); 56 | 57 | // Clear file error after 5 seconds 58 | useEffect(() => { 59 | if (fileError) { 60 | const timer = setTimeout(() => { 61 | setFileError(''); 62 | }, 5000); 63 | return () => clearTimeout(timer); 64 | } 65 | }, [fileError]); 66 | 67 | const handleSubmit = (e) => { 68 | e.preventDefault(); 69 | if ((inputValue.trim() || selectedFiles.length > 0) && !isLoading) { 70 | onSendMessage(inputValue, selectedFiles, feynmanMode); 71 | setInputValue(''); 72 | setSelectedFiles([]); 73 | setFileError(''); 74 | } 75 | }; 76 | 77 | const handleKeyDown = (e) => { 78 | if (e.key === 'Enter' && !e.shiftKey) { 79 | e.preventDefault(); 80 | handleSubmit(e); 81 | } 82 | }; 83 | 84 | const handleFileChange = (e) => { 85 | const files = Array.from(e.target.files); 86 | 87 | if (selectedFiles.length + files.length > MAX_FILES) { 88 | setFileError(`Maximum ${MAX_FILES} files allowed`); 89 | return; 90 | } 91 | 92 | const validFiles = files.map(file => ({ 93 | file, 94 | name: file.name, 95 | size: file.size, 96 | type: file.type, 97 | isLectureTranscript: false 98 | })); 99 | 100 | setSelectedFiles(prev => [...prev, ...validFiles]); 101 | e.target.value = ''; 102 | }; 103 | 104 | const removeFile = (index) => { 105 | setSelectedFiles(prev => prev.filter((_, i) => i !== index)); 106 | }; 107 | 108 | const formatFileSize = (bytes) => { 109 | if (bytes === 0) return '0 Bytes'; 110 | const k = 1024; 111 | const sizes = ['Bytes', 'KB', 'MB', 'GB']; 112 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 113 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 114 | }; 115 | 116 | const handleImportLecture = async (url) => { 117 | setIsImporting(true); 118 | try { 119 | const result = await importLecture(url); 120 | 121 | const lectureFile = { 122 | file: null, 123 | name: result.title || 'Imported Lecture', 124 | size: 0, 125 | type: 'lecture/transcript', 126 | isLectureTranscript: true, 127 | content: result.content, 128 | transcript_id: result.transcript_id 129 | }; 130 | 131 | setSelectedFiles(prev => [...prev, lectureFile]); 132 | setModalOpen(false); 133 | } catch (error) { 134 | setFileError('Failed to import lecture. Please try again.'); 135 | console.error('Import error:', error); 136 | } finally { 137 | setIsImporting(false); 138 | } 139 | }; 140 | 141 | return ( 142 |
    143 | {/* File error display */} 144 | {fileError && ( 145 |
    146 | 147 | {fileError} 148 |
    149 | )} 150 | 151 | {/* Selected files display */} 152 | {selectedFiles.length > 0 && ( 153 |
    154 |
    Attached files:
    155 |
    156 | {selectedFiles.map((file, index) => ( 157 |
    161 | {file.isLectureTranscript ? ( 162 | 163 | ) : ( 164 | 165 | )} 166 | 167 | {file.name} {!file.isLectureTranscript && `(${formatFileSize(file.size)})`} 168 | 169 | 176 |
    177 | ))} 178 |
    179 |
    180 | )} 181 | 182 |
    183 |