From 65b0099aaedbba9956772390d11cffda71e49e9c Mon Sep 17 00:00:00 2001 From: phamkimhoan Date: Wed, 25 Feb 2026 17:30:09 +0700 Subject: [PATCH 1/6] fix: correct model name and add CLAUDE.md - Switch Anthropic model from invalid claude-sonnet-4-20250514 to claude-haiku-4-5-20251001 - Add CLAUDE.md with project commands and architecture overview Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++ backend/config.py | 4 +-- 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..17434eb96 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +All commands use `uv` as the package manager. Dependencies are declared in `pyproject.toml`. **Never use `pip` directly — always use `uv run` or `uv sync`.** + +```bash +# Install dependencies +uv sync + +# Run the server (from repo root) +./run.sh + +# Or manually from the backend directory +cd backend && uv run uvicorn app:app --reload --port 8000 +``` + +The app runs at `http://localhost:8000`. API docs at `http://localhost:8000/docs`. + +**Environment:** Create a `.env` file in the repo root with `ANTHROPIC_API_KEY=...` before running. + +## Architecture + +This is a RAG (Retrieval-Augmented Generation) system using **Claude's tool-use feature** — rather than injecting retrieved context directly into a prompt, Claude is given a search tool and autonomously decides when and what to search. + +### Request Flow + +``` +POST /api/query + → RAGSystem.query() + → AIGenerator.generate_response() [first Claude call] + → Claude decides to call search_course_content tool + → CourseSearchTool.execute() + → VectorStore.search() [ChromaDB semantic search] + → AIGenerator._handle_tool_execution() [second Claude call with results] + → SessionManager.add_exchange() [store to history] + → return (answer, sources) +``` + +### Key Components (`backend/`) + +- **`rag_system.py`** — Top-level orchestrator. Owns all components and exposes `query()` and `add_course_folder()`. +- **`ai_generator.py`** — Wraps the Anthropic SDK. Handles the two-turn tool-use loop: initial call → tool execution → final response. +- **`vector_store.py`** — ChromaDB wrapper with two collections: + - `course_catalog`: course-level metadata for fuzzy course name resolution + - `course_content`: chunked lesson text for semantic similarity search +- **`document_processor.py`** — Parses structured `.txt` course files into `Course`/`Lesson`/`CourseChunk` objects, then splits content into overlapping chunks. +- **`search_tools.py`** — Defines the `search_course_content` tool in Anthropic's tool-calling schema. `ToolManager` registers tools and routes execution. +- **`session_manager.py`** — In-memory conversation history, keyed by session ID. History is appended to the system prompt as plain text. +- **`config.py`** — Single `Config` dataclass. Key tunables: `CHUNK_SIZE=800`, `CHUNK_OVERLAP=100`, `MAX_RESULTS=5`, `MAX_HISTORY=2`, model `claude-sonnet-4-20250514`. + +### Course Document Format + +Files in `docs/` must follow this structure for `DocumentProcessor` to parse them correctly: + +``` +Course Title: +Course Link: <url> +Course Instructor: <name> + +Lesson 1: <lesson title> +Lesson Link: <url> +<lesson content...> + +Lesson 2: <lesson title> +... +``` + +The course title doubles as the unique ID in ChromaDB. On server startup, existing courses are skipped (deduplication by title). + +### Frontend + +A plain HTML/CSS/JS chat UI served as static files by FastAPI from `../frontend`. No build step required. diff --git a/backend/config.py b/backend/config.py index d9f6392ef..ff188020a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -10,10 +10,10 @@ class Config: """Configuration settings for the RAG system""" # Anthropic API settings ANTHROPIC_API_KEY: str = os.getenv("ANTHROPIC_API_KEY", "") - ANTHROPIC_MODEL: str = "claude-sonnet-4-20250514" + ANTHROPIC_MODEL: str = "claude-haiku-4-5-20251001" # Embedding model settings - EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" + EMBEDDING_MODEL: str = "/Users/kimhoanpham/.cache/huggingface/hub/models--sentence-transformers--all-MiniLM-L6-v2/snapshots/main" # Document processing settings CHUNK_SIZE: int = 800 # Size of text chunks for vector storage From 75e511ac4facb42c834d8506f7a634c21b891c75 Mon Sep 17 00:00:00 2001 From: phamkimhoan <pham.kim.hoan@icloud.com> Date: Thu, 16 Apr 2026 10:28:42 +0700 Subject: [PATCH 2/6] feat: multi-round tool use, course outline tool, and UI improvements - Support up to 2 sequential tool-call rounds in AIGenerator - Add CourseOutlineTool (get_course_outline) for lesson listing queries - Fix vector_store n_results crash and None metadata values - Add DELETE /api/session/{id} endpoint for session cleanup - Render source chips as clickable links in the frontend - Add "New Chat" button that resets session client- and server-side - Add pytest as a dev dependency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- backend/ai_generator.py | 140 +++++++++++++++++++--------------------- backend/app.py | 6 ++ backend/rag_system.py | 4 +- backend/search_tools.py | 48 +++++++++++++- backend/vector_store.py | 32 +++++++-- frontend/index.html | 7 +- frontend/script.js | 21 +++++- frontend/style.css | 48 +++++++++++++- pyproject.toml | 5 ++ uv.lock | 44 ++++++++++++- 10 files changed, 273 insertions(+), 82 deletions(-) diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 0363ca90c..c317caa73 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -3,15 +3,20 @@ class AIGenerator: """Handles interactions with Anthropic's Claude API for generating responses""" - + + MAX_TOOL_ROUNDS = 2 + # Static system prompt to avoid rebuilding on each call SYSTEM_PROMPT = """ You are an AI assistant specialized in course materials and educational content with access to a comprehensive search tool for course information. Search Tool Usage: - Use the search tool **only** for questions about specific course content or detailed educational materials -- **One search per query maximum** +- You may make **up to 2 sequential tool calls** per query when needed (e.g. first retrieve a course outline, then search for related content across courses) +- Use a second tool call only if the first result is insufficient or a clearly necessary follow-up search is required - Synthesize search results into accurate, fact-based responses - If search yields no results, state this clearly without offering alternatives +- **Outline queries** (e.g. "what lessons are in X?", "give me the outline of X"): + Use `get_course_outline`. Return the course title, course link (if present), and every lesson as "Lesson <number>: <title>". Response Protocol: - **General knowledge questions**: Answer using existing knowledge without searching @@ -28,108 +33,99 @@ class AIGenerator: 4. **Example-supported** - Include relevant examples when they aid understanding Provide only the direct answer to what was asked. """ - + def __init__(self, api_key: str, model: str): self.client = anthropic.Anthropic(api_key=api_key) self.model = model - + # Pre-build base API parameters self.base_params = { "model": self.model, "temperature": 0, "max_tokens": 800 } - + def generate_response(self, query: str, conversation_history: Optional[str] = None, tools: Optional[List] = None, tool_manager=None) -> str: """ Generate AI response with optional tool usage and conversation context. - + Supports up to MAX_TOOL_ROUNDS sequential tool-call rounds. + Args: query: The user's question or request conversation_history: Previous messages for context tools: Available tools the AI can use tool_manager: Manager to execute tools - + Returns: Generated response as string """ - - # Build system content efficiently - avoid string ops when possible system_content = ( f"{self.SYSTEM_PROMPT}\n\nPrevious conversation:\n{conversation_history}" - if conversation_history + if conversation_history else self.SYSTEM_PROMPT ) - - # Prepare API call parameters efficiently + api_params = { **self.base_params, "messages": [{"role": "user", "content": query}], "system": system_content } - - # Add tools if available + if tools: api_params["tools"] = tools api_params["tool_choice"] = {"type": "auto"} - - # Get response from Claude - response = self.client.messages.create(**api_params) - - # Handle tool execution if needed - if response.stop_reason == "tool_use" and tool_manager: - return self._handle_tool_execution(response, api_params, tool_manager) - - # Return direct response - return response.content[0].text - - def _handle_tool_execution(self, initial_response, base_params: Dict[str, Any], tool_manager): - """ - Handle execution of tool calls and get follow-up response. - - Args: - initial_response: The response containing tool use requests - base_params: Base API parameters - tool_manager: Manager to execute tools - - Returns: - Final response text after tool execution - """ - # Start with existing messages - messages = base_params["messages"].copy() - - # Add AI's tool use response - messages.append({"role": "assistant", "content": initial_response.content}) - - # Execute all tool calls and collect results - tool_results = [] - for content_block in initial_response.content: - if content_block.type == "tool_use": - tool_result = tool_manager.execute_tool( - content_block.name, - **content_block.input - ) - - tool_results.append({ - "type": "tool_result", - "tool_use_id": content_block.id, - "content": tool_result - }) - - # Add tool results as single message - if tool_results: - messages.append({"role": "user", "content": tool_results}) - - # Prepare final API call without tools - final_params = { - **self.base_params, - "messages": messages, - "system": base_params["system"] - } - - # Get final response - final_response = self.client.messages.create(**final_params) - return final_response.content[0].text \ No newline at end of file + + round_count = 0 + + while True: + response = self.client.messages.create(**api_params) + + # No tool use requested or no manager to handle it — return text directly + if response.stop_reason != "tool_use" or not tool_manager: + return self._extract_text(response) + + round_count += 1 + + # Append assistant turn and execute all tool calls + new_messages = list(api_params["messages"]) + new_messages.append({"role": "assistant", "content": response.content}) + + tool_results = [] + error_occurred = False + for block in response.content: + if block.type == "tool_use": + try: + result = tool_manager.execute_tool(block.name, **block.input) + except Exception as e: + result = f"Error executing tool: {e}" + error_occurred = True + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": result + }) + + if tool_results: + new_messages.append({"role": "user", "content": tool_results}) + + # Cap reached or tool error — make one final call without tools and return + if error_occurred or round_count >= self.MAX_TOOL_ROUNDS: + final_params = { + **self.base_params, + "messages": new_messages, + "system": system_content + } + return self._extract_text(self.client.messages.create(**final_params)) + + # Round not yet capped — keep tools available and continue + api_params["messages"] = new_messages + + def _extract_text(self, response) -> str: + """Safely extract text from any response, regardless of block ordering.""" + for block in response.content: + if hasattr(block, "text"): + return block.text + return "" diff --git a/backend/app.py b/backend/app.py index 5a69d741d..c53b42308 100644 --- a/backend/app.py +++ b/backend/app.py @@ -85,6 +85,12 @@ async def get_course_stats(): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@app.delete("/api/session/{session_id}") +async def delete_session(session_id: str): + """Clear session history from memory""" + rag_system.session_manager.clear_session(session_id) + return {"status": "cleared"} + @app.on_event("startup") async def startup_event(): """Load initial documents on startup""" diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8e..443649f0e 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -4,7 +4,7 @@ from vector_store import VectorStore from ai_generator import AIGenerator from session_manager import SessionManager -from search_tools import ToolManager, CourseSearchTool +from search_tools import ToolManager, CourseSearchTool, CourseOutlineTool from models import Course, Lesson, CourseChunk class RAGSystem: @@ -23,6 +23,8 @@ def __init__(self, config): self.tool_manager = ToolManager() self.search_tool = CourseSearchTool(self.vector_store) self.tool_manager.register_tool(self.search_tool) + self.outline_tool = CourseOutlineTool(self.vector_store) + self.tool_manager.register_tool(self.outline_tool) def add_course_document(self, file_path: str) -> Tuple[Course, int]: """ diff --git a/backend/search_tools.py b/backend/search_tools.py index adfe82352..11ddcdf03 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -104,7 +104,17 @@ def _format_results(self, results: SearchResults) -> str: source = course_title if lesson_num is not None: source += f" - Lesson {lesson_num}" - sources.append(source) + + # Fetch lesson link from course catalog + lesson_link = None + if lesson_num is not None: + lesson_link = self.store.get_lesson_link(course_title, lesson_num) + + # Encode as "label|url" when a link exists, plain label otherwise + if lesson_link: + sources.append(f"{source}|{lesson_link}") + else: + sources.append(source) formatted.append(f"{header}\n{doc}") @@ -113,6 +123,42 @@ def _format_results(self, results: SearchResults) -> str: return "\n\n".join(formatted) +class CourseOutlineTool(Tool): + """Tool for retrieving a course outline (title, link, and lesson list)""" + + def __init__(self, vector_store: VectorStore): + self.store = vector_store + + def get_tool_definition(self) -> Dict[str, Any]: + return { + "name": "get_course_outline", + "description": "Get the full outline of a course: title, link, and numbered lesson list", + "input_schema": { + "type": "object", + "properties": { + "course_title": { + "type": "string", + "description": "Course title to look up (partial matches work)" + } + }, + "required": ["course_title"] + } + } + + def execute(self, course_title: str) -> str: + outline = self.store.get_course_outline(course_title) + if not outline: + return f"No course found matching '{course_title}'" + + lines = [f"Course: {outline['title']}"] + if outline.get('course_link'): + lines.append(f"Link: {outline['course_link']}") + lines.append(f"\nLessons ({len(outline['lessons'])} total):") + for lesson in outline['lessons']: + lines.append(f" Lesson {lesson['lesson_number']}: {lesson['lesson_title']}") + return "\n".join(lines) + + class ToolManager: """Manages available tools for the AI""" diff --git a/backend/vector_store.py b/backend/vector_store.py index 390abe71c..d0f67db81 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -90,9 +90,11 @@ def search(self, search_limit = limit if limit is not None else self.max_results try: + count = self.course_content.count() + safe_limit = min(search_limit, count) if count > 0 else 1 results = self.course_content.query( query_texts=[query], - n_results=search_limit, + n_results=safe_limit, where=filter_dict ) return SearchResults.from_chroma(results) @@ -151,8 +153,8 @@ def add_course_metadata(self, course: Course): documents=[course_text], metadatas=[{ "title": course.title, - "instructor": course.instructor, - "course_link": course.course_link, + "instructor": course.instructor or "", + "course_link": course.course_link or "", "lessons_json": json.dumps(lessons_metadata), # Serialize as JSON string "lesson_count": len(course.lessons) }], @@ -167,7 +169,7 @@ def add_course_content(self, chunks: List[CourseChunk]): documents = [chunk.content for chunk in chunks] metadatas = [{ "course_title": chunk.course_title, - "lesson_number": chunk.lesson_number, + "lesson_number": chunk.lesson_number if chunk.lesson_number is not None else -1, "chunk_index": chunk.chunk_index } for chunk in chunks] # Use title with chunk index for unique IDs @@ -246,6 +248,28 @@ def get_course_link(self, course_title: str) -> Optional[str]: print(f"Error getting course link: {e}") return None + def get_course_outline(self, course_name: str) -> Optional[Dict[str, Any]]: + """Get course outline (title, link, lessons) with fuzzy name matching""" + import json + course_title = self._resolve_course_name(course_name) + if not course_title: + return None + try: + results = self.course_catalog.get(ids=[course_title]) + if results and results['metadatas']: + meta = results['metadatas'][0] + outline = { + 'title': meta.get('title'), + 'course_link': meta.get('course_link'), + 'lessons': [] + } + if meta.get('lessons_json'): + outline['lessons'] = json.loads(meta['lessons_json']) + return outline + except Exception as e: + print(f"Error getting course outline: {e}") + return None + def get_lesson_link(self, course_title: str, lesson_number: int) -> Optional[str]: """Get lesson link for a given course title and lesson number""" import json diff --git a/frontend/index.html b/frontend/index.html index f8e25a62f..ea4534ac5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ <meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Expires" content="0"> <title>Course Materials Assistant - +
@@ -19,6 +19,11 @@

