diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..2d5c16a17 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "mcp__playwright__browser_take_screenshot", + "Bash(uv run pytest:*)" + ], + "deny": [] + }, + "includeCoAuthoredBy": false +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..190eb7e87 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Running the Application + +```bash +# Quick start (recommended) +./run.sh + +# Manual start +cd backend +uv run uvicorn app:app --reload --port 8000 +``` + +### Development Setup + +```bash +# Install dependencies (requires Python 3.13+) +uv sync + +# Create .env file with required API key +echo "ANTHROPIC_API_KEY=your_key_here" > .env +``` + +## Architecture + +This is a RAG (Retrieval-Augmented Generation) system for course materials with three main layers: + +### Core Components + +- **RAGSystem** ([backend/rag_system.py](backend/rag_system.py)): Main orchestrator that coordinates all components +- **VectorStore** ([backend/vector_store.py](backend/vector_store.py)): ChromaDB integration with dual collections - `course_catalog` for metadata and `course_content` for chunks +- **AIGenerator** ([backend/ai_generator.py](backend/ai_generator.py)): Claude integration using tool-calling for structured search +- **DocumentProcessor** ([backend/document_processor.py](backend/document_processor.py)): Chunks documents (800 chars, 100 overlap) + +### Key Design Decisions + +1. **Tool-Based Search**: Uses Anthropic's tool-calling feature instead of direct RAG retrieval. The AI decides when and how to search. + +2. **Dual Embedding Strategy**: + + - Course titles are embedded separately for semantic course name resolution + - Course content is embedded with metadata for filtered search + +3. **Embedding Creation**: Happens automatically on startup from [/docs](docs) folder and when calling `add_course_*` methods. Uses `all-MiniLM-L6-v2` model via SentenceTransformers. + +4. **Session Management**: Maintains conversation history (last 2 exchanges) for context-aware responses. + +## API Endpoints + +- `POST /api/query` - Main RAG query with session support +- `GET /api/courses` - Course statistics and metadata +- Static files served from [/frontend](frontend) + +## Configuration + +Required environment variable: + +- `ANTHROPIC_API_KEY` - Claude API access + +Key settings in [backend/config.py](backend/config.py): + +- `ANTHROPIC_MODEL`: claude-sonnet-4-20250514 +- `EMBEDDING_MODEL`: all-MiniLM-L6-v2 +- `CHUNK_SIZE`: 800 characters +- `MAX_RESULTS`: 5 search results +- `CHROMA_PATH`: ./chroma_db (persistent storage) + +## Data Flow + +1. Documents in [/docs](docs) are loaded on startup +2. Text is chunked and embedded into ChromaDB +3. User queries trigger tool-based search via Claude +4. Search results provide context for answer generation +5. Responses include source citations + +## Important Notes + +- ChromaDB data persists in [backend/chroma_db/](backend/chroma_db/) +- Duplicate courses are automatically skipped during loading +- Frontend uses vanilla JavaScript (no framework) +- No formal testing framework - this is a learning/demo project + +## Package Management Guidelines + +- Always use `uv`, never `pip` +- Use `uv` for: + - Package management + - Python versions handling + - Module executions diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 000000000..cec4b65f9 Binary files /dev/null and b/backend/.coverage differ diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 0363ca90c..796f49401 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -5,14 +5,26 @@ 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. + SYSTEM_PROMPT = """ You are an AI assistant specialized in course materials and educational content with access to comprehensive search tools for course information. -Search Tool Usage: -- Use the search tool **only** for questions about specific course content or detailed educational materials +Available Tools: +- **search_course_content**: For questions about specific course content, lessons, or detailed educational materials +- **get_course_outline**: For questions about course structure, lesson lists, course overviews, or what a course covers + +Tool Usage Guidelines: +- **Content questions**: Use search_course_content for specific topics, concepts, or lesson details +- **Outline questions**: Use get_course_outline for course structure, lesson titles, or what topics are covered - **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 for Outline Queries: +When using get_course_outline, ensure your response includes: +- Course title and instructor (if available) +- Course link (if available) +- Total number of lessons +- Complete lesson list with lesson numbers and titles + Response Protocol: - **General knowledge questions**: Answer using existing knowledge without searching - **Course-specific questions**: Search first, then answer @@ -20,7 +32,6 @@ class AIGenerator: - 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 diff --git a/backend/app.py b/backend/app.py index 5a69d741d..f2873a433 100644 --- a/backend/app.py +++ b/backend/app.py @@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.middleware.trustedhost import TrustedHostMiddleware from pydantic import BaseModel -from typing import List, Optional +from typing import List, Optional, Union, Dict, Any import os from config import config @@ -43,7 +43,7 @@ class QueryRequest(BaseModel): class QueryResponse(BaseModel): """Response model for course queries""" answer: str - sources: List[str] + sources: List[Union[str, Dict[str, Any]]] # Can be strings or objects with text/url session_id: str class CourseStats(BaseModel): @@ -51,6 +51,15 @@ class CourseStats(BaseModel): total_courses: int course_titles: List[str] +class ClearSessionRequest(BaseModel): + """Request model for clearing a session""" + session_id: str + +class ClearSessionResponse(BaseModel): + """Response model for clearing a session""" + success: bool + message: str + # API Endpoints @app.post("/api/query", response_model=QueryResponse) @@ -85,6 +94,18 @@ async def get_course_stats(): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@app.post("/api/session/clear", response_model=ClearSessionResponse) +async def clear_session(request: ClearSessionRequest): + """Clear a conversation session""" + try: + rag_system.session_manager.clear_session(request.session_id) + return ClearSessionResponse( + success=True, + message=f"Session {request.session_id} cleared successfully" + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + @app.on_event("startup") async def startup_event(): """Load initial documents on startup""" diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8e..1a79eb748 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -4,7 +4,7 @@ from vector_store import VectorStore from ai_generator import AIGenerator from session_manager import SessionManager -from search_tools import ToolManager, CourseSearchTool +from search_tools import ToolManager, CourseSearchTool, CourseOutlineTool from models import Course, Lesson, CourseChunk class RAGSystem: @@ -22,7 +22,9 @@ def __init__(self, config): # Initialize search tools self.tool_manager = ToolManager() self.search_tool = CourseSearchTool(self.vector_store) + self.outline_tool = CourseOutlineTool(self.vector_store) self.tool_manager.register_tool(self.search_tool) + self.tool_manager.register_tool(self.outline_tool) def add_course_document(self, file_path: str) -> Tuple[Course, int]: """ diff --git a/backend/search_tools.py b/backend/search_tools.py index adfe82352..785196271 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -100,11 +100,21 @@ def _format_results(self, results: SearchResults) -> str: header += f" - Lesson {lesson_num}" header += "]" - # Track source for the UI - source = course_title + # Track source for the UI with link if available + source_text = course_title if lesson_num is not None: - source += f" - Lesson {lesson_num}" - sources.append(source) + source_text += f" - Lesson {lesson_num}" + + # Try to get lesson link if we have a lesson number + source_url = None + if lesson_num is not None: + source_url = self.store.get_lesson_link(course_title, lesson_num) + + # Create source object with text and optional URL + if source_url: + sources.append({"text": source_text, "url": source_url}) + else: + sources.append({"text": source_text, "url": None}) formatted.append(f"{header}\n{doc}") @@ -113,6 +123,75 @@ def _format_results(self, results: SearchResults) -> str: return "\n\n".join(formatted) + +class CourseOutlineTool(Tool): + """Tool for retrieving complete course outlines with lesson details""" + + def __init__(self, vector_store: VectorStore): + self.store = vector_store + + def get_tool_definition(self) -> Dict[str, Any]: + """Return Anthropic tool definition for this tool""" + return { + "name": "get_course_outline", + "description": "Get complete course outline including course title, course link, and detailed lesson list", + "input_schema": { + "type": "object", + "properties": { + "course_title": { + "type": "string", + "description": "Course title to get outline for (partial matches work)" + } + }, + "required": ["course_title"] + } + } + + def execute(self, course_title: str) -> str: + """ + Execute the outline tool with given course title. + + Args: + course_title: Course title to get outline for + + Returns: + Formatted course outline or error message + """ + outline = self.store.get_course_outline(course_title) + + if not outline: + return f"No course found matching '{course_title}'" + + return self._format_outline(outline) + + def _format_outline(self, outline: Dict[str, Any]) -> str: + """Format course outline data into readable text""" + result = [] + + # Course header + result.append(f"Course: {outline['course_title']}") + + if outline.get('instructor'): + result.append(f"Instructor: {outline['instructor']}") + + if outline.get('course_link'): + result.append(f"Course Link: {outline['course_link']}") + + lesson_count = outline.get('lesson_count', len(outline.get('lessons', []))) + result.append(f"Total Lessons: {lesson_count}") + + # Lessons list + lessons = outline.get('lessons', []) + if lessons: + result.append("\nLessons:") + for lesson in lessons: + lesson_num = lesson.get('lesson_number', 'N/A') + lesson_title = lesson.get('lesson_title', 'Untitled') + result.append(f" {lesson_num}. {lesson_title}") + + return "\n".join(result) + + class ToolManager: """Manages available tools for the AI""" diff --git a/backend/tests/TEST_REPORT.md b/backend/tests/TEST_REPORT.md new file mode 100644 index 000000000..db364ed0a --- /dev/null +++ b/backend/tests/TEST_REPORT.md @@ -0,0 +1,175 @@ +# RAG Chatbot Testing Report + +## Test Implementation Summary + +### Tests Created +1. **test_search_tools.py** (22 tests) + - CourseSearchTool.execute() method validation + - CourseOutlineTool functionality + - ToolManager operations + - Source tracking and formatting + +2. **test_ai_generator.py** (10 tests) + - Tool calling integration with Anthropic API + - Response generation with and without tools + - Multi-tool execution handling + - System prompt validation + +3. **test_rag_system.py** (12 tests) + - End-to-end query processing + - Document and folder addition + - Session management integration + - Course analytics functionality + +### Test Coverage Results +- **Overall Coverage**: 71% +- **Key Components**: + - ai_generator.py: 100% ✅ + - search_tools.py: 98% ✅ + - rag_system.py: 97% ✅ + - models.py: 100% ✅ + - config.py: 100% ✅ + +## Testing Findings + +### ✅ Working Correctly +1. **CourseSearchTool** properly executes searches with: + - Query-only searches + - Course name filtering + - Lesson number filtering + - Combined filters + - Source URL tracking + +2. **AIGenerator** correctly: + - Initializes Anthropic client + - Handles tool-based responses + - Manages conversation history + - Executes multiple tools in sequence + +3. **RAGSystem** successfully: + - Orchestrates all components + - Manages session context + - Processes queries with tool integration + - Tracks and returns sources + +### 🐛 Issues Discovered During Testing + +#### 1. Initial Test Failures (Now Fixed) +- **Issue**: Mock objects not properly configured for iterable returns +- **Fix**: Added proper return values for `get_existing_course_titles()` +- **Issue**: `reset_sources` method not mocked correctly +- **Fix**: Added explicit Mock() for `reset_sources` method + +#### 2. Potential Production Issues Identified + +##### A. Error Handling Gaps +**Location**: backend/rag_system.py:99-100 +```python +except Exception as e: + print(f"Error processing {file_name}: {e}") +``` +**Issue**: Silent failures when processing documents +**Proposed Fix**: +```python +except Exception as e: + logger.error(f"Error processing {file_name}: {e}") + error_count += 1 + if error_count > max_errors: + raise ProcessingError(f"Too many errors processing documents: {error_count}") +``` + +##### B. Missing Input Validation +**Location**: backend/search_tools.py:52 +```python +def execute(self, query: str, course_name: Optional[str] = None, lesson_number: Optional[int] = None) -> str: +``` +**Issue**: No validation for empty queries or invalid lesson numbers +**Proposed Fix**: +```python +def execute(self, query: str, course_name: Optional[str] = None, lesson_number: Optional[int] = None) -> str: + if not query or not query.strip(): + return "Query cannot be empty" + if lesson_number is not None and lesson_number < 1: + return "Lesson number must be positive" +``` + +##### C. Race Condition in Session Management +**Location**: backend/session_manager.py +**Issue**: Concurrent access to session data could cause issues +**Proposed Fix**: Add thread-safe locking mechanism +```python +import threading + +class SessionManager: + def __init__(self, max_history: int): + self.lock = threading.Lock() + # ... rest of init + + def add_exchange(self, session_id: str, query: str, response: str): + with self.lock: + # ... existing implementation +``` + +##### D. Memory Leak Risk +**Location**: backend/search_tools.py:25 +```python +self.last_sources = [] # Track sources from last search +``` +**Issue**: Sources accumulate without cleanup if reset_sources isn't called +**Proposed Fix**: Implement automatic cleanup after N queries or time-based cleanup + +## Recommendations + +### 1. Immediate Actions +- ✅ Add logging instead of print statements +- ✅ Implement input validation for all public methods +- ✅ Add thread safety to SessionManager +- ✅ Implement proper error recovery mechanisms + +### 2. Future Improvements +- Add integration tests with real ChromaDB +- Implement performance benchmarking tests +- Add load testing for concurrent queries +- Create end-to-end tests with actual Anthropic API (using test keys) +- Add monitoring and metrics collection + +### 3. CI/CD Integration +```yaml +# .github/workflows/test.yml +name: Test Suite +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: astral-sh/setup-uv@v3 + - run: uv sync + - run: cd backend && uv run pytest tests/ --cov=. --cov-fail-under=70 +``` + +## Test Execution Commands + +### Run all tests: +```bash +cd backend && uv run pytest tests/ -v +``` + +### Run with coverage: +```bash +cd backend && uv run pytest tests/ --cov=. --cov-report=term-missing +``` + +### Run specific test file: +```bash +cd backend && uv run pytest tests/test_search_tools.py -v +``` + +### Run with detailed output: +```bash +cd backend && uv run pytest tests/ -vv --tb=long +``` + +## Conclusion + +The test suite successfully validates the core functionality of the RAG chatbot system. All 44 tests are passing, achieving 71% overall coverage with 98-100% coverage on critical components. The testing revealed minor issues that were fixed and identified several areas for production hardening. The system's tool-calling architecture and RAG pipeline are functioning correctly as designed. \ No newline at end of file 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..f946901cd --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,246 @@ +import pytest +import sys +import os +from unittest.mock import Mock, MagicMock, patch +from typing import List, Dict, Any + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from models import Course, Lesson, CourseChunk +from vector_store import SearchResults +from config import Config + +@pytest.fixture +def mock_config(): + config = Config() + config.ANTHROPIC_API_KEY = "test-api-key" + config.ANTHROPIC_MODEL = "claude-sonnet-4-20250514" + config.EMBEDDING_MODEL = "all-MiniLM-L6-v2" + config.CHUNK_SIZE = 800 + config.CHUNK_OVERLAP = 100 + config.MAX_RESULTS = 5 + config.MAX_HISTORY = 2 + config.CHROMA_PATH = "./test_chroma_db" + return config + +@pytest.fixture +def sample_courses(): + courses = [ + Course( + title="Introduction to MCP", + course_link="https://example.com/mcp", + instructor="Dr. Smith", + lessons=[ + Lesson(lesson_number=1, title="Getting Started", lesson_link="https://example.com/mcp/lesson1"), + Lesson(lesson_number=2, title="Core Concepts", lesson_link="https://example.com/mcp/lesson2"), + Lesson(lesson_number=3, title="Advanced Topics", lesson_link="https://example.com/mcp/lesson3"), + ] + ), + Course( + title="Advanced AI Systems", + course_link="https://example.com/ai", + instructor="Prof. Johnson", + lessons=[ + Lesson(lesson_number=1, title="Neural Networks", lesson_link="https://example.com/ai/lesson1"), + Lesson(lesson_number=2, title="Deep Learning", lesson_link="https://example.com/ai/lesson2"), + ] + ) + ] + return courses + +@pytest.fixture +def sample_course_chunks(): + chunks = [ + CourseChunk( + content="MCP is a powerful framework for building modern applications.", + course_title="Introduction to MCP", + lesson_number=1, + chunk_index=0 + ), + CourseChunk( + content="The core concepts of MCP include components, services, and pipelines.", + course_title="Introduction to MCP", + lesson_number=2, + chunk_index=0 + ), + CourseChunk( + content="Neural networks are the foundation of deep learning systems.", + course_title="Advanced AI Systems", + lesson_number=1, + chunk_index=0 + ), + CourseChunk( + content="Deep learning enables complex pattern recognition tasks.", + course_title="Advanced AI Systems", + lesson_number=2, + chunk_index=0 + ), + ] + return chunks + +@pytest.fixture +def sample_search_results(): + return SearchResults( + documents=[ + "MCP is a powerful framework for building modern applications.", + "The core concepts of MCP include components, services, and pipelines." + ], + metadata=[ + {"course_title": "Introduction to MCP", "lesson_number": 1}, + {"course_title": "Introduction to MCP", "lesson_number": 2} + ], + distances=[0.1, 0.2] + ) + +@pytest.fixture +def empty_search_results(): + return SearchResults( + documents=[], + metadata=[], + distances=[] + ) + +@pytest.fixture +def mock_vector_store(): + mock_store = Mock() + + mock_store.search.return_value = SearchResults( + documents=["Sample content about MCP framework"], + metadata=[{"course_title": "Introduction to MCP", "lesson_number": 1}], + distances=[0.1] + ) + + mock_store.get_lesson_link.return_value = "https://example.com/mcp/lesson1" + + mock_store.get_course_outline.return_value = { + "course_title": "Introduction to MCP", + "instructor": "Dr. Smith", + "course_link": "https://example.com/mcp", + "lesson_count": 3, + "lessons": [ + {"lesson_number": 1, "lesson_title": "Getting Started"}, + {"lesson_number": 2, "lesson_title": "Core Concepts"}, + {"lesson_number": 3, "lesson_title": "Advanced Topics"} + ] + } + + mock_store.get_course_count.return_value = 2 + mock_store.get_existing_course_titles.return_value = ["Introduction to MCP", "Advanced AI Systems"] + mock_store.add_course_metadata = Mock() + mock_store.add_course_content = Mock() + + return mock_store + +@pytest.fixture +def mock_anthropic_client(): + mock_client = Mock() + + mock_response = Mock() + mock_response.stop_reason = "end_turn" + mock_response.content = [Mock(text="This is a test response from Claude.")] + + mock_client.messages.create.return_value = mock_response + + return mock_client + +@pytest.fixture +def mock_anthropic_client_with_tool_use(): + mock_client = Mock() + + initial_response = Mock() + initial_response.stop_reason = "tool_use" + + tool_use_block = Mock() + tool_use_block.type = "tool_use" + tool_use_block.name = "search_course_content" + tool_use_block.id = "tool_123" + tool_use_block.input = {"query": "MCP framework", "course_name": "Introduction to MCP"} + + initial_response.content = [tool_use_block] + + final_response = Mock() + final_response.stop_reason = "end_turn" + final_response.content = [Mock(text="Based on the search, MCP is a powerful framework.")] + + mock_client.messages.create.side_effect = [initial_response, final_response] + + return mock_client + +@pytest.fixture +def mock_document_processor(): + mock_processor = Mock() + + course = Course( + title="Test Course", + lessons=[Lesson(lesson_number=1, title="Test Lesson")] + ) + + chunks = [ + CourseChunk( + content="Test content", + course_title="Test Course", + lesson_number=1, + chunk_index=0 + ) + ] + + mock_processor.process_course_document.return_value = (course, chunks) + + return mock_processor + +@pytest.fixture +def mock_session_manager(): + mock_manager = Mock() + mock_manager.get_conversation_history.return_value = "Previous conversation context" + mock_manager.add_exchange = Mock() + return mock_manager + +@pytest.fixture +def mock_tool_manager(): + mock_manager = Mock() + + mock_manager.get_tool_definitions.return_value = [ + { + "name": "search_course_content", + "description": "Search course materials", + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "course_name": {"type": "string"}, + "lesson_number": {"type": "integer"} + }, + "required": ["query"] + } + } + ] + + mock_manager.execute_tool.return_value = "Search results: MCP is a framework" + mock_manager.get_last_sources.return_value = [ + {"text": "Introduction to MCP - Lesson 1", "url": "https://example.com/mcp/lesson1"} + ] + mock_manager.reset_sources = Mock() + + return mock_manager + +@pytest.fixture +def mock_chroma_collection(): + mock_collection = Mock() + + mock_collection.query.return_value = { + 'documents': [["Sample document content"]], + 'metadatas': [[{"course_title": "Test Course", "lesson_number": 1}]], + 'distances': [[0.1]] + } + + mock_collection.add = Mock() + mock_collection.get = Mock(return_value={'ids': []}) + mock_collection.delete = Mock() + + return mock_collection + +@pytest.fixture +def mock_chroma_client(mock_chroma_collection): + mock_client = Mock() + mock_client.get_or_create_collection.return_value = mock_chroma_collection + return mock_client \ No newline at end of file diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py new file mode 100644 index 000000000..b503c04ef --- /dev/null +++ b/backend/tests/test_ai_generator.py @@ -0,0 +1,268 @@ +import pytest +from unittest.mock import Mock, patch, MagicMock +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ai_generator import AIGenerator + +class TestAIGenerator: + + def test_initialization(self, mock_config): + with patch('ai_generator.anthropic.Anthropic') as mock_anthropic_class: + ai_gen = AIGenerator(mock_config.ANTHROPIC_API_KEY, mock_config.ANTHROPIC_MODEL) + + mock_anthropic_class.assert_called_once_with(api_key=mock_config.ANTHROPIC_API_KEY) + assert ai_gen.model == mock_config.ANTHROPIC_MODEL + assert ai_gen.base_params["model"] == mock_config.ANTHROPIC_MODEL + assert ai_gen.base_params["temperature"] == 0 + assert ai_gen.base_params["max_tokens"] == 800 + + def test_generate_response_without_tools(self, mock_config, mock_anthropic_client): + with patch('ai_generator.anthropic.Anthropic', return_value=mock_anthropic_client): + ai_gen = AIGenerator(mock_config.ANTHROPIC_API_KEY, mock_config.ANTHROPIC_MODEL) + + response = ai_gen.generate_response( + query="What is MCP?", + conversation_history=None, + tools=None, + tool_manager=None + ) + + assert response == "This is a test response from Claude." + + call_args = mock_anthropic_client.messages.create.call_args + assert call_args.kwargs["model"] == mock_config.ANTHROPIC_MODEL + assert call_args.kwargs["messages"][0]["role"] == "user" + assert call_args.kwargs["messages"][0]["content"] == "What is MCP?" + assert "tools" not in call_args.kwargs + + def test_generate_response_with_conversation_history(self, mock_config, mock_anthropic_client): + with patch('ai_generator.anthropic.Anthropic', return_value=mock_anthropic_client): + ai_gen = AIGenerator(mock_config.ANTHROPIC_API_KEY, mock_config.ANTHROPIC_MODEL) + + response = ai_gen.generate_response( + query="Tell me more", + conversation_history="Previous context about MCP", + tools=None, + tool_manager=None + ) + + call_args = mock_anthropic_client.messages.create.call_args + expected_system = AIGenerator.SYSTEM_PROMPT + "\n\nPrevious conversation:\nPrevious context about MCP" + assert call_args.kwargs["system"] == expected_system + + def test_generate_response_with_tools_no_usage(self, mock_config, mock_anthropic_client, mock_tool_manager): + tool_definitions = [ + { + "name": "search_course_content", + "description": "Search course materials", + "input_schema": {"type": "object", "properties": {}} + } + ] + + with patch('ai_generator.anthropic.Anthropic', return_value=mock_anthropic_client): + ai_gen = AIGenerator(mock_config.ANTHROPIC_API_KEY, mock_config.ANTHROPIC_MODEL) + + response = ai_gen.generate_response( + query="What is 2+2?", + conversation_history=None, + tools=tool_definitions, + tool_manager=mock_tool_manager + ) + + assert response == "This is a test response from Claude." + + call_args = mock_anthropic_client.messages.create.call_args + assert call_args.kwargs["tools"] == tool_definitions + assert call_args.kwargs["tool_choice"] == {"type": "auto"} + + def test_generate_response_with_tool_usage(self, mock_config, mock_anthropic_client_with_tool_use, mock_tool_manager): + tool_definitions = [ + { + "name": "search_course_content", + "description": "Search course materials", + "input_schema": {"type": "object", "properties": {}} + } + ] + + mock_tool_manager.execute_tool.return_value = "MCP is a powerful framework for building applications." + + with patch('ai_generator.anthropic.Anthropic', return_value=mock_anthropic_client_with_tool_use): + ai_gen = AIGenerator(mock_config.ANTHROPIC_API_KEY, mock_config.ANTHROPIC_MODEL) + + response = ai_gen.generate_response( + query="What is MCP framework?", + conversation_history=None, + tools=tool_definitions, + tool_manager=mock_tool_manager + ) + + assert response == "Based on the search, MCP is a powerful framework." + + mock_tool_manager.execute_tool.assert_called_once_with( + "search_course_content", + query="MCP framework", + course_name="Introduction to MCP" + ) + + assert mock_anthropic_client_with_tool_use.messages.create.call_count == 2 + + def test_handle_tool_execution_single_tool(self, mock_config, mock_tool_manager): + initial_response = Mock() + initial_response.stop_reason = "tool_use" + + tool_block = Mock() + tool_block.type = "tool_use" + tool_block.name = "search_course_content" + tool_block.id = "tool_123" + tool_block.input = {"query": "neural networks"} + + initial_response.content = [tool_block] + + base_params = { + "messages": [{"role": "user", "content": "Tell me about neural networks"}], + "system": "System prompt", + "model": "claude-sonnet-4-20250514", + "temperature": 0, + "max_tokens": 800 + } + + mock_tool_manager.execute_tool.return_value = "Neural networks are computational models." + + final_response = Mock() + final_response.content = [Mock(text="Neural networks are fascinating computational models.")] + + with patch('ai_generator.anthropic.Anthropic') as mock_anthropic_class: + mock_client = Mock() + mock_client.messages.create.return_value = final_response + mock_anthropic_class.return_value = mock_client + + ai_gen = AIGenerator(mock_config.ANTHROPIC_API_KEY, mock_config.ANTHROPIC_MODEL) + result = ai_gen._handle_tool_execution(initial_response, base_params, mock_tool_manager) + + assert result == "Neural networks are fascinating computational models." + + mock_tool_manager.execute_tool.assert_called_once_with( + "search_course_content", + query="neural networks" + ) + + final_call_args = mock_client.messages.create.call_args + assert len(final_call_args.kwargs["messages"]) == 3 + assert final_call_args.kwargs["messages"][2]["role"] == "user" + assert final_call_args.kwargs["messages"][2]["content"][0]["type"] == "tool_result" + + def test_handle_tool_execution_multiple_tools(self, mock_config, mock_tool_manager): + initial_response = Mock() + initial_response.stop_reason = "tool_use" + + tool_block1 = Mock() + tool_block1.type = "tool_use" + tool_block1.name = "search_course_content" + tool_block1.id = "tool_1" + tool_block1.input = {"query": "MCP basics"} + + tool_block2 = Mock() + tool_block2.type = "tool_use" + tool_block2.name = "get_course_outline" + tool_block2.id = "tool_2" + tool_block2.input = {"course_title": "Introduction to MCP"} + + initial_response.content = [tool_block1, tool_block2] + + base_params = { + "messages": [{"role": "user", "content": "Tell me about MCP course"}], + "system": "System prompt", + "model": "claude-sonnet-4-20250514", + "temperature": 0, + "max_tokens": 800 + } + + mock_tool_manager.execute_tool.side_effect = [ + "MCP is a framework.", + "Course outline: 3 lessons" + ] + + final_response = Mock() + final_response.content = [Mock(text="Combined response about MCP.")] + + with patch('ai_generator.anthropic.Anthropic') as mock_anthropic_class: + mock_client = Mock() + mock_client.messages.create.return_value = final_response + mock_anthropic_class.return_value = mock_client + + ai_gen = AIGenerator(mock_config.ANTHROPIC_API_KEY, mock_config.ANTHROPIC_MODEL) + result = ai_gen._handle_tool_execution(initial_response, base_params, mock_tool_manager) + + assert result == "Combined response about MCP." + assert mock_tool_manager.execute_tool.call_count == 2 + + mock_tool_manager.execute_tool.assert_any_call( + "search_course_content", + query="MCP basics" + ) + mock_tool_manager.execute_tool.assert_any_call( + "get_course_outline", + course_title="Introduction to MCP" + ) + + def test_system_prompt_content(self): + expected_keywords = [ + "search_course_content", + "get_course_outline", + "Tool Usage Guidelines", + "Response Protocol", + "Brief, Concise and focused", + "Educational" + ] + + for keyword in expected_keywords: + assert keyword in AIGenerator.SYSTEM_PROMPT + + def test_generate_response_with_empty_tools_list(self, mock_config, mock_anthropic_client): + with patch('ai_generator.anthropic.Anthropic', return_value=mock_anthropic_client): + ai_gen = AIGenerator(mock_config.ANTHROPIC_API_KEY, mock_config.ANTHROPIC_MODEL) + + response = ai_gen.generate_response( + query="Test query", + conversation_history=None, + tools=[], + tool_manager=None + ) + + call_args = mock_anthropic_client.messages.create.call_args + assert "tools" not in call_args.kwargs + + def test_handle_tool_execution_no_tool_blocks(self, mock_config, mock_tool_manager): + initial_response = Mock() + initial_response.stop_reason = "tool_use" + + text_block = Mock() + text_block.type = "text" + text_block.text = "Some text" + + initial_response.content = [text_block] + + base_params = { + "messages": [{"role": "user", "content": "Query"}], + "system": "System prompt", + "model": "claude-sonnet-4-20250514", + "temperature": 0, + "max_tokens": 800 + } + + final_response = Mock() + final_response.content = [Mock(text="Final response")] + + with patch('ai_generator.anthropic.Anthropic') as mock_anthropic_class: + mock_client = Mock() + mock_client.messages.create.return_value = final_response + mock_anthropic_class.return_value = mock_client + + ai_gen = AIGenerator(mock_config.ANTHROPIC_API_KEY, mock_config.ANTHROPIC_MODEL) + result = ai_gen._handle_tool_execution(initial_response, base_params, mock_tool_manager) + + assert result == "Final response" + mock_tool_manager.execute_tool.assert_not_called() \ No newline at end of file diff --git a/backend/tests/test_rag_system.py b/backend/tests/test_rag_system.py new file mode 100644 index 000000000..f901b71e0 --- /dev/null +++ b/backend/tests/test_rag_system.py @@ -0,0 +1,320 @@ +import pytest +from unittest.mock import Mock, patch, MagicMock, call +import sys +import os +from typing import List, Tuple + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from rag_system import RAGSystem +from models import Course, Lesson, CourseChunk + +class TestRAGSystem: + + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_initialization(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, mock_config): + rag_system = RAGSystem(mock_config) + + mock_doc_processor_class.assert_called_once_with( + mock_config.CHUNK_SIZE, + mock_config.CHUNK_OVERLAP + ) + mock_vector_store_class.assert_called_once_with( + mock_config.CHROMA_PATH, + mock_config.EMBEDDING_MODEL, + mock_config.MAX_RESULTS + ) + mock_ai_gen_class.assert_called_once_with( + mock_config.ANTHROPIC_API_KEY, + mock_config.ANTHROPIC_MODEL + ) + mock_session_manager_class.assert_called_once_with(mock_config.MAX_HISTORY) + + assert hasattr(rag_system, 'tool_manager') + assert hasattr(rag_system, 'search_tool') + assert hasattr(rag_system, 'outline_tool') + + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_add_course_document_successful(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, + mock_config, sample_courses, sample_course_chunks): + mock_doc_processor = Mock() + mock_doc_processor.process_course_document.return_value = ( + sample_courses[0], sample_course_chunks[:2] + ) + mock_doc_processor_class.return_value = mock_doc_processor + + mock_vector_store = Mock() + mock_vector_store_class.return_value = mock_vector_store + + rag_system = RAGSystem(mock_config) + course, chunk_count = rag_system.add_course_document("/path/to/course.txt") + + assert course == sample_courses[0] + assert chunk_count == 2 + + mock_doc_processor.process_course_document.assert_called_once_with("/path/to/course.txt") + mock_vector_store.add_course_metadata.assert_called_once_with(sample_courses[0]) + mock_vector_store.add_course_content.assert_called_once_with(sample_course_chunks[:2]) + + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_add_course_document_with_error(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, mock_config): + mock_doc_processor = Mock() + mock_doc_processor.process_course_document.side_effect = Exception("Processing error") + mock_doc_processor_class.return_value = mock_doc_processor + + rag_system = RAGSystem(mock_config) + course, chunk_count = rag_system.add_course_document("/path/to/bad_file.txt") + + assert course is None + assert chunk_count == 0 + + @patch('os.path.exists') + @patch('os.listdir') + @patch('os.path.isfile') + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_add_course_folder_new_courses(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, + mock_isfile, mock_listdir, mock_exists, + mock_config, sample_courses, sample_course_chunks): + mock_exists.return_value = True + mock_listdir.return_value = ["course1.txt", "course2.pdf", "readme.md"] + mock_isfile.side_effect = lambda x: True + + mock_doc_processor = Mock() + mock_doc_processor.process_course_document.side_effect = [ + (sample_courses[0], sample_course_chunks[:2]), + (sample_courses[1], sample_course_chunks[2:]) + ] + mock_doc_processor_class.return_value = mock_doc_processor + + mock_vector_store = Mock() + mock_vector_store.get_existing_course_titles.return_value = [] + mock_vector_store_class.return_value = mock_vector_store + + rag_system = RAGSystem(mock_config) + total_courses, total_chunks = rag_system.add_course_folder("/docs") + + assert total_courses == 2 + assert total_chunks == 4 + assert mock_vector_store.add_course_metadata.call_count == 2 + assert mock_vector_store.add_course_content.call_count == 2 + + @patch('os.path.exists') + @patch('os.listdir') + @patch('os.path.isfile') + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_add_course_folder_skip_existing(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, + mock_isfile, mock_listdir, mock_exists, + mock_config, sample_courses): + mock_exists.return_value = True + mock_listdir.return_value = ["course1.txt"] + mock_isfile.return_value = True + + mock_doc_processor = Mock() + mock_doc_processor.process_course_document.return_value = (sample_courses[0], []) + mock_doc_processor_class.return_value = mock_doc_processor + + mock_vector_store = Mock() + mock_vector_store.get_existing_course_titles.return_value = ["Introduction to MCP"] + mock_vector_store_class.return_value = mock_vector_store + + rag_system = RAGSystem(mock_config) + total_courses, total_chunks = rag_system.add_course_folder("/docs") + + assert total_courses == 0 + assert total_chunks == 0 + mock_vector_store.add_course_metadata.assert_not_called() + mock_vector_store.add_course_content.assert_not_called() + + @patch('os.path.exists') + @patch('os.listdir') + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_add_course_folder_clear_existing(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, + mock_listdir, mock_exists, mock_config): + mock_exists.return_value = True + mock_listdir.return_value = [] + + mock_vector_store = Mock() + mock_vector_store.get_existing_course_titles.return_value = [] + mock_vector_store_class.return_value = mock_vector_store + + rag_system = RAGSystem(mock_config) + rag_system.add_course_folder("/docs", clear_existing=True) + + mock_vector_store.clear_all_data.assert_called_once() + + @patch('os.path.exists') + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_add_course_folder_nonexistent(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, + mock_exists, mock_config): + mock_exists.return_value = False + + rag_system = RAGSystem(mock_config) + total_courses, total_chunks = rag_system.add_course_folder("/nonexistent") + + assert total_courses == 0 + assert total_chunks == 0 + + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_query_without_session(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, mock_config): + mock_ai_gen = Mock() + mock_ai_gen.generate_response.return_value = "MCP is a framework for building applications." + mock_ai_gen_class.return_value = mock_ai_gen + + mock_session_manager = Mock() + mock_session_manager_class.return_value = mock_session_manager + + rag_system = RAGSystem(mock_config) + rag_system.tool_manager.get_last_sources = Mock(return_value=[ + {"text": "Introduction to MCP - Lesson 1", "url": "https://example.com/mcp/1"} + ]) + rag_system.tool_manager.reset_sources = Mock() + + response, sources = rag_system.query("What is MCP?") + + assert response == "MCP is a framework for building applications." + assert len(sources) == 1 + assert sources[0]["text"] == "Introduction to MCP - Lesson 1" + + mock_ai_gen.generate_response.assert_called_once() + call_args = mock_ai_gen.generate_response.call_args + assert "What is MCP?" in call_args.kwargs["query"] + assert call_args.kwargs["conversation_history"] is None + assert call_args.kwargs["tools"] is not None + assert call_args.kwargs["tool_manager"] is not None + + mock_session_manager.get_conversation_history.assert_not_called() + mock_session_manager.add_exchange.assert_not_called() + rag_system.tool_manager.reset_sources.assert_called_once() + + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_query_with_session(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, mock_config): + mock_ai_gen = Mock() + mock_ai_gen.generate_response.return_value = "Based on our previous discussion, MCP is..." + mock_ai_gen_class.return_value = mock_ai_gen + + mock_session_manager = Mock() + mock_session_manager.get_conversation_history.return_value = "Previous context about MCP" + mock_session_manager_class.return_value = mock_session_manager + + rag_system = RAGSystem(mock_config) + rag_system.tool_manager.get_last_sources = Mock(return_value=[]) + rag_system.tool_manager.reset_sources = Mock() + + response, sources = rag_system.query("Tell me more", session_id="session123") + + assert response == "Based on our previous discussion, MCP is..." + assert sources == [] + + mock_session_manager.get_conversation_history.assert_called_once_with("session123") + mock_session_manager.add_exchange.assert_called_once_with( + "session123", "Tell me more", "Based on our previous discussion, MCP is..." + ) + + call_args = mock_ai_gen.generate_response.call_args + assert call_args.kwargs["conversation_history"] == "Previous context about MCP" + + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_query_with_multiple_sources(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, mock_config): + mock_ai_gen = Mock() + mock_ai_gen.generate_response.return_value = "Comprehensive answer" + mock_ai_gen_class.return_value = mock_ai_gen + + rag_system = RAGSystem(mock_config) + + multiple_sources = [ + {"text": "Course A - Lesson 1", "url": "https://example.com/a/1"}, + {"text": "Course B - Lesson 2", "url": "https://example.com/b/2"}, + {"text": "Course C", "url": None} + ] + rag_system.tool_manager.get_last_sources = Mock(return_value=multiple_sources) + rag_system.tool_manager.reset_sources = Mock() + + response, sources = rag_system.query("Complex question") + + assert response == "Comprehensive answer" + assert len(sources) == 3 + assert sources[0]["text"] == "Course A - Lesson 1" + assert sources[1]["url"] == "https://example.com/b/2" + assert sources[2]["url"] is None + + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_get_course_analytics(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, mock_config): + mock_vector_store = Mock() + mock_vector_store.get_course_count.return_value = 5 + mock_vector_store.get_existing_course_titles.return_value = [ + "Course 1", "Course 2", "Course 3", "Course 4", "Course 5" + ] + mock_vector_store_class.return_value = mock_vector_store + + rag_system = RAGSystem(mock_config) + analytics = rag_system.get_course_analytics() + + assert analytics["total_courses"] == 5 + assert len(analytics["course_titles"]) == 5 + assert "Course 1" in analytics["course_titles"] + + @patch('rag_system.SessionManager') + @patch('rag_system.AIGenerator') + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_tool_registration(self, mock_doc_processor_class, mock_vector_store_class, + mock_ai_gen_class, mock_session_manager_class, mock_config): + mock_vector_store = Mock() + mock_vector_store_class.return_value = mock_vector_store + + rag_system = RAGSystem(mock_config) + + assert rag_system.search_tool is not None + assert rag_system.outline_tool is not None + assert "search_course_content" in rag_system.tool_manager.tools + assert "get_course_outline" in rag_system.tool_manager.tools + + tool_definitions = rag_system.tool_manager.get_tool_definitions() + assert len(tool_definitions) == 2 + tool_names = [t["name"] for t in tool_definitions] + assert "search_course_content" in tool_names + assert "get_course_outline" in tool_names \ No newline at end of file diff --git a/backend/tests/test_search_tools.py b/backend/tests/test_search_tools.py new file mode 100644 index 000000000..83afb6ab5 --- /dev/null +++ b/backend/tests/test_search_tools.py @@ -0,0 +1,337 @@ +import pytest +from unittest.mock import Mock, patch +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from search_tools import CourseSearchTool, CourseOutlineTool, ToolManager +from vector_store import SearchResults + +class TestCourseSearchTool: + + def test_get_tool_definition(self, mock_vector_store): + tool = CourseSearchTool(mock_vector_store) + definition = tool.get_tool_definition() + + assert definition["name"] == "search_course_content" + assert "description" in definition + assert "input_schema" in definition + assert definition["input_schema"]["properties"]["query"]["type"] == "string" + assert definition["input_schema"]["properties"]["course_name"]["type"] == "string" + assert definition["input_schema"]["properties"]["lesson_number"]["type"] == "integer" + assert definition["input_schema"]["required"] == ["query"] + + def test_execute_with_query_only(self, mock_vector_store, sample_search_results): + mock_vector_store.search.return_value = sample_search_results + mock_vector_store.get_lesson_link.return_value = "https://example.com/mcp/lesson1" + + tool = CourseSearchTool(mock_vector_store) + result = tool.execute(query="MCP framework") + + mock_vector_store.search.assert_called_once_with( + query="MCP framework", + course_name=None, + lesson_number=None + ) + + assert "[Introduction to MCP - Lesson 1]" in result + assert "MCP is a powerful framework" in result + assert len(tool.last_sources) == 2 + assert tool.last_sources[0]["text"] == "Introduction to MCP - Lesson 1" + assert tool.last_sources[0]["url"] == "https://example.com/mcp/lesson1" + + def test_execute_with_course_filter(self, mock_vector_store, sample_search_results): + mock_vector_store.search.return_value = sample_search_results + mock_vector_store.get_lesson_link.return_value = None + + tool = CourseSearchTool(mock_vector_store) + result = tool.execute(query="components", course_name="Introduction to MCP") + + mock_vector_store.search.assert_called_once_with( + query="components", + course_name="Introduction to MCP", + lesson_number=None + ) + + assert "[Introduction to MCP - Lesson 1]" in result + assert "[Introduction to MCP - Lesson 2]" in result + assert tool.last_sources[0]["url"] is None + + def test_execute_with_lesson_filter(self, mock_vector_store): + filtered_results = SearchResults( + documents=["Content from lesson 2"], + metadata=[{"course_title": "Introduction to MCP", "lesson_number": 2}], + distances=[0.15] + ) + mock_vector_store.search.return_value = filtered_results + mock_vector_store.get_lesson_link.return_value = "https://example.com/mcp/lesson2" + + tool = CourseSearchTool(mock_vector_store) + result = tool.execute(query="concepts", lesson_number=2) + + mock_vector_store.search.assert_called_once_with( + query="concepts", + course_name=None, + lesson_number=2 + ) + + assert "[Introduction to MCP - Lesson 2]" in result + assert "Content from lesson 2" in result + + def test_execute_with_both_filters(self, mock_vector_store): + specific_results = SearchResults( + documents=["Specific content from MCP lesson 3"], + metadata=[{"course_title": "Introduction to MCP", "lesson_number": 3}], + distances=[0.05] + ) + mock_vector_store.search.return_value = specific_results + + tool = CourseSearchTool(mock_vector_store) + result = tool.execute( + query="advanced topics", + course_name="Introduction to MCP", + lesson_number=3 + ) + + mock_vector_store.search.assert_called_once_with( + query="advanced topics", + course_name="Introduction to MCP", + lesson_number=3 + ) + + assert "[Introduction to MCP - Lesson 3]" in result + assert "Specific content from MCP lesson 3" in result + + def test_execute_with_error(self, mock_vector_store): + error_results = SearchResults( + documents=[], + metadata=[], + distances=[], + error="Course not found" + ) + mock_vector_store.search.return_value = error_results + + tool = CourseSearchTool(mock_vector_store) + result = tool.execute(query="test", course_name="NonExistent") + + assert result == "Course not found" + assert tool.last_sources == [] + + def test_execute_with_empty_results(self, mock_vector_store, empty_search_results): + mock_vector_store.search.return_value = empty_search_results + + tool = CourseSearchTool(mock_vector_store) + result = tool.execute(query="nonexistent content") + + assert result == "No relevant content found." + assert tool.last_sources == [] + + def test_execute_empty_results_with_filters(self, mock_vector_store, empty_search_results): + mock_vector_store.search.return_value = empty_search_results + + tool = CourseSearchTool(mock_vector_store) + result = tool.execute( + query="nonexistent", + course_name="Test Course", + lesson_number=5 + ) + + assert result == "No relevant content found in course 'Test Course' in lesson 5." + + def test_format_results_multiple_documents(self, mock_vector_store): + results = SearchResults( + documents=[ + "First document content", + "Second document content", + "Third document content" + ], + metadata=[ + {"course_title": "Course A", "lesson_number": 1}, + {"course_title": "Course B", "lesson_number": None}, + {"course_title": "Course A", "lesson_number": 2} + ], + distances=[0.1, 0.2, 0.3] + ) + + mock_vector_store.get_lesson_link.side_effect = [ + "https://example.com/a/1", + None, + "https://example.com/a/2" + ] + + tool = CourseSearchTool(mock_vector_store) + formatted = tool._format_results(results) + + assert "[Course A - Lesson 1]" in formatted + assert "[Course B]" in formatted + assert "[Course A - Lesson 2]" in formatted + assert "First document content" in formatted + assert "Second document content" in formatted + assert "Third document content" in formatted + + assert len(tool.last_sources) == 3 + assert tool.last_sources[0]["text"] == "Course A - Lesson 1" + assert tool.last_sources[0]["url"] == "https://example.com/a/1" + assert tool.last_sources[1]["text"] == "Course B" + assert tool.last_sources[1]["url"] is None + + +class TestCourseOutlineTool: + + def test_get_tool_definition(self, mock_vector_store): + tool = CourseOutlineTool(mock_vector_store) + definition = tool.get_tool_definition() + + assert definition["name"] == "get_course_outline" + assert "description" in definition + assert "input_schema" in definition + assert definition["input_schema"]["properties"]["course_title"]["type"] == "string" + assert definition["input_schema"]["required"] == ["course_title"] + + def test_execute_successful(self, mock_vector_store): + outline_data = { + "course_title": "Introduction to MCP", + "instructor": "Dr. Smith", + "course_link": "https://example.com/mcp", + "lesson_count": 3, + "lessons": [ + {"lesson_number": 1, "lesson_title": "Getting Started"}, + {"lesson_number": 2, "lesson_title": "Core Concepts"}, + {"lesson_number": 3, "lesson_title": "Advanced Topics"} + ] + } + mock_vector_store.get_course_outline.return_value = outline_data + + tool = CourseOutlineTool(mock_vector_store) + result = tool.execute(course_title="Introduction to MCP") + + mock_vector_store.get_course_outline.assert_called_once_with("Introduction to MCP") + + assert "Course: Introduction to MCP" in result + assert "Instructor: Dr. Smith" in result + assert "Course Link: https://example.com/mcp" in result + assert "Total Lessons: 3" in result + assert "1. Getting Started" in result + assert "2. Core Concepts" in result + assert "3. Advanced Topics" in result + + def test_execute_course_not_found(self, mock_vector_store): + mock_vector_store.get_course_outline.return_value = None + + tool = CourseOutlineTool(mock_vector_store) + result = tool.execute(course_title="Nonexistent Course") + + assert result == "No course found matching 'Nonexistent Course'" + + def test_format_outline_minimal_data(self, mock_vector_store): + minimal_outline = { + "course_title": "Basic Course", + "lessons": [] + } + + tool = CourseOutlineTool(mock_vector_store) + result = tool._format_outline(minimal_outline) + + assert "Course: Basic Course" in result + assert "Total Lessons: 0" in result + assert "Instructor:" not in result + assert "Course Link:" not in result + + def test_format_outline_with_missing_lesson_details(self, mock_vector_store): + outline = { + "course_title": "Test Course", + "lessons": [ + {"lesson_number": 1}, + {"lesson_title": "Unnamed Lesson"}, + {"lesson_number": 3, "lesson_title": "Complete Lesson"} + ] + } + + tool = CourseOutlineTool(mock_vector_store) + result = tool._format_outline(outline) + + assert "1. Untitled" in result + assert "N/A. Unnamed Lesson" in result + assert "3. Complete Lesson" in result + + +class TestToolManager: + + def test_register_tool(self, mock_vector_store): + manager = ToolManager() + tool = CourseSearchTool(mock_vector_store) + + manager.register_tool(tool) + + assert "search_course_content" in manager.tools + assert manager.tools["search_course_content"] == tool + + def test_register_tool_without_name(self, mock_vector_store): + manager = ToolManager() + + mock_tool = Mock() + mock_tool.get_tool_definition.return_value = {"description": "No name"} + + with pytest.raises(ValueError, match="Tool must have a 'name'"): + manager.register_tool(mock_tool) + + def test_get_tool_definitions(self, mock_vector_store): + manager = ToolManager() + search_tool = CourseSearchTool(mock_vector_store) + outline_tool = CourseOutlineTool(mock_vector_store) + + manager.register_tool(search_tool) + manager.register_tool(outline_tool) + + definitions = manager.get_tool_definitions() + + assert len(definitions) == 2 + assert any(d["name"] == "search_course_content" for d in definitions) + assert any(d["name"] == "get_course_outline" for d in definitions) + + def test_execute_tool_successful(self, mock_vector_store, sample_search_results): + mock_vector_store.search.return_value = sample_search_results + + manager = ToolManager() + tool = CourseSearchTool(mock_vector_store) + manager.register_tool(tool) + + result = manager.execute_tool("search_course_content", query="test query") + + assert "Introduction to MCP" in result + + def test_execute_tool_not_found(self): + manager = ToolManager() + result = manager.execute_tool("nonexistent_tool", param="value") + + assert result == "Tool 'nonexistent_tool' not found" + + def test_get_last_sources(self, mock_vector_store): + manager = ToolManager() + search_tool = CourseSearchTool(mock_vector_store) + search_tool.last_sources = [ + {"text": "Source 1", "url": "https://example.com/1"} + ] + manager.register_tool(search_tool) + + sources = manager.get_last_sources() + assert sources == [{"text": "Source 1", "url": "https://example.com/1"}] + + def test_get_last_sources_empty(self, mock_vector_store): + manager = ToolManager() + search_tool = CourseSearchTool(mock_vector_store) + manager.register_tool(search_tool) + + sources = manager.get_last_sources() + assert sources == [] + + def test_reset_sources(self, mock_vector_store): + manager = ToolManager() + search_tool = CourseSearchTool(mock_vector_store) + search_tool.last_sources = [{"text": "Source", "url": "url"}] + manager.register_tool(search_tool) + + manager.reset_sources() + + assert search_tool.last_sources == [] \ No newline at end of file diff --git a/backend/vector_store.py b/backend/vector_store.py index 390abe71c..7b66ab757 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -264,4 +264,41 @@ def get_lesson_link(self, course_title: str, lesson_number: int) -> Optional[str return None except Exception as e: print(f"Error getting lesson link: {e}") + return None + + def get_course_outline(self, course_title: str) -> Optional[Dict[str, Any]]: + """Get complete course outline with lesson details""" + import json + try: + # First try exact match + results = self.course_catalog.get(ids=[course_title]) + + # If no exact match, try semantic search + if not results or not results.get('metadatas') or not results['metadatas']: + resolved_title = self._resolve_course_name(course_title) + if not resolved_title: + return None + results = self.course_catalog.get(ids=[resolved_title]) + + if results and 'metadatas' in results and results['metadatas']: + metadata = results['metadatas'][0] + lessons_json = metadata.get('lessons_json') + + outline = { + 'course_title': metadata.get('title'), + 'course_link': metadata.get('course_link'), + 'instructor': metadata.get('instructor'), + 'lesson_count': metadata.get('lesson_count', 0), + 'lessons': [] + } + + if lessons_json: + lessons = json.loads(lessons_json) + outline['lessons'] = lessons + + return outline + return None + except Exception as e: + print(f"Error getting course outline: {e}") + return None \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index f8e25a62f..ce3d27846 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@