From 29c066ba72ab68040e8157b717789014c856e49d Mon Sep 17 00:00:00 2001 From: dalang059 Date: Sat, 23 May 2026 23:50:30 +0800 Subject: [PATCH 1/8] Tune chunk size and search results for all-MiniLM-L6-v2 token limit Reduce CHUNK_SIZE from 800 to 500 to stay within the embedding model's 256-token window, preventing silent truncation. Increase MAX_RESULTS from 5 to 8 to maintain retrieval coverage with the smaller chunks. Co-Authored-By: Claude Sonnet 4.6 --- backend/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/config.py b/backend/config.py index d9f6392ef..13e9ddc04 100644 --- a/backend/config.py +++ b/backend/config.py @@ -16,9 +16,9 @@ class Config: EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" # Document processing settings - CHUNK_SIZE: int = 800 # Size of text chunks for vector storage + CHUNK_SIZE: int = 500 # Size of text chunks for vector storage CHUNK_OVERLAP: int = 100 # Characters to overlap between chunks - MAX_RESULTS: int = 5 # Maximum search results to return + MAX_RESULTS: int = 8 # Maximum search results to return MAX_HISTORY: int = 2 # Number of conversation messages to remember # Database paths From 1f91185b18f6e50699e3a1ea0ea9e27043fbad36 Mon Sep 17 00:00:00 2001 From: dalang059 Date: Sat, 23 May 2026 23:56:15 +0800 Subject: [PATCH 2/8] Add CLAUDE.md --- CLAUDE.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..5f5b895ee --- /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 + +**Install dependencies:** +```bash +uv sync +``` + +**Run the server** (from the `backend/` directory): +```bash +cd backend +uv run uvicorn app:app --reload --port 8000 +``` + +The web UI is at `http://localhost:8000` and the auto-generated API docs are at `http://localhost:8000/docs`. + +**Environment setup:** Copy `.env` and set `ANTHROPIC_API_KEY`. + +There are no tests in this codebase. + +## Architecture + +This is a full-stack RAG chatbot. The backend is a FastAPI app (`backend/app.py`) that serves both the REST API and the static frontend (`frontend/`). All backend modules run from within the `backend/` directory, so relative imports and paths (e.g. `../docs`, `./chroma_db`) are relative to that directory. + +### Request flow + +1. The browser (`frontend/script.js`) POSTs a query to `POST /api/query`. +2. `app.py` delegates to `RAGSystem.query()` (`rag_system.py`). +3. `RAGSystem` calls `AIGenerator.generate_response()` (`ai_generator.py`), passing the Claude API client, conversation history (from `SessionManager`), and the registered `search_course_content` tool definition. +4. If Claude decides to search, it calls the tool; `AIGenerator._handle_tool_execution()` routes this to `ToolManager.execute_tool()` → `CourseSearchTool.execute()` (`search_tools.py`), which queries `VectorStore` (`vector_store.py`). +5. Search results are injected back into the Claude conversation as a `tool_result` message, and Claude generates the final answer. +6. Sources collected by `CourseSearchTool` are returned to the browser alongside the answer. + +### Key components + +- **`RAGSystem`** (`rag_system.py`) — top-level orchestrator; the only component that coordinates all others. +- **`VectorStore`** (`vector_store.py`) — wraps ChromaDB with two collections: `course_catalog` (course titles/metadata, used for fuzzy course-name resolution) and `course_content` (chunked text, used for semantic search). Embeddings are generated locally via `sentence-transformers` (`all-MiniLM-L6-v2`). The ChromaDB store is persisted at `backend/chroma_db/`. +- **`DocumentProcessor`** (`document_processor.py`) — parses `.txt`/`.pdf`/`.docx` files from `docs/` into `Course` + `CourseChunk` objects. Expects a specific header format (see below) but falls back to flat chunking if no `Lesson N:` markers are found. +- **`AIGenerator`** (`ai_generator.py`) — thin wrapper around `anthropic.Anthropic`. Uses `tool_choice: auto` and handles one round of tool use (search → final answer). Model and token limits are configured here. +- **`ToolManager` / `CourseSearchTool`** (`search_tools.py`) — extensible tool registry. Adding a new tool means subclassing `Tool` and calling `tool_manager.register_tool()`. +- **`SessionManager`** (`session_manager.py`) — in-memory conversation history, keyed by `session_id`. History is serialized as a plain string and injected into the system prompt. + +### Document format + +Documents in `docs/` must follow this structure for full metadata extraction: + +``` +Course Title: ← used as the unique ID in ChromaDB +Course Link: <url> ← optional +Course Instructor: <name> ← optional + +Lesson 0: <lesson title> +Lesson Link: <url> ← optional, must immediately follow lesson header +<lesson content> + +Lesson 1: <next lesson title> +... +``` + +If no `Lesson N:` markers are present, the entire file body is chunked as a single flat document. + +### Adding a new document source + +1. Drop `.txt`, `.pdf`, or `.docx` files into `docs/`. +2. Delete `backend/chroma_db/` to clear stale embeddings. +3. Restart the server — `startup_event()` in `app.py` re-indexes everything. + +To clear and re-index programmatically, call `rag_system.add_course_folder(path, clear_existing=True)`. + +### Configuration + +All tuneable parameters are in `backend/config.py` via the `Config` dataclass: chunk size/overlap, max search results, conversation history length, ChromaDB path, and the Anthropic model name. From 16b867b3f9439c417a0b38102172a2ccb3eff3d6 Mon Sep 17 00:00:00 2001 From: dalang059 <ty_wb@163.com> Date: Sun, 7 Jun 2026 12:34:15 +0800 Subject: [PATCH 3/8] Add frontend updates, backend improvements, tests, and Claude Code config --- .claude/commands/implement-feature.md | 7 + .claude/settings.json | 6 + .../console-2026-05-24T13-11-58-926Z.log | 3 + .../page-2026-05-24T13-11-59-810Z.yml | 14 + .../page-2026-05-24T13-12-10-911Z.png | Bin 0 -> 23547 bytes .../console-2026-05-24T12-58-16-051Z.log | 3 + .../console-2026-05-24T13-00-22-107Z.log | 5 + .../page-2026-05-24T12-58-16-450Z.yml | 14 + .../page-2026-05-24T12-58-37-007Z.png | Bin 0 -> 23664 bytes .../page-2026-05-24T13-00-22-145Z.yml | 14 + .../page-2026-05-24T13-00-30-649Z.png | Bin 0 -> 23736 bytes backend/ai_generator.py | 250 ++++----- backend/app.py | 6 + backend/search_tools.py | 3 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 79 +++ backend/tests/test_ai_generator.py | 496 ++++++++++++++++++ backend/tests/test_rag_system.py | 192 +++++++ backend/tests/test_search_tools.py | 256 +++++++++ frontend/index.html | 5 + frontend/script.js | 11 +- frontend/style.css | 23 + pyproject.toml | 5 + uv.lock | 44 +- 24 files changed, 1299 insertions(+), 137 deletions(-) create mode 100644 .claude/commands/implement-feature.md create mode 100644 .claude/settings.json create mode 100644 .playwright-mcp/console-2026-05-24T13-11-58-926Z.log create mode 100644 .playwright-mcp/page-2026-05-24T13-11-59-810Z.yml create mode 100644 .playwright-mcp/page-2026-05-24T13-12-10-911Z.png create mode 100644 backend/.playwright-mcp/console-2026-05-24T12-58-16-051Z.log create mode 100644 backend/.playwright-mcp/console-2026-05-24T13-00-22-107Z.log create mode 100644 backend/.playwright-mcp/page-2026-05-24T12-58-16-450Z.yml create mode 100644 backend/.playwright-mcp/page-2026-05-24T12-58-37-007Z.png create mode 100644 backend/.playwright-mcp/page-2026-05-24T13-00-22-145Z.yml create mode 100644 backend/.playwright-mcp/page-2026-05-24T13-00-30-649Z.png create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_ai_generator.py create mode 100644 backend/tests/test_rag_system.py create mode 100644 backend/tests/test_search_tools.py diff --git a/.claude/commands/implement-feature.md b/.claude/commands/implement-feature.md new file mode 100644 index 000000000..33302a4fd --- /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 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..992d4abaf --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "permissions": { + "allow": [], + "deny": [] + } +} diff --git a/.playwright-mcp/console-2026-05-24T13-11-58-926Z.log b/.playwright-mcp/console-2026-05-24T13-11-58-926Z.log new file mode 100644 index 000000000..2953faf2c --- /dev/null +++ b/.playwright-mcp/console-2026-05-24T13-11-58-926Z.log @@ -0,0 +1,3 @@ +[ 845ms] [LOG] Loading course stats... @ http://localhost:8000/script.js?v=9:166 +[ 851ms] [LOG] Course data received: {total_courses: 4, course_titles: Array(4)} @ http://localhost:8000/script.js?v=9:171 +[ 852ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:8000/favicon.ico:0 diff --git a/.playwright-mcp/page-2026-05-24T13-11-59-810Z.yml b/.playwright-mcp/page-2026-05-24T13-11-59-810Z.yml new file mode 100644 index 000000000..eb09eef32 --- /dev/null +++ b/.playwright-mcp/page-2026-05-24T13-11-59-810Z.yml @@ -0,0 +1,14 @@ +- generic [ref=e3]: + - complementary [ref=e4]: + - button "NEW CHAT" [ref=e6] [cursor=pointer] + - group [ref=e8]: + - generic "▶ Courses" [ref=e9] [cursor=pointer] + - group [ref=e11]: + - generic "▶ Try asking:" [ref=e12] [cursor=pointer] + - main [ref=e13]: + - generic [ref=e14]: + - paragraph [ref=e18]: Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know? + - generic [ref=e19]: + - textbox "Ask about courses, lessons, or specific content..." [ref=e20] + - button [ref=e21] [cursor=pointer]: + - img [ref=e22] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-24T13-12-10-911Z.png b/.playwright-mcp/page-2026-05-24T13-12-10-911Z.png new file mode 100644 index 0000000000000000000000000000000000000000..f0b26c2c98872665548144169c8fb0e7ab713e4f GIT binary patch literal 23547 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 G&wl`d5=@K$ literal 0 HcmV?d00001 diff --git a/backend/.playwright-mcp/console-2026-05-24T12-58-16-051Z.log b/backend/.playwright-mcp/console-2026-05-24T12-58-16-051Z.log new file mode 100644 index 000000000..b4f1d3f54 --- /dev/null +++ b/backend/.playwright-mcp/console-2026-05-24T12-58-16-051Z.log @@ -0,0 +1,3 @@ +[ 365ms] [LOG] Loading course stats... @ http://127.0.0.1:8000/script.js?v=9:166 +[ 371ms] [LOG] Course data received: {total_courses: 4, course_titles: Array(4)} @ http://127.0.0.1:8000/script.js?v=9:171 +[ 373ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:8000/favicon.ico:0 diff --git a/backend/.playwright-mcp/console-2026-05-24T13-00-22-107Z.log b/backend/.playwright-mcp/console-2026-05-24T13-00-22-107Z.log new file mode 100644 index 000000000..d6d6b5878 --- /dev/null +++ b/backend/.playwright-mcp/console-2026-05-24T13-00-22-107Z.log @@ -0,0 +1,5 @@ +[ 22ms] [LOG] Loading course stats... @ http://127.0.0.1:8000/script.js?v=9:166 +[ 27ms] [LOG] Course data received: {total_courses: 4, course_titles: Array(4)} @ http://127.0.0.1:8000/script.js?v=9:171 +[ 32365ms] [LOG] Loading course stats... @ http://127.0.0.1:8000/script.js?v=9:166 +[ 32369ms] [LOG] Course data received: {total_courses: 4, course_titles: Array(4)} @ http://127.0.0.1:8000/script.js?v=9:171 +[ 147538ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://127.0.0.1:8000/api/query:0 diff --git a/backend/.playwright-mcp/page-2026-05-24T12-58-16-450Z.yml b/backend/.playwright-mcp/page-2026-05-24T12-58-16-450Z.yml new file mode 100644 index 000000000..cc2654c8c --- /dev/null +++ b/backend/.playwright-mcp/page-2026-05-24T12-58-16-450Z.yml @@ -0,0 +1,14 @@ +- generic [ref=e3]: + - complementary [ref=e4]: + - button "+ NEW CHAT" [ref=e6] [cursor=pointer] + - group [ref=e8]: + - generic "▶ Courses" [ref=e9] [cursor=pointer] + - group [ref=e11]: + - generic "▶ Try asking:" [ref=e12] [cursor=pointer] + - main [ref=e13]: + - generic [ref=e14]: + - paragraph [ref=e18]: Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know? + - generic [ref=e19]: + - textbox "Ask about courses, lessons, or specific content..." [ref=e20] + - button [ref=e21] [cursor=pointer]: + - img [ref=e22] \ No newline at end of file diff --git a/backend/.playwright-mcp/page-2026-05-24T12-58-37-007Z.png b/backend/.playwright-mcp/page-2026-05-24T12-58-37-007Z.png new file mode 100644 index 0000000000000000000000000000000000000000..9a2ff827ab2398293551b5a76f6da477a1f3a0a4 GIT binary patch literal 23664 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 literal 0 HcmV?d00001 diff --git a/backend/.playwright-mcp/page-2026-05-24T13-00-22-145Z.yml b/backend/.playwright-mcp/page-2026-05-24T13-00-22-145Z.yml new file mode 100644 index 000000000..c8acb4f2d --- /dev/null +++ b/backend/.playwright-mcp/page-2026-05-24T13-00-22-145Z.yml @@ -0,0 +1,14 @@ +- generic [ref=e3]: + - complementary [ref=e4]: + - button "▶ NEW CHAT" [ref=e6] [cursor=pointer] + - group [ref=e8]: + - generic "▶ Courses" [ref=e9] [cursor=pointer] + - group [ref=e11]: + - generic "▶ Try asking:" [ref=e12] [cursor=pointer] + - main [ref=e13]: + - generic [ref=e14]: + - paragraph [ref=e18]: Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know? + - generic [ref=e19]: + - textbox "Ask about courses, lessons, or specific content..." [ref=e20] + - button [ref=e21] [cursor=pointer]: + - img [ref=e22] \ No newline at end of file diff --git a/backend/.playwright-mcp/page-2026-05-24T13-00-30-649Z.png b/backend/.playwright-mcp/page-2026-05-24T13-00-30-649Z.png new file mode 100644 index 0000000000000000000000000000000000000000..ae4ea7794c7de14f70a5c2a588cde1f3ed16cd27 GIT binary patch literal 23736 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 W3MUKPVEMomki~V|f7YAbfBbLb+q{YZ literal 0 HcmV?d00001 diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 0363ca90c..26c95c98b 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -1,135 +1,115 @@ -import anthropic -from typing import List, Optional, Dict, Any - -class AIGenerator: - """Handles interactions with Anthropic's Claude API for generating responses""" - - # 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** -- Synthesize search results into accurate, fact-based responses -- If search yields no results, state this clearly without offering alternatives - -Response Protocol: -- **General knowledge questions**: Answer using existing knowledge without searching -- **Course-specific questions**: Search first, then answer -- **No meta-commentary**: - - Provide direct answers only — no reasoning process, search explanations, or question-type analysis - - Do not mention "based on the search results" - - -All responses must be: -1. **Brief, Concise and focused** - Get to the point quickly -2. **Educational** - Maintain instructional value -3. **Clear** - Use accessible language -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. - - 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 - 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 +import anthropic +from typing import List, Optional + +class AIGenerator: + """Handles interactions with Anthropic's Claude API for generating responses""" + + 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 +- You may search up to 2 times per query when a follow-up search would meaningfully refine the answer (e.g. searching for a lesson title first, then using that title to find related content across courses) +- Use multi-step search only when necessary — prefer a single targeted search +- Synthesize all search results into a single accurate, fact-based response +- If search yields no results, state this clearly without offering alternatives + +Response Protocol: +- **General knowledge questions**: Answer using existing knowledge without searching +- **Course-specific questions**: Search first, then answer +- **No meta-commentary**: + - Provide direct answers only — no reasoning process, search explanations, or question-type analysis + - Do not mention "based on the search results" + + +All responses must be: +1. **Brief, Concise and focused** - Get to the point quickly +2. **Educational** - Maintain instructional value +3. **Clear** - Use accessible language +4. **Example-supported** - Include relevant examples when they aid understanding +Provide only the direct answer to what was asked. +""" + + _FALLBACK = "I was unable to generate a response. Please try again." + _MAX_TOOL_ROUNDS = 2 + + def __init__(self, api_key: str, model: str): + self.client = anthropic.Anthropic(api_key=api_key) + self.model = model + 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 an AI response, running up to _MAX_TOOL_ROUNDS sequential tool + calls when Claude requests them. + + Each tool-use round is a separate API request so Claude can reason about + prior search results before deciding whether another search is needed. + + Terminates when: + (a) Claude returns a text response (stop_reason != "tool_use") + (b) _MAX_TOOL_ROUNDS rounds have been completed + (c) A tool call raises an exception + """ + system_content = ( + f"{self.SYSTEM_PROMPT}\n\nPrevious conversation:\n{conversation_history}" + if conversation_history + else self.SYSTEM_PROMPT + ) + + messages = [{"role": "user", "content": query}] + api_params = {**self.base_params, "messages": messages, "system": system_content} + if tools: + api_params["tools"] = tools + api_params["tool_choice"] = {"type": "auto"} + + response = self.client.messages.create(**api_params) + + for _ in range(self._MAX_TOOL_ROUNDS): + if response.stop_reason != "tool_use" or not tool_manager: + break + + messages.append({"role": "assistant", "content": response.content}) + + tool_results = [] + error_occurred = False + for block in response.content: + if block.type != "tool_use": + continue + try: + result = tool_manager.execute_tool(block.name, **block.input) + except Exception as e: + result = f"Tool error: {str(e)}" + error_occurred = True + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": result, + }) + + messages.append({"role": "user", "content": tool_results}) + + if error_occurred: + break + + # tools must be included in follow-up calls — Anthropic returns HTTP 400 + # when messages contain tool_use blocks but no tools are defined. + followup_params = {**self.base_params, "messages": messages, "system": system_content} + if tools: + followup_params["tools"] = tools + response = self.client.messages.create(**followup_params) + + for block in response.content: + if block.type == "text": + return block.text + + return self._FALLBACK diff --git a/backend/app.py b/backend/app.py index 5a69d741d..44589a7d3 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 clear_session(session_id: str): + """Clear session history when user starts a new chat""" + 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/search_tools.py b/backend/search_tools.py index adfe82352..3c2788171 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -62,6 +62,9 @@ def execute(self, query: str, course_name: Optional[str] = None, lesson_number: Formatted search results or error message """ + # Reset sources before each search so stale citations never leak + self.last_sources = [] + # Use the vector store's unified search interface results = self.store.search( query=query, 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..e0c894744 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,79 @@ +import sys +import os + +# Add the backend directory to the Python path so test files can import backend modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +import tempfile + +from models import Course, Lesson, CourseChunk +from vector_store import VectorStore + + +@pytest.fixture +def tmp_chroma_path(): + """Create a temporary directory for ChromaDB during tests. + + ignore_cleanup_errors=True avoids PermissionError on Windows where + ChromaDB holds file handles open until the process exits. + """ + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: + yield tmpdir + + +@pytest.fixture +def empty_vector_store(tmp_chroma_path): + """A VectorStore backed by a fresh empty ChromaDB.""" + return VectorStore( + chroma_path=tmp_chroma_path, + embedding_model="all-MiniLM-L6-v2", + max_results=5, + ) + + +@pytest.fixture +def sample_course(): + return Course( + title="Introduction to RAG", + course_link="https://example.com/rag", + instructor="Test Instructor", + lessons=[ + Lesson(lesson_number=1, title="What is RAG"), + Lesson(lesson_number=2, title="Vector Databases"), + ], + ) + + +@pytest.fixture +def sample_chunks(): + return [ + CourseChunk( + content="Lesson 1 content: RAG stands for Retrieval-Augmented Generation. " + "It combines a retrieval system with a generative language model.", + course_title="Introduction to RAG", + lesson_number=1, + chunk_index=0, + ), + CourseChunk( + content="Vector databases store embeddings and enable fast similarity search " + "over large document collections.", + course_title="Introduction to RAG", + lesson_number=2, + chunk_index=1, + ), + CourseChunk( + content="ChromaDB is an open-source vector database well-suited for local development.", + course_title="Introduction to RAG", + lesson_number=2, + chunk_index=2, + ), + ] + + +@pytest.fixture +def populated_vector_store(empty_vector_store, sample_course, sample_chunks): + """A VectorStore with one test course and three content chunks loaded.""" + empty_vector_store.add_course_metadata(sample_course) + empty_vector_store.add_course_content(sample_chunks) + return empty_vector_store diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py new file mode 100644 index 000000000..40bbf834b --- /dev/null +++ b/backend/tests/test_ai_generator.py @@ -0,0 +1,496 @@ +""" +Tests for AIGenerator in ai_generator.py. + +All assertions are on observable external behavior: + - how many times the Anthropic client was called + - which tools were executed and with what arguments + - what string generate_response() returned + - what parameters were passed to each API call +""" + +from unittest.mock import MagicMock, patch, call +import pytest + +from ai_generator import AIGenerator + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_generator(): + """Return an AIGenerator whose Anthropic client is fully mocked.""" + with patch("ai_generator.anthropic.Anthropic"): + gen = AIGenerator(api_key="fake-key", model="fake-model") + gen.client = MagicMock() + return gen + + +def _make_text_response(text="Answer text"): + """Simulate a Claude response that produces plain text (no tool use).""" + resp = MagicMock() + resp.stop_reason = "end_turn" + text_block = MagicMock() + text_block.type = "text" + text_block.text = text + resp.content = [text_block] + return resp + + +def _make_tool_use_response( + tool_name="search_course_content", + tool_input=None, + tool_id="tool_abc", +): + """Simulate a Claude response that requests a tool call.""" + if tool_input is None: + tool_input = {"query": "what is RAG"} + tool_block = MagicMock() + tool_block.type = "tool_use" + tool_block.id = tool_id + tool_block.name = tool_name + tool_block.input = tool_input + + resp = MagicMock() + resp.stop_reason = "tool_use" + resp.content = [tool_block] + return resp + + +def _make_tool_manager(return_value="search results"): + tm = MagicMock() + tm.execute_tool.return_value = return_value + return tm + + +TOOL_DEFINITIONS = [ + { + "name": "search_course_content", + "description": "Search course materials", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + } +] + + +# --------------------------------------------------------------------------- +# Direct-response path (no tool use) +# --------------------------------------------------------------------------- + +class TestGenerateResponseDirect: + + def test_returns_text_when_stop_reason_is_end_turn(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_text_response("Hello world") + + result = gen.generate_response("What is 2+2?") + + assert result == "Hello world" + + def test_api_called_once_for_direct_response(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_text_response() + + gen.generate_response("test") + + assert gen.client.messages.create.call_count == 1 + + def test_api_called_with_correct_model(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_text_response() + + gen.generate_response("test") + + kwargs = gen.client.messages.create.call_args[1] + assert kwargs["model"] == "fake-model" + + def test_system_prompt_included(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_text_response() + + gen.generate_response("test") + + kwargs = gen.client.messages.create.call_args[1] + assert "system" in kwargs and len(kwargs["system"]) > 0 + + def test_conversation_history_appended_to_system_prompt(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_text_response() + + gen.generate_response("test", conversation_history="User: hi\nAssistant: hello") + + kwargs = gen.client.messages.create.call_args[1] + assert "Previous conversation" in kwargs["system"] + assert "User: hi" in kwargs["system"] + + def test_tools_passed_to_first_call_when_provided(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_text_response() + + gen.generate_response("test", tools=TOOL_DEFINITIONS) + + kwargs = gen.client.messages.create.call_args[1] + assert kwargs["tools"] == TOOL_DEFINITIONS + + def test_tool_choice_auto_set_when_tools_provided(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_text_response() + + gen.generate_response("test", tools=TOOL_DEFINITIONS) + + kwargs = gen.client.messages.create.call_args[1] + assert kwargs.get("tool_choice") == {"type": "auto"} + + def test_no_tool_choice_when_no_tools(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_text_response() + + gen.generate_response("test") + + kwargs = gen.client.messages.create.call_args[1] + assert "tool_choice" not in kwargs + + +# --------------------------------------------------------------------------- +# Single tool-use round +# --------------------------------------------------------------------------- + +class TestSingleRoundToolUse: + """Claude requests one tool call, then returns a text answer.""" + + def test_returns_final_text(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(), + _make_text_response("RAG means Retrieval-Augmented Generation"), + ] + + result = gen.generate_response( + "What is RAG?", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) + + assert result == "RAG means Retrieval-Augmented Generation" + + def test_api_called_twice(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(), + _make_text_response(), + ] + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + + assert gen.client.messages.create.call_count == 2 + + def test_execute_tool_called_once_with_correct_args(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_input={"query": "vector stores", "course_name": "RAG"}), + _make_text_response(), + ] + tm = _make_tool_manager() + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + + tm.execute_tool.assert_called_once_with( + "search_course_content", query="vector stores", course_name="RAG" + ) + + def test_follow_up_call_includes_tools(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(), + _make_text_response(), + ] + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + + second_call_kwargs = gen.client.messages.create.call_args_list[1][1] + assert "tools" in second_call_kwargs + + def test_follow_up_messages_are_user_assistant_user(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(), + _make_text_response(), + ] + + gen.generate_response("my question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + + second_call_kwargs = gen.client.messages.create.call_args_list[1][1] + messages = second_call_kwargs["messages"] + assert len(messages) == 3 + assert messages[0]["role"] == "user" + assert messages[1]["role"] == "assistant" + assert messages[2]["role"] == "user" + + def test_tool_result_carries_correct_tool_use_id(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_id="tid_xyz"), + _make_text_response(), + ] + + gen.generate_response("q", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + + second_call_kwargs = gen.client.messages.create.call_args_list[1][1] + tool_result_block = second_call_kwargs["messages"][2]["content"][0] + assert tool_result_block["tool_use_id"] == "tid_xyz" + + def test_follow_up_call_does_not_set_tool_choice(self): + """Follow-up calls omit tool_choice so Claude can freely choose to answer without tools.""" + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(), + _make_text_response(), + ] + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + + second_call_kwargs = gen.client.messages.create.call_args_list[1][1] + assert "tool_choice" not in second_call_kwargs + + +# --------------------------------------------------------------------------- +# Two sequential tool-use rounds +# --------------------------------------------------------------------------- + +class TestTwoRoundToolUse: + """Claude uses the search tool twice in separate rounds before answering.""" + + def test_returns_final_text_after_two_rounds(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_input={"query": "lesson 4 title"}, tool_id="t1"), + _make_tool_use_response(tool_input={"query": "found topic"}, tool_id="t2"), + _make_text_response("Here is the course that covers the same topic."), + ] + + result = gen.generate_response( + "Find a course covering lesson 4 of course X", + tools=TOOL_DEFINITIONS, + tool_manager=_make_tool_manager(), + ) + + assert result == "Here is the course that covers the same topic." + + def test_api_called_three_times(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_id="t1"), + _make_tool_use_response(tool_id="t2"), + _make_text_response(), + ] + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + + assert gen.client.messages.create.call_count == 3 + + def test_execute_tool_called_twice(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_input={"query": "first search"}, tool_id="t1"), + _make_tool_use_response(tool_input={"query": "second search"}, tool_id="t2"), + _make_text_response(), + ] + tm = _make_tool_manager() + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + + assert tm.execute_tool.call_count == 2 + + def test_both_tool_inputs_passed_to_execute_tool(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_input={"query": "first search"}, tool_id="t1"), + _make_tool_use_response(tool_input={"query": "second search"}, tool_id="t2"), + _make_text_response(), + ] + tm = _make_tool_manager() + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + + call_queries = [c[1]["query"] for c in tm.execute_tool.call_args_list] + assert "first search" in call_queries + assert "second search" in call_queries + + def test_all_follow_up_calls_include_tools(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_id="t1"), + _make_tool_use_response(tool_id="t2"), + _make_text_response(), + ] + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + + for i in (1, 2): + kwargs = gen.client.messages.create.call_args_list[i][1] + assert "tools" in kwargs, f"Call #{i + 1} is missing 'tools'" + + def test_messages_accumulate_across_both_rounds(self): + """Third API call must see all five prior messages.""" + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_id="t1"), + _make_tool_use_response(tool_id="t2"), + _make_text_response(), + ] + + gen.generate_response("my query", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + + third_call_kwargs = gen.client.messages.create.call_args_list[2][1] + messages = third_call_kwargs["messages"] + # [user_query, asst(round1), user(round1_results), asst(round2), user(round2_results)] + assert len(messages) == 5 + roles = [m["role"] for m in messages] + assert roles == ["user", "assistant", "user", "assistant", "user"] + + +# --------------------------------------------------------------------------- +# Max-rounds termination +# --------------------------------------------------------------------------- + +class TestMaxRoundsTermination: + """Loop stops after _MAX_TOOL_ROUNDS even if Claude still wants more tools.""" + + def test_api_called_three_times_when_all_responses_are_tool_use(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_id="t1"), + _make_tool_use_response(tool_id="t2"), + _make_tool_use_response(tool_id="t3"), # loop exits after this + ] + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + + assert gen.client.messages.create.call_count == 3 + + def test_execute_tool_called_exactly_twice_at_max_rounds(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_id="t1"), + _make_tool_use_response(tool_id="t2"), + _make_tool_use_response(tool_id="t3"), + ] + tm = _make_tool_manager() + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + + assert tm.execute_tool.call_count == 2 + + def test_returns_fallback_string_when_max_rounds_exhausted(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_id="t1"), + _make_tool_use_response(tool_id="t2"), + _make_tool_use_response(tool_id="t3"), + ] + + result = gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) + + assert isinstance(result, str) and len(result) > 0 + + +# --------------------------------------------------------------------------- +# Tool execution errors +# --------------------------------------------------------------------------- + +class TestToolExecutionErrors: + + def test_exception_from_execute_tool_does_not_propagate(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_tool_use_response() + tm = MagicMock() + tm.execute_tool.side_effect = Exception("DB unavailable") + + result = gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + + assert isinstance(result, str) + + def test_exception_aborts_after_first_api_call(self): + """No follow-up API call should be made after a tool execution exception.""" + gen = _make_generator() + gen.client.messages.create.return_value = _make_tool_use_response() + tm = MagicMock() + tm.execute_tool.side_effect = Exception("crash") + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + + assert gen.client.messages.create.call_count == 1 + + def test_error_string_from_execute_tool_continues_loop(self): + """An error string (not exception) is treated as valid content — Claude continues.""" + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(), + _make_text_response("Based on available information..."), + ] + tm = MagicMock() + tm.execute_tool.return_value = "No relevant content found" + + result = gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + + assert gen.client.messages.create.call_count == 2 + assert result == "Based on available information..." + + def test_error_string_content_appears_in_follow_up_messages(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(tool_id="t1"), + _make_text_response("answer"), + ] + tm = MagicMock() + tm.execute_tool.return_value = "No relevant content found" + + gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + + second_call_kwargs = gen.client.messages.create.call_args_list[1][1] + tool_result_content = second_call_kwargs["messages"][2]["content"][0]["content"] + assert tool_result_content == "No relevant content found" + + +# --------------------------------------------------------------------------- +# Miscellaneous / edge cases +# --------------------------------------------------------------------------- + +class TestMiscBehavior: + + def test_no_tool_manager_with_tool_use_response_returns_fallback(self): + gen = _make_generator() + gen.client.messages.create.return_value = _make_tool_use_response() + + result = gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=None) + + assert isinstance(result, str) and len(result) > 0 + assert gen.client.messages.create.call_count == 1 + + def test_conversation_history_present_in_all_api_calls(self): + gen = _make_generator() + gen.client.messages.create.side_effect = [ + _make_tool_use_response(), + _make_text_response("done"), + ] + + gen.generate_response( + "question", + conversation_history="User: hello\nAssistant: hi", + tools=TOOL_DEFINITIONS, + tool_manager=_make_tool_manager(), + ) + + for i, call in enumerate(gen.client.messages.create.call_args_list): + system = call[1]["system"] + assert "User: hello" in system, f"Call #{i + 1} missing conversation history" + + def test_system_prompt_removed_one_search_limit(self): + assert "One search per query maximum" not in AIGenerator.SYSTEM_PROMPT + + def test_system_prompt_allows_up_to_two_searches(self): + prompt = AIGenerator.SYSTEM_PROMPT.lower() + assert "2" in prompt or "two" in prompt or "sequential" in prompt diff --git a/backend/tests/test_rag_system.py b/backend/tests/test_rag_system.py new file mode 100644 index 000000000..b92b0f7a5 --- /dev/null +++ b/backend/tests/test_rag_system.py @@ -0,0 +1,192 @@ +""" +Tests for RAGSystem.query() in rag_system.py. + +The Anthropic client is always mocked so these tests run without a real API key. +ChromaDB uses the temporary fixture from conftest.py. +""" + +from unittest.mock import MagicMock, patch +import pytest + +from models import Course, Lesson, CourseChunk + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_text_response(text="Answer"): + resp = MagicMock() + resp.stop_reason = "end_turn" + text_block = MagicMock() + text_block.type = "text" + text_block.text = text + resp.content = [text_block] + return resp + + +def _make_tool_use_response(query="test query", tool_id="tid_1"): + tool_block = MagicMock() + tool_block.type = "tool_use" + tool_block.id = tool_id + tool_block.name = "search_course_content" + tool_block.input = {"query": query} + + resp = MagicMock() + resp.stop_reason = "tool_use" + resp.content = [tool_block] + return resp + + +def _build_rag_system(tmp_chroma_path, mock_anthropic_client): + """Build a RAGSystem wired with a temp ChromaDB and a mocked Anthropic client.""" + from dataclasses import dataclass + + @dataclass + class TestConfig: + ANTHROPIC_API_KEY: str = "fake-key" + ANTHROPIC_MODEL: str = "fake-model" + EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" + CHUNK_SIZE: int = 500 + CHUNK_OVERLAP: int = 100 + MAX_RESULTS: int = 5 + MAX_HISTORY: int = 2 + CHROMA_PATH: str = tmp_chroma_path + + from rag_system import RAGSystem + rag = RAGSystem(TestConfig()) + rag.ai_generator.client = mock_anthropic_client + return rag + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestRAGSystemQuery: + + @pytest.fixture(autouse=True) + def setup(self, tmp_chroma_path): + self.mock_client = MagicMock() + self.rag = _build_rag_system(tmp_chroma_path, self.mock_client) + + # --- basic contract --- + + def test_query_returns_tuple_of_answer_and_sources(self): + self.mock_client.messages.create.return_value = _make_text_response("Hello") + + answer, sources = self.rag.query("What is 2+2?") + + assert isinstance(answer, str) + assert isinstance(sources, list) + + def test_query_returns_non_empty_answer(self): + self.mock_client.messages.create.return_value = _make_text_response("42") + + answer, _ = self.rag.query("What is 2+2?") + + assert answer == "42" + + def test_query_returns_empty_sources_when_no_tool_used(self): + self.mock_client.messages.create.return_value = _make_text_response("General answer") + + _, sources = self.rag.query("What is Python?") + + assert sources == [] + + # --- session / history --- + + def test_query_creates_session_when_none_provided(self): + self.mock_client.messages.create.return_value = _make_text_response("ok") + + answer, sources = self.rag.query("test", session_id=None) + + assert answer == "ok" + + def test_query_updates_conversation_history_with_session(self): + self.mock_client.messages.create.return_value = _make_text_response("first") + + session = self.rag.session_manager.create_session() + self.rag.query("first question", session_id=session) + + history = self.rag.session_manager.get_conversation_history(session) + assert history is not None + assert "first question" in history + + # --- tool use flow --- + + def test_query_with_content_question_triggers_tool_search(self, sample_course, sample_chunks): + # Load course data so the search tool has something to return + self.rag.vector_store.add_course_metadata(sample_course) + self.rag.vector_store.add_course_content(sample_chunks) + + tool_resp = _make_tool_use_response(query="retrieval augmented generation") + final_resp = _make_text_response("RAG is retrieval augmented generation.") + self.mock_client.messages.create.side_effect = [tool_resp, final_resp] + + answer, sources = self.rag.query("What is RAG?") + + # Two API calls should have happened: initial + follow-up + assert self.mock_client.messages.create.call_count == 2 + assert answer == "RAG is retrieval augmented generation." + + def test_query_tool_sources_returned_after_search(self, sample_course, sample_chunks): + self.rag.vector_store.add_course_metadata(sample_course) + self.rag.vector_store.add_course_content(sample_chunks) + + tool_resp = _make_tool_use_response(query="RAG retrieval") + final_resp = _make_text_response("Answer about RAG") + self.mock_client.messages.create.side_effect = [tool_resp, final_resp] + + _, sources = self.rag.query("Explain RAG") + + assert len(sources) > 0 + assert any("Introduction to RAG" in s for s in sources) + + def test_query_sources_reset_between_calls(self, sample_course, sample_chunks): + self.rag.vector_store.add_course_metadata(sample_course) + self.rag.vector_store.add_course_content(sample_chunks) + + # First call uses tool + tool_resp = _make_tool_use_response(query="RAG") + final_resp = _make_text_response("answer1") + self.mock_client.messages.create.side_effect = [tool_resp, final_resp] + self.rag.query("Content question 1") + + # Second call is a general question (no tool) + self.mock_client.messages.create.side_effect = None + self.mock_client.messages.create.return_value = _make_text_response("answer2") + _, sources2 = self.rag.query("What is Python?") + + assert sources2 == [], ( + "Sources from a previous tool-using query leaked into a subsequent " + "non-tool query. reset_sources() must be called before each query or " + "sources must be gathered after the current query only." + ) + + # --- final API call includes tools (mirrors ai_generator test) --- + + def test_final_api_call_in_tool_flow_includes_tools(self, sample_course, sample_chunks): + """ + Verify that when RAGSystem drives the tool-use flow, the second Anthropic + API call (the follow-up after tool execution) includes the 'tools' parameter. + Without it the Anthropic API returns HTTP 400 → 'query failed'. + """ + self.rag.vector_store.add_course_metadata(sample_course) + self.rag.vector_store.add_course_content(sample_chunks) + + tool_resp = _make_tool_use_response(query="RAG") + final_resp = _make_text_response("final") + self.mock_client.messages.create.side_effect = [tool_resp, final_resp] + + self.rag.query("What is RAG?") + + assert self.mock_client.messages.create.call_count == 2, ( + "Expected exactly two API calls (initial + follow-up after tool execution)." + ) + second_call_kwargs = self.mock_client.messages.create.call_args_list[1][1] + assert "tools" in second_call_kwargs, ( + "The follow-up API call (after tool execution) is missing 'tools'. " + "Anthropic API requires 'tools' whenever messages contain tool_use blocks. " + "This is the root cause of the 'query failed' error." + ) diff --git a/backend/tests/test_search_tools.py b/backend/tests/test_search_tools.py new file mode 100644 index 000000000..eedb7e3ea --- /dev/null +++ b/backend/tests/test_search_tools.py @@ -0,0 +1,256 @@ +""" +Tests for CourseSearchTool.execute() in search_tools.py. + +Covers: +- Unit tests using a mocked VectorStore (fast, no ChromaDB I/O) +- Integration tests using a real temp ChromaDB (validates the full retrieval path) +""" + +from unittest.mock import MagicMock +import pytest + +from search_tools import CourseSearchTool, ToolManager +from vector_store import SearchResults + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_search_results(docs, metas): + """Build a SearchResults from lists of docs and metadata dicts.""" + return SearchResults( + documents=docs, + metadata=metas, + distances=[0.1] * len(docs), + ) + + +def _make_error_results(msg): + return SearchResults.empty(msg) + + +# --------------------------------------------------------------------------- +# Unit tests — VectorStore is mocked +# --------------------------------------------------------------------------- + +class TestCourseSearchToolExecute: + + def setup_method(self): + self.mock_store = MagicMock() + self.tool = CourseSearchTool(self.mock_store) + + # --- happy path --- + + def test_execute_returns_formatted_results_when_results_exist(self): + self.mock_store.search.return_value = _make_search_results( + docs=["RAG combines retrieval with generation."], + metas=[{"course_title": "Introduction to RAG", "lesson_number": 1}], + ) + result = self.tool.execute(query="what is RAG") + + assert "Introduction to RAG" in result + assert "Lesson 1" in result + assert "RAG combines retrieval with generation." in result + + def test_execute_passes_query_to_store(self): + self.mock_store.search.return_value = _make_search_results( + docs=["some content"], + metas=[{"course_title": "Course A", "lesson_number": 0}], + ) + self.tool.execute(query="vector databases") + + self.mock_store.search.assert_called_once_with( + query="vector databases", + course_name=None, + lesson_number=None, + ) + + def test_execute_forwards_course_name_filter(self): + self.mock_store.search.return_value = _make_search_results( + docs=["content"], metas=[{"course_title": "RAG Course", "lesson_number": 1}] + ) + self.tool.execute(query="embeddings", course_name="RAG") + + self.mock_store.search.assert_called_once_with( + query="embeddings", course_name="RAG", lesson_number=None + ) + + def test_execute_forwards_lesson_number_filter(self): + self.mock_store.search.return_value = _make_search_results( + docs=["content"], metas=[{"course_title": "RAG Course", "lesson_number": 2}] + ) + self.tool.execute(query="chroma", lesson_number=2) + + self.mock_store.search.assert_called_once_with( + query="chroma", course_name=None, lesson_number=2 + ) + + # --- empty results --- + + def test_execute_returns_no_results_message_when_empty(self): + self.mock_store.search.return_value = SearchResults( + documents=[], metadata=[], distances=[] + ) + result = self.tool.execute(query="something obscure") + + assert "No relevant content found" in result + + def test_execute_no_results_message_includes_course_name_filter(self): + self.mock_store.search.return_value = SearchResults( + documents=[], metadata=[], distances=[] + ) + result = self.tool.execute(query="test", course_name="Nonexistent Course") + + assert "Nonexistent Course" in result + + def test_execute_no_results_message_includes_lesson_filter(self): + self.mock_store.search.return_value = SearchResults( + documents=[], metadata=[], distances=[] + ) + result = self.tool.execute(query="test", lesson_number=99) + + assert "lesson 99" in result + + # --- error handling --- + + def test_execute_returns_error_string_when_store_errors(self): + self.mock_store.search.return_value = _make_error_results( + "No course found matching 'XYZ'" + ) + result = self.tool.execute(query="anything", course_name="XYZ") + + assert "No course found matching 'XYZ'" in result + + # --- source tracking --- + + def test_execute_updates_last_sources_with_course_and_lesson(self): + self.mock_store.search.return_value = _make_search_results( + docs=["doc1", "doc2"], + metas=[ + {"course_title": "RAG Course", "lesson_number": 1}, + {"course_title": "RAG Course", "lesson_number": 2}, + ], + ) + self.tool.execute(query="test") + + assert self.tool.last_sources == [ + "RAG Course - Lesson 1", + "RAG Course - Lesson 2", + ] + + def test_execute_clears_last_sources_from_previous_call(self): + self.mock_store.search.return_value = _make_search_results( + docs=["doc"], + metas=[{"course_title": "Course A", "lesson_number": 1}], + ) + self.tool.execute(query="first") + + self.mock_store.search.return_value = SearchResults( + documents=[], metadata=[], distances=[] + ) + self.tool.execute(query="second that returns nothing") + + assert self.tool.last_sources == [], ( + "last_sources was not cleared before the second search. " + "Sources from the previous call leaked through — the UI would " + "show stale citations for the new response." + ) + + +# --------------------------------------------------------------------------- +# ToolManager unit tests +# --------------------------------------------------------------------------- + +class TestToolManager: + + def test_register_and_execute_tool(self): + manager = ToolManager() + mock_store = MagicMock() + mock_store.search.return_value = _make_search_results( + ["content"], [{"course_title": "T", "lesson_number": 1}] + ) + tool = CourseSearchTool(mock_store) + manager.register_tool(tool) + + result = manager.execute_tool("search_course_content", query="test") + + assert "content" in result + + def test_execute_unknown_tool_returns_error_string(self): + manager = ToolManager() + result = manager.execute_tool("nonexistent_tool", query="test") + + assert "not found" in result.lower() + + def test_get_last_sources_returns_sources_from_search_tool(self): + manager = ToolManager() + mock_store = MagicMock() + mock_store.search.return_value = _make_search_results( + ["doc"], [{"course_title": "Course", "lesson_number": 1}] + ) + tool = CourseSearchTool(mock_store) + manager.register_tool(tool) + + manager.execute_tool("search_course_content", query="test") + sources = manager.get_last_sources() + + assert sources == ["Course - Lesson 1"] + + def test_reset_sources_clears_last_sources(self): + manager = ToolManager() + mock_store = MagicMock() + mock_store.search.return_value = _make_search_results( + ["doc"], [{"course_title": "Course", "lesson_number": 1}] + ) + tool = CourseSearchTool(mock_store) + manager.register_tool(tool) + + manager.execute_tool("search_course_content", query="test") + manager.reset_sources() + + assert manager.get_last_sources() == [] + + +# --------------------------------------------------------------------------- +# Integration tests — real temp ChromaDB +# --------------------------------------------------------------------------- + +class TestCourseSearchToolIntegration: + """Uses a real populated VectorStore fixture (temp ChromaDB).""" + + def test_search_returns_results_for_relevant_query(self, populated_vector_store): + tool = CourseSearchTool(populated_vector_store) + result = tool.execute(query="retrieval augmented generation") + + assert "Introduction to RAG" in result + assert "No relevant content found" not in result + + def test_search_with_exact_course_name(self, populated_vector_store): + tool = CourseSearchTool(populated_vector_store) + result = tool.execute(query="embeddings", course_name="Introduction to RAG") + + assert "Introduction to RAG" in result + + def test_search_with_lesson_filter(self, populated_vector_store): + tool = CourseSearchTool(populated_vector_store) + result = tool.execute(query="vector database", lesson_number=2) + + assert "Lesson 2" in result + + def test_search_with_nonexistent_course_resolves_to_closest_match(self, populated_vector_store): + # _resolve_course_name uses vector similarity — it always returns the + # nearest course in the catalog, so no "No course found" error is raised + # for a fabricated name; instead the closest real course's content is returned. + tool = CourseSearchTool(populated_vector_store) + result = tool.execute(query="test", course_name="Does Not Exist At All") + + # The catalog only contains "Introduction to RAG"; that will be matched. + assert "Introduction to RAG" in result + + def test_search_populates_last_sources(self, populated_vector_store): + tool = CourseSearchTool(populated_vector_store) + tool.execute(query="chromadb vector database") + + assert len(tool.last_sources) > 0 + assert all("Introduction to RAG" in s for s in tool.last_sources) diff --git a/frontend/index.html b/frontend/index.html index f8e25a62f..1a65a9a3f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,6 +19,11 @@ <h1>Course Materials Assistant</h1> <div class="main-content"> <!-- Left Sidebar --> <aside class="sidebar"> + <!-- New Chat Button --> + <div class="sidebar-section"> + <button class="new-chat-btn" id="newChatBtn">NEW CHAT</button> + </div> + <!-- Course Stats --> <div class="sidebar-section"> <details class="stats-collapsible"> diff --git a/frontend/script.js b/frontend/script.js index 562a8a363..9622d4522 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -5,7 +5,7 @@ const API_URL = '/api'; let currentSessionId = null; // DOM elements -let chatMessages, chatInput, sendButton, totalCourses, courseTitles; +let chatMessages, chatInput, sendButton, totalCourses, courseTitles, newChatBtn; // Initialize document.addEventListener('DOMContentLoaded', () => { @@ -16,6 +16,7 @@ document.addEventListener('DOMContentLoaded', () => { totalCourses = document.getElementById('totalCourses'); courseTitles = document.getElementById('courseTitles'); + newChatBtn = document.getElementById('newChatBtn'); setupEventListeners(); createNewSession(); loadCourseStats(); @@ -24,6 +25,7 @@ document.addEventListener('DOMContentLoaded', () => { // Event Listeners function setupEventListeners() { // Chat functionality + newChatBtn.addEventListener('click', createNewSession); sendButton.addEventListener('click', sendMessage); chatInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); @@ -147,6 +149,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 + } + } 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..063a45c61 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -491,6 +491,29 @@ details[open] .suggested-header::before { transform: rotate(90deg); } +/* New Chat Button */ +.new-chat-btn { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + background: none; + border: none; + outline: none; + cursor: pointer; + padding: 0.5rem 0; + width: 100%; + text-align: left; + font-family: inherit; + transition: color 0.2s ease; +} + + +.new-chat-btn:hover { + color: var(--primary-color); +} + /* Course Stats in Sidebar */ .course-stats { display: flex; diff --git a/pyproject.toml b/pyproject.toml index 3f05e2de0..806bb5395 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.3", +] diff --git a/uv.lock b/uv.lock index 9ae65c557..b4e03cf59 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.3" +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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[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.3" }] + [[package]] name = "sympy" version = "1.14.0" From 8be90a5235782323ecf30ffd86b6dc27f24ccf42 Mon Sep 17 00:00:00 2001 From: dalang059 <ty_wb@163.com> Date: Sun, 7 Jun 2026 21:40:59 +0800 Subject: [PATCH 4/8] Add dark/light theme toggle button - Add fixed-position circular toggle button (top-right) with sun/moon SVG icons - Add [data-theme=light] CSS variable overrides: light gray/white surfaces, dark slate text, same blue primary, adjusted shadows and focus rings - Swap code block backgrounds from hardcoded rgba values to --code-bg variable so they adapt correctly in both themes - Add .theme-transitioning class for smooth 300ms color transitions on toggle - Persist theme choice to localStorage; restore on page load (defaults to dark) --- frontend/index.html | 21 ++++++++++-- frontend/script.js | 19 ++++++++++- frontend/style.css | 78 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 1a65a9a3f..ebe2f2d18 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 - +
@@ -80,7 +80,24 @@

Course Materials Assistant

+ + - + \ No newline at end of file diff --git a/frontend/script.js b/frontend/script.js index 9622d4522..2deb65832 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -7,16 +7,33 @@ let currentSessionId = null; // DOM elements let chatMessages, chatInput, sendButton, totalCourses, courseTitles, newChatBtn; +// Theme management +function initTheme() { + const saved = localStorage.getItem('theme') || 'dark'; + document.documentElement.setAttribute('data-theme', saved === 'light' ? 'light' : 'dark'); +} + +function toggleTheme() { + const isLight = document.documentElement.getAttribute('data-theme') === 'light'; + document.body.classList.add('theme-transitioning'); + document.documentElement.setAttribute('data-theme', isLight ? 'dark' : 'light'); + localStorage.setItem('theme', isLight ? 'dark' : 'light'); + setTimeout(() => document.body.classList.remove('theme-transitioning'), 300); +} + // Initialize document.addEventListener('DOMContentLoaded', () => { + 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'); - + newChatBtn = document.getElementById('newChatBtn'); + document.getElementById('themeToggle').addEventListener('click', toggleTheme); setupEventListeners(); createNewSession(); loadCourseStats(); diff --git a/frontend/style.css b/frontend/style.css index 063a45c61..e4a8b40d4 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -5,7 +5,7 @@ padding: 0; } -/* CSS Variables */ +/* CSS Variables — dark theme (default) */ :root { --primary-color: #2563eb; --primary-hover: #1d4ed8; @@ -22,6 +22,78 @@ --focus-ring: rgba(37, 99, 235, 0.2); --welcome-bg: #1e3a5f; --welcome-border: #2563eb; + --code-bg: rgba(0, 0, 0, 0.25); +} + +/* Light theme overrides */ +[data-theme="light"] { + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --background: #f8fafc; + --surface: #ffffff; + --surface-hover: #f1f5f9; + --text-primary: #0f172a; + --text-secondary: #64748b; + --border-color: #e2e8f0; + --user-message: #2563eb; + --assistant-message: #f1f5f9; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --radius: 12px; + --focus-ring: rgba(37, 99, 235, 0.15); + --welcome-bg: #dbeafe; + --welcome-border: #2563eb; + --code-bg: rgba(0, 0, 0, 0.06); +} + +/* Smooth theme transitions */ +.theme-transitioning, +.theme-transitioning * { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease !important; +} + +/* Theme toggle button */ +.theme-toggle { + position: fixed; + top: 1rem; + right: 1rem; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--surface); + border: 1px solid var(--border-color); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + box-shadow: var(--shadow); + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; + padding: 0; +} + +.theme-toggle:hover { + color: var(--primary-color); + border-color: var(--primary-color); + transform: scale(1.1); +} + +.theme-toggle:focus { + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring); +} + +/* Icon visibility: dark mode shows sun, light mode shows moon */ +.icon-moon { + display: none; +} + +[data-theme="light"] .icon-sun { + display: none; +} + +[data-theme="light"] .icon-moon { + display: block; } /* Base Styles */ @@ -277,7 +349,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; @@ -285,7 +357,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; From 576dcac00df707c20f94ed73f7c72720aa717b89 Mon Sep 17 00:00:00 2001 From: dalang059 Date: Sun, 7 Jun 2026 21:41:00 +0800 Subject: [PATCH 5/8] Add black and ruff code quality tooling - Add black and ruff as dev dependencies in pyproject.toml - Configure [tool.black] (line-length 88, py313 target) and [tool.ruff.lint] (E/F/I rules, E501 ignored, E402 per-file ignore for app.py where warnings.filterwarnings must precede imports) - Apply black formatting across all 13 Python source files - Fix 29 ruff issues: unsorted imports, unused imports, duplicate StaticFiles import in app.py, and move stray FileResponse import to top of app.py - Add scripts/format.sh (applies black + ruff --fix in-place) - Add scripts/lint.sh (check-only mode for CI / pre-commit use) --- backend/ai_generator.py | 28 +++- backend/app.py | 55 ++++--- backend/config.py | 20 +-- backend/document_processor.py | 154 +++++++++--------- backend/models.py | 23 ++- backend/rag_system.py | 108 +++++++------ backend/search_tools.py | 88 ++++++----- backend/session_manager.py | 35 +++-- backend/tests/conftest.py | 10 +- backend/tests/test_ai_generator.py | 79 +++++++--- backend/tests/test_rag_system.py | 31 ++-- backend/tests/test_search_tools.py | 10 +- backend/vector_store.py | 241 ++++++++++++++++------------- pyproject.toml | 17 ++ scripts/format.sh | 13 ++ scripts/lint.sh | 13 ++ uv.lock | 111 ++++++++++++- 17 files changed, 666 insertions(+), 370 deletions(-) create mode 100644 scripts/format.sh create mode 100644 scripts/lint.sh diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 26c95c98b..5eb09b68c 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -1,6 +1,8 @@ -import anthropic from typing import List, Optional +import anthropic + + class AIGenerator: """Handles interactions with Anthropic's Claude API for generating responses""" @@ -67,7 +69,11 @@ def generate_response( ) messages = [{"role": "user", "content": query}] - api_params = {**self.base_params, "messages": messages, "system": system_content} + api_params = { + **self.base_params, + "messages": messages, + "system": system_content, + } if tools: api_params["tools"] = tools api_params["tool_choice"] = {"type": "auto"} @@ -90,11 +96,13 @@ def generate_response( except Exception as e: result = f"Tool error: {str(e)}" error_occurred = True - tool_results.append({ - "type": "tool_result", - "tool_use_id": block.id, - "content": result, - }) + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": result, + } + ) messages.append({"role": "user", "content": tool_results}) @@ -103,7 +111,11 @@ def generate_response( # tools must be included in follow-up calls — Anthropic returns HTTP 400 # when messages contain tool_use blocks but no tools are defined. - followup_params = {**self.base_params, "messages": messages, "system": system_content} + followup_params = { + **self.base_params, + "messages": messages, + "system": system_content, + } if tools: followup_params["tools"] = tools response = self.client.messages.create(**followup_params) diff --git a/backend/app.py b/backend/app.py index 44589a7d3..3b911bb0e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,25 +1,24 @@ import warnings + warnings.filterwarnings("ignore", message="resource_tracker: There appear to be.*") +import os +from typing import List, Optional + +from config import config from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from fastapi.middleware.trustedhost import TrustedHostMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from typing import List, Optional -import os - -from config import config from rag_system import RAGSystem # Initialize FastAPI app app = FastAPI(title="Course Materials RAG System", root_path="") # Add trusted host middleware for proxy -app.add_middleware( - TrustedHostMiddleware, - allowed_hosts=["*"] -) +app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"]) # Enable CORS with proper settings for proxy app.add_middleware( @@ -34,25 +33,33 @@ # Initialize RAG system rag_system = RAGSystem(config) + # Pydantic models for request/response class QueryRequest(BaseModel): """Request model for course queries""" + query: str session_id: Optional[str] = None + class QueryResponse(BaseModel): """Response model for course queries""" + answer: str sources: List[str] session_id: str + class CourseStats(BaseModel): """Response model for course statistics""" + total_courses: int course_titles: List[str] + # API Endpoints + @app.post("/api/query", response_model=QueryResponse) async def query_documents(request: QueryRequest): """Process a query and return response with sources""" @@ -61,18 +68,15 @@ async def query_documents(request: QueryRequest): session_id = request.session_id if not session_id: session_id = rag_system.session_manager.create_session() - + # Process query using RAG system answer, sources = rag_system.query(request.query, session_id) - - return QueryResponse( - answer=answer, - sources=sources, - session_id=session_id - ) + + return QueryResponse(answer=answer, sources=sources, session_id=session_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @app.get("/api/courses", response_model=CourseStats) async def get_course_stats(): """Get course analytics and statistics""" @@ -80,17 +84,19 @@ async def get_course_stats(): analytics = rag_system.get_course_analytics() return CourseStats( total_courses=analytics["total_courses"], - course_titles=analytics["course_titles"] + course_titles=analytics["course_titles"], ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @app.delete("/api/session/{session_id}") async def clear_session(session_id: str): """Clear session history when user starts a new chat""" rag_system.session_manager.clear_session(session_id) return {"status": "cleared"} + @app.on_event("startup") async def startup_event(): """Load initial documents on startup""" @@ -98,16 +104,15 @@ async def startup_event(): if os.path.exists(docs_path): print("Loading initial documents...") try: - courses, chunks = rag_system.add_course_folder(docs_path, clear_existing=False) + courses, chunks = rag_system.add_course_folder( + docs_path, clear_existing=False + ) print(f"Loaded {courses} courses with {chunks} chunks") except Exception as e: print(f"Error loading documents: {e}") + # Custom static file handler with no-cache headers for development -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse -import os -from pathlib import Path class DevStaticFiles(StaticFiles): @@ -119,7 +124,7 @@ async def get_response(self, path: str, scope): response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response - - + + # Serve static files for the frontend -app.mount("/", StaticFiles(directory="../frontend", html=True), name="static") \ No newline at end of file +app.mount("/", StaticFiles(directory="../frontend", html=True), name="static") diff --git a/backend/config.py b/backend/config.py index 13e9ddc04..33bd57edb 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,29 +1,31 @@ import os from dataclasses import dataclass + from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() + @dataclass 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" - + # Embedding model settings EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" - + # Document processing settings - CHUNK_SIZE: int = 500 # Size of text chunks for vector storage - CHUNK_OVERLAP: int = 100 # Characters to overlap between chunks - MAX_RESULTS: int = 8 # Maximum search results to return - MAX_HISTORY: int = 2 # Number of conversation messages to remember - + CHUNK_SIZE: int = 500 # Size of text chunks for vector storage + CHUNK_OVERLAP: int = 100 # Characters to overlap between chunks + MAX_RESULTS: int = 8 # Maximum search results to return + MAX_HISTORY: int = 2 # Number of conversation messages to remember + # Database paths CHROMA_PATH: str = "./chroma_db" # ChromaDB storage location -config = Config() - +config = Config() diff --git a/backend/document_processor.py b/backend/document_processor.py index 266e85904..bc0662a31 100644 --- a/backend/document_processor.py +++ b/backend/document_processor.py @@ -1,83 +1,87 @@ import os import re from typing import List, Tuple -from models import Course, Lesson, CourseChunk + +from models import Course, CourseChunk, Lesson + class DocumentProcessor: """Processes course documents and extracts structured information""" - + def __init__(self, chunk_size: int, chunk_overlap: int): self.chunk_size = chunk_size self.chunk_overlap = chunk_overlap - + def read_file(self, file_path: str) -> str: """Read content from file with UTF-8 encoding""" try: - with open(file_path, 'r', encoding='utf-8') as file: + with open(file_path, "r", encoding="utf-8") as file: return file.read() except UnicodeDecodeError: # If UTF-8 fails, try with error handling - with open(file_path, 'r', encoding='utf-8', errors='ignore') as file: + with open(file_path, "r", encoding="utf-8", errors="ignore") as file: return file.read() - - def chunk_text(self, text: str) -> List[str]: """Split text into sentence-based chunks with overlap using config settings""" - + # Clean up the text - text = re.sub(r'\s+', ' ', text.strip()) # Normalize whitespace - + text = re.sub(r"\s+", " ", text.strip()) # Normalize whitespace + # Better sentence splitting that handles abbreviations # This regex looks for periods followed by whitespace and capital letters # but ignores common abbreviations - sentence_endings = re.compile(r'(? self.chunk_size and current_chunk: break - + current_chunk.append(sentence) current_size += total_addition - + # Add chunk if we have content if current_chunk: - chunks.append(' '.join(current_chunk)) - + chunks.append(" ".join(current_chunk)) + # Calculate overlap for next chunk - if hasattr(self, 'chunk_overlap') and self.chunk_overlap > 0: + if hasattr(self, "chunk_overlap") and self.chunk_overlap > 0: # Find how many sentences to overlap overlap_size = 0 overlap_sentences = 0 - + # Count backwards from end of current chunk for k in range(len(current_chunk) - 1, -1, -1): - sentence_len = len(current_chunk[k]) + (1 if k < len(current_chunk) - 1 else 0) + sentence_len = len(current_chunk[k]) + ( + 1 if k < len(current_chunk) - 1 else 0 + ) if overlap_size + sentence_len <= self.chunk_overlap: overlap_size += sentence_len overlap_sentences += 1 else: break - + # Move start position considering overlap next_start = i + len(current_chunk) - overlap_sentences i = max(next_start, i + 1) # Ensure we make progress @@ -87,14 +91,12 @@ def chunk_text(self, text: str) -> List[str]: else: # No sentences fit, move to next i += 1 - - return chunks - - + return chunks - - def process_course_document(self, file_path: str) -> Tuple[Course, List[CourseChunk]]: + def process_course_document( + self, file_path: str + ) -> Tuple[Course, List[CourseChunk]]: """ Process a course document with expected format: Line 1: Course Title: [title] @@ -104,47 +106,51 @@ def process_course_document(self, file_path: str) -> Tuple[Course, List[CourseCh """ content = self.read_file(file_path) filename = os.path.basename(file_path) - - lines = content.strip().split('\n') - + + lines = content.strip().split("\n") + # Extract course metadata from first three lines course_title = filename # Default fallback course_link = None instructor_name = "Unknown" - + # Parse course title from first line if len(lines) >= 1 and lines[0].strip(): - title_match = re.match(r'^Course Title:\s*(.+)$', lines[0].strip(), re.IGNORECASE) + title_match = re.match( + r"^Course Title:\s*(.+)$", lines[0].strip(), re.IGNORECASE + ) if title_match: course_title = title_match.group(1).strip() else: course_title = lines[0].strip() - + # Parse remaining lines for course metadata for i in range(1, min(len(lines), 4)): # Check first 4 lines for metadata line = lines[i].strip() if not line: continue - + # Try to match course link - link_match = re.match(r'^Course Link:\s*(.+)$', line, re.IGNORECASE) + link_match = re.match(r"^Course Link:\s*(.+)$", line, re.IGNORECASE) if link_match: course_link = link_match.group(1).strip() continue - + # Try to match instructor - instructor_match = re.match(r'^Course Instructor:\s*(.+)$', line, re.IGNORECASE) + instructor_match = re.match( + r"^Course Instructor:\s*(.+)$", line, re.IGNORECASE + ) if instructor_match: instructor_name = instructor_match.group(1).strip() continue - + # Create course object with title as ID course = Course( title=course_title, course_link=course_link, - instructor=instructor_name if instructor_name != "Unknown" else None + instructor=instructor_name if instructor_name != "Unknown" else None, ) - + # Process lessons and create chunks course_chunks = [] current_lesson = None @@ -152,108 +158,114 @@ def process_course_document(self, file_path: str) -> Tuple[Course, List[CourseCh lesson_link = None lesson_content = [] chunk_counter = 0 - + # Start processing from line 4 (after metadata) start_index = 3 if len(lines) > 3 and not lines[3].strip(): start_index = 4 # Skip empty line after instructor - + i = start_index while i < len(lines): line = lines[i] - + # Check for lesson markers (e.g., "Lesson 0: Introduction") - lesson_match = re.match(r'^Lesson\s+(\d+):\s*(.+)$', line.strip(), re.IGNORECASE) - + lesson_match = re.match( + r"^Lesson\s+(\d+):\s*(.+)$", line.strip(), re.IGNORECASE + ) + if lesson_match: # Process previous lesson if it exists if current_lesson is not None and lesson_content: - lesson_text = '\n'.join(lesson_content).strip() + lesson_text = "\n".join(lesson_content).strip() if lesson_text: # Add lesson to course lesson = Lesson( lesson_number=current_lesson, title=lesson_title, - lesson_link=lesson_link + lesson_link=lesson_link, ) course.lessons.append(lesson) - + # Create chunks for this lesson chunks = self.chunk_text(lesson_text) for idx, chunk in enumerate(chunks): # For the first chunk of each lesson, add lesson context if idx == 0: - chunk_with_context = f"Lesson {current_lesson} content: {chunk}" + chunk_with_context = ( + f"Lesson {current_lesson} content: {chunk}" + ) else: chunk_with_context = chunk - + course_chunk = CourseChunk( content=chunk_with_context, course_title=course.title, lesson_number=current_lesson, - chunk_index=chunk_counter + chunk_index=chunk_counter, ) course_chunks.append(course_chunk) chunk_counter += 1 - + # Start new lesson current_lesson = int(lesson_match.group(1)) lesson_title = lesson_match.group(2).strip() lesson_link = None - + # Check if next line is a lesson link if i + 1 < len(lines): next_line = lines[i + 1].strip() - link_match = re.match(r'^Lesson Link:\s*(.+)$', next_line, re.IGNORECASE) + link_match = re.match( + r"^Lesson Link:\s*(.+)$", next_line, re.IGNORECASE + ) if link_match: lesson_link = link_match.group(1).strip() i += 1 # Skip the link line so it's not added to content - + lesson_content = [] else: # Add line to current lesson content lesson_content.append(line) - + i += 1 - + # Process the last lesson if current_lesson is not None and lesson_content: - lesson_text = '\n'.join(lesson_content).strip() + lesson_text = "\n".join(lesson_content).strip() if lesson_text: lesson = Lesson( lesson_number=current_lesson, title=lesson_title, - lesson_link=lesson_link + lesson_link=lesson_link, ) course.lessons.append(lesson) - + chunks = self.chunk_text(lesson_text) for idx, chunk in enumerate(chunks): # For any chunk of each lesson, add lesson context & course title - + chunk_with_context = f"Course {course_title} Lesson {current_lesson} content: {chunk}" - + course_chunk = CourseChunk( content=chunk_with_context, course_title=course.title, lesson_number=current_lesson, - chunk_index=chunk_counter + chunk_index=chunk_counter, ) course_chunks.append(course_chunk) chunk_counter += 1 - + # If no lessons found, treat entire content as one document if not course_chunks and len(lines) > 2: - remaining_content = '\n'.join(lines[start_index:]).strip() + remaining_content = "\n".join(lines[start_index:]).strip() if remaining_content: chunks = self.chunk_text(remaining_content) for chunk in chunks: course_chunk = CourseChunk( content=chunk, course_title=course.title, - chunk_index=chunk_counter + chunk_index=chunk_counter, ) course_chunks.append(course_chunk) chunk_counter += 1 - + return course, course_chunks diff --git a/backend/models.py b/backend/models.py index 7f7126fa3..3d08e1e73 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,22 +1,29 @@ -from typing import List, Dict, Optional +from typing import List, Optional + from pydantic import BaseModel + class Lesson(BaseModel): """Represents a lesson within a course""" + lesson_number: int # Sequential lesson number (1, 2, 3, etc.) - title: str # Lesson title + title: str # Lesson title lesson_link: Optional[str] = None # URL link to the lesson + class Course(BaseModel): """Represents a complete course with its lessons""" - title: str # Full course title (used as unique identifier) + + title: str # Full course title (used as unique identifier) course_link: Optional[str] = None # URL link to the course instructor: Optional[str] = None # Course instructor name (optional metadata) - lessons: List[Lesson] = [] # List of lessons in this course + lessons: List[Lesson] = [] # List of lessons in this course + class CourseChunk(BaseModel): """Represents a text chunk from a course for vector storage""" - content: str # The actual text content - course_title: str # Which course this chunk belongs to - lesson_number: Optional[int] = None # Which lesson this chunk is from - chunk_index: int # Position of this chunk in the document \ No newline at end of file + + content: str # The actual text content + course_title: str # Which course this chunk belongs to + lesson_number: Optional[int] = None # Which lesson this chunk is from + chunk_index: int # Position of this chunk in the document diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8e..341fb91ee 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -1,147 +1,167 @@ -from typing import List, Tuple, Optional, Dict import os -from document_processor import DocumentProcessor -from vector_store import VectorStore +from typing import Dict, List, Optional, Tuple + from ai_generator import AIGenerator +from document_processor import DocumentProcessor +from models import Course +from search_tools import CourseSearchTool, ToolManager from session_manager import SessionManager -from search_tools import ToolManager, CourseSearchTool -from models import Course, Lesson, CourseChunk +from vector_store import VectorStore + class RAGSystem: """Main orchestrator for the Retrieval-Augmented Generation system""" - + def __init__(self, config): self.config = config - + # Initialize core components - self.document_processor = DocumentProcessor(config.CHUNK_SIZE, config.CHUNK_OVERLAP) - self.vector_store = VectorStore(config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS) - self.ai_generator = AIGenerator(config.ANTHROPIC_API_KEY, config.ANTHROPIC_MODEL) + self.document_processor = DocumentProcessor( + config.CHUNK_SIZE, config.CHUNK_OVERLAP + ) + self.vector_store = VectorStore( + config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS + ) + self.ai_generator = AIGenerator( + config.ANTHROPIC_API_KEY, config.ANTHROPIC_MODEL + ) self.session_manager = SessionManager(config.MAX_HISTORY) - + # Initialize search tools self.tool_manager = ToolManager() self.search_tool = CourseSearchTool(self.vector_store) self.tool_manager.register_tool(self.search_tool) - + def add_course_document(self, file_path: str) -> Tuple[Course, int]: """ Add a single course document to the knowledge base. - + Args: file_path: Path to the course document - + Returns: Tuple of (Course object, number of chunks created) """ try: # Process the document - course, course_chunks = self.document_processor.process_course_document(file_path) - + course, course_chunks = self.document_processor.process_course_document( + file_path + ) + # Add course metadata to vector store for semantic search self.vector_store.add_course_metadata(course) - + # Add course content chunks to vector store self.vector_store.add_course_content(course_chunks) - + return course, len(course_chunks) except Exception as e: print(f"Error processing course document {file_path}: {e}") return None, 0 - - def add_course_folder(self, folder_path: str, clear_existing: bool = False) -> Tuple[int, int]: + + def add_course_folder( + self, folder_path: str, clear_existing: bool = False + ) -> Tuple[int, int]: """ Add all course documents from a folder. - + Args: folder_path: Path to folder containing course documents clear_existing: Whether to clear existing data first - + Returns: Tuple of (total courses added, total chunks created) """ total_courses = 0 total_chunks = 0 - + # Clear existing data if requested if clear_existing: print("Clearing existing data for fresh rebuild...") self.vector_store.clear_all_data() - + if not os.path.exists(folder_path): print(f"Folder {folder_path} does not exist") return 0, 0 - + # Get existing course titles to avoid re-processing existing_course_titles = set(self.vector_store.get_existing_course_titles()) - + # Process each file in the folder for file_name in os.listdir(folder_path): file_path = os.path.join(folder_path, file_name) - if os.path.isfile(file_path) and file_name.lower().endswith(('.pdf', '.docx', '.txt')): + if os.path.isfile(file_path) and file_name.lower().endswith( + (".pdf", ".docx", ".txt") + ): try: # Check if this course might already exist # We'll process the document to get the course ID, but only add if new - course, course_chunks = self.document_processor.process_course_document(file_path) - + course, course_chunks = ( + self.document_processor.process_course_document(file_path) + ) + if course and course.title not in existing_course_titles: # This is a new course - add it to the vector store self.vector_store.add_course_metadata(course) self.vector_store.add_course_content(course_chunks) total_courses += 1 total_chunks += len(course_chunks) - print(f"Added new course: {course.title} ({len(course_chunks)} chunks)") + print( + f"Added new course: {course.title} ({len(course_chunks)} chunks)" + ) existing_course_titles.add(course.title) elif course: print(f"Course already exists: {course.title} - skipping") except Exception as e: print(f"Error processing {file_name}: {e}") - + return total_courses, total_chunks - - def query(self, query: str, session_id: Optional[str] = None) -> Tuple[str, List[str]]: + + def query( + self, query: str, session_id: Optional[str] = None + ) -> Tuple[str, List[str]]: """ Process a user query using the RAG system with tool-based search. - + Args: query: User's question session_id: Optional session ID for conversation context - + Returns: Tuple of (response, sources list - empty for tool-based approach) """ # Create prompt for the AI with clear instructions prompt = f"""Answer this question about course materials: {query}""" - + # Get conversation history if session exists history = None if session_id: history = self.session_manager.get_conversation_history(session_id) - + # Generate response using AI with tools response = self.ai_generator.generate_response( query=prompt, conversation_history=history, tools=self.tool_manager.get_tool_definitions(), - tool_manager=self.tool_manager + tool_manager=self.tool_manager, ) - + # Get sources from the search tool sources = self.tool_manager.get_last_sources() # Reset sources after retrieving them self.tool_manager.reset_sources() - + # Update conversation history if session_id: self.session_manager.add_exchange(session_id, query, response) - + # Return response with sources from tool searches return response, sources - + def get_course_analytics(self) -> Dict: """Get analytics about the course catalog""" return { "total_courses": self.vector_store.get_course_count(), - "course_titles": self.vector_store.get_existing_course_titles() - } \ No newline at end of file + "course_titles": self.vector_store.get_existing_course_titles(), + } diff --git a/backend/search_tools.py b/backend/search_tools.py index 3c2788171..58b80fceb 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -1,16 +1,17 @@ -from typing import Dict, Any, Optional, Protocol from abc import ABC, abstractmethod -from vector_store import VectorStore, SearchResults +from typing import Any, Dict, Optional + +from vector_store import SearchResults, VectorStore class Tool(ABC): """Abstract base class for all tools""" - + @abstractmethod def get_tool_definition(self) -> Dict[str, Any]: """Return Anthropic tool definition for this tool""" pass - + @abstractmethod def execute(self, **kwargs) -> str: """Execute the tool with given parameters""" @@ -19,11 +20,11 @@ def execute(self, **kwargs) -> str: class CourseSearchTool(Tool): """Tool for searching course content with semantic course name matching""" - + def __init__(self, vector_store: VectorStore): self.store = vector_store self.last_sources = [] # Track sources from last search - + def get_tool_definition(self) -> Dict[str, Any]: """Return Anthropic tool definition for this tool""" return { @@ -33,49 +34,52 @@ def get_tool_definition(self) -> Dict[str, Any]: "type": "object", "properties": { "query": { - "type": "string", - "description": "What to search for in the course content" + "type": "string", + "description": "What to search for in the course content", }, "course_name": { "type": "string", - "description": "Course title (partial matches work, e.g. 'MCP', 'Introduction')" + "description": "Course title (partial matches work, e.g. 'MCP', 'Introduction')", }, "lesson_number": { "type": "integer", - "description": "Specific lesson number to search within (e.g. 1, 2, 3)" - } + "description": "Specific lesson number to search within (e.g. 1, 2, 3)", + }, }, - "required": ["query"] - } + "required": ["query"], + }, } - - def execute(self, query: str, course_name: Optional[str] = None, lesson_number: Optional[int] = None) -> str: + + def execute( + self, + query: str, + course_name: Optional[str] = None, + lesson_number: Optional[int] = None, + ) -> str: """ Execute the search tool with given parameters. - + Args: query: What to search for course_name: Optional course filter lesson_number: Optional lesson filter - + Returns: Formatted search results or error message """ - + # Reset sources before each search so stale citations never leak self.last_sources = [] # Use the vector store's unified search interface results = self.store.search( - query=query, - course_name=course_name, - lesson_number=lesson_number + query=query, course_name=course_name, lesson_number=lesson_number ) - + # Handle errors if results.error: return results.error - + # Handle empty results if results.is_empty(): filter_info = "" @@ -84,44 +88,45 @@ def execute(self, query: str, course_name: Optional[str] = None, lesson_number: if lesson_number: filter_info += f" in lesson {lesson_number}" return f"No relevant content found{filter_info}." - + # Format and return results return self._format_results(results) - + def _format_results(self, results: SearchResults) -> str: """Format search results with course and lesson context""" formatted = [] sources = [] # Track sources for the UI - + for doc, meta in zip(results.documents, results.metadata): - course_title = meta.get('course_title', 'unknown') - lesson_num = meta.get('lesson_number') - + course_title = meta.get("course_title", "unknown") + lesson_num = meta.get("lesson_number") + # Build context header header = f"[{course_title}" if lesson_num is not None: header += f" - Lesson {lesson_num}" header += "]" - + # Track source for the UI source = course_title if lesson_num is not None: source += f" - Lesson {lesson_num}" sources.append(source) - + formatted.append(f"{header}\n{doc}") - + # Store sources for retrieval self.last_sources = sources - + return "\n\n".join(formatted) + class ToolManager: """Manages available tools for the AI""" - + def __init__(self): self.tools = {} - + def register_tool(self, tool: Tool): """Register any tool that implements the Tool interface""" tool_def = tool.get_tool_definition() @@ -130,28 +135,27 @@ def register_tool(self, tool: Tool): raise ValueError("Tool must have a 'name' in its definition") self.tools[tool_name] = tool - def get_tool_definitions(self) -> list: """Get all tool definitions for Anthropic tool calling""" return [tool.get_tool_definition() for tool in self.tools.values()] - + def execute_tool(self, tool_name: str, **kwargs) -> str: """Execute a tool by name with given parameters""" if tool_name not in self.tools: return f"Tool '{tool_name}' not found" - + return self.tools[tool_name].execute(**kwargs) - + def get_last_sources(self) -> list: """Get sources from the last search operation""" # Check all tools for last_sources attribute for tool in self.tools.values(): - if hasattr(tool, 'last_sources') and tool.last_sources: + if hasattr(tool, "last_sources") and tool.last_sources: return tool.last_sources return [] def reset_sources(self): """Reset sources from all tools that track sources""" for tool in self.tools.values(): - if hasattr(tool, 'last_sources'): - tool.last_sources = [] \ No newline at end of file + if hasattr(tool, "last_sources"): + tool.last_sources = [] diff --git a/backend/session_manager.py b/backend/session_manager.py index a5a96b1a1..374db489e 100644 --- a/backend/session_manager.py +++ b/backend/session_manager.py @@ -1,61 +1,66 @@ -from typing import Dict, List, Optional from dataclasses import dataclass +from typing import Dict, List, Optional + @dataclass class Message: """Represents a single message in a conversation""" - role: str # "user" or "assistant" + + role: str # "user" or "assistant" content: str # The message content + class SessionManager: """Manages conversation sessions and message history""" - + def __init__(self, max_history: int = 5): self.max_history = max_history self.sessions: Dict[str, List[Message]] = {} self.session_counter = 0 - + def create_session(self) -> str: """Create a new conversation session""" self.session_counter += 1 session_id = f"session_{self.session_counter}" self.sessions[session_id] = [] return session_id - + def add_message(self, session_id: str, role: str, content: str): """Add a message to the conversation history""" if session_id not in self.sessions: self.sessions[session_id] = [] - + message = Message(role=role, content=content) self.sessions[session_id].append(message) - + # Keep conversation history within limits if len(self.sessions[session_id]) > self.max_history * 2: - self.sessions[session_id] = self.sessions[session_id][-self.max_history * 2:] - + self.sessions[session_id] = self.sessions[session_id][ + -self.max_history * 2 : + ] + def add_exchange(self, session_id: str, user_message: str, assistant_message: str): """Add a complete question-answer exchange""" self.add_message(session_id, "user", user_message) self.add_message(session_id, "assistant", assistant_message) - + def get_conversation_history(self, session_id: Optional[str]) -> Optional[str]: """Get formatted conversation history for a session""" if not session_id or session_id not in self.sessions: return None - + messages = self.sessions[session_id] if not messages: return None - + # Format messages for context formatted_messages = [] for msg in messages: formatted_messages.append(f"{msg.role.title()}: {msg.content}") - + return "\n".join(formatted_messages) - + def clear_session(self, session_id: str): """Clear all messages from a session""" if session_id in self.sessions: - self.sessions[session_id] = [] \ No newline at end of file + self.sessions[session_id] = [] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e0c894744..be4b7e6d0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,13 +1,13 @@ -import sys import os +import sys # Add the backend directory to the Python path so test files can import backend modules sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import pytest import tempfile -from models import Course, Lesson, CourseChunk +import pytest +from models import Course, CourseChunk, Lesson from vector_store import VectorStore @@ -50,14 +50,14 @@ def sample_chunks(): return [ CourseChunk( content="Lesson 1 content: RAG stands for Retrieval-Augmented Generation. " - "It combines a retrieval system with a generative language model.", + "It combines a retrieval system with a generative language model.", course_title="Introduction to RAG", lesson_number=1, chunk_index=0, ), CourseChunk( content="Vector databases store embeddings and enable fast similarity search " - "over large document collections.", + "over large document collections.", course_title="Introduction to RAG", lesson_number=2, chunk_index=1, diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py index 40bbf834b..ad7c0a447 100644 --- a/backend/tests/test_ai_generator.py +++ b/backend/tests/test_ai_generator.py @@ -8,16 +8,15 @@ - what parameters were passed to each API call """ -from unittest.mock import MagicMock, patch, call -import pytest +from unittest.mock import MagicMock, patch from ai_generator import AIGenerator - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _make_generator(): """Return an AIGenerator whose Anthropic client is fully mocked.""" with patch("ai_generator.anthropic.Anthropic"): @@ -80,6 +79,7 @@ def _make_tool_manager(return_value="search results"): # Direct-response path (no tool use) # --------------------------------------------------------------------------- + class TestGenerateResponseDirect: def test_returns_text_when_stop_reason_is_end_turn(self): @@ -158,6 +158,7 @@ def test_no_tool_choice_when_no_tools(self): # Single tool-use round # --------------------------------------------------------------------------- + class TestSingleRoundToolUse: """Claude requests one tool call, then returns a text answer.""" @@ -181,14 +182,18 @@ def test_api_called_twice(self): _make_text_response(), ] - gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) assert gen.client.messages.create.call_count == 2 def test_execute_tool_called_once_with_correct_args(self): gen = _make_generator() gen.client.messages.create.side_effect = [ - _make_tool_use_response(tool_input={"query": "vector stores", "course_name": "RAG"}), + _make_tool_use_response( + tool_input={"query": "vector stores", "course_name": "RAG"} + ), _make_text_response(), ] tm = _make_tool_manager() @@ -206,7 +211,9 @@ def test_follow_up_call_includes_tools(self): _make_text_response(), ] - gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) second_call_kwargs = gen.client.messages.create.call_args_list[1][1] assert "tools" in second_call_kwargs @@ -218,7 +225,9 @@ def test_follow_up_messages_are_user_assistant_user(self): _make_text_response(), ] - gen.generate_response("my question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + gen.generate_response( + "my question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) second_call_kwargs = gen.client.messages.create.call_args_list[1][1] messages = second_call_kwargs["messages"] @@ -234,7 +243,9 @@ def test_tool_result_carries_correct_tool_use_id(self): _make_text_response(), ] - gen.generate_response("q", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + gen.generate_response( + "q", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) second_call_kwargs = gen.client.messages.create.call_args_list[1][1] tool_result_block = second_call_kwargs["messages"][2]["content"][0] @@ -248,7 +259,9 @@ def test_follow_up_call_does_not_set_tool_choice(self): _make_text_response(), ] - gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) second_call_kwargs = gen.client.messages.create.call_args_list[1][1] assert "tool_choice" not in second_call_kwargs @@ -258,13 +271,16 @@ def test_follow_up_call_does_not_set_tool_choice(self): # Two sequential tool-use rounds # --------------------------------------------------------------------------- + class TestTwoRoundToolUse: """Claude uses the search tool twice in separate rounds before answering.""" def test_returns_final_text_after_two_rounds(self): gen = _make_generator() gen.client.messages.create.side_effect = [ - _make_tool_use_response(tool_input={"query": "lesson 4 title"}, tool_id="t1"), + _make_tool_use_response( + tool_input={"query": "lesson 4 title"}, tool_id="t1" + ), _make_tool_use_response(tool_input={"query": "found topic"}, tool_id="t2"), _make_text_response("Here is the course that covers the same topic."), ] @@ -285,7 +301,9 @@ def test_api_called_three_times(self): _make_text_response(), ] - gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) assert gen.client.messages.create.call_count == 3 @@ -293,7 +311,9 @@ def test_execute_tool_called_twice(self): gen = _make_generator() gen.client.messages.create.side_effect = [ _make_tool_use_response(tool_input={"query": "first search"}, tool_id="t1"), - _make_tool_use_response(tool_input={"query": "second search"}, tool_id="t2"), + _make_tool_use_response( + tool_input={"query": "second search"}, tool_id="t2" + ), _make_text_response(), ] tm = _make_tool_manager() @@ -306,7 +326,9 @@ def test_both_tool_inputs_passed_to_execute_tool(self): gen = _make_generator() gen.client.messages.create.side_effect = [ _make_tool_use_response(tool_input={"query": "first search"}, tool_id="t1"), - _make_tool_use_response(tool_input={"query": "second search"}, tool_id="t2"), + _make_tool_use_response( + tool_input={"query": "second search"}, tool_id="t2" + ), _make_text_response(), ] tm = _make_tool_manager() @@ -325,7 +347,9 @@ def test_all_follow_up_calls_include_tools(self): _make_text_response(), ] - gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) for i in (1, 2): kwargs = gen.client.messages.create.call_args_list[i][1] @@ -340,7 +364,9 @@ def test_messages_accumulate_across_both_rounds(self): _make_text_response(), ] - gen.generate_response("my query", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + gen.generate_response( + "my query", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) third_call_kwargs = gen.client.messages.create.call_args_list[2][1] messages = third_call_kwargs["messages"] @@ -354,6 +380,7 @@ def test_messages_accumulate_across_both_rounds(self): # Max-rounds termination # --------------------------------------------------------------------------- + class TestMaxRoundsTermination: """Loop stops after _MAX_TOOL_ROUNDS even if Claude still wants more tools.""" @@ -365,7 +392,9 @@ def test_api_called_three_times_when_all_responses_are_tool_use(self): _make_tool_use_response(tool_id="t3"), # loop exits after this ] - gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager()) + gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=_make_tool_manager() + ) assert gen.client.messages.create.call_count == 3 @@ -401,6 +430,7 @@ def test_returns_fallback_string_when_max_rounds_exhausted(self): # Tool execution errors # --------------------------------------------------------------------------- + class TestToolExecutionErrors: def test_exception_from_execute_tool_does_not_propagate(self): @@ -409,7 +439,9 @@ def test_exception_from_execute_tool_does_not_propagate(self): tm = MagicMock() tm.execute_tool.side_effect = Exception("DB unavailable") - result = gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + result = gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=tm + ) assert isinstance(result, str) @@ -434,7 +466,9 @@ def test_error_string_from_execute_tool_continues_loop(self): tm = MagicMock() tm.execute_tool.return_value = "No relevant content found" - result = gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=tm) + result = gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=tm + ) assert gen.client.messages.create.call_count == 2 assert result == "Based on available information..." @@ -459,13 +493,16 @@ def test_error_string_content_appears_in_follow_up_messages(self): # Miscellaneous / edge cases # --------------------------------------------------------------------------- + class TestMiscBehavior: def test_no_tool_manager_with_tool_use_response_returns_fallback(self): gen = _make_generator() gen.client.messages.create.return_value = _make_tool_use_response() - result = gen.generate_response("question", tools=TOOL_DEFINITIONS, tool_manager=None) + result = gen.generate_response( + "question", tools=TOOL_DEFINITIONS, tool_manager=None + ) assert isinstance(result, str) and len(result) > 0 assert gen.client.messages.create.call_count == 1 @@ -486,7 +523,9 @@ def test_conversation_history_present_in_all_api_calls(self): for i, call in enumerate(gen.client.messages.create.call_args_list): system = call[1]["system"] - assert "User: hello" in system, f"Call #{i + 1} missing conversation history" + assert ( + "User: hello" in system + ), f"Call #{i + 1} missing conversation history" def test_system_prompt_removed_one_search_limit(self): assert "One search per query maximum" not in AIGenerator.SYSTEM_PROMPT diff --git a/backend/tests/test_rag_system.py b/backend/tests/test_rag_system.py index b92b0f7a5..c48a8c0c8 100644 --- a/backend/tests/test_rag_system.py +++ b/backend/tests/test_rag_system.py @@ -5,16 +5,15 @@ ChromaDB uses the temporary fixture from conftest.py. """ -from unittest.mock import MagicMock, patch -import pytest - -from models import Course, Lesson, CourseChunk +from unittest.mock import MagicMock +import pytest # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _make_text_response(text="Answer"): resp = MagicMock() resp.stop_reason = "end_turn" @@ -54,6 +53,7 @@ class TestConfig: CHROMA_PATH: str = tmp_chroma_path from rag_system import RAGSystem + rag = RAGSystem(TestConfig()) rag.ai_generator.client = mock_anthropic_client return rag @@ -63,6 +63,7 @@ class TestConfig: # Tests # --------------------------------------------------------------------------- + class TestRAGSystemQuery: @pytest.fixture(autouse=True) @@ -88,7 +89,9 @@ def test_query_returns_non_empty_answer(self): assert answer == "42" def test_query_returns_empty_sources_when_no_tool_used(self): - self.mock_client.messages.create.return_value = _make_text_response("General answer") + self.mock_client.messages.create.return_value = _make_text_response( + "General answer" + ) _, sources = self.rag.query("What is Python?") @@ -115,7 +118,9 @@ def test_query_updates_conversation_history_with_session(self): # --- tool use flow --- - def test_query_with_content_question_triggers_tool_search(self, sample_course, sample_chunks): + def test_query_with_content_question_triggers_tool_search( + self, sample_course, sample_chunks + ): # Load course data so the search tool has something to return self.rag.vector_store.add_course_metadata(sample_course) self.rag.vector_store.add_course_content(sample_chunks) @@ -130,7 +135,9 @@ def test_query_with_content_question_triggers_tool_search(self, sample_course, s assert self.mock_client.messages.create.call_count == 2 assert answer == "RAG is retrieval augmented generation." - def test_query_tool_sources_returned_after_search(self, sample_course, sample_chunks): + def test_query_tool_sources_returned_after_search( + self, sample_course, sample_chunks + ): self.rag.vector_store.add_course_metadata(sample_course) self.rag.vector_store.add_course_content(sample_chunks) @@ -166,7 +173,9 @@ def test_query_sources_reset_between_calls(self, sample_course, sample_chunks): # --- final API call includes tools (mirrors ai_generator test) --- - def test_final_api_call_in_tool_flow_includes_tools(self, sample_course, sample_chunks): + def test_final_api_call_in_tool_flow_includes_tools( + self, sample_course, sample_chunks + ): """ Verify that when RAGSystem drives the tool-use flow, the second Anthropic API call (the follow-up after tool execution) includes the 'tools' parameter. @@ -181,9 +190,9 @@ def test_final_api_call_in_tool_flow_includes_tools(self, sample_course, sample_ self.rag.query("What is RAG?") - assert self.mock_client.messages.create.call_count == 2, ( - "Expected exactly two API calls (initial + follow-up after tool execution)." - ) + assert ( + self.mock_client.messages.create.call_count == 2 + ), "Expected exactly two API calls (initial + follow-up after tool execution)." second_call_kwargs = self.mock_client.messages.create.call_args_list[1][1] assert "tools" in second_call_kwargs, ( "The follow-up API call (after tool execution) is missing 'tools'. " diff --git a/backend/tests/test_search_tools.py b/backend/tests/test_search_tools.py index eedb7e3ea..8cd7a3c40 100644 --- a/backend/tests/test_search_tools.py +++ b/backend/tests/test_search_tools.py @@ -7,16 +7,15 @@ """ from unittest.mock import MagicMock -import pytest from search_tools import CourseSearchTool, ToolManager from vector_store import SearchResults - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _make_search_results(docs, metas): """Build a SearchResults from lists of docs and metadata dicts.""" return SearchResults( @@ -34,6 +33,7 @@ def _make_error_results(msg): # Unit tests — VectorStore is mocked # --------------------------------------------------------------------------- + class TestCourseSearchToolExecute: def setup_method(self): @@ -162,6 +162,7 @@ def test_execute_clears_last_sources_from_previous_call(self): # ToolManager unit tests # --------------------------------------------------------------------------- + class TestToolManager: def test_register_and_execute_tool(self): @@ -216,6 +217,7 @@ def test_reset_sources_clears_last_sources(self): # Integration tests — real temp ChromaDB # --------------------------------------------------------------------------- + class TestCourseSearchToolIntegration: """Uses a real populated VectorStore fixture (temp ChromaDB).""" @@ -238,7 +240,9 @@ def test_search_with_lesson_filter(self, populated_vector_store): assert "Lesson 2" in result - def test_search_with_nonexistent_course_resolves_to_closest_match(self, populated_vector_store): + def test_search_with_nonexistent_course_resolves_to_closest_match( + self, populated_vector_store + ): # _resolve_course_name uses vector similarity — it always returns the # nearest course in the catalog, so no "No course found" error is raised # for a fabricated name; instead the closest real course's content is returned. diff --git a/backend/vector_store.py b/backend/vector_store.py index 390abe71c..ebf4cc18c 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -1,77 +1,92 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + import chromadb from chromadb.config import Settings -from typing import List, Dict, Any, Optional -from dataclasses import dataclass from models import Course, CourseChunk -from sentence_transformers import SentenceTransformer + @dataclass class SearchResults: """Container for search results with metadata""" + documents: List[str] metadata: List[Dict[str, Any]] distances: List[float] error: Optional[str] = None - + @classmethod - def from_chroma(cls, chroma_results: Dict) -> 'SearchResults': + def from_chroma(cls, chroma_results: Dict) -> "SearchResults": """Create SearchResults from ChromaDB query results""" return cls( - documents=chroma_results['documents'][0] if chroma_results['documents'] else [], - metadata=chroma_results['metadatas'][0] if chroma_results['metadatas'] else [], - distances=chroma_results['distances'][0] if chroma_results['distances'] else [] + documents=( + chroma_results["documents"][0] if chroma_results["documents"] else [] + ), + metadata=( + chroma_results["metadatas"][0] if chroma_results["metadatas"] else [] + ), + distances=( + chroma_results["distances"][0] if chroma_results["distances"] else [] + ), ) - + @classmethod - def empty(cls, error_msg: str) -> 'SearchResults': + def empty(cls, error_msg: str) -> "SearchResults": """Create empty results with error message""" return cls(documents=[], metadata=[], distances=[], error=error_msg) - + def is_empty(self) -> bool: """Check if results are empty""" return len(self.documents) == 0 + class VectorStore: """Vector storage using ChromaDB for course content and metadata""" - + def __init__(self, chroma_path: str, embedding_model: str, max_results: int = 5): self.max_results = max_results # Initialize ChromaDB client self.client = chromadb.PersistentClient( - path=chroma_path, - settings=Settings(anonymized_telemetry=False) + path=chroma_path, settings=Settings(anonymized_telemetry=False) ) - + # Set up sentence transformer embedding function - self.embedding_function = chromadb.utils.embedding_functions.SentenceTransformerEmbeddingFunction( - model_name=embedding_model + self.embedding_function = ( + chromadb.utils.embedding_functions.SentenceTransformerEmbeddingFunction( + model_name=embedding_model + ) ) - + # Create collections for different types of data - self.course_catalog = self._create_collection("course_catalog") # Course titles/instructors - self.course_content = self._create_collection("course_content") # Actual course material - + self.course_catalog = self._create_collection( + "course_catalog" + ) # Course titles/instructors + self.course_content = self._create_collection( + "course_content" + ) # Actual course material + def _create_collection(self, name: str): """Create or get a ChromaDB collection""" return self.client.get_or_create_collection( - name=name, - embedding_function=self.embedding_function + name=name, embedding_function=self.embedding_function ) - - def search(self, - query: str, - course_name: Optional[str] = None, - lesson_number: Optional[int] = None, - limit: Optional[int] = None) -> SearchResults: + + def search( + self, + query: str, + course_name: Optional[str] = None, + lesson_number: Optional[int] = None, + limit: Optional[int] = None, + ) -> SearchResults: """ Main search interface that handles course resolution and content search. - + Args: query: What to search for in course content course_name: Optional course name/title to filter by lesson_number: Optional lesson number to filter by limit: Maximum results to return - + Returns: SearchResults object with documents and metadata """ @@ -81,104 +96,111 @@ def search(self, course_title = self._resolve_course_name(course_name) if not course_title: return SearchResults.empty(f"No course found matching '{course_name}'") - + # Step 2: Build filter for content search filter_dict = self._build_filter(course_title, lesson_number) - + # Step 3: Search course content # Use provided limit or fall back to configured max_results search_limit = limit if limit is not None else self.max_results - + try: results = self.course_content.query( - query_texts=[query], - n_results=search_limit, - where=filter_dict + query_texts=[query], n_results=search_limit, where=filter_dict ) return SearchResults.from_chroma(results) except Exception as e: return SearchResults.empty(f"Search error: {str(e)}") - + def _resolve_course_name(self, course_name: str) -> Optional[str]: """Use vector search to find best matching course by name""" try: - results = self.course_catalog.query( - query_texts=[course_name], - n_results=1 - ) - - if results['documents'][0] and results['metadatas'][0]: + results = self.course_catalog.query(query_texts=[course_name], n_results=1) + + if results["documents"][0] and results["metadatas"][0]: # Return the title (which is now the ID) - return results['metadatas'][0][0]['title'] + return results["metadatas"][0][0]["title"] except Exception as e: print(f"Error resolving course name: {e}") - + return None - - def _build_filter(self, course_title: Optional[str], lesson_number: Optional[int]) -> Optional[Dict]: + + def _build_filter( + self, course_title: Optional[str], lesson_number: Optional[int] + ) -> Optional[Dict]: """Build ChromaDB filter from search parameters""" if not course_title and lesson_number is None: return None - + # Handle different filter combinations if course_title and lesson_number is not None: - return {"$and": [ - {"course_title": course_title}, - {"lesson_number": lesson_number} - ]} - + return { + "$and": [ + {"course_title": course_title}, + {"lesson_number": lesson_number}, + ] + } + if course_title: return {"course_title": course_title} - + return {"lesson_number": lesson_number} - + def add_course_metadata(self, course: Course): """Add course information to the catalog for semantic search""" import json course_text = course.title - + # Build lessons metadata and serialize as JSON string lessons_metadata = [] for lesson in course.lessons: - lessons_metadata.append({ - "lesson_number": lesson.lesson_number, - "lesson_title": lesson.title, - "lesson_link": lesson.lesson_link - }) - + lessons_metadata.append( + { + "lesson_number": lesson.lesson_number, + "lesson_title": lesson.title, + "lesson_link": lesson.lesson_link, + } + ) + self.course_catalog.add( documents=[course_text], - metadatas=[{ - "title": course.title, - "instructor": course.instructor, - "course_link": course.course_link, - "lessons_json": json.dumps(lessons_metadata), # Serialize as JSON string - "lesson_count": len(course.lessons) - }], - ids=[course.title] + metadatas=[ + { + "title": course.title, + "instructor": course.instructor, + "course_link": course.course_link, + "lessons_json": json.dumps( + lessons_metadata + ), # Serialize as JSON string + "lesson_count": len(course.lessons), + } + ], + ids=[course.title], ) - + def add_course_content(self, chunks: List[CourseChunk]): """Add course content chunks to the vector store""" if not chunks: return - + documents = [chunk.content for chunk in chunks] - metadatas = [{ - "course_title": chunk.course_title, - "lesson_number": chunk.lesson_number, - "chunk_index": chunk.chunk_index - } for chunk in chunks] + metadatas = [ + { + "course_title": chunk.course_title, + "lesson_number": chunk.lesson_number, + "chunk_index": chunk.chunk_index, + } + for chunk in chunks + ] # Use title with chunk index for unique IDs - ids = [f"{chunk.course_title.replace(' ', '_')}_{chunk.chunk_index}" for chunk in chunks] - - self.course_content.add( - documents=documents, - metadatas=metadatas, - ids=ids - ) - + ids = [ + f"{chunk.course_title.replace(' ', '_')}_{chunk.chunk_index}" + for chunk in chunks + ] + + self.course_content.add(documents=documents, metadatas=metadatas, ids=ids) + def clear_all_data(self): """Clear all data from both collections""" try: @@ -189,43 +211,46 @@ def clear_all_data(self): self.course_content = self._create_collection("course_content") except Exception as e: print(f"Error clearing data: {e}") - + def get_existing_course_titles(self) -> List[str]: """Get all existing course titles from the vector store""" try: # Get all documents from the catalog results = self.course_catalog.get() - if results and 'ids' in results: - return results['ids'] + if results and "ids" in results: + return results["ids"] return [] except Exception as e: print(f"Error getting existing course titles: {e}") return [] - + def get_course_count(self) -> int: """Get the total number of courses in the vector store""" try: results = self.course_catalog.get() - if results and 'ids' in results: - return len(results['ids']) + if results and "ids" in results: + return len(results["ids"]) return 0 except Exception as e: print(f"Error getting course count: {e}") return 0 - + def get_all_courses_metadata(self) -> List[Dict[str, Any]]: """Get metadata for all courses in the vector store""" import json + try: results = self.course_catalog.get() - if results and 'metadatas' in results: + if results and "metadatas" in results: # Parse lessons JSON for each course parsed_metadata = [] - for metadata in results['metadatas']: + for metadata in results["metadatas"]: course_meta = metadata.copy() - if 'lessons_json' in course_meta: - course_meta['lessons'] = json.loads(course_meta['lessons_json']) - del course_meta['lessons_json'] # Remove the JSON string version + if "lessons_json" in course_meta: + course_meta["lessons"] = json.loads(course_meta["lessons_json"]) + del course_meta[ + "lessons_json" + ] # Remove the JSON string version parsed_metadata.append(course_meta) return parsed_metadata return [] @@ -238,30 +263,30 @@ def get_course_link(self, course_title: str) -> Optional[str]: try: # Get course by ID (title is the ID) results = self.course_catalog.get(ids=[course_title]) - if results and 'metadatas' in results and results['metadatas']: - metadata = results['metadatas'][0] - return metadata.get('course_link') + if results and "metadatas" in results and results["metadatas"]: + metadata = results["metadatas"][0] + return metadata.get("course_link") return None except Exception as e: print(f"Error getting course link: {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 + try: # Get course by ID (title is the ID) results = self.course_catalog.get(ids=[course_title]) - if results and 'metadatas' in results and results['metadatas']: - metadata = results['metadatas'][0] - lessons_json = metadata.get('lessons_json') + if results and "metadatas" in results and results["metadatas"]: + metadata = results["metadatas"][0] + lessons_json = metadata.get("lessons_json") if lessons_json: lessons = json.loads(lessons_json) # Find the lesson with matching number for lesson in lessons: - if lesson.get('lesson_number') == lesson_number: - return lesson.get('lesson_link') + if lesson.get("lesson_number") == lesson_number: + return lesson.get("lesson_link") return None except Exception as e: print(f"Error getting lesson link: {e}") - \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 806bb5395..86c1b729a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,5 +16,22 @@ dependencies = [ [dependency-groups] dev = [ + "black>=26.5.1", "pytest>=9.0.3", + "ruff>=0.15.16", ] + +[tool.black] +line-length = 88 +target-version = ["py313"] + +[tool.ruff] +line-length = 88 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"backend/app.py" = ["E402"] diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100644 index 000000000..d7e50dfb3 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Apply black formatting and ruff import-sort fixes in place. +set -e + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +echo "=== black (format) ===" +uv run black "$ROOT/backend" "$ROOT/main.py" + +echo "=== ruff (fix imports) ===" +uv run ruff check --fix "$ROOT/backend" "$ROOT/main.py" + +echo "Formatting complete." diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100644 index 000000000..bf49169c5 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Run all code quality checks. Exit non-zero if any check fails. +set -e + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +echo "=== black (format check) ===" +uv run black --check "$ROOT/backend" "$ROOT/main.py" + +echo "=== ruff (lint) ===" +uv run ruff check "$ROOT/backend" "$ROOT/main.py" + +echo "All quality checks passed." diff --git a/uv.lock b/uv.lock index b4e03cf59..8ad62de12 100644 --- a/uv.lock +++ b/uv.lock @@ -110,6 +110,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, ] +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + [[package]] name = "build" version = "1.2.2.post1" @@ -667,6 +694,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "networkx" version = "3.5" @@ -992,6 +1028,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pillow" version = "11.3.0" @@ -1047,6 +1092,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 = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1271,6 +1325,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1439,6 +1517,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "ruff" +version = "0.15.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, + { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, +] + [[package]] name = "safetensors" version = "0.5.3" @@ -1597,7 +1700,9 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "black" }, { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] @@ -1612,7 +1717,11 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.0.3" }] +dev = [ + { name = "black", specifier = ">=26.5.1" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "ruff", specifier = ">=0.15.16" }, +] [[package]] name = "sympy" From 0ea981776b0d9d46f15a2ac4f8b240be58dcff71 Mon Sep 17 00:00:00 2001 From: dalang059 Date: Sun, 7 Jun 2026 21:41:13 +0800 Subject: [PATCH 6/8] Add API endpoint tests and pytest configuration - Add backend/tests/test_api_endpoints.py with 18 tests covering all three FastAPI endpoints (POST /api/query, GET /api/courses, DELETE /api/session/{id}). Tests verify status codes, response body fields, session auto-creation vs reuse, input validation (422 on missing query), and 500 error propagation. - Extend backend/tests/conftest.py with shared API test infrastructure: _build_test_api_app() builds a minimal FastAPI app mirroring app.py routes with an injected rag_system, avoiding the static-file mount and ChromaDB init that prevent direct app.py import in tests. mock_rag_system fixture provides a pre-configured MagicMock. api_client fixture wires TestClient to the test app. - Add [tool.pytest.ini_options] to pyproject.toml (testpaths, pythonpath, addopts) so `uv run pytest` works from the project root without extra args. - Add httpx>=0.28 to dev dependencies (required by starlette TestClient in starlette 0.46+ / FastAPI 0.116+). --- backend/tests/conftest.py | 81 ++++++++++++++++++++ backend/tests/test_api_endpoints.py | 111 ++++++++++++++++++++++++++++ pyproject.toml | 6 ++ uv.lock | 6 +- 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_api_endpoints.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e0c894744..a1bed255e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,6 +6,12 @@ import pytest import tempfile +from unittest.mock import MagicMock + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import BaseModel +from typing import List, Optional from models import Course, Lesson, CourseChunk from vector_store import VectorStore @@ -77,3 +83,78 @@ def populated_vector_store(empty_vector_store, sample_course, sample_chunks): empty_vector_store.add_course_metadata(sample_course) empty_vector_store.add_course_content(sample_chunks) return empty_vector_store + + +# --------------------------------------------------------------------------- +# API endpoint test infrastructure +# --------------------------------------------------------------------------- + +def _build_test_api_app(rag_system): + """ + Minimal FastAPI app mirroring the routes in app.py with an injected + rag_system. Avoids the static-file mount and ChromaDB init that make + importing app.py directly fail in test environments. + """ + test_app = FastAPI() + + 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.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 e: + raise HTTPException(status_code=500, detail=str(e)) + + @test_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 e: + raise HTTPException(status_code=500, detail=str(e)) + + @test_app.delete("/api/session/{session_id}") + async def clear_session(session_id: str): + rag_system.session_manager.clear_session(session_id) + return {"status": "cleared"} + + return test_app + + +@pytest.fixture +def mock_rag_system(): + """A fully-mocked RAGSystem for use in API endpoint tests.""" + rag = MagicMock() + rag.session_manager.create_session.return_value = "session_1" + rag.query.return_value = ("Test answer", ["Introduction to RAG - Lesson 1"]) + rag.get_course_analytics.return_value = { + "total_courses": 1, + "course_titles": ["Introduction to RAG"], + } + return rag + + +@pytest.fixture +def api_client(mock_rag_system): + """Starlette TestClient wired to the minimal test API app.""" + app = _build_test_api_app(mock_rag_system) + return TestClient(app) diff --git a/backend/tests/test_api_endpoints.py b/backend/tests/test_api_endpoints.py new file mode 100644 index 000000000..80b8dad66 --- /dev/null +++ b/backend/tests/test_api_endpoints.py @@ -0,0 +1,111 @@ +""" +Tests for the FastAPI endpoints defined in app.py. + +Uses the minimal test app from conftest.py (api_client fixture) so tests +run without importing app.py directly — avoiding the static-file mount and +ChromaDB initialisation that fail in a test environment. + +Endpoints covered: + POST /api/query + GET /api/courses + DELETE /api/session/{session_id} +""" + +import pytest + + +# --------------------------------------------------------------------------- +# POST /api/query +# --------------------------------------------------------------------------- + +class TestQueryEndpoint: + + def test_returns_200_for_valid_request(self, api_client): + response = api_client.post("/api/query", json={"query": "What is RAG?"}) + assert response.status_code == 200 + + def test_answer_matches_rag_system_response(self, api_client): + response = api_client.post("/api/query", json={"query": "What is RAG?"}) + assert response.json()["answer"] == "Test answer" + + def test_sources_match_rag_system_response(self, api_client): + response = api_client.post("/api/query", json={"query": "What is RAG?"}) + assert response.json()["sources"] == ["Introduction to RAG - Lesson 1"] + + def test_response_includes_session_id(self, api_client): + response = api_client.post("/api/query", json={"query": "test"}) + assert "session_id" in response.json() + + def test_auto_creates_session_when_none_provided(self, api_client, mock_rag_system): + api_client.post("/api/query", json={"query": "test"}) + mock_rag_system.session_manager.create_session.assert_called_once() + + def test_does_not_create_session_when_one_is_provided(self, api_client, mock_rag_system): + api_client.post("/api/query", json={"query": "test", "session_id": "existing"}) + mock_rag_system.session_manager.create_session.assert_not_called() + + def test_provided_session_id_forwarded_to_rag_query(self, api_client, mock_rag_system): + api_client.post("/api/query", json={"query": "test", "session_id": "existing"}) + mock_rag_system.query.assert_called_once_with("test", "existing") + + def test_missing_query_field_returns_422(self, api_client): + response = api_client.post("/api/query", json={}) + assert response.status_code == 422 + + def test_rag_exception_returns_500(self, api_client, mock_rag_system): + mock_rag_system.query.side_effect = RuntimeError("DB unavailable") + response = api_client.post("/api/query", json={"query": "test"}) + assert response.status_code == 500 + + def test_500_detail_contains_exception_message(self, api_client, mock_rag_system): + mock_rag_system.query.side_effect = RuntimeError("DB unavailable") + response = api_client.post("/api/query", json={"query": "test"}) + assert "DB unavailable" in response.json()["detail"] + + +# --------------------------------------------------------------------------- +# GET /api/courses +# --------------------------------------------------------------------------- + +class TestCoursesEndpoint: + + def test_returns_200(self, api_client): + response = api_client.get("/api/courses") + assert response.status_code == 200 + + def test_total_courses_matches_analytics(self, api_client): + response = api_client.get("/api/courses") + assert response.json()["total_courses"] == 1 + + def test_course_titles_matches_analytics(self, api_client): + response = api_client.get("/api/courses") + assert response.json()["course_titles"] == ["Introduction to RAG"] + + def test_analytics_exception_returns_500(self, api_client, mock_rag_system): + mock_rag_system.get_course_analytics.side_effect = RuntimeError("analytics failed") + response = api_client.get("/api/courses") + assert response.status_code == 500 + + def test_500_detail_contains_exception_message(self, api_client, mock_rag_system): + mock_rag_system.get_course_analytics.side_effect = RuntimeError("analytics failed") + response = api_client.get("/api/courses") + assert "analytics failed" in response.json()["detail"] + + +# --------------------------------------------------------------------------- +# DELETE /api/session/{session_id} +# --------------------------------------------------------------------------- + +class TestClearSessionEndpoint: + + def test_returns_200(self, api_client): + response = api_client.delete("/api/session/abc123") + assert response.status_code == 200 + + def test_response_body_is_cleared_status(self, api_client): + response = api_client.delete("/api/session/abc123") + assert response.json() == {"status": "cleared"} + + def test_calls_clear_session_with_correct_id(self, api_client, mock_rag_system): + api_client.delete("/api/session/abc123") + mock_rag_system.session_manager.clear_session.assert_called_once_with("abc123") diff --git a/pyproject.toml b/pyproject.toml index 806bb5395..2848caf8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,4 +17,10 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=9.0.3", + "httpx>=0.28", ] + +[tool.pytest.ini_options] +testpaths = ["backend/tests"] +pythonpath = ["backend"] +addopts = "-v --tb=short" diff --git a/uv.lock b/uv.lock index b4e03cf59..9c9bd42ac 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.3" }] +dev = [ + { name = "httpx", specifier = ">=0.28" }, + { name = "pytest", specifier = ">=9.0.3" }, +] [[package]] name = "sympy" From 525110a4f3196b96a277ebdb526c63d9becf5e66 Mon Sep 17 00:00:00 2001 From: dalang059 Date: Mon, 8 Jun 2026 00:09:07 +0800 Subject: [PATCH 7/8] "Claude PR Assistant workflow" --- .github/workflows/claude.yml | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..6b15fac7a --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + From 22af37a47781b8f0853aea24f5533e5c87d00d4c Mon Sep 17 00:00:00 2001 From: dalang059 Date: Mon, 8 Jun 2026 00:09:08 +0800 Subject: [PATCH 8/8] "Claude Code Review workflow" --- .github/workflows/claude-code-review.yml | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..b5e8cfd4d --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options +