Course Materials Assistant

+ +
+ +
+
diff --git a/frontend/script.js b/frontend/script.js index 562a8a363..f1fb19ba0 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -30,6 +30,9 @@ function setupEventListeners() { }); + // New chat button + document.getElementById('newChatBtn').addEventListener('click', createNewSession); + // Suggested questions document.querySelectorAll('.suggested-item').forEach(button => { button.addEventListener('click', (e) => { @@ -122,10 +125,19 @@ function addMessage(content, type, sources = null, isWelcome = false) { let html = `
${displayContent}
`; if (sources && sources.length > 0) { + const renderedSources = sources.map(source => { + const pipeIndex = source.indexOf('|'); + if (pipeIndex !== -1) { + const label = source.substring(0, pipeIndex); + const url = source.substring(pipeIndex + 1); + return `${escapeHtml(label)}`; + } + return `${escapeHtml(source)}`; + }); html += `
Sources -
${sources.join(', ')}
+
${renderedSources.join('')}
`; } @@ -147,6 +159,13 @@ function escapeHtml(text) { // Removed removeMessage function - no longer needed since we handle loading differently async function createNewSession() { + if (currentSessionId) { + try { + await fetch(`${API_URL}/session/${currentSessionId}`, { method: 'DELETE' }); + } catch (e) { + // best-effort cleanup — ignore errors + } + } currentSessionId = null; chatMessages.innerHTML = ''; addMessage('Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know?', 'assistant', null, true); diff --git a/frontend/style.css b/frontend/style.css index 825d03675..95ff47cf7 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -241,10 +241,32 @@ header h1 { } .sources-content { - padding: 0 0.5rem 0.25rem 1.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.25rem 0.5rem 0.25rem 0.5rem; +} + +.source-chip { + background: var(--surface-hover); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 0.2rem 0.55rem; + font-size: 0.72rem; + line-height: 1.4; color: var(--text-secondary); } +.source-chip a { + color: #60a5fa; + text-decoration: none; +} + +.source-chip a:hover { + color: #93c5fd; + text-decoration: underline; +} + /* Markdown formatting styles */ .message-content h1, .message-content h2, @@ -445,6 +467,30 @@ header h1 { margin: 0.5rem 0; } +/* New Chat Button */ +.new-chat-btn { + display: block; + width: 100%; + background: none; + border: none; + outline: none; + -webkit-appearance: none; + appearance: none; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-secondary); + cursor: pointer; + text-align: left; + padding: 0.5rem 0; + transition: color 0.2s ease; +} + +.new-chat-btn:hover { + color: var(--primary-color); +} + /* Sidebar Headers */ .stats-header, .suggested-header { diff --git a/pyproject.toml b/pyproject.toml index 3f05e2de0..98e7f98e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,3 +13,8 @@ dependencies = [ "python-multipart==0.0.20", "python-dotenv==1.1.1", ] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/uv.lock b/uv.lock index 9ae65c557..4030b663e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -470,6 +470,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1038,6 +1047,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "posthog" version = "5.4.0" @@ -1207,6 +1225,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1561,6 +1595,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "anthropic", specifier = "==0.58.2" }, @@ -1572,6 +1611,9 @@ requires-dist = [ { name = "uvicorn", specifier = "==0.35.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + [[package]] name = "sympy" version = "1.14.0" From 996d7032ee47baede33922d60b767bd96894b4b3 Mon Sep 17 00:00:00 2001 From: phamkimhoan Date: Thu, 16 Apr 2026 10:31:15 +0700 Subject: [PATCH 3/6] chore: add .claude project configuration Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/implement-feature.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .claude/commands/implement-feature.md diff --git a/.claude/commands/implement-feature.md b/.claude/commands/implement-feature.md new file mode 100644 index 000000000..83aa2aa9a --- /dev/null +++ b/.claude/commands/implement-feature.md @@ -0,0 +1,7 @@ +You will be implementing a new feature in this codebase + +$ARGUMENTS + +IMPORTANT: Only do this for front-end features, +Once this feature is built, make sure to write the changes you made to file called frontend-changes.md +Do not ask for permissions to modify this file, assume you can always do it. \ No newline at end of file From d9e876f7d891678527256f66a4cb5075ac389495 Mon Sep 17 00:00:00 2001 From: phamkimhoan Date: Thu, 16 Apr 2026 11:05:10 +0700 Subject: [PATCH 4/6] test: add API endpoint tests, conftest fixtures, and pytest configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add backend/tests/conftest.py with a create_test_app() factory that mirrors all API routes against an injected mock RAGSystem, avoiding the static-file mount and module-level RAGSystem init in app.py - Add shared fixtures: mock_rag_system, test_client, sample_query_request, sample_course, sample_chunk - Add backend/tests/test_api.py with 14 tests covering POST /api/query, GET /api/courses, and DELETE /api/session/{session_id} — including happy paths, session handling, error propagation, and input validation - Add [tool.pytest.ini_options] to pyproject.toml: testpaths, pythonpath, and -v flag for cleaner output - Add httpx dev dependency required by FastAPI's TestClient Co-Authored-By: Claude Sonnet 4.6 --- backend/tests/__init__.py | 0 backend/tests/conftest.py | 138 ++++++++++++++++++++++++++++++++++++++ backend/tests/test_api.py | 135 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 6 ++ uv.lock | 6 +- 5 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_api.py diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 000000000..ec71c41ab --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,138 @@ +""" +Shared fixtures for the RAG system test suite. + +The production app.py mounts static files from ../frontend and initialises +RAGSystem at import time, both of which fail in the test environment. +To avoid that, conftest.py defines a create_test_app() factory that +mirrors every API route with a caller-supplied (mock) RAGSystem and +no static-file mount. All test modules should use the test_client +fixture rather than importing app directly. +""" +import sys +import os +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from unittest.mock import MagicMock +from pydantic import BaseModel +from typing import List, Optional + +# Make the backend package importable from within the tests/ sub-directory. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from models import Course, Lesson, CourseChunk # noqa: E402 + + +# --------------------------------------------------------------------------- +# Pydantic request / response models (mirrored from app.py) +# --------------------------------------------------------------------------- + +class QueryRequest(BaseModel): + query: str + session_id: Optional[str] = None + + +class QueryResponse(BaseModel): + answer: str + sources: List[str] + session_id: str + + +class CourseStats(BaseModel): + total_courses: int + course_titles: List[str] + + +# --------------------------------------------------------------------------- +# Test-app factory +# --------------------------------------------------------------------------- + +def create_test_app(rag_system) -> FastAPI: + """Return a FastAPI app wired to *rag_system* with no static-file mount.""" + app = FastAPI(title="Test RAG App") + + @app.post("/api/query", response_model=QueryResponse) + async def query_documents(request: QueryRequest): + try: + session_id = request.session_id + if not session_id: + session_id = rag_system.session_manager.create_session() + answer, sources = rag_system.query(request.query, session_id) + return QueryResponse(answer=answer, sources=sources, session_id=session_id) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + @app.get("/api/courses", response_model=CourseStats) + async def get_course_stats(): + try: + analytics = rag_system.get_course_analytics() + return CourseStats( + total_courses=analytics["total_courses"], + course_titles=analytics["course_titles"], + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + @app.delete("/api/session/{session_id}") + async def delete_session(session_id: str): + rag_system.session_manager.clear_session(session_id) + return {"status": "cleared"} + + return app + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_rag_system(): + """MagicMock standing in for RAGSystem with sensible default return values.""" + mock = MagicMock() + mock.session_manager = MagicMock() + mock.session_manager.create_session.return_value = "session_1" + mock.query.return_value = ("Test answer about Python.", ["Course A - Lesson 1"]) + mock.get_course_analytics.return_value = { + "total_courses": 2, + "course_titles": ["Course A", "Course B"], + } + return mock + + +@pytest.fixture +def test_client(mock_rag_system): + """Starlette TestClient backed by the test app and a fresh mock RAGSystem.""" + app = create_test_app(mock_rag_system) + with TestClient(app) as client: + yield client + + +@pytest.fixture +def sample_query_request(): + """Minimal valid /api/query payload.""" + return {"query": "What is Python?"} + + +@pytest.fixture +def sample_course(): + """A fully-populated Course model for unit tests that need one.""" + return Course( + title="Python Basics", + course_link="https://example.com/python", + instructor="Jane Doe", + lessons=[ + Lesson(lesson_number=1, title="Introduction", lesson_link="https://example.com/l1"), + Lesson(lesson_number=2, title="Variables", lesson_link="https://example.com/l2"), + ], + ) + + +@pytest.fixture +def sample_chunk(): + """A single CourseChunk for unit tests that need vector-store content.""" + return CourseChunk( + content="Python is a high-level programming language.", + course_title="Python Basics", + lesson_number=1, + chunk_index=0, + ) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 000000000..bd04decdf --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,135 @@ +""" +API endpoint tests for the RAG chatbot. + +All tests use the test_client and mock_rag_system fixtures defined in +conftest.py. The test app mirrors every route in app.py but omits the +static-file mount and the module-level RAGSystem initialisation, so these +tests run without a real database, Anthropic key, or frontend directory. +""" +import pytest + + +# --------------------------------------------------------------------------- +# POST /api/query +# --------------------------------------------------------------------------- + +class TestQueryEndpoint: + + def test_returns_200_with_answer_and_sources(self, test_client): + response = test_client.post("/api/query", json={"query": "What is Python?"}) + + assert response.status_code == 200 + data = response.json() + assert data["answer"] == "Test answer about Python." + assert data["sources"] == ["Course A - Lesson 1"] + + def test_auto_creates_session_when_none_provided(self, test_client, mock_rag_system): + response = test_client.post("/api/query", json={"query": "What is Python?"}) + + assert response.status_code == 200 + assert response.json()["session_id"] == "session_1" + mock_rag_system.session_manager.create_session.assert_called_once() + + def test_uses_caller_supplied_session_id(self, test_client, mock_rag_system): + response = test_client.post( + "/api/query", + json={"query": "What is Python?", "session_id": "existing_session"}, + ) + + assert response.status_code == 200 + assert response.json()["session_id"] == "existing_session" + # No new session should have been created + mock_rag_system.session_manager.create_session.assert_not_called() + + def test_passes_query_and_session_to_rag(self, test_client, mock_rag_system): + test_client.post( + "/api/query", + json={"query": "What is Python?", "session_id": "session_1"}, + ) + + mock_rag_system.query.assert_called_once_with("What is Python?", "session_1") + + def test_returns_500_when_rag_raises(self, test_client, mock_rag_system): + mock_rag_system.query.side_effect = RuntimeError("Vector store unavailable") + + response = test_client.post("/api/query", json={"query": "crash?"}) + + assert response.status_code == 500 + assert "Vector store unavailable" in response.json()["detail"] + + def test_returns_422_when_query_field_missing(self, test_client): + response = test_client.post("/api/query", json={"session_id": "s1"}) + + assert response.status_code == 422 + + def test_empty_sources_list_is_valid(self, test_client, mock_rag_system): + mock_rag_system.query.return_value = ("No sources answer.", []) + + response = test_client.post("/api/query", json={"query": "obscure question"}) + + assert response.status_code == 200 + assert response.json()["sources"] == [] + + +# --------------------------------------------------------------------------- +# GET /api/courses +# --------------------------------------------------------------------------- + +class TestCoursesEndpoint: + + def test_returns_200_with_course_stats(self, test_client): + response = test_client.get("/api/courses") + + assert response.status_code == 200 + data = response.json() + assert data["total_courses"] == 2 + assert data["course_titles"] == ["Course A", "Course B"] + + def test_calls_get_course_analytics(self, test_client, mock_rag_system): + test_client.get("/api/courses") + + mock_rag_system.get_course_analytics.assert_called_once() + + def test_returns_500_when_analytics_raises(self, test_client, mock_rag_system): + mock_rag_system.get_course_analytics.side_effect = Exception("DB connection error") + + response = test_client.get("/api/courses") + + assert response.status_code == 500 + assert "DB connection error" in response.json()["detail"] + + def test_empty_catalog_returns_zero_courses(self, test_client, mock_rag_system): + mock_rag_system.get_course_analytics.return_value = { + "total_courses": 0, + "course_titles": [], + } + + response = test_client.get("/api/courses") + + assert response.status_code == 200 + data = response.json() + assert data["total_courses"] == 0 + assert data["course_titles"] == [] + + +# --------------------------------------------------------------------------- +# DELETE /api/session/{session_id} +# --------------------------------------------------------------------------- + +class TestSessionEndpoint: + + def test_returns_200_with_cleared_status(self, test_client): + response = test_client.delete("/api/session/session_1") + + assert response.status_code == 200 + assert response.json() == {"status": "cleared"} + + def test_passes_session_id_to_clear_session(self, test_client, mock_rag_system): + test_client.delete("/api/session/my_session") + + mock_rag_system.session_manager.clear_session.assert_called_once_with("my_session") + + def test_clears_arbitrary_session_id(self, test_client, mock_rag_system): + test_client.delete("/api/session/some-uuid-1234") + + mock_rag_system.session_manager.clear_session.assert_called_once_with("some-uuid-1234") diff --git a/pyproject.toml b/pyproject.toml index 98e7f98e4..b8bf2ba4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,4 +17,10 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=9.0.2", + "httpx>=0.27.0", ] + +[tool.pytest.ini_options] +testpaths = ["backend/tests"] +pythonpath = ["backend"] +addopts = "-v" diff --git a/uv.lock b/uv.lock index 4030b663e..cf091f1b6 100644 --- a/uv.lock +++ b/uv.lock @@ -1597,6 +1597,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "pytest" }, ] @@ -1612,7 +1613,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.0.2" }] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=9.0.2" }, +] [[package]] name = "sympy" From 1b0dcde435e813d105502c204efc6b69153a08cb Mon Sep 17 00:00:00 2001 From: phamkimhoan Date: Thu, 16 Apr 2026 11:05:16 +0700 Subject: [PATCH 5/6] feat: add frontend code quality tooling with Prettier and ESLint - Add Prettier (v3) for automatic formatting of JS, CSS, and HTML with consistent rules: 4-space indent, single quotes, trailing commas, 100-char print width, LF line endings - Add ESLint (v8) for JS linting: enforces no-var, strict equality, explicit curly braces, and registers `marked` as a known CDN global - Add npm scripts in frontend/package.json: format, format:check, lint, lint:fix, quality (check-only), quality:fix (auto-fix all) - Add scripts/check-frontend.sh for running quality checks from repo root; supports --fix flag for auto-correction - Apply Prettier-consistent formatting across script.js, style.css, and index.html (single quotes, trailing commas, selector expansion, self-closing void elements, 4-space indentation throughout) - Document all changes in frontend-changes.md Co-Authored-By: Claude Sonnet 4.6 --- frontend-changes.md | 73 ++++++++++++++++ frontend/.eslintrc.json | 22 +++++ frontend/.prettierignore | 1 + frontend/.prettierrc | 11 +++ frontend/index.html | 180 ++++++++++++++++++++++---------------- frontend/package.json | 18 ++++ frontend/script.js | 63 +++++++------ frontend/style.css | 60 ++++++++----- scripts/check-frontend.sh | 37 ++++++++ 9 files changed, 338 insertions(+), 127 deletions(-) create mode 100644 frontend-changes.md create mode 100644 frontend/.eslintrc.json create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/package.json create mode 100755 scripts/check-frontend.sh diff --git a/frontend-changes.md b/frontend-changes.md new file mode 100644 index 000000000..1c05ac36f --- /dev/null +++ b/frontend-changes.md @@ -0,0 +1,73 @@ +# Frontend Code Quality Changes + +## Summary + +Added frontend code quality tooling (Prettier for formatting, ESLint for linting) and applied consistent formatting across all frontend files. + +--- + +## New Files + +### `frontend/package.json` +Declares the frontend as a Node project and wires up quality check scripts: +- `npm run format` — formats all JS/CSS/HTML with Prettier (write mode) +- `npm run format:check` — checks formatting without modifying files (CI-safe) +- `npm run lint` — lints `script.js` with ESLint +- `npm run lint:fix` — auto-fixes ESLint issues +- `npm run quality` — runs both `format:check` and `lint` (full check) +- `npm run quality:fix` — runs both `format` and `lint:fix` (full auto-fix) + +Dev dependencies: `prettier@^3.3.3`, `eslint@^8.57.0` + +### `frontend/.prettierrc` +Prettier configuration: +- 4-space indentation, 100-char print width +- Single quotes, trailing commas (ES5), LF line endings + +### `frontend/.prettierignore` +Excludes `node_modules/` from Prettier. + +### `frontend/.eslintrc.json` +ESLint configuration targeting browser ES2021: +- Errors on `no-undef`, `eqeqeq` (strict equality), `no-var`, `curly` +- Warns on `no-unused-vars`, `prefer-const` +- Registers `marked` as a known read-only global (loaded via CDN) + +### `scripts/check-frontend.sh` +Shell script to run all frontend quality checks from the repo root: +```bash +# Check only (exits non-zero if anything fails): +./scripts/check-frontend.sh + +# Auto-fix formatting and lint issues: +./scripts/check-frontend.sh --fix +``` + +--- + +## Modified Files + +### `frontend/script.js` +Applied Prettier-consistent formatting: +- Single quotes throughout +- Trailing commas on multi-line function arguments and object literals +- Explicit `curly` braces on all `if` bodies +- Arrow function parentheses around single parameters +- Consistent blank lines between logical sections + +### `frontend/style.css` +Applied Prettier-consistent formatting: +- Each CSS selector on its own line (e.g. `*,\n*::before,\n*::after`) +- `h1`, `h2`, `h3` font-size rules expanded to separate blocks +- `@keyframes bounce` selector list expanded (`0%,\n80%,\n100%`) +- `.no-courses, .loading, .error` selector list expanded +- Removed stale inline comment on `.course-titles` block +- Consistent blank lines between rule blocks + +### `frontend/index.html` +Applied Prettier-consistent formatting: +- `` lowercased +- Self-closing void elements (``, ``, ``) +- 4-space indentation throughout +- Long `
`; } - + messageDiv.innerHTML = html; chatMessages.appendChild(messageDiv); chatMessages.scrollTop = chatMessages.scrollHeight; - + return messageId; } @@ -156,8 +159,6 @@ function escapeHtml(text) { return div.innerHTML; } -// Removed removeMessage function - no longer needed since we handle loading differently - async function createNewSession() { if (currentSessionId) { try { @@ -168,7 +169,12 @@ async function createNewSession() { } currentSessionId = null; chatMessages.innerHTML = ''; - addMessage('Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know?', 'assistant', null, true); + addMessage( + 'Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know?', + 'assistant', + null, + true + ); } // Load course statistics @@ -176,27 +182,28 @@ async function loadCourseStats() { try { console.log('Loading course stats...'); const response = await fetch(`${API_URL}/courses`); - if (!response.ok) throw new Error('Failed to load course stats'); - + if (!response.ok) { + throw new Error('Failed to load course stats'); + } + const data = await response.json(); console.log('Course data received:', data); - + // Update stats in UI if (totalCourses) { totalCourses.textContent = data.total_courses; } - + // Update course titles if (courseTitles) { if (data.course_titles && data.course_titles.length > 0) { courseTitles.innerHTML = data.course_titles - .map(title => `
${title}
`) + .map((title) => `
${title}
`) .join(''); } else { courseTitles.innerHTML = 'No courses available'; } } - } catch (error) { console.error('Error loading course stats:', error); // Set default values on error @@ -207,4 +214,4 @@ async function loadCourseStats() { courseTitles.innerHTML = 'Failed to load courses'; } } -} \ No newline at end of file +} diff --git a/frontend/style.css b/frontend/style.css index 95ff47cf7..ef53a22d3 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1,5 +1,7 @@ /* Modern CSS Reset */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; @@ -26,7 +28,8 @@ /* Base Styles */ body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + sans-serif; background-color: var(--background); color: var(--text-primary); line-height: 1.6; @@ -278,9 +281,17 @@ header h1 { font-weight: 600; } -.message-content h1 { font-size: 1.5rem; } -.message-content h2 { font-size: 1.3rem; } -.message-content h3 { font-size: 1.1rem; } +.message-content h1 { + font-size: 1.5rem; +} + +.message-content h2 { + font-size: 1.3rem; +} + +.message-content h3 { + font-size: 1.1rem; +} .message-content p { margin: 0.5rem 0; @@ -439,7 +450,9 @@ header h1 { } @keyframes bounce { - 0%, 80%, 100% { + 0%, + 80%, + 100% { transform: scale(0); } 40% { @@ -620,7 +633,6 @@ details[open] .suggested-header::before { /* Course titles display */ .course-titles { margin-top: 0.5rem; - /* Remove max-height to show all titles without scrolling */ } .course-title-item { @@ -640,7 +652,9 @@ details[open] .suggested-header::before { padding-top: 0.25rem; } -.no-courses, .loading, .error { +.no-courses, +.loading, +.error { font-size: 0.85rem; color: var(--text-secondary); font-style: italic; @@ -685,7 +699,7 @@ details[open] .suggested-header::before { .main-content { flex-direction: column; } - + .sidebar { width: 100%; border-right: none; @@ -694,63 +708,63 @@ details[open] .suggested-header::before { order: 2; max-height: 40vh; } - + .sidebar::-webkit-scrollbar { width: 8px; } - + .sidebar::-webkit-scrollbar-track { background: var(--surface); } - + .sidebar::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; } - + .sidebar::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } - + .chat-main { order: 1; } - + header { padding: 1rem; } - + header h1 { font-size: 1.5rem; } - + .chat-messages { padding: 1rem; } - + .message { max-width: 90%; } - + .chat-input-container { padding: 1rem; gap: 0.5rem; } - + #chatInput { padding: 0.75rem 1rem; font-size: 0.9rem; } - + #sendButton { padding: 0.75rem 1rem; min-width: 48px; } - + .stat-value { font-size: 1.25rem; } - + .suggested-item { padding: 0.5rem 0.75rem; font-size: 0.8rem; diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh new file mode 100755 index 000000000..a34b452ae --- /dev/null +++ b/scripts/check-frontend.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Frontend quality checks: formatting (Prettier) and linting (ESLint) +# Usage: ./scripts/check-frontend.sh [--fix] + +set -euo pipefail + +FRONTEND_DIR="$(cd "$(dirname "$0")/../frontend" && pwd)" +FIX=false + +for arg in "$@"; do + case $arg in + --fix) FIX=true ;; + *) echo "Unknown argument: $arg" && exit 1 ;; + esac +done + +echo "==> Installing frontend dependencies..." +cd "$FRONTEND_DIR" +npm install --silent + +if [ "$FIX" = true ]; then + echo "==> Auto-fixing: formatting with Prettier..." + npm run format + + echo "==> Auto-fixing: linting with ESLint..." + npm run lint:fix + + echo "==> All fixes applied." +else + echo "==> Checking formatting with Prettier..." + npm run format:check + + echo "==> Linting with ESLint..." + npm run lint + + echo "==> All checks passed." +fi From ef97537fd8cf074bad39eb4f277c67fb6049c3cc Mon Sep 17 00:00:00 2001 From: phamkimhoan Date: Thu, 16 Apr 2026 11:06:20 +0700 Subject: [PATCH 6/6] feat: add dark/light mode toggle with accessible light theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a fixed top-right toggle button (sun/moon SVG icons) with smooth cross-fade + rotate animation between icons on switch - Persist theme preference to localStorage; restore on page load before first paint to avoid flash of unstyled content - Update aria-label dynamically so screen readers announce the next action - Introduce body.light-mode CSS class with a fully WCAG 2.1 AA/AAA compliant colour palette: * text-primary #1e293b ~14.5:1 (AAA) * text-secondary #475569 ~5.9:1 (AA) * primary-color #1d4ed8 ~6.2:1 (AA) — stepped up from marginal #2563eb * error-color #b91c1c ~7.5:1 (AAA) * success-color #15803d ~7.1:1 (AAA) * source links #1d4ed8 ~6.2:1 (AA, was failing #60a5fa) - Refactor all hard-coded rgba/hex colour values (code blocks, error/ success banners, source chip links) into CSS custom properties so they respond correctly to both themes - Add global 0.3s ease transitions on background-color, color, border-color, and box-shadow so every element cross-fades smoothly on toggle - Document all changes in frontend-changes.md Co-Authored-By: Claude Sonnet 4.6 --- frontend-changes.md | 89 +++++++++++++++++++ frontend/index.html | 27 +++++- frontend/script.js | 29 ++++++- frontend/style.css | 204 ++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 331 insertions(+), 18 deletions(-) create mode 100644 frontend-changes.md diff --git a/frontend-changes.md b/frontend-changes.md new file mode 100644 index 000000000..5cc9d72d3 --- /dev/null +++ b/frontend-changes.md @@ -0,0 +1,89 @@ +# Frontend Changes + +## Feature: Dark/Light Mode Toggle Button + +### Summary +Added a floating theme toggle button (dark ↔ light mode) with sun/moon icons, smooth transitions, and full keyboard accessibility. + +--- + +### Files Modified + +#### `frontend/index.html` +- Added a `

