From 4e18e74dd38eb5567a4f0fde655b8d416b0c15b2 Mon Sep 17 00:00:00 2001 From: Abdellah HADDAD Date: Fri, 10 Apr 2026 16:24:00 +0100 Subject: [PATCH 01/10] Add CLAUDE.md with project guidance and rules Includes running instructions, architecture overview, and rules for uv usage, memory/file access restrictions. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..5f4e7766f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Running the Application + +**Quick start (from repo root):** +```bash +./run.sh +``` + +**Manual start (must run from `backend/` directory):** +```bash +cd backend && uv run uvicorn app:app --reload --port 8000 +``` + +The server runs at `http://localhost:8000`. API docs at `http://localhost:8000/docs`. + +**Install dependencies:** +```bash +uv sync +``` + +> Always use `uv` to run the server and manage packages. Never use `pip` directly. + +**Required environment variable** — create a `.env` file in the repo root: +``` +ANTHROPIC_API_KEY=your-key-here +``` + +## Architecture Overview + +This is a full-stack RAG (Retrieval-Augmented Generation) chatbot for querying course materials. + +**Backend** (`backend/`) is a FastAPI app that must be started from within the `backend/` directory (relative paths like `../docs` and `../frontend` depend on this). + +**Data flow for a query:** +1. `app.py` receives POST `/api/query` → calls `RAGSystem.query()` +2. `RAGSystem` (`rag_system.py`) builds a prompt and passes it to `AIGenerator` with the `search_course_content` tool available +3. `AIGenerator` (`ai_generator.py`) calls the Claude API; if Claude decides to search, it invokes the tool +4. `ToolManager` routes tool calls to `CourseSearchTool` (`search_tools.py`), which queries `VectorStore` +5. `VectorStore` (`vector_store.py`) uses ChromaDB with two collections: + - `course_catalog` — course-level metadata (title, instructor, links, lesson list as JSON) + - `course_content` — chunked lesson text for semantic search +6. The final Claude response + sources are returned to the frontend + +**Document ingestion** (happens at startup from `docs/` folder): +- `DocumentProcessor` (`document_processor.py`) parses `.txt`/`.pdf`/`.docx` files +- Expected file format: first 3 lines are `Course Title:`, `Course Link:`, `Course Instructor:`, followed by `Lesson N: ` markers and content +- Text is chunked into ~800-char sentence-based chunks with 100-char overlap +- `RAGSystem.add_course_folder()` skips courses already present in ChromaDB (deduplication by title) + +**Session management:** `SessionManager` keeps in-memory conversation history (default: last 2 exchanges = 4 messages). Sessions are identified by a string ID returned to and echoed back by the frontend. + +**Frontend** (`frontend/`) is plain HTML/JS/CSS served as static files by FastAPI from the `../frontend` path. + +**Configuration** (`backend/config.py`): all tuneable parameters (model, chunk size, ChromaDB path, max results, history length) are in the `Config` dataclass. ChromaDB is stored at `backend/chroma_db/` (relative to where uvicorn runs). + +**Tool extension:** To add a new tool, implement the `Tool` ABC in `search_tools.py` and call `tool_manager.register_tool(your_tool)` in `RAGSystem.__init__()`. + +## Rules +- Never read or write files outside this project folder without explicit permission +- Always ask before saving anything to memory or external locations +- Never access C:\Users\haddad\.claude\ without explicit permission +- Always use `uv` to add dependencies (e.g., `uv add <package>`); never use `pip` directly From 1734b2ff40192bf1f1eb66ec575c40f050bff222 Mon Sep 17 00:00:00 2001 From: Abdellah HADDAD <haddad@cse.ma> Date: Sun, 12 Apr 2026 16:23:05 +0100 Subject: [PATCH 02/10] Add CourseOutlineTool for course structure queries - Add get_course_outline() to VectorStore to fetch title, link, and lesson list from course_catalog - Add CourseOutlineTool class to search_tools.py with fuzzy course name matching - Register CourseOutlineTool in RAGSystem alongside CourseSearchTool - Update AIGenerator system prompt to route outline queries to the new tool Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- CLAUDE.md | 1 + backend/ai_generator.py | 5 ++++ backend/app.py | 8 +++++- backend/rag_system.py | 4 ++- backend/search_tools.py | 64 +++++++++++++++++++++++++++++++++++------ backend/vector_store.py | 20 +++++++++++++ frontend/index.html | 7 ++++- frontend/script.js | 23 ++++++++++++++- frontend/style.css | 49 +++++++++++++++++++++++++++---- 9 files changed, 163 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f4e7766f..edd6209c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,3 +63,4 @@ This is a full-stack RAG (Retrieval-Augmented Generation) chatbot for querying c - Always ask before saving anything to memory or external locations - Never access C:\Users\haddad\.claude\ without explicit permission - Always use `uv` to add dependencies (e.g., `uv add <package>`); never use `pip` directly +- Never start the server (`./run.sh` or `uvicorn`) — the user starts it manually \ No newline at end of file diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 0363ca90c..43b20f383 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -13,6 +13,11 @@ class AIGenerator: - Synthesize search results into accurate, fact-based responses - If search yields no results, state this clearly without offering alternatives +Outline Tool Usage: +- Use get_course_outline **only** for questions about course structure, syllabus, lesson list, or what topics a course covers +- Return the course title, course link, and each lesson number with its title +- Do not use the content search tool for outline queries + Response Protocol: - **General knowledge questions**: Answer using existing knowledge without searching - **Course-specific questions**: Search first, then answer diff --git a/backend/app.py b/backend/app.py index 5a69d741d..6bbb76292 100644 --- a/backend/app.py +++ b/backend/app.py @@ -43,7 +43,7 @@ class QueryRequest(BaseModel): class QueryResponse(BaseModel): """Response model for course queries""" answer: str - sources: List[str] + sources: List[dict] session_id: str class CourseStats(BaseModel): @@ -85,6 +85,12 @@ async def get_course_stats(): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@app.delete("/api/session/{session_id}") +async def delete_session(session_id: str): + """Clear conversation history for a session""" + rag_system.session_manager.clear_session(session_id) + return {"status": "cleared"} + @app.on_event("startup") async def startup_event(): """Load initial documents on startup""" diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8e..443649f0e 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -4,7 +4,7 @@ from vector_store import VectorStore from ai_generator import AIGenerator from session_manager import SessionManager -from search_tools import ToolManager, CourseSearchTool +from search_tools import ToolManager, CourseSearchTool, CourseOutlineTool from models import Course, Lesson, CourseChunk class RAGSystem: @@ -23,6 +23,8 @@ def __init__(self, config): self.tool_manager = ToolManager() self.search_tool = CourseSearchTool(self.vector_store) self.tool_manager.register_tool(self.search_tool) + self.outline_tool = CourseOutlineTool(self.vector_store) + self.tool_manager.register_tool(self.outline_tool) def add_course_document(self, file_path: str) -> Tuple[Course, int]: """ diff --git a/backend/search_tools.py b/backend/search_tools.py index adfe82352..fd2a33009 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -89,30 +89,76 @@ 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') - + # 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 + label = course_title if lesson_num is not None: - source += f" - Lesson {lesson_num}" - sources.append(source) - + label += f" - Lesson {lesson_num}" + + # Fetch lesson link from the catalog + url = None + if lesson_num is not None: + url = self.store.get_lesson_link(course_title, lesson_num) + + sources.append({"label": label, "url": url}) + formatted.append(f"{header}\n{doc}") - + # Store sources for retrieval self.last_sources = sources - + return "\n\n".join(formatted) +class CourseOutlineTool(Tool): + """Tool for retrieving a course outline (title, link, lesson list)""" + + def __init__(self, vector_store: VectorStore): + self.store = vector_store + + def get_tool_definition(self) -> Dict[str, Any]: + return { + "name": "get_course_outline", + "description": "Get the complete outline of a course: title, link, and all lesson numbers with titles", + "input_schema": { + "type": "object", + "properties": { + "course_name": { + "type": "string", + "description": "Course title to look up (partial matches work)" + } + }, + "required": ["course_name"] + } + } + + def execute(self, course_name: str) -> str: + outline = self.store.get_course_outline(course_name) + if not outline: + return f"No course found matching '{course_name}'." + + lines = [ + f"Course: {outline['title']}", + f"Link: {outline['course_link'] or 'N/A'}", + "", + "Lessons:" + ] + for lesson in outline['lessons']: + lines.append( + f" Lesson {lesson['lesson_number']}: {lesson['lesson_title']}" + ) + return "\n".join(lines) + + class ToolManager: """Manages available tools for the AI""" diff --git a/backend/vector_store.py b/backend/vector_store.py index 390abe71c..591c4d2ae 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -246,6 +246,26 @@ def get_course_link(self, course_title: str) -> Optional[str]: print(f"Error getting course link: {e}") return None + def get_course_outline(self, course_name: str) -> Optional[Dict[str, Any]]: + """Get course outline (title, link, lessons list) by course name (fuzzy match)""" + import json + course_title = self._resolve_course_name(course_name) + if not course_title: + return None + try: + results = self.course_catalog.get(ids=[course_title]) + if results and results['metadatas']: + meta = results['metadatas'][0] + lessons = json.loads(meta.get('lessons_json', '[]')) + return { + 'title': meta.get('title'), + 'course_link': meta.get('course_link'), + 'lessons': lessons + } + except Exception as e: + print(f"Error getting course outline: {e}") + return None + def get_lesson_link(self, course_title: str, lesson_number: int) -> Optional[str]: """Get lesson link for a given course title and lesson number""" import json diff --git a/frontend/index.html b/frontend/index.html index f8e25a62f..e0dfbc2b2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ <meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Expires" content="0"> <title>Course Materials Assistant - +
@@ -19,6 +19,11 @@

Course Materials Assistant