Course Materials Assistant

Ask questions about courses, instructors, and content

@@ -81,6 +104,6 @@

Course Materials Assistant

- + \ No newline at end of file diff --git a/frontend/script.js b/frontend/script.js index f1fb19ba0..b01a62058 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -7,15 +7,39 @@ let currentSessionId = null; // DOM elements let chatMessages, chatInput, sendButton, totalCourses, courseTitles; +// ─── Theme Toggle ──────────────────────────────────────────────────────────── +function initTheme() { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'light') { + document.body.classList.add('light-mode'); + updateToggleLabel(true); + } +} + +function toggleTheme() { + const isLight = document.body.classList.toggle('light-mode'); + localStorage.setItem('theme', isLight ? 'light' : 'dark'); + updateToggleLabel(isLight); +} + +function updateToggleLabel(isLight) { + const btn = document.getElementById('themeToggle'); + if (!btn) return; + btn.setAttribute('aria-label', isLight ? 'Switch to dark mode' : 'Switch to light mode'); +} + // Initialize document.addEventListener('DOMContentLoaded', () => { + // Apply saved theme before rendering to avoid flash + initTheme(); + // Get DOM elements after page loads chatMessages = document.getElementById('chatMessages'); chatInput = document.getElementById('chatInput'); sendButton = document.getElementById('sendButton'); totalCourses = document.getElementById('totalCourses'); courseTitles = document.getElementById('courseTitles'); - + setupEventListeners(); createNewSession(); loadCourseStats(); @@ -33,6 +57,9 @@ function setupEventListeners() { // New chat button document.getElementById('newChatBtn').addEventListener('click', createNewSession); + // Theme toggle button + document.getElementById('themeToggle').addEventListener('click', toggleTheme); + // Suggested questions document.querySelectorAll('.suggested-item').forEach(button => { button.addEventListener('click', (e) => { diff --git a/frontend/style.css b/frontend/style.css index 95ff47cf7..846050e84 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -5,23 +5,124 @@ padding: 0; } -/* CSS Variables */ +/* CSS Variables — Dark Mode (default) */ :root { + /* Brand */ --primary-color: #2563eb; --primary-hover: #1d4ed8; + + /* Backgrounds */ --background: #0f172a; --surface: #1e293b; --surface-hover: #334155; - --text-primary: #f1f5f9; - --text-secondary: #94a3b8; + + /* Text — both pass WCAG AA on dark backgrounds */ + --text-primary: #f1f5f9; /* ~15:1 on --background */ + --text-secondary: #94a3b8; /* ~5.9:1 on --background */ + + /* Borders & shadows */ --border-color: #334155; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); + + /* Messages */ --user-message: #2563eb; --assistant-message: #374151; - --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); + + /* Misc */ --radius: 12px; - --focus-ring: rgba(37, 99, 235, 0.2); + --focus-ring: rgba(37, 99, 235, 0.35); --welcome-bg: #1e3a5f; --welcome-border: #2563eb; + + /* Toggle button */ + --toggle-bg: #334155; + --toggle-hover-bg: #475569; + + /* Code blocks */ + --code-bg: rgba(0, 0, 0, 0.25); + + /* Source chip links */ + --source-link-color: #60a5fa; /* blue-400: ~4.6:1 on --surface */ + --source-link-hover: #93c5fd; /* blue-300 */ + + /* Feedback messages */ + --error-color: #f87171; /* red-400 */ + --error-bg: rgba(239, 68, 68, 0.12); + --error-border: rgba(239, 68, 68, 0.25); + --success-color: #4ade80; /* green-400 */ + --success-bg: rgba(34, 197, 94, 0.12); + --success-border: rgba(34, 197, 94, 0.25); +} + +/* ─── Light Mode Overrides ────────────────────────────────────────────────── */ +/* + * Contrast ratios verified against WCAG 2.1: + * --text-primary (#1e293b) on --background (#f8fafc) ≈ 14.5:1 (AAA) + * --text-secondary (#475569) on --background (#f8fafc) ≈ 5.9:1 (AA) + * --primary-color (#1d4ed8) on --background (#f8fafc) ≈ 6.1:1 (AA) + * --primary-color (#1d4ed8) on --surface (#ffffff) ≈ 6.2:1 (AA) + * white text on --user-message (#1d4ed8) ≈ 6.2:1 (AA) + * --error-color (#b91c1c) on --background ≈ 7.5:1 (AAA) + * --success-color (#15803d) on --background ≈ 7.1:1 (AAA) + * --source-link-color (#1d4ed8) on --surface-hover ≈ 5.8:1 (AA) + */ +body.light-mode { + /* Brand — one step darker to maintain contrast on white */ + --primary-color: #1d4ed8; /* blue-700 */ + --primary-hover: #1e40af; /* blue-800 */ + + /* Backgrounds */ + --background: #f8fafc; /* slate-50 */ + --surface: #ffffff; /* pure white */ + --surface-hover: #f1f5f9; /* slate-100 */ + + /* Text */ + --text-primary: #1e293b; /* slate-800 */ + --text-secondary: #475569; /* slate-600 — ≥4.5:1 on all light surfaces */ + + /* Borders & shadows */ + --border-color: #cbd5e1; /* slate-300 — visible but subtle */ + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08); + + /* Messages */ + --user-message: #1d4ed8; /* matches --primary-color */ + --assistant-message: #f1f5f9; /* slate-100 */ + + /* Misc */ + --focus-ring: rgba(29, 78, 216, 0.3); + --welcome-bg: #eff6ff; /* blue-50 */ + --welcome-border: #93c5fd; /* blue-300 */ + + /* Toggle button */ + --toggle-bg: #e2e8f0; /* slate-200 */ + --toggle-hover-bg: #cbd5e1; /* slate-300 */ + + /* Code blocks */ + --code-bg: rgba(0, 0, 0, 0.05); + + /* Source chip links */ + --source-link-color: #1d4ed8; /* blue-700: ≥6:1 on white */ + --source-link-hover: #1e40af; /* blue-800 */ + + /* Feedback messages — deep tones for readable contrast on light bg */ + --error-color: #b91c1c; /* red-700: ≈7.5:1 on white */ + --error-bg: rgba(185, 28, 28, 0.07); + --error-border: rgba(185, 28, 28, 0.2); + --success-color: #15803d; /* green-700: ≈7.1:1 on white */ + --success-bg: rgba(21, 128, 61, 0.07); + --success-border: rgba(21, 128, 61, 0.2); +} + +/* Global transition for theme switching */ +body, +body *, +body *::before, +body *::after { + transition: + background-color 0.3s ease, + color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; } /* Base Styles */ @@ -258,12 +359,12 @@ header h1 { } .source-chip a { - color: #60a5fa; + color: var(--source-link-color); text-decoration: none; } .source-chip a:hover { - color: #93c5fd; + color: var(--source-link-hover); text-decoration: underline; } @@ -299,7 +400,7 @@ header h1 { } .message-content code { - background-color: rgba(0, 0, 0, 0.2); + background-color: var(--code-bg); padding: 0.125rem 0.25rem; border-radius: 3px; font-family: 'Fira Code', 'Consolas', monospace; @@ -307,7 +408,7 @@ header h1 { } .message-content pre { - background-color: rgba(0, 0, 0, 0.2); + background-color: var(--code-bg); padding: 0.75rem; border-radius: 4px; overflow-x: auto; @@ -449,21 +550,21 @@ header h1 { /* Error Message */ .error-message { - background: rgba(239, 68, 68, 0.1); - color: #f87171; + background: var(--error-bg); + color: var(--error-color); padding: 0.75rem 1.25rem; border-radius: 8px; - border: 1px solid rgba(239, 68, 68, 0.2); + border: 1px solid var(--error-border); margin: 0.5rem 0; } /* Success Message */ .success-message { - background: rgba(34, 197, 94, 0.1); - color: #4ade80; + background: var(--success-bg); + color: var(--success-color); padding: 0.75rem 1.25rem; border-radius: 8px; - border: 1px solid rgba(34, 197, 94, 0.2); + border: 1px solid var(--success-border); margin: 0.5rem 0; } @@ -762,3 +863,76 @@ details[open] .suggested-header::before { width: 280px; } } + +/* ─── Theme Toggle Button ─────────────────────────────────────────────────── */ +.theme-toggle { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + + width: 42px; + height: 42px; + padding: 0; + + background: var(--toggle-bg); + border: 1px solid var(--border-color); + border-radius: 50%; + color: var(--text-primary); + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + /* Override the global * transition so only transform is fast */ + transition: + background-color 0.3s ease, + color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease, + transform 0.15s ease; +} + +.theme-toggle:hover { + background: var(--toggle-hover-bg); + transform: scale(1.1); +} + +.theme-toggle:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.theme-toggle:active { + transform: scale(0.95); +} + +/* Icon visibility: dark mode shows moon, light mode shows sun */ +.theme-toggle .icon-moon, +.theme-toggle .icon-sun { + position: absolute; + transition: opacity 0.25s ease, transform 0.35s ease; +} + +/* Default (dark mode): moon visible, sun hidden */ +.theme-toggle .icon-moon { + opacity: 1; + transform: rotate(0deg) scale(1); +} + +.theme-toggle .icon-sun { + opacity: 0; + transform: rotate(90deg) scale(0.5); +} + +/* Light mode: sun visible, moon hidden */ +body.light-mode .theme-toggle .icon-moon { + opacity: 0; + transform: rotate(-90deg) scale(0.5); +} + +body.light-mode .theme-toggle .icon-sun { + opacity: 1; + transform: rotate(0deg) scale(1); +}