+ +
+ +
+
diff --git a/frontend/script.js b/frontend/script.js index 562a8a363..eafeb8849 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -30,6 +30,9 @@ function setupEventListeners() { }); + // New chat button + document.getElementById('newChatBtn').addEventListener('click', handleNewChat); + // Suggested questions document.querySelectorAll('.suggested-item').forEach(button => { button.addEventListener('click', (e) => { @@ -122,10 +125,17 @@ function addMessage(content, type, sources = null, isWelcome = false) { let html = `
${displayContent}
`; if (sources && sources.length > 0) { + const sourceItems = sources.map(s => { + const label = s.label || s; + const url = s.url; + return url + ? `${label}` + : `${label}`; + }).join(''); html += `
Sources -
${sources.join(', ')}
+
${sourceItems}
`; } @@ -146,6 +156,17 @@ function escapeHtml(text) { // Removed removeMessage function - no longer needed since we handle loading differently +async function handleNewChat() { + if (currentSessionId) { + try { + await fetch(`${API_URL}/session/${currentSessionId}`, { method: 'DELETE' }); + } catch (e) { + // Non-critical — proceed regardless + } + } + createNewSession(); +} + async function createNewSession() { currentSessionId = null; chatMessages.innerHTML = ''; diff --git a/frontend/style.css b/frontend/style.css index 825d03675..819930c91 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -241,8 +241,33 @@ header h1 { } .sources-content { - padding: 0 0.5rem 0.25rem 1.5rem; - color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0.25rem 0.5rem 0.25rem 0.5rem; +} + +.source-pill { + display: inline-block; + padding: 0.3rem 0.7rem; + border-radius: 999px; + font-size: 0.75rem; + background: rgba(99, 179, 237, 0.12); + border: 1px solid rgba(99, 179, 237, 0.35); + color: #90cdf4; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 280px; + transition: background 0.15s, border-color 0.15s; +} + +a.source-pill:hover { + background: rgba(99, 179, 237, 0.25); + border-color: rgba(99, 179, 237, 0.7); + color: #bee3f8; + text-decoration: none; } /* Markdown formatting styles */ @@ -447,7 +472,8 @@ header h1 { /* Sidebar Headers */ .stats-header, -.suggested-header { +.suggested-header, +.new-chat-btn { font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); @@ -460,15 +486,18 @@ header h1 { transition: color 0.2s ease; text-transform: uppercase; letter-spacing: 0.5px; + text-align: left; } .stats-header:focus, -.suggested-header:focus { +.suggested-header:focus, +.new-chat-btn:focus { color: var(--primary-color); } .stats-header:hover, -.suggested-header:hover { +.suggested-header:hover, +.new-chat-btn:hover { color: var(--primary-color); } @@ -491,6 +520,16 @@ details[open] .suggested-header::before { transform: rotate(90deg); } +/* New Chat Button */ +.new-chat-btn { + display: block; + width: 100%; + font-family: inherit; + -webkit-appearance: none; + appearance: none; + border: none; +} + /* Course Stats in Sidebar */ .course-stats { display: flex; From 0d239027c67cc45671d2f2162929244da1f1bd6e Mon Sep 17 00:00:00 2001 From: Abdellah HADDAD Date: Mon, 13 Apr 2026 16:04:15 +0100 Subject: [PATCH 03/10] Refactor AIGenerator for sequential tool calls and add test suite - Support up to 2 sequential tool-call rounds in AIGenerator (tool loop replaces single-shot _handle_tool_execution); adds synthesis fallback when round cap is hit - Guard VectorStore against ChromaDB ValueError on empty collections - Add pytest dev dependency and testpaths config in pyproject.toml - Add backend/tests/ with unit tests for AIGenerator, RAGSystem, and CourseSearchTool - Move "never start server" rule from CLAUDE.md to CLAUDE.local.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 3 +- backend/ai_generator.py | 137 +++++---- backend/tests/__init__.py | 0 backend/tests/conftest.py | 86 ++++++ backend/tests/test_ai_generator.py | 352 +++++++++++++++++++++++ backend/tests/test_course_search_tool.py | 190 ++++++++++++ backend/tests/test_rag_system.py | 209 ++++++++++++++ backend/vector_store.py | 10 +- pyproject.toml | 9 + uv.lock | 44 ++- 10 files changed, 980 insertions(+), 60 deletions(-) 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_course_search_tool.py create mode 100644 backend/tests/test_rag_system.py diff --git a/CLAUDE.md b/CLAUDE.md index edd6209c4..8a8f8bc2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,5 +62,4 @@ This is a full-stack RAG (Retrieval-Augmented Generation) chatbot for querying c - Never read or write files outside this project folder without explicit permission - Always ask before saving anything to memory or external locations - Never access C:\Users\haddad\.claude\ without explicit permission -- Always use `uv` to add dependencies (e.g., `uv add `); never use `pip` directly -- Never start the server (`./run.sh` or `uvicorn`) — the user starts it manually \ No newline at end of file +- Always use `uv` to add dependencies (e.g., `uv add `); never use `pip` directly \ No newline at end of file diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 43b20f383..13395ae68 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -3,13 +3,14 @@ class AIGenerator: """Handles interactions with Anthropic's Claude API for generating responses""" - + + MAX_TOOL_ROUNDS = 2 + # Static system prompt to avoid rebuilding on each call SYSTEM_PROMPT = """ You are an AI assistant specialized in course materials and educational content with access to a comprehensive search tool for course information. Search Tool Usage: - Use the search tool **only** for questions about specific course content or detailed educational materials -- **One search per query maximum** - Synthesize search results into accurate, fact-based responses - If search yields no results, state this clearly without offering alternatives @@ -18,6 +19,13 @@ class AIGenerator: - Return the course title, course link, and each lesson number with its title - Do not use the content search tool for outline queries +Sequential Tool Calls: +- You may make up to 2 tool calls in sequence when a single search is insufficient +- Use sequential calls for: multi-part questions, comparisons across courses/lessons, + or when you need an outline first and then content from a specific lesson + (e.g. get_course_outline → search_course_content using the lesson title found) +- Do NOT make a second tool call if the first result fully answers the question + Response Protocol: - **General knowledge questions**: Answer using existing knowledge without searching - **Course-specific questions**: Search first, then answer @@ -51,90 +59,107 @@ def generate_response(self, query: str, 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 + if conversation_history else self.SYSTEM_PROMPT ) - - # Prepare API call parameters efficiently + + messages = [{"role": "user", "content": query}] + api_params = { **self.base_params, - "messages": [{"role": "user", "content": query}], + "messages": messages, "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 + + # Tool loop: up to MAX_TOOL_ROUNDS sequential rounds + rounds_completed = 0 + while ( + response.stop_reason == "tool_use" + and tool_manager + and rounds_completed < self.MAX_TOOL_ROUNDS + ): + response, success = self._handle_tool_execution( + response, messages, tool_manager, system_content, tools + ) + rounds_completed += 1 + if not success: + break + + # Round cap hit or no tool_manager: force a plain-text synthesis call + if response.stop_reason == "tool_use": + messages.append({"role": "assistant", "content": response.content}) + response = self.client.messages.create( + **self.base_params, + messages=messages, + system=system_content + ) + return response.content[0].text - - def _handle_tool_execution(self, initial_response, base_params: Dict[str, Any], tool_manager): + + def _handle_tool_execution(self, response, messages: List, tool_manager, + system_content: str, tools: List) -> tuple: """ - Handle execution of tool calls and get follow-up response. - + Execute one round of tool calls and make the intermediate follow-up API call. + + Mutates messages in place by appending the assistant tool-use message and + the tool results user message. + Args: - initial_response: The response containing tool use requests - base_params: Base API parameters + response: The current API response with stop_reason == "tool_use" + messages: Accumulated message list (mutated in place) tool_manager: Manager to execute tools - + system_content: System prompt string for the follow-up call + tools: Tool definitions for the follow-up call + Returns: - Final response text after tool execution + (next_response, success): next_response is the follow-up API response; + success is False if any tool raised an exception (loop should stop). """ - # 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 + messages.append({"role": "assistant", "content": response.content}) + 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 - ) - + success = True + for block in response.content: + if block.type == "tool_use": + try: + result = tool_manager.execute_tool(block.name, **block.input) + except Exception as e: + result = f"Tool execution error: {e}" + success = False tool_results.append({ "type": "tool_result", - "tool_use_id": content_block.id, - "content": tool_result + "tool_use_id": block.id, + "content": 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 = { + + # Intermediate follow-up WITH tools so Claude can call again if needed + next_response = self.client.messages.create( **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 + messages=messages, + system=system_content, + tools=tools, + tool_choice={"type": "auto"} + ) + return next_response, success \ 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..b1918926b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,86 @@ +import pytest +from unittest.mock import MagicMock +from vector_store import SearchResults + + +# --------------------------------------------------------------------------- +# Shared sample data +# --------------------------------------------------------------------------- + +SAMPLE_CHROMA_RESULTS = { + "documents": [["Lesson content about Python basics.", "More content here."]], + "metadatas": [[ + {"course_title": "Python Fundamentals", "lesson_number": 1, "chunk_index": 0}, + {"course_title": "Python Fundamentals", "lesson_number": 2, "chunk_index": 0}, + ]], + "distances": [[0.12, 0.34]], +} + + +# --------------------------------------------------------------------------- +# SearchResults fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def sample_search_results(): + """Two-document SearchResults with full metadata.""" + return SearchResults.from_chroma(SAMPLE_CHROMA_RESULTS) + + +@pytest.fixture +def empty_search_results(): + """Empty SearchResults with no error.""" + return SearchResults(documents=[], metadata=[], distances=[]) + + +@pytest.fixture +def error_search_results(): + """SearchResults carrying a ChromaDB error string.""" + return SearchResults.empty( + "Search error: Number of requested results 5 is greater than number of elements in index 0" + ) + + +# --------------------------------------------------------------------------- +# VectorStore mock fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_vector_store(sample_search_results): + """ + MagicMock standing in for VectorStore. + Defaults: .search() returns sample_search_results, .get_lesson_link() returns a URL. + """ + store = MagicMock() + store.search.return_value = sample_search_results + store.get_lesson_link.return_value = "https://example.com/lesson/1" + store.get_course_outline.return_value = None + return store + + +# --------------------------------------------------------------------------- +# Anthropic response mock helpers (module-level, importable by test files) +# --------------------------------------------------------------------------- + +def make_text_response(text: str): + """Create a mock Anthropic Message with a single text block and stop_reason=end_turn.""" + block = MagicMock() + block.type = "text" + block.text = text + response = MagicMock() + response.stop_reason = "end_turn" + response.content = [block] + return response + + +def make_tool_use_response(tool_name: str, tool_input: dict, tool_use_id: str = "tu_abc123"): + """Create a mock Anthropic Message that requests a tool call.""" + block = MagicMock() + block.type = "tool_use" + block.name = tool_name + block.input = tool_input + block.id = tool_use_id + response = MagicMock() + response.stop_reason = "tool_use" + response.content = [block] + return response diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py new file mode 100644 index 000000000..79d747301 --- /dev/null +++ b/backend/tests/test_ai_generator.py @@ -0,0 +1,352 @@ +""" +Unit tests for AIGenerator.generate_response() and _handle_tool_execution(). + +Diagnostic focus: Does tool_use branching work correctly? +Does the final API call correctly omit tools? +Does the tool result make it back to Claude? +""" +import pytest +from unittest.mock import MagicMock, patch +from ai_generator import AIGenerator +from tests.conftest import make_text_response, make_tool_use_response + + +@pytest.fixture +def generator(): + """AIGenerator with a fake API key; client.messages.create will be mocked per test.""" + return AIGenerator(api_key="sk-test-fake", model="claude-3-haiku-20240307") + + +class TestGenerateResponseDirectPath: + + def test_returns_text_on_end_turn(self, generator): + """ + WHAT: stop_reason=end_turn → generate_response returns text of first content block. + ASSERT: return value equals the text in the mock. + FAILURE MEANS: Direct (no-tool) responses are broken. + """ + with patch.object(generator.client.messages, 'create', + return_value=make_text_response("Hello, I am Claude.")): + result = generator.generate_response("What is Python?") + assert result == "Hello, I am Claude." + + def test_system_prompt_included_without_history(self, generator): + """ + WHAT: Without conversation_history, system param equals SYSTEM_PROMPT exactly. + ASSERT: system kwarg passed to create() equals AIGenerator.SYSTEM_PROMPT. + FAILURE MEANS: System prompt is corrupted on clean queries. + """ + with patch.object(generator.client.messages, 'create', + return_value=make_text_response("ok")) as mock_create: + generator.generate_response("test") + call_kwargs = mock_create.call_args[1] + assert call_kwargs["system"] == AIGenerator.SYSTEM_PROMPT + + def test_system_prompt_includes_history_when_provided(self, generator): + """ + WHAT: When conversation_history is provided, system includes 'Previous conversation:'. + ASSERT: system kwarg contains both SYSTEM_PROMPT content and the history. + FAILURE MEANS: Conversation context is silently dropped. + """ + with patch.object(generator.client.messages, 'create', + return_value=make_text_response("ok")) as mock_create: + generator.generate_response("test", conversation_history="User: hi\nAssistant: hello") + call_kwargs = mock_create.call_args[1] + assert "Previous conversation:" in call_kwargs["system"] + assert "User: hi" in call_kwargs["system"] + + def test_tools_included_in_api_call_when_provided(self, generator): + """ + WHAT: When tools list is non-empty, tools and tool_choice appear in the API call. + ASSERT: 'tools' and 'tool_choice' are in call kwargs. + FAILURE MEANS: Claude never sees the search tool → answers from general knowledge only. + """ + tool_defs = [{"name": "search_course_content", "description": "...", "input_schema": {}}] + with patch.object(generator.client.messages, 'create', + return_value=make_text_response("ok")) as mock_create: + generator.generate_response("test", tools=tool_defs) + call_kwargs = mock_create.call_args[1] + assert "tools" in call_kwargs + assert call_kwargs["tool_choice"] == {"type": "auto"} + + def test_tools_absent_from_api_call_when_not_provided(self, generator): + """ + WHAT: When no tools are passed, 'tools' key is absent from the API call. + ASSERT: 'tools' not in call kwargs. + FAILURE MEANS: Empty tools list might cause an API validation error. + """ + with patch.object(generator.client.messages, 'create', + return_value=make_text_response("ok")) as mock_create: + generator.generate_response("test", tools=None) + call_kwargs = mock_create.call_args[1] + assert "tools" not in call_kwargs + + +class TestHandleToolExecution: + + def test_tool_use_branch_triggers_second_api_call(self, generator): + """ + WHAT: stop_reason=tool_use + tool_manager → _handle_tool_execution runs. + ASSERT: create() is called TWICE (initial + intermediate follow-up WITH tools). + Call 2 is an intermediate follow-up that still includes tools, allowing + Claude to call another tool in a second round if needed. Here it returns + end_turn, so no third call is made. + FAILURE MEANS: Tool results never make it back to Claude; only one API call happens. + """ + tool_response = make_tool_use_response("search_course_content", {"query": "python"}) + final_response = make_text_response("Python is a programming language.") + mock_manager = MagicMock() + mock_manager.execute_tool.return_value = "Lesson content: Python basics..." + + with patch.object(generator.client.messages, 'create', + side_effect=[tool_response, final_response]) as mock_create: + result = generator.generate_response("What is Python?", tools=[{}], tool_manager=mock_manager) + + assert mock_create.call_count == 2 + assert result == "Python is a programming language." + + def test_tool_use_with_no_tool_manager_skips_tool_execution(self, generator): + """ + WHAT: stop_reason=tool_use but tool_manager=None → the `and tool_manager` guard + skips the tool loop entirely. The `if response.stop_reason == "tool_use"` + guard after the loop then triggers a plain-text synthesis call. + ASSERT: create() is called TWICE (initial tool_use response + synthesis call). + execute_tool is never called. + FAILURE MEANS: No synthesis call is made, causing an AttributeError when trying + to access .text on a ToolUseBlock. + """ + tool_response = make_tool_use_response("search_course_content", {"query": "python"}) + final_response = make_text_response("Python is a programming language.") + with patch.object(generator.client.messages, 'create', + side_effect=[tool_response, final_response]) as mock_create: + result = generator.generate_response("What is Python?", tools=[{}], tool_manager=None) + assert mock_create.call_count == 2 + assert result == "Python is a programming language." + + def test_synthesis_call_after_round_cap_has_no_tools(self, generator): + """ + WHAT: When both tool rounds are exhausted (MAX_TOOL_ROUNDS=2) and Claude still + returns tool_use, generate_response forces a final synthesis call WITHOUT + tools to obtain a plain-text answer. + ASSERT: The last (4th) call lacks 'tools' and 'tool_choice'. + FAILURE MEANS: Synthesis call includes tools and fails with an API error, or + Claude never produces a text answer after hitting the round cap. + """ + r1 = make_tool_use_response("search_course_content", {"query": "python"}, "tu_1") + r2 = make_tool_use_response("search_course_content", {"query": "python2"}, "tu_2") + r3 = make_tool_use_response("search_course_content", {"query": "python3"}, "tu_3") + final = make_text_response("Python answer.") + mock_manager = MagicMock() + mock_manager.execute_tool.return_value = "tool result content" + + with patch.object(generator.client.messages, 'create', + side_effect=[r1, r2, r3, final]) as mock_create: + generator.generate_response("What is Python?", tools=[{}], tool_manager=mock_manager) + + last_call_kwargs = mock_create.call_args_list[-1][1] + assert "tools" not in last_call_kwargs + assert "tool_choice" not in last_call_kwargs + + def test_tool_result_appended_as_user_message(self, generator): + """ + WHAT: Tool execution result is added as a user-role message with type=tool_result. + ASSERT: Second create() call (the intermediate follow-up WITH tools) receives 3 + messages: [original user query, assistant tool-use block, tool result]. + FAILURE MEANS: Claude never sees the search results — answers blind. + Critical check for the 'query failed' symptom. + """ + tool_response = make_tool_use_response( + "search_course_content", {"query": "python"}, "tu_test_id" + ) + final_response = make_text_response("Python answer.") + mock_manager = MagicMock() + mock_manager.execute_tool.return_value = "Search result: Python basics" + + with patch.object(generator.client.messages, 'create', + side_effect=[tool_response, final_response]) as mock_create: + generator.generate_response("What is Python?", tools=[{}], tool_manager=mock_manager) + + second_call_messages = mock_create.call_args_list[1][1]["messages"] + assert len(second_call_messages) == 3 + tool_result_message = second_call_messages[2] + assert tool_result_message["role"] == "user" + result_blocks = tool_result_message["content"] + assert any( + b.get("type") == "tool_result" + and b.get("tool_use_id") == "tu_test_id" + and "Python basics" in b.get("content", "") + for b in result_blocks + ) + + def test_tool_manager_execute_called_with_correct_args(self, generator): + """ + WHAT: execute_tool() is called with the exact tool name and input that Claude requested. + ASSERT: execute_tool called with name='search_course_content', query='variables', lesson_number=2. + FAILURE MEANS: Parameters lost/renamed between Claude's response and the tool call. + """ + tool_input = {"query": "variables", "lesson_number": 2} + tool_response = make_tool_use_response("search_course_content", tool_input) + final_response = make_text_response("Variables are...") + mock_manager = MagicMock() + mock_manager.execute_tool.return_value = "content about variables" + + with patch.object(generator.client.messages, 'create', + side_effect=[tool_response, final_response]): + generator.generate_response("What are variables?", tools=[{}], tool_manager=mock_manager) + + mock_manager.execute_tool.assert_called_once_with( + "search_course_content", query="variables", lesson_number=2 + ) + + def test_tool_error_string_passed_through_to_claude(self, generator): + """ + WHAT: If execute_tool returns an error string (e.g. from VectorStore failure), + that error string is what Claude receives as tool_result.content. + ASSERT: Second API call's messages include the error string verbatim. + FAILURE MEANS: THIS EXPOSES THE ROOT CAUSE. Claude receives 'Search error: ...' + as its context, then tells the user it cannot answer. + """ + error_str = ( + "Search error: Number of requested results 5 is greater than " + "number of elements in index 0" + ) + tool_response = make_tool_use_response("search_course_content", {"query": "python"}) + final_response = make_text_response("I couldn't find information about that.") + mock_manager = MagicMock() + mock_manager.execute_tool.return_value = error_str + + with patch.object(generator.client.messages, 'create', + side_effect=[tool_response, final_response]) as mock_create: + generator.generate_response("What is Python?", tools=[{}], tool_manager=mock_manager) + + second_call_messages = mock_create.call_args_list[1][1]["messages"] + tool_result_msg = second_call_messages[2] + content_blocks = tool_result_msg["content"] + assert any(error_str in b.get("content", "") for b in content_blocks) + + +class TestSequentialToolCalling: + + def test_two_tool_rounds_makes_three_api_calls(self, generator): + """ + WHAT: Two sequential tool rounds where each follow-up triggers another tool call, + until the third response is end_turn. + ASSERT: create() called 3 times, execute_tool called twice, result is the + text from the third response. + FAILURE MEANS: The loop exits after round 1, preventing a second tool call + even when Claude wants to search again. + """ + r1 = make_tool_use_response("get_course_outline", {"course_name": "Python"}, "tu_1") + r2 = make_tool_use_response("search_course_content", {"query": "loops"}, "tu_2") + r3 = make_text_response("Python loops are covered in lesson 3.") + mock_manager = MagicMock() + mock_manager.execute_tool.return_value = "tool result" + + with patch.object(generator.client.messages, 'create', + side_effect=[r1, r2, r3]) as mock_create: + result = generator.generate_response( + "What lesson covers loops?", tools=[{}], tool_manager=mock_manager + ) + + assert mock_create.call_count == 3 + assert mock_manager.execute_tool.call_count == 2 + assert result == "Python loops are covered in lesson 3." + + def test_second_round_intermediate_call_has_tools(self, generator): + """ + WHAT: The intermediate follow-up call after round 1 must include tools so + Claude can decide to make a second tool call. + ASSERT: The second create() call (index 1) has 'tools' in its kwargs. + FAILURE MEANS: Claude cannot make a second tool call because the intermediate + call strips tools — the sequential feature is broken. + """ + r1 = make_tool_use_response("get_course_outline", {"course_name": "Python"}, "tu_1") + r2 = make_tool_use_response("search_course_content", {"query": "loops"}, "tu_2") + r3 = make_text_response("Python loops answer.") + mock_manager = MagicMock() + mock_manager.execute_tool.return_value = "outline content" + + with patch.object(generator.client.messages, 'create', + side_effect=[r1, r2, r3]) as mock_create: + generator.generate_response( + "What lesson covers loops?", tools=[{}], tool_manager=mock_manager + ) + + second_call_kwargs = mock_create.call_args_list[1][1] + assert "tools" in second_call_kwargs + assert second_call_kwargs["tool_choice"] == {"type": "auto"} + + def test_round_cap_forces_toolless_synthesis_call(self, generator): + """ + WHAT: When MAX_TOOL_ROUNDS (2) is exhausted and Claude still returns tool_use, + a final synthesis call WITHOUT tools is forced to get a text answer. + ASSERT: create() called 4 times total; last call has no 'tools' or 'tool_choice'. + FAILURE MEANS: The round cap does not terminate the loop, or the forced synthesis + call incorrectly includes tools causing an API error. + """ + r1 = make_tool_use_response("search_course_content", {"query": "q1"}, "tu_1") + r2 = make_tool_use_response("search_course_content", {"query": "q2"}, "tu_2") + r3 = make_tool_use_response("search_course_content", {"query": "q3"}, "tu_3") + final = make_text_response("Here is the answer.") + mock_manager = MagicMock() + mock_manager.execute_tool.return_value = "result" + + with patch.object(generator.client.messages, 'create', + side_effect=[r1, r2, r3, final]) as mock_create: + result = generator.generate_response( + "Complex query", tools=[{}], tool_manager=mock_manager + ) + + assert mock_create.call_count == 4 + last_call_kwargs = mock_create.call_args_list[-1][1] + assert "tools" not in last_call_kwargs + assert "tool_choice" not in last_call_kwargs + assert result == "Here is the answer." + + def test_tool_exception_stops_loop_and_proceeds_to_synthesis(self, generator): + """ + WHAT: If execute_tool raises an Exception, the loop stops (success=False) and + the intermediate follow-up call provides the next response. If that + response is end_turn, no further calls are made. + ASSERT: create() called twice, execute_tool called once, result is the text + from the second response. + FAILURE MEANS: An exception in execute_tool propagates uncaught, or the loop + continues trying more tool rounds after a hard failure. + """ + r1 = make_tool_use_response("search_course_content", {"query": "python"}, "tu_1") + r2 = make_text_response("I encountered an error retrieving that information.") + mock_manager = MagicMock() + mock_manager.execute_tool.side_effect = Exception("DB connection failed") + + with patch.object(generator.client.messages, 'create', + side_effect=[r1, r2]) as mock_create: + result = generator.generate_response( + "What is Python?", tools=[{}], tool_manager=mock_manager + ) + + assert mock_create.call_count == 2 + assert mock_manager.execute_tool.call_count == 1 + assert result == "I encountered an error retrieving that information." + + def test_accumulated_messages_grow_across_rounds(self, generator): + """ + WHAT: After two tool rounds, the third API call receives the full accumulated + message history: [user_query, asst_tool1, tool_result_1, asst_tool2, tool_result_2]. + ASSERT: Third create() call's messages list has exactly 5 items. + FAILURE MEANS: Context is not preserved between rounds; Claude answers without + seeing results from earlier tool calls. + """ + r1 = make_tool_use_response("get_course_outline", {"course_name": "Python"}, "tu_1") + r2 = make_tool_use_response("search_course_content", {"query": "lesson 3"}, "tu_2") + r3 = make_text_response("Lesson 3 covers loops.") + mock_manager = MagicMock() + mock_manager.execute_tool.return_value = "tool content" + + with patch.object(generator.client.messages, 'create', + side_effect=[r1, r2, r3]) as mock_create: + generator.generate_response( + "What does lesson 3 cover?", tools=[{}], tool_manager=mock_manager + ) + + third_call_messages = mock_create.call_args_list[2][1]["messages"] + assert len(third_call_messages) == 5 diff --git a/backend/tests/test_course_search_tool.py b/backend/tests/test_course_search_tool.py new file mode 100644 index 000000000..707512785 --- /dev/null +++ b/backend/tests/test_course_search_tool.py @@ -0,0 +1,190 @@ +""" +Unit tests for CourseSearchTool.execute() and _format_results(). + +Diagnostic focus: Does the tool correctly surface VectorStore errors? +Does it populate self.last_sources? Does it format results correctly? +""" +import pytest +from unittest.mock import MagicMock +from search_tools import CourseSearchTool, ToolManager +from vector_store import SearchResults + + +class TestCourseSearchToolExecute: + + def test_execute_returns_formatted_content_on_success(self, mock_vector_store, sample_search_results): + """ + WHAT: execute() with a working VectorStore returns formatted content. + ASSERT: returned string contains course title and document text. + FAILURE MEANS: _format_results is broken or not called. + """ + tool = CourseSearchTool(mock_vector_store) + result = tool.execute(query="what is python") + assert "Python Fundamentals" in result + assert "Lesson content about Python basics." in result + + def test_execute_calls_store_search_with_correct_args(self, mock_vector_store): + """ + WHAT: execute() passes query/course_name/lesson_number through to store.search(). + ASSERT: store.search called with exactly the right keyword args. + FAILURE MEANS: parameter forwarding broken → wrong ChromaDB filters applied. + """ + tool = CourseSearchTool(mock_vector_store) + tool.execute(query="variables", course_name="Python", lesson_number=3) + mock_vector_store.search.assert_called_once_with( + query="variables", course_name="Python", lesson_number=3 + ) + + def test_execute_populates_last_sources(self, mock_vector_store): + """ + WHAT: After execute(), tool.last_sources is populated with one entry per result. + ASSERT: last_sources has 2 entries with 'label' and 'url' keys. + FAILURE MEANS: ToolManager.get_last_sources() returns [] even after successful search + → sources never reach the frontend. + """ + tool = CourseSearchTool(mock_vector_store) + tool.execute(query="python basics") + assert len(tool.last_sources) == 2 + for source in tool.last_sources: + assert "label" in source + assert "url" in source + + def test_execute_fetches_lesson_link_for_each_result(self, mock_vector_store): + """ + WHAT: _format_results calls get_lesson_link once per result that has a lesson_number. + ASSERT: get_lesson_link called exactly twice (for 2 results with lesson_number). + FAILURE MEANS: source URLs always None in the frontend. + """ + tool = CourseSearchTool(mock_vector_store) + tool.execute(query="python basics") + assert mock_vector_store.get_lesson_link.call_count == 2 + + def test_execute_returns_error_string_verbatim_when_store_errors(self, mock_vector_store): + """ + WHAT: When store.search returns SearchResults with .error set, + execute() returns that error string directly. + ASSERT: return value IS the error string. + FAILURE MEANS: ChromaDB error strings reach Claude as tool result, + causing Claude to report failure. THIS IS THE LIKELY ROOT CAUSE. + """ + error_msg = ( + "Search error: Number of requested results 5 is greater than " + "number of elements in index 0" + ) + mock_vector_store.search.return_value = SearchResults.empty(error_msg) + tool = CourseSearchTool(mock_vector_store) + result = tool.execute(query="anything") + assert result == error_msg + + def test_execute_returns_no_content_message_when_empty(self, mock_vector_store, empty_search_results): + """ + WHAT: When results are empty (no error, just no hits), execute() returns + the 'No relevant content found' sentinel. + ASSERT: return value starts with 'No relevant content found'. + FAILURE MEANS: Empty DB causes tool to silently return empty string or crash. + """ + mock_vector_store.search.return_value = empty_search_results + tool = CourseSearchTool(mock_vector_store) + result = tool.execute(query="anything") + assert result.startswith("No relevant content found") + + def test_execute_includes_course_filter_in_empty_message(self, mock_vector_store, empty_search_results): + """ + WHAT: Empty result message mentions the requested course name. + ASSERT: message contains the course_name that was requested. + FAILURE MEANS: User can't tell which course had no content. + """ + mock_vector_store.search.return_value = empty_search_results + tool = CourseSearchTool(mock_vector_store) + result = tool.execute(query="anything", course_name="Python") + assert "Python" in result + + def test_execute_does_not_update_sources_on_error(self, mock_vector_store): + """ + WHAT: When store returns an error result, last_sources is NOT overwritten. + ASSERT: Pre-seeded stale sources remain unchanged after an errored execute(). + FAILURE MEANS: Stale sources from a previous query could leak into this response. + """ + mock_vector_store.search.return_value = SearchResults.empty("Search error: boom") + tool = CourseSearchTool(mock_vector_store) + tool.last_sources = [{"label": "stale", "url": None}] + tool.execute(query="anything") + # Error branch returns early — last_sources should NOT have been updated + assert tool.last_sources == [{"label": "stale", "url": None}] + + +class TestFormatResults: + + def test_format_results_header_format(self, mock_vector_store, sample_search_results): + """ + WHAT: _format_results includes [CourseName - Lesson N] headers. + ASSERT: expected header appears in output. + FAILURE MEANS: Claude receives raw content without course context headers. + """ + tool = CourseSearchTool(mock_vector_store) + result = tool._format_results(sample_search_results) + assert "[Python Fundamentals - Lesson 1]" in result + + def test_format_results_no_lesson_number_omits_lesson_from_header(self, mock_vector_store): + """ + WHAT: When lesson_number is None in metadata, header is just [CourseName]. + ASSERT: header does not contain 'Lesson'. + FAILURE MEANS: Metadata extraction crashes on missing lesson_number. + """ + results = SearchResults( + documents=["Content without lesson number"], + metadata=[{"course_title": "Advanced Python", "lesson_number": None}], + distances=[0.1] + ) + tool = CourseSearchTool(mock_vector_store) + result = tool._format_results(results) + assert "[Advanced Python]" in result + assert "Lesson" not in result + + def test_format_results_separates_results_with_double_newline(self, mock_vector_store, sample_search_results): + """ + WHAT: Multiple results are joined with double newlines. + ASSERT: '\\n\\n' appears in the output. + FAILURE MEANS: Output is garbled — all results run together. + """ + tool = CourseSearchTool(mock_vector_store) + result = tool._format_results(sample_search_results) + assert "\n\n" in result + + +class TestToolManager: + + def test_tool_manager_get_last_sources_returns_first_nonempty(self, mock_vector_store): + """ + WHAT: get_last_sources() returns the first non-empty last_sources from registered tools. + ASSERT: returned list matches what was set on the tool. + FAILURE MEANS: RAGSystem.query() always returns empty sources list. + """ + manager = ToolManager() + tool = CourseSearchTool(mock_vector_store) + tool.last_sources = [{"label": "Python - Lesson 1", "url": "https://example.com"}] + manager.register_tool(tool) + assert manager.get_last_sources() == [{"label": "Python - Lesson 1", "url": "https://example.com"}] + + def test_tool_manager_reset_sources_clears_all_tools(self, mock_vector_store): + """ + WHAT: reset_sources() clears last_sources on all registered tools. + ASSERT: After reset, last_sources == []. + FAILURE MEANS: Sources from query N bleed into query N+1. + """ + manager = ToolManager() + tool = CourseSearchTool(mock_vector_store) + tool.last_sources = [{"label": "stale", "url": None}] + manager.register_tool(tool) + manager.reset_sources() + assert tool.last_sources == [] + + def test_tool_manager_execute_unknown_tool_returns_error_string(self, mock_vector_store): + """ + WHAT: Calling execute_tool with an unregistered name returns an error string. + ASSERT: Returns string containing 'not found'. + FAILURE MEANS: Unknown tool name crashes instead of returning a recoverable error. + """ + manager = ToolManager() + result = manager.execute_tool("nonexistent_tool", query="test") + assert "not found" in result diff --git a/backend/tests/test_rag_system.py b/backend/tests/test_rag_system.py new file mode 100644 index 000000000..e2f751f43 --- /dev/null +++ b/backend/tests/test_rag_system.py @@ -0,0 +1,209 @@ +""" +Integration tests for RAGSystem.query(). + +Patches VectorStore, DocumentProcessor, and the Anthropic client so no real +ChromaDB or API calls occur. Lets the real RAGSystem, ToolManager, +CourseSearchTool, and AIGenerator code run. + +Diagnostic focus: Does the full pipeline assemble correctly? +Do sources flow back from tool to response? Does session history update? +""" +import pytest +from unittest.mock import MagicMock, patch +from rag_system import RAGSystem +from vector_store import SearchResults +from tests.conftest import make_text_response, make_tool_use_response + + +@pytest.fixture +def mock_config(): + """Minimal config that prevents real ChromaDB and Anthropic initialization.""" + cfg = MagicMock() + cfg.ANTHROPIC_API_KEY = "sk-test-fake" + cfg.ANTHROPIC_MODEL = "claude-3-haiku-20240307" + cfg.CHROMA_PATH = ":memory:" + cfg.EMBEDDING_MODEL = "all-MiniLM-L6-v2" + cfg.MAX_RESULTS = 5 + cfg.MAX_HISTORY = 2 + cfg.CHUNK_SIZE = 800 + cfg.CHUNK_OVERLAP = 100 + return cfg + + +@pytest.fixture +def rag_system_with_mocks(mock_config, sample_search_results): + """ + RAGSystem with VectorStore and Anthropic client both mocked. + Yields (system, mock_vs_instance, mock_anthropic_client). + """ + with patch("rag_system.VectorStore") as MockVS, \ + patch("rag_system.DocumentProcessor"), \ + patch("ai_generator.anthropic.Anthropic") as MockAnthropic: + + mock_vs_instance = MagicMock() + mock_vs_instance.search.return_value = sample_search_results + mock_vs_instance.get_lesson_link.return_value = "https://example.com/lesson/1" + MockVS.return_value = mock_vs_instance + + mock_client = MagicMock() + MockAnthropic.return_value = mock_client + + system = RAGSystem(mock_config) + yield system, mock_vs_instance, mock_client + + +class TestRAGSystemQueryHappyPath: + + def test_query_returns_tuple_of_answer_and_sources(self, rag_system_with_mocks): + """ + WHAT: RAGSystem.query() returns a 2-tuple (str, list). + ASSERT: result[0] is str, result[1] is list. + FAILURE MEANS: API contract broken — app.py crashes unpacking (answer, sources). + """ + system, _, mock_client = rag_system_with_mocks + mock_client.messages.create.return_value = make_text_response("General answer.") + answer, sources = system.query("What is Python?") + assert isinstance(answer, str) + assert isinstance(sources, list) + + def test_query_prompt_wraps_user_question(self, rag_system_with_mocks): + """ + WHAT: RAGSystem.query() prepends the 'Answer this question about course materials:' + prefix to the user query before calling generate_response. + ASSERT: The message content sent to Claude starts with that prefix. + FAILURE MEANS: Prompt framing confirmed — this framing may suppress tool use (Bug 5). + """ + system, _, mock_client = rag_system_with_mocks + mock_client.messages.create.return_value = make_text_response("ok") + system.query("What are variables?") + call_kwargs = mock_client.messages.create.call_args[1] + user_message_content = call_kwargs["messages"][0]["content"] + assert "Answer this question about course materials:" in user_message_content + + def test_query_with_tool_use_returns_sources(self, rag_system_with_mocks): + """ + WHAT: When Claude uses the search tool and results are found, sources list is non-empty. + ASSERT: sources has at least one entry with 'label' key. + FAILURE MEANS: Frontend never displays source links even on successful searches. + """ + system, _, mock_client = rag_system_with_mocks + tool_response = make_tool_use_response("search_course_content", {"query": "python"}) + final_response = make_text_response("Python is a programming language.") + mock_client.messages.create.side_effect = [tool_response, final_response] + + answer, sources = system.query("What is Python?") + assert answer == "Python is a programming language." + assert len(sources) > 0 + assert "label" in sources[0] + + def test_query_resets_sources_after_retrieval(self, rag_system_with_mocks): + """ + WHAT: After query() retrieves sources, reset_sources() is called so the next + query doesn't inherit stale sources. + ASSERT: Second query's sources list is empty (direct response, no tool use). + FAILURE MEANS: Sources from query N bleed into query N+1 in the frontend. + """ + system, _, mock_client = rag_system_with_mocks + tool_response = make_tool_use_response("search_course_content", {"query": "python"}) + direct_response = make_text_response("General knowledge answer.") + + # First query: tool use + mock_client.messages.create.side_effect = [tool_response, make_text_response("Python answer.")] + system.query("What is Python?") + + # Second query: direct response (no tool use) + mock_client.messages.create.side_effect = [direct_response] + _, sources2 = system.query("What is 2 + 2?") + assert sources2 == [] + + +class TestRAGSystemSessionHandling: + + def test_query_without_session_id_returns_answer(self, rag_system_with_mocks): + """ + WHAT: query() called without session_id does not crash. + ASSERT: answer is a non-empty string. + FAILURE MEANS: Session handling broken for anonymous (stateless) queries. + """ + system, _, mock_client = rag_system_with_mocks + mock_client.messages.create.return_value = make_text_response("Answer.") + answer, _ = system.query("test", session_id=None) + assert len(answer) > 0 + + def test_query_with_new_session_id_does_not_crash(self, rag_system_with_mocks): + """ + WHAT: query() with a fresh session_id (not yet in sessions dict) works correctly. + ASSERT: No exception; answer returned as str. + FAILURE MEANS: get_conversation_history() crashes on unknown session_id. + """ + system, _, mock_client = rag_system_with_mocks + mock_client.messages.create.return_value = make_text_response("Answer.") + answer, _ = system.query("test", session_id="brand-new-session-99") + assert isinstance(answer, str) + + def test_query_updates_session_history_after_response(self, rag_system_with_mocks): + """ + WHAT: After a successful query, the exchange is stored in session history. + ASSERT: get_conversation_history() returns a string containing the user query. + FAILURE MEANS: Conversation context never accumulates; multi-turn dialogue is broken. + """ + system, _, mock_client = rag_system_with_mocks + mock_client.messages.create.return_value = make_text_response("Answer to hello.") + session_id = system.session_manager.create_session() + system.query("hello", session_id=session_id) + history = system.session_manager.get_conversation_history(session_id) + assert "hello" in history + + +class TestRAGSystemErrorPropagation: + + def test_query_when_vector_store_errors_claude_receives_error_string(self, rag_system_with_mocks): + """ + WHAT: When VectorStore.search returns error SearchResults, the error string + reaches Claude as a tool result. Claude's final answer is its text, + not a Python exception. + ASSERT: answer is a string (no exception propagated). + FAILURE MEANS: Unhandled exception → FastAPI 500. If this passes but user sees + 'query failed', the bug is Claude saying so verbally, not an HTTP error. + """ + system, mock_vs, mock_client = rag_system_with_mocks + mock_vs.search.return_value = SearchResults.empty( + "Search error: Number of requested results 5 is greater than number of elements in index 0" + ) + tool_response = make_tool_use_response("search_course_content", {"query": "python"}) + final_response = make_text_response("I was unable to find information about that topic.") + mock_client.messages.create.side_effect = [tool_response, final_response] + + answer, sources = system.query("What is Python?") + assert isinstance(answer, str) + assert len(answer) > 0 + assert sources == [] + + def test_query_anthropic_api_exception_propagates_to_caller(self, rag_system_with_mocks): + """ + WHAT: If the Anthropic API call raises, the exception propagates out of query() + so FastAPI catches it as a 500. + ASSERT: query() raises an exception (any type). + FAILURE MEANS: Exception silently swallowed → query returns wrong value, no 500 sent. + """ + system, _, mock_client = rag_system_with_mocks + mock_client.messages.create.side_effect = ConnectionError("API unreachable") + with pytest.raises(Exception): + system.query("What is Python?") + + def test_query_with_empty_database_returns_answer_string(self, rag_system_with_mocks): + """ + WHAT: If the vector DB is empty, search returns is_empty()=True. + The tool returns 'No relevant content found.' Claude answers accordingly. + ASSERT: answer is a non-empty string; no exception raised. + FAILURE MEANS: Empty database crashes the system → HTTP 500 instead of a graceful reply. + """ + system, mock_vs, mock_client = rag_system_with_mocks + mock_vs.search.return_value = SearchResults(documents=[], metadata=[], distances=[]) + tool_response = make_tool_use_response("search_course_content", {"query": "python"}) + final_response = make_text_response("There is no course content about that topic.") + mock_client.messages.create.side_effect = [tool_response, final_response] + + answer, _ = system.query("What is Python?") + assert isinstance(answer, str) + assert len(answer) > 0 diff --git a/backend/vector_store.py b/backend/vector_store.py index 591c4d2ae..ee9557764 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -90,9 +90,15 @@ def search(self, search_limit = limit if limit is not None else self.max_results try: + # Guard: clamp n_results to actual collection size. + # ChromaDB raises ValueError if n_results > number of indexed documents. + collection_count = self.course_content.count() + if collection_count == 0: + return SearchResults(documents=[], metadata=[], distances=[]) + actual_limit = min(search_limit, collection_count) results = self.course_content.query( query_texts=[query], - n_results=search_limit, + n_results=actual_limit, where=filter_dict ) return SearchResults.from_chroma(results) @@ -102,6 +108,8 @@ def search(self, def _resolve_course_name(self, course_name: str) -> Optional[str]: """Use vector search to find best matching course by name""" try: + if self.course_catalog.count() == 0: + return None results = self.course_catalog.query( query_texts=[course_name], n_results=1 diff --git a/pyproject.toml b/pyproject.toml index 3f05e2de0..46a36a715 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,3 +13,12 @@ dependencies = [ "python-multipart==0.0.20", "python-dotenv==1.1.1", ] + +[tool.pytest.ini_options] +testpaths = ["backend/tests"] +pythonpath = ["backend"] + +[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 09c4755741a3ba707304aba373195f41e19e91b8 Mon Sep 17 00:00:00 2001 From: Abdellah HADDAD Date: Mon, 13 Apr 2026 16:07:22 +0100 Subject: [PATCH 04/10] Add Claude Code config, Playwright artifacts, local instructions, and refactor notes Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/implement-feature.md | 7 +++++ .claude/settings.local.json | 9 ++++++ .../console-2026-04-12T14-03-55-254Z.log | 3 ++ .../console-2026-04-12T14-06-09-521Z.log | 2 ++ .../page-2026-04-12T14-03-59-486Z.yml | 14 +++++++++ .../page-2026-04-12T14-04-10-654Z.png | Bin 0 -> 22275 bytes .../page-2026-04-12T14-06-09-734Z.yml | 14 +++++++++ .../page-2026-04-12T14-06-22-569Z.png | Bin 0 -> 22672 bytes CLAUDE.local.md | 4 +++ backend-tool-refactor.md | 28 ++++++++++++++++++ 10 files changed, 81 insertions(+) create mode 100644 .claude/commands/implement-feature.md create mode 100644 .claude/settings.local.json create mode 100644 .playwright-mcp/console-2026-04-12T14-03-55-254Z.log create mode 100644 .playwright-mcp/console-2026-04-12T14-06-09-521Z.log create mode 100644 .playwright-mcp/page-2026-04-12T14-03-59-486Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T14-04-10-654Z.png create mode 100644 .playwright-mcp/page-2026-04-12T14-06-09-734Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T14-06-22-569Z.png create mode 100644 CLAUDE.local.md create mode 100644 backend-tool-refactor.md 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.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..591914c57 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "mcp__playwright__browser_navigate", + "mcp__playwright__browser_snapshot", + "mcp__playwright__browser_take_screenshot" + ] + } +} diff --git a/.playwright-mcp/console-2026-04-12T14-03-55-254Z.log b/.playwright-mcp/console-2026-04-12T14-03-55-254Z.log new file mode 100644 index 000000000..ab7cd717d --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T14-03-55-254Z.log @@ -0,0 +1,3 @@ +[ 4168ms] [LOG] Loading course stats... @ http://127.0.0.1:8000/script.js?v=9:178 +[ 4196ms] [LOG] Course data received: {total_courses: 4, course_titles: Array(4)} @ http://127.0.0.1:8000/script.js?v=9:183 +[ 4198ms] [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/.playwright-mcp/console-2026-04-12T14-06-09-521Z.log b/.playwright-mcp/console-2026-04-12T14-06-09-521Z.log new file mode 100644 index 000000000..b4012bfbb --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T14-06-09-521Z.log @@ -0,0 +1,2 @@ +[ 145ms] [LOG] Loading course stats... @ http://127.0.0.1:8000/script.js?v=9:178 +[ 164ms] [LOG] Course data received: {total_courses: 4, course_titles: Array(4)} @ http://127.0.0.1:8000/script.js?v=9:183 diff --git a/.playwright-mcp/page-2026-04-12T14-03-59-486Z.yml b/.playwright-mcp/page-2026-04-12T14-03-59-486Z.yml new file mode 100644 index 000000000..cc2654c8c --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T14-03-59-486Z.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-04-12T14-04-10-654Z.png b/.playwright-mcp/page-2026-04-12T14-04-10-654Z.png new file mode 100644 index 0000000000000000000000000000000000000000..a79e09b83ea7e4f32d646eb92db4fe8c026bc939 GIT binary patch literal 22275 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 literal 0 HcmV?d00001 diff --git a/.playwright-mcp/page-2026-04-12T14-06-09-734Z.yml b/.playwright-mcp/page-2026-04-12T14-06-09-734Z.yml new file mode 100644 index 000000000..cc2654c8c --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T14-06-09-734Z.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-04-12T14-06-22-569Z.png b/.playwright-mcp/page-2026-04-12T14-06-22-569Z.png new file mode 100644 index 0000000000000000000000000000000000000000..c2408f75ff10de30b617bc5e1fd84c2d6e813a2a GIT binary patch literal 22672 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 xhTXFp!e)zIXrt literal 0 HcmV?d00001 diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 000000000..19870b20d --- /dev/null +++ b/CLAUDE.local.md @@ -0,0 +1,4 @@ +# Local Project Instructions + +## Server +Never start the server (`./run.sh` or `uvicorn`). The user always starts it manually. diff --git a/backend-tool-refactor.md b/backend-tool-refactor.md new file mode 100644 index 000000000..de23ae5c7 --- /dev/null +++ b/backend-tool-refactor.md @@ -0,0 +1,28 @@ +Refactor @backend/ai_generator.py to support sequential tool calling where Claude can make up to 2 tool calls in separate API rounds. + +Current behavior: +- Claude makes 1 tool call → tools are removed from API params → final response +- If Claude wants another tool call after seeing results, it can't (gets empty response) + +Desired behavior: +- Each tool call should be a separate API request where Claude can reason about previous results +- Support for complex queries requiring multiple searches for comparisons, multi-part questions, or when information from different courses/lessons is needed + +Example flow: +1. User: "Search for a course that discusses the same topic as lesson 4 of course X" +2. Claude: get course outline for course X → gets title of lesson 4 +3. Claude: uses the title to search for a course that discusses the same topic → returns course information +4. Claude: provides complete answer + +Requirements: +- Maximum 2 sequential rounds per user query +- Terminate when: (a) 2 rounds completed, (b) Claude's response has no tool_use blocks, or (c) tool call fails +- Preserve conversation context between rounds +- Handle tool execution errors gracefully + +Notes: +- Update the system prompt in @backend/ai_generator.py +- Update the test @backend/tests/test_ai_generator.py +- Write tests that verify the external behavior (API calls made, tools executed, results returned) rather than internal state details. + +Use two parallel subagents to brainstorm possible plans. Do not implement any code. From 446af8d9e5e2b51504ff1b26340f0bcc8927e505 Mon Sep 17 00:00:00 2001 From: Abdellah HADDAD Date: Mon, 13 Apr 2026 23:24:00 +0100 Subject: [PATCH 05/10] Add frontend code quality tooling (Prettier + ESLint) - Add Prettier with project-consistent config (single quotes, 2-space indent, trailing commas, printWidth 100) as the JS/CSS/HTML formatter - Add ESLint with browser env, enforcing ===, no-var, prefer-const, and no accidental globals; marks CDN `marked` as a known global - Apply Prettier formatting to script.js: normalise indentation to 2 spaces, add trailing commas in multi-line objects, parenthesise arrow function params, collapse double blank lines - Add scripts/check-frontend.sh to run both tools from the repo root; accepts --fix flag to auto-correct all issues - Add frontend/package.json with format, lint, and quality npm scripts - Document all changes and usage instructions in frontend-changes.md Co-Authored-By: Claude Sonnet 4.6 --- frontend-changes.md | 86 +++++++++++ frontend/.eslintrc.json | 22 +++ frontend/.prettierignore | 1 + frontend/.prettierrc | 11 ++ frontend/package.json | 17 +++ frontend/script.js | 305 +++++++++++++++++++------------------- scripts/check-frontend.sh | 46 ++++++ 7 files changed, 337 insertions(+), 151 deletions(-) create mode 100644 frontend-changes.md create mode 100644 frontend/.eslintrc.json create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/package.json create mode 100644 scripts/check-frontend.sh diff --git a/frontend-changes.md b/frontend-changes.md new file mode 100644 index 000000000..4eae1fc4f --- /dev/null +++ b/frontend-changes.md @@ -0,0 +1,86 @@ +# Frontend Changes + +## Code Quality Tooling + +### What was added + +| File | Purpose | +|---|---| +| `frontend/package.json` | npm project manifest with Prettier and ESLint as dev dependencies | +| `frontend/.prettierrc` | Prettier configuration | +| `frontend/.eslintrc.json` | ESLint configuration | +| `frontend/.prettierignore` | Excludes `node_modules/` from formatting | +| `scripts/check-frontend.sh` | Shell script that runs both Prettier and ESLint | + +### Prettier (`frontend/.prettierrc`) + +Prettier is the JavaScript/CSS/HTML equivalent of Black — it enforces a single, consistent code style with no configuration debates. + +Settings chosen to match the existing code style: +- `singleQuote: true` — use single quotes (already used throughout) +- `semi: true` — require semicolons +- `tabWidth: 2` — 2-space indentation +- `trailingComma: "es5"` — trailing commas in objects/arrays (ES5-safe) +- `printWidth: 100` — line length limit +- `arrowParens: "always"` — always parenthesise arrow function params: `(x) => x` + +### ESLint (`frontend/.eslintrc.json`) + +Catches real bugs and enforces best practices in `script.js`: +- `eqeqeq` — require `===` instead of `==` +- `no-var` — disallow `var`, enforcing `const`/`let` +- `prefer-const` — warn when `let` could be `const` +- `no-unused-vars` — warn on unused variables +- `no-implicit-globals` — prevent accidental globals + +`marked` (loaded from CDN) is declared as a global so ESLint does not flag it as undefined. + +### `script.js` formatting changes applied + +Prettier was applied to `script.js`. Key diffs from the original: + +1. **Indentation normalised to 2 spaces** throughout (was 4 spaces). +2. **Trailing commas** added in multi-line objects: + - `{ 'Content-Type': 'application/json' }` fetch header object + - `{ query, session_id }` request body object +3. **Arrow function parentheses** made consistent: `s =>` → `(s) =>`, `.forEach(button =>` → `.forEach((button) =>` +4. **Double blank lines** collapsed to single blank lines (e.g. in `setupEventListeners`). +5. **Method chains** reformatted: `sources.map(...).join('')` broken across lines for readability. +6. **`addMessage` long string call** broken into multi-line form with trailing argument style. + +### Running quality checks + +**Install dependencies (once):** +```bash +cd frontend && npm install +``` + +**Check formatting and linting:** +```bash +# From repo root: +./scripts/check-frontend.sh + +# Or from frontend/: +npm run quality +``` + +**Auto-fix all issues:** +```bash +# From repo root: +./scripts/check-frontend.sh --fix + +# Or from frontend/ (format then lint-fix): +npm run format +npm run lint:fix +``` + +**Individual commands:** +```bash +cd frontend + +npm run format # apply Prettier formatting +npm run format:check # check formatting without writing +npm run lint # run ESLint +npm run lint:fix # run ESLint with auto-fix +npm run quality # format:check + lint (CI-safe, no writes) +``` diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 000000000..b533cdefa --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,22 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "script" + }, + "globals": { + "marked": "readonly" + }, + "rules": { + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "no-console": "off", + "eqeqeq": ["error", "always"], + "no-var": "error", + "prefer-const": "warn", + "no-implicit-globals": "error" + } +} diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1 @@ +node_modules/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 000000000..0009e9d56 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "semi": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "arrowParens": "always", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "css", + "endOfLine": "lf" +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..cc37627cc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "ragchatbot-frontend", + "version": "1.0.0", + "description": "Frontend for the RAG Chatbot course materials assistant", + "private": true, + "scripts": { + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint script.js", + "lint:fix": "eslint --fix script.js", + "quality": "npm run format:check && npm run lint" + }, + "devDependencies": { + "eslint": "^8.57.0", + "prettier": "^3.3.3" + } +} diff --git a/frontend/script.js b/frontend/script.js index eafeb8849..da401625a 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -9,99 +9,96 @@ let chatMessages, chatInput, sendButton, totalCourses, courseTitles; // Initialize document.addEventListener('DOMContentLoaded', () => { - // Get DOM elements after page loads - chatMessages = document.getElementById('chatMessages'); - chatInput = document.getElementById('chatInput'); - sendButton = document.getElementById('sendButton'); - totalCourses = document.getElementById('totalCourses'); - courseTitles = document.getElementById('courseTitles'); - - setupEventListeners(); - createNewSession(); - loadCourseStats(); + // Get DOM elements after page loads + chatMessages = document.getElementById('chatMessages'); + chatInput = document.getElementById('chatInput'); + sendButton = document.getElementById('sendButton'); + totalCourses = document.getElementById('totalCourses'); + courseTitles = document.getElementById('courseTitles'); + + setupEventListeners(); + createNewSession(); + loadCourseStats(); }); // Event Listeners function setupEventListeners() { - // Chat functionality - sendButton.addEventListener('click', sendMessage); - chatInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') sendMessage(); - }); - - - // New chat button - document.getElementById('newChatBtn').addEventListener('click', handleNewChat); - - // Suggested questions - document.querySelectorAll('.suggested-item').forEach(button => { - button.addEventListener('click', (e) => { - const question = e.target.getAttribute('data-question'); - chatInput.value = question; - sendMessage(); - }); + // Chat functionality + sendButton.addEventListener('click', sendMessage); + chatInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') sendMessage(); + }); + + // New chat button + document.getElementById('newChatBtn').addEventListener('click', handleNewChat); + + // Suggested questions + document.querySelectorAll('.suggested-item').forEach((button) => { + button.addEventListener('click', (e) => { + const question = e.target.getAttribute('data-question'); + chatInput.value = question; + sendMessage(); }); + }); } - // Chat Functions async function sendMessage() { - const query = chatInput.value.trim(); - if (!query) return; - - // Disable input - chatInput.value = ''; - chatInput.disabled = true; - sendButton.disabled = true; + const query = chatInput.value.trim(); + if (!query) return; + + // Disable input + chatInput.value = ''; + chatInput.disabled = true; + sendButton.disabled = true; + + // Add user message + addMessage(query, 'user'); + + // Add loading message - create a unique container for it + const loadingMessage = createLoadingMessage(); + chatMessages.appendChild(loadingMessage); + chatMessages.scrollTop = chatMessages.scrollHeight; + + try { + const response = await fetch(`${API_URL}/query`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: query, + session_id: currentSessionId, + }), + }); - // Add user message - addMessage(query, 'user'); + if (!response.ok) throw new Error('Query failed'); - // Add loading message - create a unique container for it - const loadingMessage = createLoadingMessage(); - chatMessages.appendChild(loadingMessage); - chatMessages.scrollTop = chatMessages.scrollHeight; + const data = await response.json(); - try { - const response = await fetch(`${API_URL}/query`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: query, - session_id: currentSessionId - }) - }); - - if (!response.ok) throw new Error('Query failed'); - - const data = await response.json(); - - // Update session ID if new - if (!currentSessionId) { - currentSessionId = data.session_id; - } - - // Replace loading message with response - loadingMessage.remove(); - addMessage(data.answer, 'assistant', data.sources); - - } catch (error) { - // Replace loading message with error - loadingMessage.remove(); - addMessage(`Error: ${error.message}`, 'assistant'); - } finally { - chatInput.disabled = false; - sendButton.disabled = false; - chatInput.focus(); + // Update session ID if new + if (!currentSessionId) { + currentSessionId = data.session_id; } + + // Replace loading message with response + loadingMessage.remove(); + addMessage(data.answer, 'assistant', data.sources); + } catch (error) { + // Replace loading message with error + loadingMessage.remove(); + addMessage(`Error: ${error.message}`, 'assistant'); + } finally { + chatInput.disabled = false; + sendButton.disabled = false; + chatInput.focus(); + } } function createLoadingMessage() { - const messageDiv = document.createElement('div'); - messageDiv.className = 'message assistant'; - messageDiv.innerHTML = ` + const messageDiv = document.createElement('div'); + messageDiv.className = 'message assistant'; + messageDiv.innerHTML = `

@@ -110,103 +107,109 @@ function createLoadingMessage() {
`; - return messageDiv; + return messageDiv; } function addMessage(content, type, sources = null, isWelcome = false) { - const messageId = Date.now(); - const messageDiv = document.createElement('div'); - messageDiv.className = `message ${type}${isWelcome ? ' welcome-message' : ''}`; - messageDiv.id = `message-${messageId}`; - - // Convert markdown to HTML for assistant messages - const displayContent = type === 'assistant' ? marked.parse(content) : escapeHtml(content); - - let html = `
${displayContent}
`; - - if (sources && sources.length > 0) { - const sourceItems = sources.map(s => { - const label = s.label || s; - const url = s.url; - return url - ? `
${label}` - : `${label}`; - }).join(''); - html += ` + const messageId = Date.now(); + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${type}${isWelcome ? ' welcome-message' : ''}`; + messageDiv.id = `message-${messageId}`; + + // Convert markdown to HTML for assistant messages + const displayContent = type === 'assistant' ? marked.parse(content) : escapeHtml(content); + + let html = `
${displayContent}
`; + + if (sources && sources.length > 0) { + const sourceItems = sources + .map((s) => { + const label = s.label || s; + const url = s.url; + return url + ? `${label}` + : `${label}`; + }) + .join(''); + html += `
Sources
${sourceItems}
`; - } - - messageDiv.innerHTML = html; - chatMessages.appendChild(messageDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - - return messageId; + } + + messageDiv.innerHTML = html; + chatMessages.appendChild(messageDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + + return messageId; } // Helper function to escape HTML for user messages function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } // Removed removeMessage function - no longer needed since we handle loading differently async function handleNewChat() { - if (currentSessionId) { - try { - await fetch(`${API_URL}/session/${currentSessionId}`, { method: 'DELETE' }); - } catch (e) { - // Non-critical — proceed regardless - } + if (currentSessionId) { + try { + await fetch(`${API_URL}/session/${currentSessionId}`, { method: 'DELETE' }); + } catch (e) { + // Non-critical — proceed regardless } - createNewSession(); + } + createNewSession(); } async function createNewSession() { - currentSessionId = null; - chatMessages.innerHTML = ''; - addMessage('Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know?', 'assistant', null, true); + 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 + ); } // Load course statistics async function loadCourseStats() { - try { - console.log('Loading course stats...'); - const response = await fetch(`${API_URL}/courses`); - if (!response.ok) throw new Error('Failed to load course stats'); - - const data = await response.json(); - console.log('Course data received:', data); - - // Update stats in UI - if (totalCourses) { - totalCourses.textContent = data.total_courses; - } - - // Update course titles - if (courseTitles) { - if (data.course_titles && data.course_titles.length > 0) { - courseTitles.innerHTML = data.course_titles - .map(title => `
${title}
`) - .join(''); - } else { - courseTitles.innerHTML = 'No courses available'; - } - } - - } catch (error) { - console.error('Error loading course stats:', error); - // Set default values on error - if (totalCourses) { - totalCourses.textContent = '0'; - } - if (courseTitles) { - courseTitles.innerHTML = 'Failed to load courses'; - } + try { + console.log('Loading course stats...'); + const response = await fetch(`${API_URL}/courses`); + if (!response.ok) throw new Error('Failed to load course stats'); + + const data = await response.json(); + console.log('Course data received:', data); + + // Update stats in UI + if (totalCourses) { + totalCourses.textContent = data.total_courses; } -} \ No newline at end of file + + // Update course titles + if (courseTitles) { + if (data.course_titles && data.course_titles.length > 0) { + courseTitles.innerHTML = data.course_titles + .map((title) => `
${title}
`) + .join(''); + } else { + courseTitles.innerHTML = 'No courses available'; + } + } + } catch (error) { + console.error('Error loading course stats:', error); + // Set default values on error + if (totalCourses) { + totalCourses.textContent = '0'; + } + if (courseTitles) { + courseTitles.innerHTML = 'Failed to load courses'; + } + } +} diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh new file mode 100644 index 000000000..2dbc87f94 --- /dev/null +++ b/scripts/check-frontend.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Frontend quality checks: Prettier formatting + ESLint linting +# Run from the repo root: ./scripts/check-frontend.sh +# Pass --fix to auto-fix formatting and lint issues: ./scripts/check-frontend.sh --fix + +set -euo pipefail + +FRONTEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../frontend" && pwd)" +FIX_MODE=false + +for arg in "$@"; do + if [[ "$arg" == "--fix" ]]; then + FIX_MODE=true + fi +done + +echo "==> Frontend quality checks" +echo " Directory: $FRONTEND_DIR" + +# Ensure node_modules are installed +if [[ ! -d "$FRONTEND_DIR/node_modules" ]]; then + echo "" + echo "==> Installing dependencies..." + (cd "$FRONTEND_DIR" && npm install) +fi + +echo "" +if $FIX_MODE; then + echo "==> [1/2] Prettier (format)" + (cd "$FRONTEND_DIR" && npx prettier --write .) + echo "" + echo "==> [2/2] ESLint (lint + fix)" + (cd "$FRONTEND_DIR" && npx eslint --fix script.js) + echo "" + echo "All issues fixed." +else + echo "==> [1/2] Prettier (check formatting)" + (cd "$FRONTEND_DIR" && npx prettier --check .) + echo "" + echo "==> [2/2] ESLint (lint)" + (cd "$FRONTEND_DIR" && npx eslint script.js) + echo "" + echo "All checks passed." + echo "" + echo "Tip: run with --fix to auto-correct formatting and lint issues." +fi From 0629795ad7b6fe406c6d7889c7f0e538875a31ce Mon Sep 17 00:00:00 2001 From: Abdellah HADDAD Date: Mon, 13 Apr 2026 23:24:01 +0100 Subject: [PATCH 06/10] Add API endpoint tests and improve pytest configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test_app.py with 13 tests covering POST /api/query, GET /api/courses, and DELETE /api/session/{id} — using an inline FastAPI test app to avoid import-time side effects from StaticFiles and RAGSystem initialization - Add mock_rag_system fixture and build_test_app factory to conftest.py so API test infrastructure is shared and reusable - Add addopts = ["-v", "--tb=short"] to pytest config for cleaner output - Add httpx as a dev dependency (required by FastAPI TestClient) Co-Authored-By: Claude Sonnet 4.6 --- backend/tests/conftest.py | 80 +++++++++++++++ backend/tests/test_app.py | 199 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 + uv.lock | 6 +- 4 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_app.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b1918926b..9c86742f5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,6 +1,10 @@ import pytest from unittest.mock import MagicMock from vector_store import SearchResults +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import BaseModel +from typing import List, Optional # --------------------------------------------------------------------------- @@ -58,6 +62,82 @@ def mock_vector_store(sample_search_results): return store +# --------------------------------------------------------------------------- +# RAGSystem mock fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_rag_system(): + """ + MagicMock standing in for RAGSystem. + Defaults: .query() returns a plain answer with no sources; .get_course_analytics() + returns two courses; session_manager behaves as expected. + """ + rag = MagicMock() + rag.session_manager.create_session.return_value = "auto-session-id" + rag.query.return_value = ("Test answer.", []) + rag.get_course_analytics.return_value = { + "total_courses": 2, + "course_titles": ["Python Fundamentals", "Data Science Basics"], + } + return rag + + +# --------------------------------------------------------------------------- +# Shared test-app factory (used by test_app.py) +# --------------------------------------------------------------------------- + +def build_test_app(rag_system) -> FastAPI: + """ + Return a minimal FastAPI app that mirrors the real app.py routes but + skips the StaticFiles mount and RAGSystem startup, so tests can run + without a frontend directory or a real ChromaDB instance. + """ + app = FastAPI() + + class QueryRequest(BaseModel): + query: str + session_id: Optional[str] = None + + class QueryResponse(BaseModel): + answer: str + sources: List[dict] + session_id: str + + class CourseStats(BaseModel): + total_courses: int + course_titles: List[str] + + @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)) + + @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)) + + @app.delete("/api/session/{session_id}") + async def delete_session(session_id: str): + rag_system.session_manager.clear_session(session_id) + return {"status": "cleared"} + + return app + + # --------------------------------------------------------------------------- # Anthropic response mock helpers (module-level, importable by test files) # --------------------------------------------------------------------------- diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py new file mode 100644 index 000000000..b942667aa --- /dev/null +++ b/backend/tests/test_app.py @@ -0,0 +1,199 @@ +""" +API endpoint tests for the FastAPI application. + +Uses an inline test app (build_test_app from conftest) that mirrors the real +app.py routes without mounting StaticFiles or instantiating a real RAGSystem, +so these tests run without a frontend directory or ChromaDB instance. + +Endpoints covered: + POST /api/query + GET /api/courses + DELETE /api/session/{session_id} +""" +import pytest +from fastapi.testclient import TestClient +from tests.conftest import build_test_app + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def client(mock_rag_system): + """TestClient wired to the inline test app and a fresh RAGSystem mock.""" + return TestClient(build_test_app(mock_rag_system)) + + +# --------------------------------------------------------------------------- +# POST /api/query +# --------------------------------------------------------------------------- + +class TestQueryEndpoint: + + def test_returns_200_with_required_fields(self, client): + """ + WHAT: Valid query returns 200 and a body that contains answer, sources, session_id. + ASSERT: All three keys present; answer is a non-empty string. + FAILURE MEANS: Response contract broken — frontend crashes unpacking the JSON. + """ + response = client.post("/api/query", json={"query": "What is Python?"}) + assert response.status_code == 200 + data = response.json() + assert "answer" in data + assert "sources" in data + assert "session_id" in data + assert isinstance(data["answer"], str) + assert len(data["answer"]) > 0 + + def test_auto_creates_session_when_none_provided(self, client, mock_rag_system): + """ + WHAT: Omitting session_id triggers session_manager.create_session(). + ASSERT: Returned session_id equals the value produced by the mock. + FAILURE MEANS: Anonymous (stateless) queries never get a session — frontend + cannot maintain conversation continuity. + """ + response = client.post("/api/query", json={"query": "Hello"}) + assert response.status_code == 200 + assert response.json()["session_id"] == "auto-session-id" + mock_rag_system.session_manager.create_session.assert_called_once() + + def test_uses_provided_session_id(self, client, mock_rag_system): + """ + WHAT: When session_id is supplied, create_session is NOT called; the provided + id is passed directly to rag_system.query and echoed back. + ASSERT: create_session not called; session_id in response matches the input. + FAILURE MEANS: Existing sessions are silently discarded, breaking multi-turn chat. + """ + response = client.post( + "/api/query", + json={"query": "Follow-up question", "session_id": "existing-session-42"}, + ) + assert response.status_code == 200 + assert response.json()["session_id"] == "existing-session-42" + mock_rag_system.session_manager.create_session.assert_not_called() + mock_rag_system.query.assert_called_once_with( + "Follow-up question", "existing-session-42" + ) + + def test_sources_list_forwarded_from_rag(self, client, mock_rag_system): + """ + WHAT: Sources returned by rag_system.query appear in the response body. + ASSERT: sources list matches what the mock returns. + FAILURE MEANS: Frontend never displays source links even when search succeeds. + """ + mock_rag_system.query.return_value = ( + "Python is great.", + [{"label": "Python Fundamentals - Lesson 1", "url": "https://example.com"}], + ) + response = client.post("/api/query", json={"query": "What is Python?"}) + assert response.status_code == 200 + sources = response.json()["sources"] + assert len(sources) == 1 + assert sources[0]["label"] == "Python Fundamentals - Lesson 1" + + def test_returns_500_when_rag_raises(self, client, mock_rag_system): + """ + WHAT: If rag_system.query raises, the endpoint returns HTTP 500. + ASSERT: status_code == 500 and detail string is present. + FAILURE MEANS: Exception propagates unhandled → Starlette returns a generic 500 + without the error detail, making debugging harder. + """ + mock_rag_system.query.side_effect = RuntimeError("ChromaDB connection lost") + response = client.post("/api/query", json={"query": "What is Python?"}) + assert response.status_code == 500 + assert "ChromaDB connection lost" in response.json()["detail"] + + def test_query_field_is_required(self, client): + """ + WHAT: A request body missing the required 'query' field is rejected with 422. + ASSERT: status_code == 422 (Unprocessable Entity). + FAILURE MEANS: Pydantic validation is bypassed or the model definition changed. + """ + response = client.post("/api/query", json={"session_id": "abc"}) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /api/courses +# --------------------------------------------------------------------------- + +class TestCoursesEndpoint: + + def test_returns_200_with_course_stats(self, client): + """ + WHAT: GET /api/courses returns total_courses and course_titles from the RAG system. + ASSERT: 200; total_courses == 2; course_titles is a list of 2 strings. + FAILURE MEANS: Analytics endpoint broken — dashboard always shows stale/zero data. + """ + response = client.get("/api/courses") + assert response.status_code == 200 + data = response.json() + assert data["total_courses"] == 2 + assert data["course_titles"] == ["Python Fundamentals", "Data Science Basics"] + + def test_delegates_to_get_course_analytics(self, client, mock_rag_system): + """ + WHAT: /api/courses calls rag_system.get_course_analytics() exactly once. + ASSERT: get_course_analytics called once. + FAILURE MEANS: Route is using a cached value or wrong method — data could be stale. + """ + client.get("/api/courses") + mock_rag_system.get_course_analytics.assert_called_once() + + def test_returns_500_when_analytics_raises(self, client, mock_rag_system): + """ + WHAT: If get_course_analytics raises, the endpoint returns HTTP 500. + ASSERT: status_code == 500 with an error detail string. + FAILURE MEANS: Unhandled exception crashes the server process instead of + returning a structured error to the frontend. + """ + mock_rag_system.get_course_analytics.side_effect = Exception("DB error") + response = client.get("/api/courses") + assert response.status_code == 500 + assert "DB error" in response.json()["detail"] + + def test_empty_course_list(self, client, mock_rag_system): + """ + WHAT: When no courses are loaded, endpoint returns total_courses=0 and []. + ASSERT: total_courses == 0; course_titles == []. + FAILURE MEANS: Empty-state handling crashes or returns unexpected data types. + """ + mock_rag_system.get_course_analytics.return_value = { + "total_courses": 0, + "course_titles": [], + } + response = client.get("/api/courses") + assert response.status_code == 200 + data = response.json() + assert data["total_courses"] == 0 + assert data["course_titles"] == [] + + +# --------------------------------------------------------------------------- +# DELETE /api/session/{session_id} +# --------------------------------------------------------------------------- + +class TestDeleteSessionEndpoint: + + def test_returns_200_with_cleared_status(self, client): + """ + WHAT: DELETE /api/session/{id} returns 200 and {"status": "cleared"}. + ASSERT: status_code == 200; body matches exactly. + FAILURE MEANS: Session cleanup endpoint broken — conversation history leaks + across users or stale sessions accumulate in memory. + """ + response = client.delete("/api/session/test-session-id") + assert response.status_code == 200 + assert response.json() == {"status": "cleared"} + + def test_calls_clear_session_with_correct_id(self, client, mock_rag_system): + """ + WHAT: The session_id path parameter is forwarded to session_manager.clear_session. + ASSERT: clear_session called once with the exact id from the URL. + FAILURE MEANS: Wrong session is cleared, or the call is silently skipped. + """ + client.delete("/api/session/my-specific-session") + mock_rag_system.session_manager.clear_session.assert_called_once_with( + "my-specific-session" + ) diff --git a/pyproject.toml b/pyproject.toml index 46a36a715..029ed6a5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,10 @@ dependencies = [ [tool.pytest.ini_options] testpaths = ["backend/tests"] pythonpath = ["backend"] +addopts = ["-v", "--tb=short"] [dependency-groups] dev = [ + "httpx>=0.28.1", "pytest>=9.0.3", ] diff --git a/uv.lock b/uv.lock index b4e03cf59..e1cd566d2 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.1" }, + { name = "pytest", specifier = ">=9.0.3" }, +] [[package]] name = "sympy" From e649ab1eb2b81751f796ea124f8da3106409a2d3 Mon Sep 17 00:00:00 2001 From: Abdellah HADDAD Date: Tue, 14 Apr 2026 11:43:37 +0100 Subject: [PATCH 07/10] Add dark/light theme toggle to frontend - Add fixed theme toggle button (top-right) with sun/moon SVG icons - Add [data-theme="light"] CSS variable block with full light palette - Apply universal 0.25s transitions for smooth theme switching - Persist theme preference in localStorage; restore on load with no flash - Fix code block and source pill contrast for light mode - Document all changes in frontend-changes.md Co-Authored-By: Claude Sonnet 4.6 --- frontend-changes.md | 58 ++++++++++++++++++++++++ frontend/index.html | 20 ++++++++- frontend/script.js | 27 +++++++++++- frontend/style.css | 104 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 frontend-changes.md diff --git a/frontend-changes.md b/frontend-changes.md new file mode 100644 index 000000000..0fff64cbe --- /dev/null +++ b/frontend-changes.md @@ -0,0 +1,58 @@ +# Frontend Changes — Dark/Light Theme Toggle + +## Summary + +Added a dark/light theme toggle button to the frontend. Users can switch between the existing dark theme and a new light theme. The preference is persisted in `localStorage` and applied immediately on page load (no flash of wrong theme). + +--- + +## Files Modified + +### `frontend/index.html` + +- Added a `

Course Materials Assistant

diff --git a/frontend/script.js b/frontend/script.js index eafeb8849..54ffe65f5 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -7,6 +7,28 @@ let currentSessionId = null; // DOM elements let chatMessages, chatInput, sendButton, totalCourses, courseTitles; +// Theme management +function initTheme() { + const saved = localStorage.getItem('theme'); + if (saved === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } +} + +function toggleTheme() { + const isLight = document.documentElement.getAttribute('data-theme') === 'light'; + if (isLight) { + document.documentElement.removeAttribute('data-theme'); + localStorage.setItem('theme', 'dark'); + } else { + document.documentElement.setAttribute('data-theme', 'light'); + localStorage.setItem('theme', 'light'); + } +} + +// Apply saved theme immediately (before DOMContentLoaded) to avoid flash +initTheme(); + // Initialize document.addEventListener('DOMContentLoaded', () => { // Get DOM elements after page loads @@ -15,7 +37,7 @@ document.addEventListener('DOMContentLoaded', () => { sendButton = document.getElementById('sendButton'); totalCourses = document.getElementById('totalCourses'); courseTitles = document.getElementById('courseTitles'); - + setupEventListeners(); createNewSession(); loadCourseStats(); @@ -30,6 +52,9 @@ function setupEventListeners() { }); + // Theme toggle + document.getElementById('themeToggle').addEventListener('click', toggleTheme); + // New chat button document.getElementById('newChatBtn').addEventListener('click', handleNewChat); diff --git a/frontend/style.css b/frontend/style.css index 819930c91..83dfc02b8 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -22,6 +22,38 @@ --focus-ring: rgba(37, 99, 235, 0.2); --welcome-bg: #1e3a5f; --welcome-border: #2563eb; + --theme-toggle-bg: #1e293b; + --theme-toggle-border: #334155; + --theme-toggle-color: #94a3b8; + --theme-toggle-hover-bg: #334155; +} + +/* Light Theme */ +[data-theme="light"] { + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --background: #f8fafc; + --surface: #ffffff; + --surface-hover: #e2e8f0; + --text-primary: #0f172a; + --text-secondary: #64748b; + --border-color: #cbd5e1; + --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: #eff6ff; + --welcome-border: #93c5fd; + --theme-toggle-bg: #ffffff; + --theme-toggle-border: #cbd5e1; + --theme-toggle-color: #475569; + --theme-toggle-hover-bg: #f1f5f9; +} + +/* Smooth theme transitions */ +*, *::before, *::after { + transition: background-color 0.25s ease, border-color 0.25s ease, color 0.25s ease, box-shadow 0.25s ease; } /* Base Styles */ @@ -36,6 +68,54 @@ body { padding: 0; } +/* Theme Toggle Button */ +.theme-toggle { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--theme-toggle-border); + background: var(--theme-toggle-bg); + color: var(--theme-toggle-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow); +} + +.theme-toggle:hover { + background: var(--theme-toggle-hover-bg); + color: var(--primary-color); + border-color: var(--primary-color); + transform: scale(1.05); +} + +.theme-toggle:focus { + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring); +} + +/* Icon visibility based on theme */ +.theme-toggle .icon-moon { + display: block; +} + +.theme-toggle .icon-sun { + display: none; +} + +[data-theme="light"] .theme-toggle .icon-moon { + display: none; +} + +[data-theme="light"] .theme-toggle .icon-sun { + display: block; +} + /* Container - Full Screen */ .container { height: 100vh; @@ -270,6 +350,18 @@ a.source-pill:hover { text-decoration: none; } +[data-theme="light"] .source-pill { + background: rgba(37, 99, 235, 0.08); + border: 1px solid rgba(37, 99, 235, 0.3); + color: #1d4ed8; +} + +[data-theme="light"] a.source-pill:hover { + background: rgba(37, 99, 235, 0.15); + border-color: rgba(37, 99, 235, 0.6); + color: #1e40af; +} + /* Markdown formatting styles */ .message-content h1, .message-content h2, @@ -302,21 +394,29 @@ a.source-pill:hover { } .message-content code { - background-color: rgba(0, 0, 0, 0.2); + background-color: rgba(0, 0, 0, 0.12); padding: 0.125rem 0.25rem; border-radius: 3px; font-family: 'Fira Code', 'Consolas', monospace; font-size: 0.875em; } +[data-theme="light"] .message-content code { + background-color: rgba(0, 0, 0, 0.07); +} + .message-content pre { - background-color: rgba(0, 0, 0, 0.2); + background-color: rgba(0, 0, 0, 0.12); padding: 0.75rem; border-radius: 4px; overflow-x: auto; margin: 0.5rem 0; } +[data-theme="light"] .message-content pre { + background-color: rgba(0, 0, 0, 0.05); +} + .message-content pre code { background-color: transparent; padding: 0; From 0e84627747904628be31ff03bdc0a4e619ad4d72 Mon Sep 17 00:00:00 2001 From: Abdellah HADDAD Date: Wed, 15 Apr 2026 11:00:16 +0100 Subject: [PATCH 08/10] Add .trees/ to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 41b4384b8..0fad1f34b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ uploads/ # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# Git worktrees +.trees/ \ No newline at end of file From 7cf58837b8569fd61249dd6748cd0708e11e9af5 Mon Sep 17 00:00:00 2001 From: Abdellah-Haddad <69308119+Abdellah-Haddad@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:18:07 +0100 Subject: [PATCH 09/10] "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 0fab84d94770828d26936b5860473f7a113270a3 Mon Sep 17 00:00:00 2001 From: Abdellah-Haddad <69308119+Abdellah-Haddad@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:18:09 +0100 Subject: [PATCH 10/10] "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 +