diff --git a/.claude/commands/implement-feature.md b/.claude/commands/implement-feature.md new file mode 100644 index 000000000..2a67628c3 --- /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..9419b9a12 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(uv add *)" + ] + } +} diff --git a/.gitignore b/.gitignore index 41b4384b8..8a5c1400b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,14 @@ __pycache__/ .Python # Virtual environments +.venv/ venv/ env/ ENV/ +# Node (Prettier is the only dev dependency; there is no build step) +node_modules/ + # ChromaDB backend/chroma_db/ @@ -26,6 +30,9 @@ uploads/ *.swp *.swo +# Playwright MCP output (screenshots, snapshots, console logs) +.playwright-mcp/ + # OS .DS_Store Thumbs.db \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..8858fa1c3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,13 @@ +# Dependencies +node_modules/ + +# Python / tooling artifacts +.venv/ +__pycache__/ +backend/chroma_db/ + +# Course material fixtures — keep verbatim, the parser depends on exact formatting +docs/ + +# Playwright MCP output +.playwright-mcp/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..2f765c5ee --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,20 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "bracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf", + "htmlWhitespaceSensitivity": "css", + "overrides": [ + { + "files": "*.html", + "options": { + "printWidth": 120 + } + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a7b9caa58 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A Retrieval-Augmented Generation (RAG) system for answering questions about course materials, using ChromaDB for vector storage, Anthropic's Claude for AI generation (via tool-calling), and a static HTML/JS frontend. + +## Commands + +Package management is via `uv` (not pip/poetry). Windows users run these from Git Bash. + +Always use `uv run` to start the server or execute scripts — never invoke `python`/`python3` directly. + +```bash +uv sync # install dependencies +``` + +Set `ANTHROPIC_API_KEY` in a `.env` file at the repo root (see `.env.example`). + +Run the app (starts the backend and serves the frontend from the same FastAPI process): + +```bash +./run.sh +# or manually: +cd backend && uv run uvicorn app:app --reload --port 8000 +``` + +- Web UI: http://localhost:8000 +- API docs: http://localhost:8000/docs + +There is no build step in this repo (the frontend is served as static files). + +**Code quality / formatting** — black + isort for Python, Prettier for `frontend/*.{html,css,js}`: + +```bash +./scripts/format.sh # auto-format everything in place +./scripts/check.sh # verify formatting only, writes nothing (exits 1 if unformatted) +./scripts/quality.sh # full gate: check.sh + pytest +``` + +Run `./scripts/format.sh` after editing any file, and `./scripts/quality.sh` before committing. +Formatter config: `[tool.black]`/`[tool.isort]` in `pyproject.toml`, `.prettierrc.json` for the +frontend. Prettier is the only npm dependency and is installed automatically on first script run. + +Tests live in `backend/tests/` and run via `uv run pytest`. Note that 3 tests in +`test_rag_system_content_queries.py` fail against the committed baseline — these are +pre-existing diagnostic failures, not regressions. + +## Architecture + +**Request flow:** frontend (`frontend/script.js`) POSTs to `/api/query` → `app.py` → `RAGSystem.query()` (`backend/rag_system.py`) → `AIGenerator.generate_response()` (`backend/ai_generator.py`) calls Claude with the `search_course_content` tool available → Claude decides whether to invoke the tool → if it does, `ToolManager` executes `CourseSearchTool.execute()` against `VectorStore` → tool results are fed back to Claude for a final synthesized answer → sources collected from `ToolManager.get_last_sources()` are returned alongside the answer. + +The key design point: retrieval is **agentic, not a fixed pipeline step**. `RAGSystem` doesn't search before calling Claude; it exposes search as a tool (see `search_tools.py`) and Claude's system prompt (`AIGenerator.SYSTEM_PROMPT`) instructs it to search only for course-specific questions, general questions are answered from Claude's own knowledge, and at most one search per query. + +**Core components (`backend/`):** +- `app.py` — FastAPI app. Two endpoints: `POST /api/query` (ask a question, returns answer + sources + session_id) and `GET /api/courses` (course analytics). On startup, loads all documents from `../docs` into the vector store (skips courses already present by title). Also mounts `../frontend` as static files at `/`. +- `rag_system.py` — orchestrator (`RAGSystem`) wiring together `DocumentProcessor`, `VectorStore`, `AIGenerator`, `SessionManager`, and `ToolManager`. Entry points: `add_course_document`/`add_course_folder` (ingestion) and `query` (answering). +- `document_processor.py` — parses course documents into a `Course` + `List[CourseChunk]`. Expects a specific text format (see below), splits lesson bodies into overlapping sentence-based chunks (`CHUNK_SIZE`/`CHUNK_OVERLAP` in `config.py`), and prefixes the first chunk of each lesson with course/lesson context so embeddings retain that context even out of order. +- `vector_store.py` — wraps ChromaDB with **two collections**: `course_catalog` (one doc per course, keyed by title, used only to resolve a fuzzy `course_name` filter to an exact title via semantic search) and `course_content` (the actual chunked material, filterable by `course_title`/`lesson_number`). `VectorStore.search()` is the unified entry point: resolves course name → builds a Chroma `where` filter → queries `course_content`. +- `search_tools.py` — `Tool`/`ToolManager` abstraction for exposing search to Claude as an Anthropic tool-use tool. `CourseSearchTool` formats results with `[Course - Lesson N]` headers and tracks `last_sources` for the UI; `ToolManager.reset_sources()` is called by `RAGSystem` after each query so sources don't leak across requests. +- `ai_generator.py` — thin wrapper around the Anthropic Messages API. Single-round tool-use loop: initial call with `tools` + `tool_choice: auto` → if `stop_reason == "tool_use"`, execute the tool call(s) via `tool_manager` and make one follow-up call *without* tools to get the final text. +- `session_manager.py` — in-memory (non-persistent) per-session conversation history, truncated to `MAX_HISTORY` exchanges. +- `models.py` — Pydantic models: `Course`, `Lesson`, `CourseChunk`. `Course.title` is used as the unique ID throughout (Chroma document ID in `course_catalog`, filter key in `course_content`). +- `config.py` — central `Config` dataclass (loaded from `.env` via `python-dotenv`): model name, embedding model, chunk size/overlap, max search results, max history, Chroma path. + +**RAG pipeline in detail:** + +*Ingestion (on startup, `app.py` → `RAGSystem.add_course_folder` → `add_course_document`):* +1. `DocumentProcessor.process_course_document` parses the file into a `Course` (title/link/instructor) and per-lesson text bodies (see format below). +2. Each lesson body is split into overlapping chunks by `chunk_text`: sentence-boundary splitting, packed up to `CHUNK_SIZE` (800 chars), with the tail `CHUNK_OVERLAP` (100 chars) of sentences repeated at the start of the next chunk so context isn't lost at chunk edges. +3. The first chunk of each lesson is prefixed with `"Course {title} Lesson {n} content: ..."` so that chunk retains course/lesson identity even when embedded and retrieved in isolation. +4. `VectorStore.add_course_metadata` embeds one document per course (title only) into the `course_catalog` collection, storing instructor/link/lesson list as metadata — this collection exists purely to resolve fuzzy course-name lookups later, not for content retrieval. +5. `VectorStore.add_course_content` embeds every chunk into the `course_content` collection, with `course_title`/`lesson_number`/`chunk_index` as metadata for filtering. +6. Both collections use the same embedding function: `sentence-transformers` model `all-MiniLM-L6-v2` (`EMBEDDING_MODEL` in `config.py`), run locally (no API calls for embeddings). +7. Ingestion is idempotent by course title: `app.py` skips any course whose title is already in `course_catalog`. + +*Retrieval (per query, `RAGSystem.query` → `AIGenerator.generate_response`):* +1. Claude receives the user question plus recent session history and the `search_course_content` tool definition; it decides whether the question needs course-specific lookup at all (general knowledge questions get answered without searching). +2. If Claude calls the tool, `CourseSearchTool.execute` → `VectorStore.search`: + - If a `course_name` was passed, it's first resolved via a semantic query against `course_catalog` (top-1 match) to get the exact stored title — so the tool tolerates fuzzy/partial course names. + - A Chroma `where` filter is built from the resolved `course_title` and/or `lesson_number`. + - `course_content` is queried with the filter, returning up to `MAX_RESULTS` (5) chunks by embedding similarity. +3. Results are formatted as `[Course Title - Lesson N]` headers followed by chunk text, and `CourseSearchTool` records them in `last_sources` (with lesson links resolved via `VectorStore.get_lesson_link`) for the frontend to display. +4. Formatted results are appended to the conversation and sent back to Claude in a second API call *without* tools, so Claude cannot loop/chain further searches — at most one search round-trip per query. +5. Claude synthesizes the final answer from the retrieved chunks; `RAGSystem` returns `(answer, sources)` and calls `ToolManager.reset_sources()` so sources don't leak into the next query. + +**Expected course document format** (see `docs/*.txt`), parsed line-by-line by `DocumentProcessor.process_course_document`: +``` +Course Title: +Course Link: <url> +Course Instructor: <name> + +Lesson 0: <lesson title> +Lesson Link: <url> +<lesson body text...> + +Lesson 1: <lesson title> +... +``` + +**Frontend (`frontend/`):** plain HTML/CSS/vanilla JS, no build step or framework. `script.js` calls `/api/query` and `/api/courses` directly and renders responses/sources into the DOM. diff --git a/README.md b/README.md index e5420d50a..8ea567380 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This application is a full-stack web application that enables users to query cou - Python 3.13 or higher - uv (Python package manager) +- Node.js 18+ and npm (only for the front-end formatter; the app itself has no build step) - An Anthropic API key (for Claude AI) - **For Windows**: Use Git Bash to run the application commands - [Download Git for Windows](https://git-scm.com/downloads/win) @@ -54,3 +55,17 @@ The application will be available at: - Web Interface: `http://localhost:8000` - API Documentation: `http://localhost:8000/docs` +## Code Quality + +Formatting is enforced by [black](https://black.readthedocs.io/) + [isort](https://pycqa.github.io/isort/) +for Python and [Prettier](https://prettier.io/) for the front end (`frontend/*.html|css|js`). + +```bash +./scripts/format.sh # auto-format everything in place +./scripts/check.sh # verify formatting only, writes nothing (exits 1 if unformatted) +./scripts/quality.sh # full gate: check.sh + pytest — run this before committing +``` + +`format.sh` and `check.sh` install the front-end dev dependency (Prettier) on first run. +Config lives in `pyproject.toml` (`[tool.black]`, `[tool.isort]`) and `.prettierrc.json`. + diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 0363ca90c..cedbffa7d 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -1,24 +1,28 @@ +from typing import Any, Dict, List, Optional + import anthropic -from typing import List, Optional, Dict, Any + class AIGenerator: """Handles interactions with Anthropic's Claude API for generating responses""" - + # Static system prompt to avoid rebuilding on each call - SYSTEM_PROMPT = """ You are an AI assistant specialized in course materials and educational content with access to a comprehensive search tool for course information. + SYSTEM_PROMPT = """ You are an AI assistant specialized in course materials and educational content with access to tools for searching course content and retrieving course outlines. -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 +Tool Usage: +- **get_course_outline**: Use whenever the query is about course structure rather than content — this includes the words/phrases "outline", "syllabus", "structure", "table of contents", or any request for the list of lessons in a course (e.g. "what lessons are in course X", "give me the outline of course Y", "what is the outline of course Z"). Never use search_course_content for these — it only returns content excerpts, not the authoritative lesson list. +- **search_course_content**: Use only for questions about specific content, concepts, or explanations *within* a lesson (e.g. "what does lesson 3 say about X", "explain how Y works in course Z") +- **At most one tool call per query** — pick the single most relevant tool +- Synthesize tool results into accurate, fact-based responses +- If a tool yields no results, state this clearly without offering alternatives Response Protocol: -- **General knowledge questions**: Answer using existing knowledge without searching -- **Course-specific questions**: Search first, then answer +- **General knowledge questions**: Answer using existing knowledge without using tools +- **Course-specific questions**: Use the appropriate tool first, then answer +- **Course outline/structure questions**: Always include the course title, course link, and every lesson's number and title in your answer — do not omit or truncate the lesson list for brevity - **No meta-commentary**: - - Provide direct answers only — no reasoning process, search explanations, or question-type analysis - - Do not mention "based on the search results" + - Provide direct answers only — no reasoning process, tool explanations, or question-type analysis + - Do not mention "based on the search results" or "based on the tool results" All responses must be: @@ -28,108 +32,129 @@ class AIGenerator: 4. **Example-supported** - Include relevant examples when they aid understanding Provide only the direct answer to what was asked. """ - + def __init__(self, api_key: str, model: str): self.client = anthropic.Anthropic(api_key=api_key) self.model = model - + # Pre-build base API parameters self.base_params = { "model": self.model, - "temperature": 0, - "max_tokens": 800 + "max_tokens": 800, + "thinking": {"type": "disabled"}, } - - def generate_response(self, query: str, - conversation_history: Optional[str] = None, - tools: Optional[List] = None, - tool_manager=None) -> str: + + def generate_response( + self, + query: str, + conversation_history: Optional[str] = None, + tools: Optional[List] = None, + tool_manager=None, + ) -> str: """ Generate AI response with optional tool usage and conversation context. - + Args: query: The user's question or request conversation_history: Previous messages for context tools: Available tools the AI can use tool_manager: Manager to execute tools - + Returns: Generated response as string """ - + # Build system content efficiently - avoid string ops when possible system_content = ( f"{self.SYSTEM_PROMPT}\n\nPrevious conversation:\n{conversation_history}" - if conversation_history + if conversation_history else self.SYSTEM_PROMPT ) - + # Prepare API call parameters efficiently api_params = { **self.base_params, "messages": [{"role": "user", "content": query}], - "system": system_content + "system": system_content, } - + # Add tools if available if tools: api_params["tools"] = tools api_params["tool_choice"] = {"type": "auto"} - + # Get response from Claude response = self.client.messages.create(**api_params) - + # Handle tool execution if needed if response.stop_reason == "tool_use" and tool_manager: return self._handle_tool_execution(response, api_params, tool_manager) - + # Return direct response - return response.content[0].text - - def _handle_tool_execution(self, initial_response, base_params: Dict[str, Any], tool_manager): + return self._extract_text_with_retry(response, api_params) + + def _handle_tool_execution( + self, initial_response, base_params: Dict[str, Any], tool_manager + ): """ Handle execution of tool calls and get follow-up response. - + Args: initial_response: The response containing tool use requests base_params: Base API parameters tool_manager: Manager to execute tools - + Returns: Final response text after tool execution """ # Start with existing messages messages = base_params["messages"].copy() - + # Add AI's tool use response messages.append({"role": "assistant", "content": initial_response.content}) - + # Execute all tool calls and collect results tool_results = [] for content_block in initial_response.content: if content_block.type == "tool_use": tool_result = tool_manager.execute_tool( - content_block.name, - **content_block.input + content_block.name, **content_block.input ) - - tool_results.append({ - "type": "tool_result", - "tool_use_id": content_block.id, - "content": tool_result - }) - + + tool_results.append( + { + "type": "tool_result", + "tool_use_id": content_block.id, + "content": tool_result, + } + ) + # Add tool results as single message if tool_results: messages.append({"role": "user", "content": tool_results}) - + # Prepare final API call without tools final_params = { **self.base_params, "messages": messages, - "system": base_params["system"] + "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 + return self._extract_text_with_retry(final_response, final_params) + + def _extract_text(self, response) -> str: + """Extract the text content from a response, skipping non-text blocks (e.g. thinking blocks)""" + for block in response.content: + if block.type == "text": + return block.text + return "" + + def _extract_text_with_retry(self, response, api_params: Dict[str, Any]) -> str: + """Extract text from a response, retrying the call once if the model ended the turn with no text (can happen after a thinking block)""" + text = self._extract_text(response) + if not text: + response = self.client.messages.create(**api_params) + text = self._extract_text(response) + return text diff --git a/backend/app.py b/backend/app.py index 5a69d741d..cc13e08cb 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,13 +1,15 @@ import warnings + warnings.filterwarnings("ignore", message="resource_tracker: There appear to be.*") +import os +from typing import List, Optional + from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from fastapi.middleware.trustedhost import TrustedHostMiddleware +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from typing import List, Optional -import os from config import config from rag_system import RAGSystem @@ -16,10 +18,7 @@ app = FastAPI(title="Course Materials RAG System", root_path="") # Add trusted host middleware for proxy -app.add_middleware( - TrustedHostMiddleware, - allowed_hosts=["*"] -) +app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"]) # Enable CORS with proper settings for proxy app.add_middleware( @@ -34,25 +33,40 @@ # Initialize RAG system rag_system = RAGSystem(config) + # Pydantic models for request/response class QueryRequest(BaseModel): """Request model for course queries""" + query: str session_id: Optional[str] = None + +class SourceItem(BaseModel): + """A single source reference with optional link""" + + text: str + link: Optional[str] = None + + class QueryResponse(BaseModel): """Response model for course queries""" + answer: str - sources: List[str] + sources: List[SourceItem] session_id: str + class CourseStats(BaseModel): """Response model for course statistics""" + total_courses: int course_titles: List[str] + # API Endpoints + @app.post("/api/query", response_model=QueryResponse) async def query_documents(request: QueryRequest): """Process a query and return response with sources""" @@ -61,18 +75,29 @@ async def query_documents(request: QueryRequest): session_id = request.session_id if not session_id: session_id = rag_system.session_manager.create_session() - + # Process query using RAG system answer, sources = rag_system.query(request.query, session_id) - + return QueryResponse( answer=answer, - sources=sources, - session_id=session_id + sources=[SourceItem(text=s.text, link=s.link) for s in sources], + session_id=session_id, ) 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): + """End a conversation session and free its stored history""" + try: + rag_system.session_manager.delete_session(session_id) + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/api/courses", response_model=CourseStats) async def get_course_stats(): """Get course analytics and statistics""" @@ -80,11 +105,12 @@ async def get_course_stats(): analytics = rag_system.get_course_analytics() return CourseStats( total_courses=analytics["total_courses"], - course_titles=analytics["course_titles"] + course_titles=analytics["course_titles"], ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @app.on_event("startup") async def startup_event(): """Load initial documents on startup""" @@ -92,17 +118,22 @@ async def startup_event(): if os.path.exists(docs_path): print("Loading initial documents...") try: - courses, chunks = rag_system.add_course_folder(docs_path, clear_existing=False) + courses, chunks = rag_system.add_course_folder( + docs_path, clear_existing=False + ) print(f"Loaded {courses} courses with {chunks} chunks") except Exception as e: print(f"Error loading documents: {e}") -# Custom static file handler with no-cache headers for development -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse + import os from pathlib import Path +from fastapi.responses import FileResponse + +# Custom static file handler with no-cache headers for development +from fastapi.staticfiles import StaticFiles + class DevStaticFiles(StaticFiles): async def get_response(self, path: str, scope): @@ -113,7 +144,7 @@ async def get_response(self, path: str, scope): response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response - - + + # Serve static files for the frontend -app.mount("/", StaticFiles(directory="../frontend", html=True), name="static") \ No newline at end of file +app.mount("/", StaticFiles(directory="../frontend", html=True), name="static") diff --git a/backend/config.py b/backend/config.py index d9f6392ef..966e630d4 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,29 +1,31 @@ import os from dataclasses import dataclass + from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() + @dataclass class Config: """Configuration settings for the RAG system""" + # Anthropic API settings ANTHROPIC_API_KEY: str = os.getenv("ANTHROPIC_API_KEY", "") - ANTHROPIC_MODEL: str = "claude-sonnet-4-20250514" - + ANTHROPIC_MODEL: str = "claude-sonnet-5" + # Embedding model settings EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" - + # Document processing settings - CHUNK_SIZE: int = 800 # Size of text chunks for vector storage - CHUNK_OVERLAP: int = 100 # Characters to overlap between chunks - MAX_RESULTS: int = 5 # Maximum search results to return - MAX_HISTORY: int = 2 # Number of conversation messages to remember - + CHUNK_SIZE: int = 800 # Size of text chunks for vector storage + CHUNK_OVERLAP: int = 100 # Characters to overlap between chunks + MAX_RESULTS: int = 5 # Maximum search results to return + MAX_HISTORY: int = 2 # Number of conversation messages to remember + # Database paths CHROMA_PATH: str = "./chroma_db" # ChromaDB storage location -config = Config() - +config = Config() diff --git a/backend/document_processor.py b/backend/document_processor.py index 266e85904..bc0662a31 100644 --- a/backend/document_processor.py +++ b/backend/document_processor.py @@ -1,83 +1,87 @@ import os import re from typing import List, Tuple -from models import Course, Lesson, CourseChunk + +from models import Course, CourseChunk, Lesson + class DocumentProcessor: """Processes course documents and extracts structured information""" - + def __init__(self, chunk_size: int, chunk_overlap: int): self.chunk_size = chunk_size self.chunk_overlap = chunk_overlap - + def read_file(self, file_path: str) -> str: """Read content from file with UTF-8 encoding""" try: - with open(file_path, 'r', encoding='utf-8') as file: + with open(file_path, "r", encoding="utf-8") as file: return file.read() except UnicodeDecodeError: # If UTF-8 fails, try with error handling - with open(file_path, 'r', encoding='utf-8', errors='ignore') as file: + with open(file_path, "r", encoding="utf-8", errors="ignore") as file: return file.read() - - def chunk_text(self, text: str) -> List[str]: """Split text into sentence-based chunks with overlap using config settings""" - + # Clean up the text - text = re.sub(r'\s+', ' ', text.strip()) # Normalize whitespace - + text = re.sub(r"\s+", " ", text.strip()) # Normalize whitespace + # Better sentence splitting that handles abbreviations # This regex looks for periods followed by whitespace and capital letters # but ignores common abbreviations - sentence_endings = re.compile(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\!|\?)\s+(?=[A-Z])') + sentence_endings = re.compile( + r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\!|\?)\s+(?=[A-Z])" + ) sentences = sentence_endings.split(text) - + # Clean sentences sentences = [s.strip() for s in sentences if s.strip()] - + chunks = [] i = 0 - + while i < len(sentences): current_chunk = [] current_size = 0 - + # Build chunk starting from sentence i for j in range(i, len(sentences)): sentence = sentences[j] - + # Calculate size with space space_size = 1 if current_chunk else 0 total_addition = len(sentence) + space_size - + # Check if adding this sentence would exceed chunk size if current_size + total_addition > self.chunk_size and current_chunk: break - + current_chunk.append(sentence) current_size += total_addition - + # Add chunk if we have content if current_chunk: - chunks.append(' '.join(current_chunk)) - + chunks.append(" ".join(current_chunk)) + # Calculate overlap for next chunk - if hasattr(self, 'chunk_overlap') and self.chunk_overlap > 0: + if hasattr(self, "chunk_overlap") and self.chunk_overlap > 0: # Find how many sentences to overlap overlap_size = 0 overlap_sentences = 0 - + # Count backwards from end of current chunk for k in range(len(current_chunk) - 1, -1, -1): - sentence_len = len(current_chunk[k]) + (1 if k < len(current_chunk) - 1 else 0) + sentence_len = len(current_chunk[k]) + ( + 1 if k < len(current_chunk) - 1 else 0 + ) if overlap_size + sentence_len <= self.chunk_overlap: overlap_size += sentence_len overlap_sentences += 1 else: break - + # Move start position considering overlap next_start = i + len(current_chunk) - overlap_sentences i = max(next_start, i + 1) # Ensure we make progress @@ -87,14 +91,12 @@ def chunk_text(self, text: str) -> List[str]: else: # No sentences fit, move to next i += 1 - - return chunks - - + return chunks - - def process_course_document(self, file_path: str) -> Tuple[Course, List[CourseChunk]]: + def process_course_document( + self, file_path: str + ) -> Tuple[Course, List[CourseChunk]]: """ Process a course document with expected format: Line 1: Course Title: [title] @@ -104,47 +106,51 @@ def process_course_document(self, file_path: str) -> Tuple[Course, List[CourseCh """ content = self.read_file(file_path) filename = os.path.basename(file_path) - - lines = content.strip().split('\n') - + + lines = content.strip().split("\n") + # Extract course metadata from first three lines course_title = filename # Default fallback course_link = None instructor_name = "Unknown" - + # Parse course title from first line if len(lines) >= 1 and lines[0].strip(): - title_match = re.match(r'^Course Title:\s*(.+)$', lines[0].strip(), re.IGNORECASE) + title_match = re.match( + r"^Course Title:\s*(.+)$", lines[0].strip(), re.IGNORECASE + ) if title_match: course_title = title_match.group(1).strip() else: course_title = lines[0].strip() - + # Parse remaining lines for course metadata for i in range(1, min(len(lines), 4)): # Check first 4 lines for metadata line = lines[i].strip() if not line: continue - + # Try to match course link - link_match = re.match(r'^Course Link:\s*(.+)$', line, re.IGNORECASE) + link_match = re.match(r"^Course Link:\s*(.+)$", line, re.IGNORECASE) if link_match: course_link = link_match.group(1).strip() continue - + # Try to match instructor - instructor_match = re.match(r'^Course Instructor:\s*(.+)$', line, re.IGNORECASE) + instructor_match = re.match( + r"^Course Instructor:\s*(.+)$", line, re.IGNORECASE + ) if instructor_match: instructor_name = instructor_match.group(1).strip() continue - + # Create course object with title as ID course = Course( title=course_title, course_link=course_link, - instructor=instructor_name if instructor_name != "Unknown" else None + instructor=instructor_name if instructor_name != "Unknown" else None, ) - + # Process lessons and create chunks course_chunks = [] current_lesson = None @@ -152,108 +158,114 @@ def process_course_document(self, file_path: str) -> Tuple[Course, List[CourseCh lesson_link = None lesson_content = [] chunk_counter = 0 - + # Start processing from line 4 (after metadata) start_index = 3 if len(lines) > 3 and not lines[3].strip(): start_index = 4 # Skip empty line after instructor - + i = start_index while i < len(lines): line = lines[i] - + # Check for lesson markers (e.g., "Lesson 0: Introduction") - lesson_match = re.match(r'^Lesson\s+(\d+):\s*(.+)$', line.strip(), re.IGNORECASE) - + lesson_match = re.match( + r"^Lesson\s+(\d+):\s*(.+)$", line.strip(), re.IGNORECASE + ) + if lesson_match: # Process previous lesson if it exists if current_lesson is not None and lesson_content: - lesson_text = '\n'.join(lesson_content).strip() + lesson_text = "\n".join(lesson_content).strip() if lesson_text: # Add lesson to course lesson = Lesson( lesson_number=current_lesson, title=lesson_title, - lesson_link=lesson_link + lesson_link=lesson_link, ) course.lessons.append(lesson) - + # Create chunks for this lesson chunks = self.chunk_text(lesson_text) for idx, chunk in enumerate(chunks): # For the first chunk of each lesson, add lesson context if idx == 0: - chunk_with_context = f"Lesson {current_lesson} content: {chunk}" + chunk_with_context = ( + f"Lesson {current_lesson} content: {chunk}" + ) else: chunk_with_context = chunk - + course_chunk = CourseChunk( content=chunk_with_context, course_title=course.title, lesson_number=current_lesson, - chunk_index=chunk_counter + chunk_index=chunk_counter, ) course_chunks.append(course_chunk) chunk_counter += 1 - + # Start new lesson current_lesson = int(lesson_match.group(1)) lesson_title = lesson_match.group(2).strip() lesson_link = None - + # Check if next line is a lesson link if i + 1 < len(lines): next_line = lines[i + 1].strip() - link_match = re.match(r'^Lesson Link:\s*(.+)$', next_line, re.IGNORECASE) + link_match = re.match( + r"^Lesson Link:\s*(.+)$", next_line, re.IGNORECASE + ) if link_match: lesson_link = link_match.group(1).strip() i += 1 # Skip the link line so it's not added to content - + lesson_content = [] else: # Add line to current lesson content lesson_content.append(line) - + i += 1 - + # Process the last lesson if current_lesson is not None and lesson_content: - lesson_text = '\n'.join(lesson_content).strip() + lesson_text = "\n".join(lesson_content).strip() if lesson_text: lesson = Lesson( lesson_number=current_lesson, title=lesson_title, - lesson_link=lesson_link + lesson_link=lesson_link, ) course.lessons.append(lesson) - + chunks = self.chunk_text(lesson_text) for idx, chunk in enumerate(chunks): # For any chunk of each lesson, add lesson context & course title - + chunk_with_context = f"Course {course_title} Lesson {current_lesson} content: {chunk}" - + course_chunk = CourseChunk( content=chunk_with_context, course_title=course.title, lesson_number=current_lesson, - chunk_index=chunk_counter + chunk_index=chunk_counter, ) course_chunks.append(course_chunk) chunk_counter += 1 - + # If no lessons found, treat entire content as one document if not course_chunks and len(lines) > 2: - remaining_content = '\n'.join(lines[start_index:]).strip() + remaining_content = "\n".join(lines[start_index:]).strip() if remaining_content: chunks = self.chunk_text(remaining_content) for chunk in chunks: course_chunk = CourseChunk( content=chunk, course_title=course.title, - chunk_index=chunk_counter + chunk_index=chunk_counter, ) course_chunks.append(course_chunk) chunk_counter += 1 - + return course, course_chunks diff --git a/backend/models.py b/backend/models.py index 7f7126fa3..9ab7381d0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,22 +1,29 @@ -from typing import List, Dict, Optional +from typing import Dict, List, Optional + from pydantic import BaseModel + class Lesson(BaseModel): """Represents a lesson within a course""" + lesson_number: int # Sequential lesson number (1, 2, 3, etc.) - title: str # Lesson title + title: str # Lesson title lesson_link: Optional[str] = None # URL link to the lesson + class Course(BaseModel): """Represents a complete course with its lessons""" - title: str # Full course title (used as unique identifier) + + title: str # Full course title (used as unique identifier) course_link: Optional[str] = None # URL link to the course instructor: Optional[str] = None # Course instructor name (optional metadata) - lessons: List[Lesson] = [] # List of lessons in this course + lessons: List[Lesson] = [] # List of lessons in this course + class CourseChunk(BaseModel): """Represents a text chunk from a course for vector storage""" - content: str # The actual text content - course_title: str # Which course this chunk belongs to - lesson_number: Optional[int] = None # Which lesson this chunk is from - chunk_index: int # Position of this chunk in the document \ No newline at end of file + + content: str # The actual text content + course_title: str # Which course this chunk belongs to + lesson_number: Optional[int] = None # Which lesson this chunk is from + chunk_index: int # Position of this chunk in the document diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8e..a064b84ba 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -1,147 +1,169 @@ -from typing import List, Tuple, Optional, Dict import os -from document_processor import DocumentProcessor -from vector_store import VectorStore +from typing import Dict, List, Optional, Tuple + from ai_generator import AIGenerator +from document_processor import DocumentProcessor +from models import Course, CourseChunk, Lesson +from search_tools import CourseOutlineTool, CourseSearchTool, Source, ToolManager from session_manager import SessionManager -from search_tools import ToolManager, CourseSearchTool -from models import Course, Lesson, CourseChunk +from vector_store import VectorStore + class RAGSystem: """Main orchestrator for the Retrieval-Augmented Generation system""" - + def __init__(self, config): self.config = config - + # Initialize core components - self.document_processor = DocumentProcessor(config.CHUNK_SIZE, config.CHUNK_OVERLAP) - self.vector_store = VectorStore(config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS) - self.ai_generator = AIGenerator(config.ANTHROPIC_API_KEY, config.ANTHROPIC_MODEL) + self.document_processor = DocumentProcessor( + config.CHUNK_SIZE, config.CHUNK_OVERLAP + ) + self.vector_store = VectorStore( + config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS + ) + self.ai_generator = AIGenerator( + config.ANTHROPIC_API_KEY, config.ANTHROPIC_MODEL + ) self.session_manager = SessionManager(config.MAX_HISTORY) - + # Initialize search tools self.tool_manager = ToolManager() self.search_tool = CourseSearchTool(self.vector_store) + self.outline_tool = CourseOutlineTool(self.vector_store) self.tool_manager.register_tool(self.search_tool) - + self.tool_manager.register_tool(self.outline_tool) + def add_course_document(self, file_path: str) -> Tuple[Course, int]: """ Add a single course document to the knowledge base. - + Args: file_path: Path to the course document - + Returns: Tuple of (Course object, number of chunks created) """ try: # Process the document - course, course_chunks = self.document_processor.process_course_document(file_path) - + course, course_chunks = self.document_processor.process_course_document( + file_path + ) + # Add course metadata to vector store for semantic search self.vector_store.add_course_metadata(course) - + # Add course content chunks to vector store self.vector_store.add_course_content(course_chunks) - + return course, len(course_chunks) except Exception as e: print(f"Error processing course document {file_path}: {e}") return None, 0 - - def add_course_folder(self, folder_path: str, clear_existing: bool = False) -> Tuple[int, int]: + + def add_course_folder( + self, folder_path: str, clear_existing: bool = False + ) -> Tuple[int, int]: """ Add all course documents from a folder. - + Args: folder_path: Path to folder containing course documents clear_existing: Whether to clear existing data first - + Returns: Tuple of (total courses added, total chunks created) """ total_courses = 0 total_chunks = 0 - + # Clear existing data if requested if clear_existing: print("Clearing existing data for fresh rebuild...") self.vector_store.clear_all_data() - + if not os.path.exists(folder_path): print(f"Folder {folder_path} does not exist") return 0, 0 - + # Get existing course titles to avoid re-processing existing_course_titles = set(self.vector_store.get_existing_course_titles()) - + # Process each file in the folder for file_name in os.listdir(folder_path): file_path = os.path.join(folder_path, file_name) - if os.path.isfile(file_path) and file_name.lower().endswith(('.pdf', '.docx', '.txt')): + if os.path.isfile(file_path) and file_name.lower().endswith( + (".pdf", ".docx", ".txt") + ): try: # Check if this course might already exist # We'll process the document to get the course ID, but only add if new - course, course_chunks = self.document_processor.process_course_document(file_path) - + course, course_chunks = ( + self.document_processor.process_course_document(file_path) + ) + if course and course.title not in existing_course_titles: # This is a new course - add it to the vector store self.vector_store.add_course_metadata(course) self.vector_store.add_course_content(course_chunks) total_courses += 1 total_chunks += len(course_chunks) - print(f"Added new course: {course.title} ({len(course_chunks)} chunks)") + print( + f"Added new course: {course.title} ({len(course_chunks)} chunks)" + ) existing_course_titles.add(course.title) elif course: print(f"Course already exists: {course.title} - skipping") except Exception as e: print(f"Error processing {file_name}: {e}") - + return total_courses, total_chunks - - def query(self, query: str, session_id: Optional[str] = None) -> Tuple[str, List[str]]: + + def query( + self, query: str, session_id: Optional[str] = None + ) -> Tuple[str, List[Source]]: """ Process a user query using the RAG system with tool-based search. - + Args: query: User's question session_id: Optional session ID for conversation context - + Returns: Tuple of (response, sources list - empty for tool-based approach) """ # Create prompt for the AI with clear instructions prompt = f"""Answer this question about course materials: {query}""" - + # Get conversation history if session exists history = None if session_id: history = self.session_manager.get_conversation_history(session_id) - + # Generate response using AI with tools response = self.ai_generator.generate_response( query=prompt, conversation_history=history, tools=self.tool_manager.get_tool_definitions(), - tool_manager=self.tool_manager + tool_manager=self.tool_manager, ) - + # Get sources from the search tool sources = self.tool_manager.get_last_sources() # Reset sources after retrieving them self.tool_manager.reset_sources() - + # Update conversation history if session_id: self.session_manager.add_exchange(session_id, query, response) - + # Return response with sources from tool searches return response, sources - + def get_course_analytics(self) -> Dict: """Get analytics about the course catalog""" return { "total_courses": self.vector_store.get_course_count(), - "course_titles": self.vector_store.get_existing_course_titles() - } \ No newline at end of file + "course_titles": self.vector_store.get_existing_course_titles(), + } diff --git a/backend/search_tools.py b/backend/search_tools.py index adfe82352..6f8a405d1 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -1,16 +1,26 @@ -from typing import Dict, Any, Optional, Protocol from abc import ABC, abstractmethod -from vector_store import VectorStore, SearchResults +from dataclasses import dataclass +from typing import Any, Dict, Optional, Protocol + +from vector_store import SearchResults, VectorStore + + +@dataclass +class Source: + """A single source reference returned to the UI""" + + text: str + link: Optional[str] = None class Tool(ABC): """Abstract base class for all tools""" - + @abstractmethod def get_tool_definition(self) -> Dict[str, Any]: """Return Anthropic tool definition for this tool""" pass - + @abstractmethod def execute(self, **kwargs) -> str: """Execute the tool with given parameters""" @@ -19,11 +29,11 @@ def execute(self, **kwargs) -> str: class CourseSearchTool(Tool): """Tool for searching course content with semantic course name matching""" - + def __init__(self, vector_store: VectorStore): self.store = vector_store self.last_sources = [] # Track sources from last search - + def get_tool_definition(self) -> Dict[str, Any]: """Return Anthropic tool definition for this tool""" return { @@ -33,92 +43,152 @@ def get_tool_definition(self) -> Dict[str, Any]: "type": "object", "properties": { "query": { - "type": "string", - "description": "What to search for in the course content" + "type": "string", + "description": "What to search for in the course content", }, "course_name": { "type": "string", - "description": "Course title (partial matches work, e.g. 'MCP', 'Introduction')" + "description": "Course title (partial matches work, e.g. 'MCP', 'Introduction')", }, "lesson_number": { "type": "integer", - "description": "Specific lesson number to search within (e.g. 1, 2, 3)" - } + "description": "Specific lesson number to search within (e.g. 1, 2, 3)", + }, }, - "required": ["query"] - } + "required": ["query"], + }, } - - def execute(self, query: str, course_name: Optional[str] = None, lesson_number: Optional[int] = None) -> str: + + def execute( + self, + query: str, + course_name: Optional[str] = None, + lesson_number: Optional[int] = None, + ) -> str: """ Execute the search tool with given parameters. - + Args: query: What to search for course_name: Optional course filter lesson_number: Optional lesson filter - + Returns: Formatted search results or error message """ - + # Use the vector store's unified search interface results = self.store.search( - query=query, - course_name=course_name, - lesson_number=lesson_number + query=query, course_name=course_name, lesson_number=lesson_number ) - + # Handle errors if results.error: return results.error - + # Handle empty results if results.is_empty(): filter_info = "" if course_name: filter_info += f" in course '{course_name}'" - if lesson_number: + if lesson_number is not None: filter_info += f" in lesson {lesson_number}" return f"No relevant content found{filter_info}." - + # Format and return results return self._format_results(results) - + def _format_results(self, results: SearchResults) -> str: """Format search results with course and lesson context""" formatted = [] sources = [] # Track sources for the UI - + seen_sources = set() # Dedup sources by (course, lesson) + for doc, meta in zip(results.documents, results.metadata): - course_title = meta.get('course_title', 'unknown') - lesson_num = meta.get('lesson_number') - + course_title = meta.get("course_title", "unknown") + lesson_num = meta.get("lesson_number") + # Build context header header = f"[{course_title}" if lesson_num is not None: header += f" - Lesson {lesson_num}" header += "]" - - # Track source for the UI - source = course_title - if lesson_num is not None: - source += f" - Lesson {lesson_num}" - sources.append(source) - + + # Track source for the UI, resolving a link if available + # (skip if this course/lesson was already added as a source) + source_key = (course_title, lesson_num) + if source_key not in seen_sources: + seen_sources.add(source_key) + source_text = course_title + if lesson_num is not None: + source_text += f" - Lesson {lesson_num}" + link = self.store.get_lesson_link(course_title, lesson_num) + else: + link = self.store.get_course_link(course_title) + sources.append(Source(text=source_text, link=link)) + 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's outline: title, link, and full lesson list""" + + def __init__(self, vector_store: VectorStore): + self.store = vector_store + self.last_sources = [] # Track sources from last lookup + + def get_tool_definition(self) -> Dict[str, Any]: + return { + "name": "get_course_outline", + "description": "Get the outline/structure of a specific course: its title, course link, and the complete list of lessons (lesson number and title for each). Use this for questions about course structure, syllabus, table of contents, or 'what lessons are in this course'.", + "input_schema": { + "type": "object", + "properties": { + "course_title": { + "type": "string", + "description": "Course title (partial matches work, e.g. 'MCP', 'Introduction')", + } + }, + "required": ["course_title"], + }, + } + + def execute(self, course_title: str) -> str: + outline = self.store.get_course_outline(course_title) + if outline is None: + return f"No course found matching '{course_title}'." + + title = outline["title"] + link = outline.get("course_link") + lessons = outline.get("lessons", []) + + header = f"Course: {title}" + header += f"\nCourse Link: {link}" if link else "\nCourse Link: not available" + + if lessons: + lessons_block = "\n".join( + f"Lesson {lesson['lesson_number']}: {lesson['lesson_title']}" + for lesson in lessons + ) + else: + lessons_block = "No lessons found for this course." + + self.last_sources = [Source(text=title, link=link)] + + return f"{header}\n\nLessons:\n{lessons_block}" + + class ToolManager: """Manages available tools for the AI""" - + def __init__(self): self.tools = {} - + def register_tool(self, tool: Tool): """Register any tool that implements the Tool interface""" tool_def = tool.get_tool_definition() @@ -127,28 +197,27 @@ def register_tool(self, tool: Tool): raise ValueError("Tool must have a 'name' in its definition") self.tools[tool_name] = tool - def get_tool_definitions(self) -> list: """Get all tool definitions for Anthropic tool calling""" return [tool.get_tool_definition() for tool in self.tools.values()] - + def execute_tool(self, tool_name: str, **kwargs) -> str: """Execute a tool by name with given parameters""" if tool_name not in self.tools: return f"Tool '{tool_name}' not found" - + return self.tools[tool_name].execute(**kwargs) - + def get_last_sources(self) -> list: - """Get sources from the last search operation""" + """Get sources from the last search operation, sorted alphabetically by label""" # Check all tools for last_sources attribute for tool in self.tools.values(): - if hasattr(tool, 'last_sources') and tool.last_sources: - return tool.last_sources + if hasattr(tool, "last_sources") and tool.last_sources: + return sorted(tool.last_sources, key=lambda source: source.text.lower()) return [] def reset_sources(self): """Reset sources from all tools that track sources""" for tool in self.tools.values(): - if hasattr(tool, 'last_sources'): - tool.last_sources = [] \ No newline at end of file + if hasattr(tool, "last_sources"): + tool.last_sources = [] diff --git a/backend/session_manager.py b/backend/session_manager.py index a5a96b1a1..916ab42b3 100644 --- a/backend/session_manager.py +++ b/backend/session_manager.py @@ -1,61 +1,70 @@ -from typing import Dict, List, Optional from dataclasses import dataclass +from typing import Dict, List, Optional + @dataclass class Message: """Represents a single message in a conversation""" - role: str # "user" or "assistant" + + role: str # "user" or "assistant" content: str # The message content + class SessionManager: """Manages conversation sessions and message history""" - + def __init__(self, max_history: int = 5): self.max_history = max_history self.sessions: Dict[str, List[Message]] = {} self.session_counter = 0 - + def create_session(self) -> str: """Create a new conversation session""" self.session_counter += 1 session_id = f"session_{self.session_counter}" self.sessions[session_id] = [] return session_id - + def add_message(self, session_id: str, role: str, content: str): """Add a message to the conversation history""" if session_id not in self.sessions: self.sessions[session_id] = [] - + message = Message(role=role, content=content) self.sessions[session_id].append(message) - + # Keep conversation history within limits if len(self.sessions[session_id]) > self.max_history * 2: - self.sessions[session_id] = self.sessions[session_id][-self.max_history * 2:] - + self.sessions[session_id] = self.sessions[session_id][ + -self.max_history * 2 : + ] + def add_exchange(self, session_id: str, user_message: str, assistant_message: str): """Add a complete question-answer exchange""" self.add_message(session_id, "user", user_message) self.add_message(session_id, "assistant", assistant_message) - + def get_conversation_history(self, session_id: Optional[str]) -> Optional[str]: """Get formatted conversation history for a session""" if not session_id or session_id not in self.sessions: return None - + messages = self.sessions[session_id] if not messages: return None - + # Format messages for context formatted_messages = [] for msg in messages: formatted_messages.append(f"{msg.role.title()}: {msg.content}") - + return "\n".join(formatted_messages) - + def clear_session(self, session_id: str): """Clear all messages from a session""" if session_id in self.sessions: - self.sessions[session_id] = [] \ No newline at end of file + self.sessions[session_id] = [] + + def delete_session(self, session_id: str): + """Remove a session entirely, freeing its stored history""" + self.sessions.pop(session_id, None) 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..96d262c5d --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,332 @@ +"""Shared fixtures for the backend diagnostic test suite.""" + +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import List, Optional +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from fastapi.testclient import TestClient +from pydantic import BaseModel + +from ai_generator import AIGenerator +from rag_system import RAGSystem +from search_tools import Source, ToolManager +from vector_store import SearchResults, VectorStore + +# --------------------------------------------------------------------------- +# Objective 1 helpers: CourseSearchTool / ToolManager unit tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_vector_store(): + """A fully mocked VectorStore -- no real Chroma, no network, no disk I/O.""" + return MagicMock(spec=VectorStore) + + +@pytest.fixture +def make_results(): + """Factory for building SearchResults without boilerplate.""" + + def _make(documents=None, metadata=None, distances=None, error=None): + return SearchResults( + documents=documents or [], + metadata=metadata or [], + distances=distances or [], + error=error, + ) + + return _make + + +# --------------------------------------------------------------------------- +# Objective 2 helpers: AIGenerator unit tests (Anthropic client mocked) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def ai_generator(): + """A real AIGenerator with its Anthropic client's create() call mocked out.""" + generator = AIGenerator(api_key="test-key-not-used", model="claude-sonnet-5") + generator.client.messages.create = MagicMock() + return generator + + +@pytest.fixture +def mock_tool_manager(): + return MagicMock(spec=ToolManager) + + +@pytest.fixture +def text_response(): + """Build a fake Anthropic response whose content is a single text block.""" + + def _make(text, stop_reason="end_turn"): + return SimpleNamespace( + stop_reason=stop_reason, + content=[SimpleNamespace(type="text", text=text)], + ) + + return _make + + +@pytest.fixture +def tool_use_response(): + """ + Build a fake Anthropic response containing one or more tool_use blocks. + tool_calls: list of {"name": str, "input": dict, "id": optional str} + """ + + def _make(tool_calls, stop_reason="tool_use"): + blocks = [ + SimpleNamespace( + type="tool_use", + name=call["name"], + input=call["input"], + id=call.get("id", f"toolu_{i}"), + ) + for i, call in enumerate(tool_calls) + ] + return SimpleNamespace(stop_reason=stop_reason, content=blocks) + + return _make + + +# --------------------------------------------------------------------------- +# Objective 3 helpers: real VectorStore + real ToolManager, scripted Anthropic +# --------------------------------------------------------------------------- + +BACKEND_DIR = Path(__file__).resolve().parent.parent +REAL_CHROMA_PATH = str(BACKEND_DIR / "chroma_db") + + +@dataclass +class _TestConfig: + """ + Mirrors config.Config but with an absolute CHROMA_PATH, so this works + regardless of the cwd pytest is invoked from (config.Config's default + './chroma_db' is relative and assumes cwd == backend/, which is NOT + true when pytest runs from the repo root per testpaths=['backend/tests']). + """ + + ANTHROPIC_API_KEY: str = "test-key-not-used" + ANTHROPIC_MODEL: str = "claude-sonnet-5" + EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" + CHUNK_SIZE: int = 800 + CHUNK_OVERLAP: int = 100 + MAX_RESULTS: int = 5 + MAX_HISTORY: int = 2 + CHROMA_PATH: str = REAL_CHROMA_PATH + + +@pytest.fixture(scope="session") +def rag_system(): + """ + A RAGSystem wired to the REAL, already-populated backend/chroma_db, + with real VectorStore/ToolManager/CourseSearchTool/CourseOutlineTool. + Session-scoped because constructing VectorStore loads the sentence- + transformer embedding model, which is slow. Never make a live Anthropic + call from this fixture -- see _isolate_rag_system below. + """ + return RAGSystem(_TestConfig()) + + +@pytest.fixture(autouse=True) +def _isolate_rag_system(request): + """ + Give every test that uses `rag_system` a clean slate: no leftover + session history, no leftover tool sources, and a fresh mock in place + of the real Anthropic client so no live network call ever happens. + """ + if "rag_system" in request.fixturenames: + rag = request.getfixturevalue("rag_system") + rag.session_manager.sessions.clear() + rag.tool_manager.reset_sources() + rag.ai_generator.client.messages.create = MagicMock() + yield + + +# --------------------------------------------------------------------------- +# Objective 4 helpers: FastAPI endpoint tests +# +# backend/app.py cannot be imported under test: at import time it constructs a +# real RAGSystem (loading the embedding model and Chroma) and mounts +# StaticFiles(directory="../frontend"), a cwd-relative path that does not +# resolve when pytest runs from the repo root. So the endpoints are re-declared +# here, inline, against an injected (mocked) RAG system. Keep create_test_app +# in sync with backend/app.py when routes or response shapes change. +# --------------------------------------------------------------------------- + +class QueryRequest(BaseModel): + """Request model for course queries""" + query: str + session_id: Optional[str] = None + + +class SourceItem(BaseModel): + """A single source reference with optional link""" + text: str + link: Optional[str] = None + + +class QueryResponse(BaseModel): + """Response model for course queries""" + answer: str + sources: List[SourceItem] + session_id: str + + +class CourseStats(BaseModel): + """Response model for course statistics""" + total_courses: int + course_titles: List[str] + + +class _NoCacheStaticFiles(StaticFiles): + """Mirror of app.DevStaticFiles -- stamps no-cache headers on file responses.""" + + async def get_response(self, path: str, scope): + response = await super().get_response(path, scope) + if isinstance(response, FileResponse): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + +def create_test_app(rag_system, static_dir=None) -> FastAPI: + """ + Build a FastAPI app with the same routes/contracts as backend/app.py. + + Args: + rag_system: anything quacking like RAGSystem (normally a MagicMock). + static_dir: directory to serve at "/". When None the static mount is + skipped entirely, so API-only tests need no frontend on disk. + """ + app = FastAPI(title="Course Materials RAG System (test)", root_path="") + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["*"], + ) + + @app.post("/api/query", response_model=QueryResponse) + async def query_documents(request: QueryRequest): + """Process a query and return response with sources""" + 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=[SourceItem(text=s.text, link=s.link) for s in sources], + session_id=session_id, + ) + 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): + """End a conversation session and free its stored history""" + try: + rag_system.session_manager.delete_session(session_id) + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/api/courses", response_model=CourseStats) + async def get_course_stats(): + """Get course analytics and statistics""" + 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)) + + if static_dir is not None: + app.mount( + "/", + _NoCacheStaticFiles(directory=str(static_dir), html=True), + name="static", + ) + + return app + + +@pytest.fixture +def sample_sources(): + """Two Source records: one with a lesson link, one without.""" + return [ + Source(text="MCP: Build Rich-Context AI Apps - Lesson 1", link="https://example.com/l1"), + Source(text="Advanced Retrieval for AI - Lesson 3", link=None), + ] + + +@pytest.fixture +def sample_analytics(): + """Course analytics payload in the shape RAGSystem.get_course_analytics returns.""" + return { + "total_courses": 2, + "course_titles": [ + "Advanced Retrieval for AI", + "MCP: Build Rich-Context AI Apps", + ], + } + + +@pytest.fixture +def mock_rag(sample_sources, sample_analytics): + """ + A stand-in RAGSystem for endpoint tests: no Chroma, no embedding model, no + Anthropic calls. Not spec'd against RAGSystem because session_manager is an + instance attribute and would not survive spec introspection. + """ + rag = MagicMock() + rag.query.return_value = ("Claude answers here.", sample_sources) + rag.get_course_analytics.return_value = sample_analytics + rag.session_manager.create_session.return_value = "session_1" + rag.session_manager.delete_session.return_value = None + return rag + + +@pytest.fixture +def static_dir(tmp_path): + """A throwaway stand-in for frontend/, so "/" can be exercised in isolation.""" + root = tmp_path / "frontend" + root.mkdir() + (root / "index.html").write_text( + "<!doctype html><title>Course Materials Assistant", encoding="utf-8" + ) + (root / "style.css").write_text("body { margin: 0; }", encoding="utf-8") + (root / "script.js").write_text("// frontend entrypoint\n", encoding="utf-8") + return root + + +@pytest.fixture +def api_client(mock_rag): + """TestClient for the API routes only -- no static mount.""" + with TestClient(create_test_app(mock_rag)) as client: + yield client + + +@pytest.fixture +def full_client(mock_rag, static_dir): + """TestClient for the whole app, including the static frontend mount at "/".""" + with TestClient(create_test_app(mock_rag, static_dir=static_dir)) as client: + yield client diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py new file mode 100644 index 000000000..c1845a298 --- /dev/null +++ b/backend/tests/test_ai_generator.py @@ -0,0 +1,233 @@ +""" +Objective 2: AIGenerator unit tests. The Anthropic client is mocked; +tool_manager is a MagicMock(spec=ToolManager) so call args can be asserted +exactly. No network calls, no real tools, no real VectorStore. +""" + +from types import SimpleNamespace + + +class TestToolUseFlow: + def test_tool_use_triggers_execute_tool_with_exact_kwargs( + self, ai_generator, mock_tool_manager, text_response, tool_use_response + ): + initial = tool_use_response( + [ + { + "name": "search_course_content", + "input": {"query": "prompt caching", "course_name": "Computer Use"}, + "id": "toolu_1", + } + ] + ) + final = text_response("Here is the answer.") + ai_generator.client.messages.create.side_effect = [initial, final] + mock_tool_manager.execute_tool.return_value = "tool result text" + + result = ai_generator.generate_response( + query="What is prompt caching?", + tools=[{"name": "search_course_content"}], + tool_manager=mock_tool_manager, + ) + + mock_tool_manager.execute_tool.assert_called_once_with( + "search_course_content", query="prompt caching", course_name="Computer Use" + ) + assert result == "Here is the answer." + + def test_follow_up_call_appends_assistant_and_tool_result_messages( + self, ai_generator, mock_tool_manager, text_response, tool_use_response + ): + initial = tool_use_response( + [ + { + "name": "search_course_content", + "input": {"query": "x"}, + "id": "toolu_42", + } + ] + ) + final = text_response("answer") + ai_generator.client.messages.create.side_effect = [initial, final] + mock_tool_manager.execute_tool.return_value = "the tool output" + + ai_generator.generate_response( + query="q", + tools=[{"name": "search_course_content"}], + tool_manager=mock_tool_manager, + ) + + assert ai_generator.client.messages.create.call_count == 2 + second_call_kwargs = ai_generator.client.messages.create.call_args_list[ + 1 + ].kwargs + messages = second_call_kwargs["messages"] + + assert messages[0] == {"role": "user", "content": "q"} + assert messages[1] == {"role": "assistant", "content": initial.content} + assert messages[2]["role"] == "user" + assert messages[2]["content"] == [ + { + "type": "tool_result", + "tool_use_id": "toolu_42", + "content": "the tool output", + } + ] + + def test_follow_up_call_excludes_tools_and_tool_choice( + self, ai_generator, mock_tool_manager, text_response, tool_use_response + ): + initial = tool_use_response( + [ + { + "name": "search_course_content", + "input": {"query": "x"}, + "id": "toolu_1", + } + ] + ) + final = text_response("answer") + ai_generator.client.messages.create.side_effect = [initial, final] + mock_tool_manager.execute_tool.return_value = "result" + + ai_generator.generate_response( + query="q", + tools=[{"name": "search_course_content"}], + tool_manager=mock_tool_manager, + ) + + second_call_kwargs = ai_generator.client.messages.create.call_args_list[ + 1 + ].kwargs + assert "tools" not in second_call_kwargs + assert "tool_choice" not in second_call_kwargs + + def test_multiple_parallel_tool_use_blocks_all_executed( + self, ai_generator, mock_tool_manager, text_response, tool_use_response + ): + initial = tool_use_response( + [ + { + "name": "search_course_content", + "input": {"query": "a"}, + "id": "toolu_1", + }, + { + "name": "get_course_outline", + "input": {"course_title": "MCP"}, + "id": "toolu_2", + }, + ] + ) + final = text_response("combined answer") + ai_generator.client.messages.create.side_effect = [initial, final] + mock_tool_manager.execute_tool.side_effect = ["result A", "result B"] + + ai_generator.generate_response( + query="q", tools=[{"name": "x"}], tool_manager=mock_tool_manager + ) + + assert mock_tool_manager.execute_tool.call_count == 2 + second_call_kwargs = ai_generator.client.messages.create.call_args_list[ + 1 + ].kwargs + tool_result_message = second_call_kwargs["messages"][2] + assert tool_result_message["content"] == [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "result A"}, + {"type": "tool_result", "tool_use_id": "toolu_2", "content": "result B"}, + ] + + +class TestNoToolUseFlow: + def test_no_tools_passed_omits_tools_and_tool_choice_keys( + self, ai_generator, text_response + ): + ai_generator.client.messages.create.return_value = text_response("plain answer") + + result = ai_generator.generate_response(query="hello") + + call_kwargs = ai_generator.client.messages.create.call_args.kwargs + assert "tools" not in call_kwargs + assert "tool_choice" not in call_kwargs + assert result == "plain answer" + + def test_plain_text_stop_reason_never_invokes_tool_manager( + self, ai_generator, mock_tool_manager, text_response + ): + ai_generator.client.messages.create.return_value = text_response( + "general knowledge answer", stop_reason="end_turn" + ) + + result = ai_generator.generate_response( + query="what is 2+2?", tools=[{"name": "x"}], tool_manager=mock_tool_manager + ) + + mock_tool_manager.execute_tool.assert_not_called() + assert result == "general knowledge answer" + assert ai_generator.client.messages.create.call_count == 1 + + +class TestExtractTextWithRetry: + def test_retries_exactly_once_when_first_response_has_no_text_block( + self, ai_generator, text_response + ): + empty = SimpleNamespace(stop_reason="end_turn", content=[]) + recovered = text_response("recovered text") + ai_generator.client.messages.create.side_effect = [empty, recovered] + + result = ai_generator.generate_response(query="q") + + assert result == "recovered text" + assert ai_generator.client.messages.create.call_count == 2 + + def test_returns_empty_string_without_looping_if_retry_is_also_textless( + self, ai_generator + ): + empty1 = SimpleNamespace(stop_reason="end_turn", content=[]) + empty2 = SimpleNamespace(stop_reason="end_turn", content=[]) + ai_generator.client.messages.create.side_effect = [empty1, empty2] + + result = ai_generator.generate_response(query="q") + + assert result == "" + # confirms no infinite retry loop: exactly initial call + one retry + assert ai_generator.client.messages.create.call_count == 2 + + +class TestSystemPromptAndParams: + def test_conversation_history_is_appended_to_system_prompt( + self, ai_generator, text_response + ): + ai_generator.client.messages.create.return_value = text_response("ok") + + ai_generator.generate_response( + query="q", conversation_history="User: hi\nAssistant: hello" + ) + + call_kwargs = ai_generator.client.messages.create.call_args.kwargs + assert "Previous conversation:" in call_kwargs["system"] + assert "User: hi" in call_kwargs["system"] + + def test_no_conversation_history_uses_bare_system_prompt( + self, ai_generator, text_response + ): + ai_generator.client.messages.create.return_value = text_response("ok") + + ai_generator.generate_response(query="q") + + call_kwargs = ai_generator.client.messages.create.call_args.kwargs + assert call_kwargs["system"] == ai_generator.SYSTEM_PROMPT + + def test_never_sends_a_temperature_param(self, ai_generator, text_response): + """ + Regression guard: this specific model rejects `temperature` as a + deprecated param (confirmed via a live 400 error). Must never appear + in base_params or in any messages.create call. + """ + ai_generator.client.messages.create.return_value = text_response("ok") + + ai_generator.generate_response(query="q") + + assert "temperature" not in ai_generator.base_params + call_kwargs = ai_generator.client.messages.create.call_args.kwargs + assert "temperature" not in call_kwargs diff --git a/backend/tests/test_api_endpoints.py b/backend/tests/test_api_endpoints.py new file mode 100644 index 000000000..6b5d5b9be --- /dev/null +++ b/backend/tests/test_api_endpoints.py @@ -0,0 +1,258 @@ +""" +Objective 4: FastAPI endpoint tests. + +Covers the request/response contract the frontend (frontend/script.js) relies +on: POST /api/query, GET /api/courses, DELETE /api/session/{id}, and the static +mount at "/". The app under test is built by conftest.create_test_app with a +mocked RAG system -- see the note there for why backend/app.py is not imported. +""" +import pytest +from fastapi.testclient import TestClient + +from conftest import create_test_app + +pytestmark = pytest.mark.api + + +# --------------------------------------------------------------------------- +# POST /api/query +# --------------------------------------------------------------------------- + +class TestQueryEndpoint: + + def test_returns_answer_sources_and_session(self, api_client): + response = api_client.post("/api/query", json={"query": "What is MCP?"}) + + assert response.status_code == 200 + body = response.json() + assert set(body) == {"answer", "sources", "session_id"} + assert body["answer"] == "Claude answers here." + assert body["session_id"] == "session_1" + + def test_sources_serialize_text_and_link(self, api_client): + body = api_client.post("/api/query", json={"query": "What is MCP?"}).json() + + assert body["sources"] == [ + {"text": "MCP: Build Rich-Context AI Apps - Lesson 1", "link": "https://example.com/l1"}, + {"text": "Advanced Retrieval for AI - Lesson 3", "link": None}, + ] + + def test_creates_session_when_none_supplied(self, api_client, mock_rag): + api_client.post("/api/query", json={"query": "What is MCP?"}) + + mock_rag.session_manager.create_session.assert_called_once_with() + mock_rag.query.assert_called_once_with("What is MCP?", "session_1") + + def test_reuses_supplied_session(self, api_client, mock_rag): + response = api_client.post( + "/api/query", json={"query": "And lesson 2?", "session_id": "session_42"} + ) + + assert response.json()["session_id"] == "session_42" + mock_rag.session_manager.create_session.assert_not_called() + mock_rag.query.assert_called_once_with("And lesson 2?", "session_42") + + def test_null_session_id_creates_a_new_one(self, api_client, mock_rag): + response = api_client.post( + "/api/query", json={"query": "What is MCP?", "session_id": None} + ) + + assert response.json()["session_id"] == "session_1" + mock_rag.session_manager.create_session.assert_called_once_with() + + def test_empty_sources_list_is_valid(self, api_client, mock_rag): + mock_rag.query.return_value = ("Paris is the capital of France.", []) + + body = api_client.post("/api/query", json={"query": "Capital of France?"}).json() + + assert body["sources"] == [] + assert body["answer"] == "Paris is the capital of France." + + def test_missing_query_field_is_422(self, api_client, mock_rag): + response = api_client.post("/api/query", json={"session_id": "session_1"}) + + assert response.status_code == 422 + mock_rag.query.assert_not_called() + + def test_wrong_query_type_is_422(self, api_client, mock_rag): + response = api_client.post("/api/query", json={"query": {"nested": "object"}}) + + assert response.status_code == 422 + mock_rag.query.assert_not_called() + + def test_malformed_json_body_is_422(self, api_client, mock_rag): + response = api_client.post( + "/api/query", + content=b"{not json", + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 422 + mock_rag.query.assert_not_called() + + def test_unknown_fields_are_ignored(self, api_client, mock_rag): + response = api_client.post( + "/api/query", json={"query": "What is MCP?", "temperature": 0.9} + ) + + assert response.status_code == 200 + mock_rag.query.assert_called_once_with("What is MCP?", "session_1") + + def test_rag_failure_becomes_500_with_detail(self, api_client, mock_rag): + mock_rag.query.side_effect = RuntimeError("vector store unavailable") + + response = api_client.post("/api/query", json={"query": "What is MCP?"}) + + assert response.status_code == 500 + assert response.json()["detail"] == "vector store unavailable" + + def test_get_is_not_allowed(self, api_client): + assert api_client.get("/api/query").status_code == 405 + + +# --------------------------------------------------------------------------- +# GET /api/courses +# --------------------------------------------------------------------------- + +class TestCoursesEndpoint: + + def test_returns_course_stats(self, api_client, sample_analytics): + response = api_client.get("/api/courses") + + assert response.status_code == 200 + body = response.json() + assert set(body) == {"total_courses", "course_titles"} + assert body["total_courses"] == sample_analytics["total_courses"] + assert body["course_titles"] == sample_analytics["course_titles"] + + def test_empty_catalog(self, api_client, mock_rag): + mock_rag.get_course_analytics.return_value = { + "total_courses": 0, + "course_titles": [], + } + + body = api_client.get("/api/courses").json() + + assert body == {"total_courses": 0, "course_titles": []} + + def test_analytics_failure_becomes_500(self, api_client, mock_rag): + mock_rag.get_course_analytics.side_effect = RuntimeError("chroma is down") + + response = api_client.get("/api/courses") + + assert response.status_code == 500 + assert response.json()["detail"] == "chroma is down" + + +# --------------------------------------------------------------------------- +# DELETE /api/session/{session_id} +# --------------------------------------------------------------------------- + +class TestDeleteSessionEndpoint: + + def test_deletes_the_named_session(self, api_client, mock_rag): + response = api_client.delete("/api/session/session_7") + + assert response.status_code == 200 + assert response.json() == {"success": True} + mock_rag.session_manager.delete_session.assert_called_once_with("session_7") + + def test_unknown_session_still_succeeds(self, api_client, mock_rag): + # delete_session is a no-op for unknown ids, so the endpoint stays 200. + response = api_client.delete("/api/session/never-existed") + + assert response.status_code == 200 + assert response.json() == {"success": True} + + def test_delete_failure_becomes_500(self, api_client, mock_rag): + mock_rag.session_manager.delete_session.side_effect = RuntimeError("boom") + + response = api_client.delete("/api/session/session_7") + + assert response.status_code == 500 + assert response.json()["detail"] == "boom" + + +# --------------------------------------------------------------------------- +# GET / (static frontend mount) +# --------------------------------------------------------------------------- + +class TestStaticFrontend: + + def test_root_serves_index_html(self, full_client): + response = full_client.get("/") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + assert "Course Materials Assistant" in response.text + + def test_static_assets_are_served(self, full_client): + assert full_client.get("/style.css").status_code == 200 + assert full_client.get("/script.js").status_code == 200 + + def test_no_cache_headers_on_static_files(self, full_client): + headers = full_client.get("/").headers + + assert headers["Cache-Control"] == "no-cache, no-store, must-revalidate" + assert headers["Pragma"] == "no-cache" + assert headers["Expires"] == "0" + + def test_missing_asset_is_404(self, full_client): + assert full_client.get("/does-not-exist.js").status_code == 404 + + def test_api_routes_win_over_the_static_mount(self, full_client): + # The mount is at "/", so it must not shadow /api/* once both exist. + assert full_client.get("/api/courses").status_code == 200 + assert full_client.post("/api/query", json={"query": "hi"}).status_code == 200 + + def test_api_only_app_has_no_static_mount(self, api_client): + assert api_client.get("/").status_code == 404 + + +# --------------------------------------------------------------------------- +# Cross-cutting: CORS, app construction +# --------------------------------------------------------------------------- + +class TestAppConfiguration: + + def test_cors_headers_present_on_api_responses(self, api_client): + response = api_client.get("/api/courses", headers={"Origin": "http://localhost:3000"}) + + # allow_origins=["*"] echoes "*" for uncredentialed requests. + assert response.headers["access-control-allow-origin"] == "*" + assert response.headers["access-control-allow-credentials"] == "true" + + def test_cors_echoes_origin_for_credentialed_requests(self, api_client): + response = api_client.get( + "/api/courses", + headers={"Origin": "http://localhost:3000", "Cookie": "sid=abc"}, + ) + + assert response.headers["access-control-allow-origin"] == "http://localhost:3000" + + def test_cors_preflight_allows_post(self, api_client): + response = api_client.options( + "/api/query", + headers={ + "Origin": "http://localhost:3000", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + ) + + assert response.status_code == 200 + assert "POST" in response.headers["access-control-allow-methods"] + + def test_openapi_schema_exposes_the_api_routes(self, api_client): + paths = api_client.get("/openapi.json").json()["paths"] + + assert "/api/query" in paths + assert "/api/courses" in paths + assert "/api/session/{session_id}" in paths + + def test_factory_builds_independent_apps(self, mock_rag): + # Two clients over two apps must not share state or interfere. + with TestClient(create_test_app(mock_rag)) as first, \ + TestClient(create_test_app(mock_rag)) as second: + assert first.get("/api/courses").status_code == 200 + assert second.get("/api/courses").status_code == 200 diff --git a/backend/tests/test_course_search_tool.py b/backend/tests/test_course_search_tool.py new file mode 100644 index 000000000..b30adfd37 --- /dev/null +++ b/backend/tests/test_course_search_tool.py @@ -0,0 +1,227 @@ +""" +Objective 1: pure unit tests for CourseSearchTool and ToolManager. +VectorStore is fully mocked -- no real Chroma, no network. +""" + +import pytest + +from search_tools import CourseOutlineTool, CourseSearchTool, Source, Tool, ToolManager + + +class TestCourseSearchToolFormatting: + def test_successful_search_formats_headers_and_joins_with_blank_line( + self, mock_vector_store, make_results + ): + mock_vector_store.search.return_value = make_results( + documents=["doc1 text", "doc2 text"], + metadata=[ + {"course_title": "Course A", "lesson_number": 1}, + {"course_title": "Course A", "lesson_number": 2}, + ], + distances=[0.1, 0.2], + ) + mock_vector_store.get_lesson_link.return_value = None + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="test") + + assert result == ( + "[Course A - Lesson 1]\ndoc1 text\n\n" "[Course A - Lesson 2]\ndoc2 text" + ) + mock_vector_store.search.assert_called_once_with( + query="test", course_name=None, lesson_number=None + ) + + def test_sources_tracked_and_deduped_by_course_and_lesson( + self, mock_vector_store, make_results + ): + mock_vector_store.search.return_value = make_results( + documents=["chunk1", "chunk2", "chunk3"], + metadata=[ + {"course_title": "X", "lesson_number": 1}, + {"course_title": "X", "lesson_number": 1}, # duplicate lesson + {"course_title": "X", "lesson_number": 2}, + ], + ) + mock_vector_store.get_lesson_link.return_value = "https://lesson-link" + tool = CourseSearchTool(mock_vector_store) + + tool.execute(query="test") + + assert len(tool.last_sources) == 2 + assert tool.last_sources[0] == Source( + text="X - Lesson 1", link="https://lesson-link" + ) + assert tool.last_sources[1] == Source( + text="X - Lesson 2", link="https://lesson-link" + ) + # dedup means the second chunk from lesson 1 must NOT trigger a second link lookup + assert mock_vector_store.get_lesson_link.call_count == 2 + + def test_lesson_scoped_source_uses_lesson_link_course_level_uses_course_link( + self, mock_vector_store, make_results + ): + mock_vector_store.search.return_value = make_results( + documents=["with lesson", "without lesson"], + metadata=[ + {"course_title": "Course A", "lesson_number": 2}, + {"course_title": "Course B"}, # no lesson_number key -> None + ], + ) + mock_vector_store.get_lesson_link.return_value = "https://lesson-link" + mock_vector_store.get_course_link.return_value = "https://course-link" + tool = CourseSearchTool(mock_vector_store) + + tool.execute(query="test") + + assert tool.last_sources == [ + Source(text="Course A - Lesson 2", link="https://lesson-link"), + Source(text="Course B", link="https://course-link"), + ] + mock_vector_store.get_lesson_link.assert_called_once_with("Course A", 2) + mock_vector_store.get_course_link.assert_called_once_with("Course B") + + +class TestCourseSearchToolEmptyResultsMessage: + def test_empty_with_course_name_only(self, mock_vector_store, make_results): + mock_vector_store.search.return_value = make_results() + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="q", course_name="Foo") + + assert result == "No relevant content found in course 'Foo'." + + def test_empty_with_positive_lesson_number_only( + self, mock_vector_store, make_results + ): + mock_vector_store.search.return_value = make_results() + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="q", lesson_number=3) + + assert result == "No relevant content found in lesson 3." + + def test_empty_with_lesson_number_zero_should_mention_lesson_zero( + self, mock_vector_store, make_results + ): + mock_vector_store.search.return_value = make_results() + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="q", lesson_number=0) + + assert result == "No relevant content found in lesson 0." + + def test_empty_with_neither_filter(self, mock_vector_store, make_results): + mock_vector_store.search.return_value = make_results() + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="q") + + assert result == "No relevant content found." + + def test_error_returned_verbatim_without_formatting( + self, mock_vector_store, make_results + ): + mock_vector_store.search.return_value = make_results( + error="No course found matching 'Bogus'" + ) + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="q", course_name="Bogus") + + assert result == "No course found matching 'Bogus'" + mock_vector_store.get_course_link.assert_not_called() + mock_vector_store.get_lesson_link.assert_not_called() + + +class TestCourseSearchToolDefinition: + def test_tool_definition_schema_shape(self, mock_vector_store): + tool = CourseSearchTool(mock_vector_store) + definition = tool.get_tool_definition() + + assert definition["name"] == "search_course_content" + assert definition["input_schema"]["required"] == ["query"] + props = definition["input_schema"]["properties"] + assert props["query"]["type"] == "string" + assert props["course_name"]["type"] == "string" + assert props["lesson_number"]["type"] == "integer" + + +class TestToolManager: + def test_register_tool_requires_a_name(self): + class NamelessTool(Tool): + def get_tool_definition(self): + return {"description": "no name field"} + + def execute(self, **kwargs): + return "irrelevant" + + manager = ToolManager() + with pytest.raises(ValueError): + manager.register_tool(NamelessTool()) + + def test_execute_tool_unknown_name_returns_message_not_exception(self): + manager = ToolManager() + result = manager.execute_tool("does_not_exist", query="x") + assert result == "Tool 'does_not_exist' not found" + + def test_get_last_sources_sorted_alphabetically_by_text( + self, mock_vector_store, make_results + ): + tool = CourseSearchTool(mock_vector_store) + manager = ToolManager() + manager.register_tool(tool) + mock_vector_store.search.return_value = make_results( + documents=["a", "b"], + metadata=[ + {"course_title": "Zebra Course"}, + {"course_title": "Alpha Course"}, + ], + ) + mock_vector_store.get_course_link.return_value = None + tool.execute(query="x") + + sources = manager.get_last_sources() + + assert [s.text for s in sources] == ["Alpha Course", "Zebra Course"] + + def test_reset_sources_clears_all_registered_tools( + self, mock_vector_store, make_results + ): + tool = CourseSearchTool(mock_vector_store) + manager = ToolManager() + manager.register_tool(tool) + mock_vector_store.search.return_value = make_results( + documents=["a"], metadata=[{"course_title": "X"}] + ) + mock_vector_store.get_course_link.return_value = None + tool.execute(query="x") + assert manager.get_last_sources() != [] + + manager.reset_sources() + + assert manager.get_last_sources() == [] + + def test_get_last_sources_only_surfaces_first_tool_with_sources( + self, mock_vector_store + ): + """ + Documents existing behavior: get_last_sources returns only the first + registered tool whose last_sources is non-empty -- it does not merge + across tools. Only reachable today if the model emits >1 tool_use + block of different tool types in a single turn (ai_generator.py does + not prevent that, even though the system prompt asks for at most one + tool call per query). + """ + search_tool = CourseSearchTool(mock_vector_store) + outline_tool = CourseOutlineTool(mock_vector_store) + manager = ToolManager() + manager.register_tool(search_tool) + manager.register_tool(outline_tool) + + search_tool.last_sources = [Source(text="From search tool")] + outline_tool.last_sources = [Source(text="From outline tool")] + + sources = manager.get_last_sources() + + assert [s.text for s in sources] == ["From search tool"] diff --git a/backend/tests/test_rag_system_content_queries.py b/backend/tests/test_rag_system_content_queries.py new file mode 100644 index 000000000..f94f0ce31 --- /dev/null +++ b/backend/tests/test_rag_system_content_queries.py @@ -0,0 +1,152 @@ +""" +Objective 3: integration tests against the REAL, populated backend/chroma_db +via the REAL VectorStore/ToolManager/CourseSearchTool/CourseOutlineTool. +Only AIGenerator.client.messages.create is scripted -- no live network call, +no API cost, fully deterministic, while still exercising real chunk +retrieval/formatting/source-resolution code against real data. + +NOTE: query/course terms below are chosen because they are the course's own +name/topic (e.g. "Computer Use" for the "Building Towards Computer Use with +Anthropic" course) to maximize confidence of real content overlap. +""" + +from unittest.mock import MagicMock + +import pytest + +# These load the embedding model and read the real Chroma store, and they only +# pass once `./run.sh` (or app startup) has ingested docs/ into backend/chroma_db. +# Skip them on a fast run with: uv run pytest -m "not slow" +pytestmark = pytest.mark.slow + + +class TestContentQueries: + def test_content_query_returns_scripted_answer_with_real_resolvable_sources( + self, rag_system, text_response, tool_use_response + ): + initial = tool_use_response( + [ + { + "name": "search_course_content", + "input": {"query": "computer use", "course_name": "Computer Use"}, + "id": "toolu_1", + } + ] + ) + final = text_response( + "Computer use lets Claude interact with a desktop environment." + ) + rag_system.ai_generator.client.messages.create.side_effect = [initial, final] + + answer, sources = rag_system.query("What is computer use in that course?") + + assert answer == "Computer use lets Claude interact with a desktop environment." + assert len(sources) > 0 + # every real source must resolve to either no link or a real http(s) link + assert all(s.link is None or s.link.startswith("http") for s in sources) + + def test_lesson_zero_scoped_search_finds_real_content_not_the_bug_path( + self, rag_system, text_response, tool_use_response + ): + initial = tool_use_response( + [ + { + "name": "search_course_content", + "input": { + "query": "introduction", + "course_name": "Computer Use", + "lesson_number": 0, + }, + "id": "toolu_1", + } + ] + ) + final = text_response("Lesson 0 introduces the course.") + rag_system.ai_generator.client.messages.create.side_effect = [initial, final] + + answer, sources = rag_system.query("What does lesson 0 cover?") + + assert answer == "Lesson 0 introduces the course." + assert len(sources) > 0 + assert any(s.text.endswith("Lesson 0") for s in sources) + + def test_nonexistent_course_name_degrades_gracefully_without_crashing( + self, rag_system + ): + # Direct tool-manager call: exercises the real fuzzy _resolve_course_name + # path against the real catalog without needing a scripted Anthropic turn. + result = rag_system.tool_manager.execute_tool( + "search_course_content", + query="anything", + course_name="Totally Fake Course Title Xyz123", + ) + + assert isinstance(result, str) + assert len(result) > 0 + + def test_sources_reset_between_sequential_queries( + self, rag_system, text_response, tool_use_response + ): + initial = tool_use_response( + [ + { + "name": "search_course_content", + "input": {"query": "computer use", "course_name": "Computer Use"}, + "id": "toolu_1", + } + ] + ) + final1 = text_response("first answer") + final2 = text_response("second answer, no tool used") + rag_system.ai_generator.client.messages.create.side_effect = [ + initial, + final1, + final2, + ] + + _, sources1 = rag_system.query("What is computer use?") + assert len(sources1) > 0 + + _, sources2 = rag_system.query("Thanks, that's all") + assert sources2 == [] + + +class TestSessionHistory: + def test_two_queries_same_session_record_both_exchanges_and_pass_history( + self, rag_system, text_response + ): + rag_system.ai_generator.client.messages.create.side_effect = [ + text_response("answer one"), + text_response("answer two"), + ] + + rag_system.query("first question", session_id="sess-1") + rag_system.query("second question", session_id="sess-1") + + history = rag_system.session_manager.get_conversation_history("sess-1") + assert "first question" in history + assert "answer one" in history + assert "second question" in history + + second_call_kwargs = ( + rag_system.ai_generator.client.messages.create.call_args_list[1].kwargs + ) + assert "Previous conversation:" in second_call_kwargs["system"] + assert "first question" in second_call_kwargs["system"] + + +class TestGeneralKnowledge: + def test_general_knowledge_question_never_touches_the_vector_store( + self, rag_system, text_response, monkeypatch + ): + rag_system.ai_generator.client.messages.create.return_value = text_response( + "Paris is the capital of France.", stop_reason="end_turn" + ) + search_spy = MagicMock(wraps=rag_system.vector_store.search) + monkeypatch.setattr(rag_system.vector_store, "search", search_spy) + + answer, sources = rag_system.query("What is the capital of France?") + + assert answer == "Paris is the capital of France." + assert sources == [] + search_spy.assert_not_called() diff --git a/backend/vector_store.py b/backend/vector_store.py index 390abe71c..c3f3c3a42 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -1,77 +1,94 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + import chromadb from chromadb.config import Settings -from typing import List, Dict, Any, Optional -from dataclasses import dataclass -from models import Course, CourseChunk from sentence_transformers import SentenceTransformer +from models import Course, CourseChunk + + @dataclass class SearchResults: """Container for search results with metadata""" + documents: List[str] metadata: List[Dict[str, Any]] distances: List[float] error: Optional[str] = None - + @classmethod - def from_chroma(cls, chroma_results: Dict) -> 'SearchResults': + def from_chroma(cls, chroma_results: Dict) -> "SearchResults": """Create SearchResults from ChromaDB query results""" return cls( - documents=chroma_results['documents'][0] if chroma_results['documents'] else [], - metadata=chroma_results['metadatas'][0] if chroma_results['metadatas'] else [], - distances=chroma_results['distances'][0] if chroma_results['distances'] else [] + documents=( + chroma_results["documents"][0] if chroma_results["documents"] else [] + ), + metadata=( + chroma_results["metadatas"][0] if chroma_results["metadatas"] else [] + ), + distances=( + chroma_results["distances"][0] if chroma_results["distances"] else [] + ), ) - + @classmethod - def empty(cls, error_msg: str) -> 'SearchResults': + def empty(cls, error_msg: str) -> "SearchResults": """Create empty results with error message""" return cls(documents=[], metadata=[], distances=[], error=error_msg) - + def is_empty(self) -> bool: """Check if results are empty""" return len(self.documents) == 0 + class VectorStore: """Vector storage using ChromaDB for course content and metadata""" - + def __init__(self, chroma_path: str, embedding_model: str, max_results: int = 5): self.max_results = max_results # Initialize ChromaDB client self.client = chromadb.PersistentClient( - path=chroma_path, - settings=Settings(anonymized_telemetry=False) + path=chroma_path, settings=Settings(anonymized_telemetry=False) ) - + # Set up sentence transformer embedding function - self.embedding_function = chromadb.utils.embedding_functions.SentenceTransformerEmbeddingFunction( - model_name=embedding_model + self.embedding_function = ( + chromadb.utils.embedding_functions.SentenceTransformerEmbeddingFunction( + model_name=embedding_model + ) ) - + # Create collections for different types of data - self.course_catalog = self._create_collection("course_catalog") # Course titles/instructors - self.course_content = self._create_collection("course_content") # Actual course material - + self.course_catalog = self._create_collection( + "course_catalog" + ) # Course titles/instructors + self.course_content = self._create_collection( + "course_content" + ) # Actual course material + def _create_collection(self, name: str): """Create or get a ChromaDB collection""" return self.client.get_or_create_collection( - name=name, - embedding_function=self.embedding_function + name=name, embedding_function=self.embedding_function ) - - def search(self, - query: str, - course_name: Optional[str] = None, - lesson_number: Optional[int] = None, - limit: Optional[int] = None) -> SearchResults: + + def search( + self, + query: str, + course_name: Optional[str] = None, + lesson_number: Optional[int] = None, + limit: Optional[int] = None, + ) -> SearchResults: """ Main search interface that handles course resolution and content search. - + Args: query: What to search for in course content course_name: Optional course name/title to filter by lesson_number: Optional lesson number to filter by limit: Maximum results to return - + Returns: SearchResults object with documents and metadata """ @@ -81,104 +98,111 @@ def search(self, course_title = self._resolve_course_name(course_name) if not course_title: return SearchResults.empty(f"No course found matching '{course_name}'") - + # Step 2: Build filter for content search filter_dict = self._build_filter(course_title, lesson_number) - + # Step 3: Search course content # Use provided limit or fall back to configured max_results search_limit = limit if limit is not None else self.max_results - + try: results = self.course_content.query( - query_texts=[query], - n_results=search_limit, - where=filter_dict + query_texts=[query], n_results=search_limit, where=filter_dict ) return SearchResults.from_chroma(results) except Exception as e: return SearchResults.empty(f"Search error: {str(e)}") - + def _resolve_course_name(self, course_name: str) -> Optional[str]: """Use vector search to find best matching course by name""" try: - results = self.course_catalog.query( - query_texts=[course_name], - n_results=1 - ) - - if results['documents'][0] and results['metadatas'][0]: + results = self.course_catalog.query(query_texts=[course_name], n_results=1) + + if results["documents"][0] and results["metadatas"][0]: # Return the title (which is now the ID) - return results['metadatas'][0][0]['title'] + return results["metadatas"][0][0]["title"] except Exception as e: print(f"Error resolving course name: {e}") - + return None - - def _build_filter(self, course_title: Optional[str], lesson_number: Optional[int]) -> Optional[Dict]: + + def _build_filter( + self, course_title: Optional[str], lesson_number: Optional[int] + ) -> Optional[Dict]: """Build ChromaDB filter from search parameters""" if not course_title and lesson_number is None: return None - + # Handle different filter combinations if course_title and lesson_number is not None: - return {"$and": [ - {"course_title": course_title}, - {"lesson_number": lesson_number} - ]} - + return { + "$and": [ + {"course_title": course_title}, + {"lesson_number": lesson_number}, + ] + } + if course_title: return {"course_title": course_title} - + return {"lesson_number": lesson_number} - + def add_course_metadata(self, course: Course): """Add course information to the catalog for semantic search""" import json course_text = course.title - + # Build lessons metadata and serialize as JSON string lessons_metadata = [] for lesson in course.lessons: - lessons_metadata.append({ - "lesson_number": lesson.lesson_number, - "lesson_title": lesson.title, - "lesson_link": lesson.lesson_link - }) - + lessons_metadata.append( + { + "lesson_number": lesson.lesson_number, + "lesson_title": lesson.title, + "lesson_link": lesson.lesson_link, + } + ) + self.course_catalog.add( documents=[course_text], - metadatas=[{ - "title": course.title, - "instructor": course.instructor, - "course_link": course.course_link, - "lessons_json": json.dumps(lessons_metadata), # Serialize as JSON string - "lesson_count": len(course.lessons) - }], - ids=[course.title] + metadatas=[ + { + "title": course.title, + "instructor": course.instructor, + "course_link": course.course_link, + "lessons_json": json.dumps( + lessons_metadata + ), # Serialize as JSON string + "lesson_count": len(course.lessons), + } + ], + ids=[course.title], ) - + def add_course_content(self, chunks: List[CourseChunk]): """Add course content chunks to the vector store""" if not chunks: return - + documents = [chunk.content for chunk in chunks] - metadatas = [{ - "course_title": chunk.course_title, - "lesson_number": chunk.lesson_number, - "chunk_index": chunk.chunk_index - } for chunk in chunks] + metadatas = [ + { + "course_title": chunk.course_title, + "lesson_number": chunk.lesson_number, + "chunk_index": chunk.chunk_index, + } + for chunk in chunks + ] # Use title with chunk index for unique IDs - ids = [f"{chunk.course_title.replace(' ', '_')}_{chunk.chunk_index}" for chunk in chunks] - - self.course_content.add( - documents=documents, - metadatas=metadatas, - ids=ids - ) - + ids = [ + f"{chunk.course_title.replace(' ', '_')}_{chunk.chunk_index}" + for chunk in chunks + ] + + self.course_content.add(documents=documents, metadatas=metadatas, ids=ids) + def clear_all_data(self): """Clear all data from both collections""" try: @@ -189,43 +213,46 @@ def clear_all_data(self): self.course_content = self._create_collection("course_content") except Exception as e: print(f"Error clearing data: {e}") - + def get_existing_course_titles(self) -> List[str]: """Get all existing course titles from the vector store""" try: # Get all documents from the catalog results = self.course_catalog.get() - if results and 'ids' in results: - return results['ids'] + if results and "ids" in results: + return results["ids"] return [] except Exception as e: print(f"Error getting existing course titles: {e}") return [] - + def get_course_count(self) -> int: """Get the total number of courses in the vector store""" try: results = self.course_catalog.get() - if results and 'ids' in results: - return len(results['ids']) + if results and "ids" in results: + return len(results["ids"]) return 0 except Exception as e: print(f"Error getting course count: {e}") return 0 - + def get_all_courses_metadata(self) -> List[Dict[str, Any]]: """Get metadata for all courses in the vector store""" import json + try: results = self.course_catalog.get() - if results and 'metadatas' in results: + if results and "metadatas" in results: # Parse lessons JSON for each course parsed_metadata = [] - for metadata in results['metadatas']: + for metadata in results["metadatas"]: course_meta = metadata.copy() - if 'lessons_json' in course_meta: - course_meta['lessons'] = json.loads(course_meta['lessons_json']) - del course_meta['lessons_json'] # Remove the JSON string version + if "lessons_json" in course_meta: + course_meta["lessons"] = json.loads(course_meta["lessons_json"]) + del course_meta[ + "lessons_json" + ] # Remove the JSON string version parsed_metadata.append(course_meta) return parsed_metadata return [] @@ -238,30 +265,60 @@ def get_course_link(self, course_title: str) -> Optional[str]: try: # Get course by ID (title is the ID) results = self.course_catalog.get(ids=[course_title]) - if results and 'metadatas' in results and results['metadatas']: - metadata = results['metadatas'][0] - return metadata.get('course_link') + if results and "metadatas" in results and results["metadatas"]: + metadata = results["metadatas"][0] + return metadata.get("course_link") return None except Exception as e: print(f"Error getting course link: {e}") return None - + def get_lesson_link(self, course_title: str, lesson_number: int) -> Optional[str]: """Get lesson link for a given course title and lesson number""" import json + try: # Get course by ID (title is the ID) results = self.course_catalog.get(ids=[course_title]) - if results and 'metadatas' in results and results['metadatas']: - metadata = results['metadatas'][0] - lessons_json = metadata.get('lessons_json') + if results and "metadatas" in results and results["metadatas"]: + metadata = results["metadatas"][0] + lessons_json = metadata.get("lessons_json") if lessons_json: lessons = json.loads(lessons_json) # Find the lesson with matching number for lesson in lessons: - if lesson.get('lesson_number') == lesson_number: - return lesson.get('lesson_link') + if lesson.get("lesson_number") == lesson_number: + return lesson.get("lesson_link") return None except Exception as e: print(f"Error getting lesson link: {e}") - \ No newline at end of file + + def get_course_outline(self, course_name: str) -> Optional[Dict[str, Any]]: + """Get course title, link, and full lesson list for a given (possibly partial) course name""" + 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 not results or "metadatas" not in results or not results["metadatas"]: + return None + metadata = results["metadatas"][0] + lessons_json = metadata.get("lessons_json") + lessons = json.loads(lessons_json) if lessons_json else [] + lessons.sort(key=lambda l: l.get("lesson_number", 0)) + return { + "title": metadata.get("title", course_title), + "course_link": metadata.get("course_link"), + "lessons": [ + { + "lesson_number": l.get("lesson_number"), + "lesson_title": l.get("lesson_title"), + } + for l in lessons + ], + } + except Exception as e: + print(f"Error getting course outline: {e}") + return None diff --git a/frontend-changes.md b/frontend-changes.md new file mode 100644 index 000000000..c240adf39 --- /dev/null +++ b/frontend-changes.md @@ -0,0 +1,342 @@ +# Frontend Changes — Code Quality Tooling + +Adds automatic code formatting and dev scripts for running quality checks. + +The feature request named **black**, which only formats Python, while the scope was restricted to +the front end. Both halves are covered: black + isort for `backend/`, and **Prettier** for +`frontend/` — so the front-end files are actually formatted rather than left out of a +"code quality" change. One set of scripts drives both. + +## What changed + +### New files + +| File | Purpose | +| --- | --- | +| `package.json` | Declares Prettier as the only npm dev dependency; `format` / `format:check` scripts scoped to `frontend/**/*.{html,css,js}`. `"private": true`, no build step. | +| `package-lock.json` | Lockfile so every machine gets the identical Prettier version. | +| `.prettierrc.json` | Prettier config (see below). | +| `.prettierignore` | Excludes `node_modules/`, `.venv/`, `__pycache__/`, `backend/chroma_db/`, `.playwright-mcp/`, and `docs/`. | +| `scripts/format.sh` | Auto-formats everything in place: isort → black → Prettier. | +| `scripts/check.sh` | Verifies formatting without writing; prints diffs, exits 1 if anything is unformatted. | +| `scripts/quality.sh` | Full gate: `check.sh` + `pytest`. Run before committing; suitable for CI. | + +`docs/` is deliberately Prettier-ignored — `DocumentProcessor` parses those course files +line-by-line against an exact `Course Title:` / `Lesson N:` layout, so reformatting them would +break ingestion. + +### Modified files + +- **`pyproject.toml`** — added `black>=25.1.0` and `isort>=6.0.1` to the `dev` dependency group, + plus `[tool.black]` (line length 88, target `py313`) and `[tool.isort]` (`profile = "black"` so + the two tools never fight, with the `backend/` modules listed as `known_first_party` — they are + imported as top-level names via pytest's `pythonpath`, so isort would otherwise sort them as + third-party). +- **`.gitignore`** — added `.venv/` and `node_modules/`. +- **`README.md`** — new "Code Quality" section; Node.js 18+ noted as a prerequisite for the + formatter only. +- **`CLAUDE.md`** — replaced the now-stale "no test suite, linter, or build step" line with the + script list and a note about the pre-existing test failures. + +### Reformatted front-end sources + +All three front-end files were reformatted by Prettier — cosmetic only, no behavior change: + +| File | Lines changed | +| --- | --- | +| `frontend/index.html` | +96 / −74 | +| `frontend/script.js` | +161 / −157 | +| `frontend/style.css` | +422 / −409 | + +The front end had **mixed indentation** (some blocks 4-space, some 2-space, some misaligned) and +mixed quote styles; that is what most of the diff is. Specifically: + +- **Consistent 2-space indentation** across HTML, CSS and JS, replacing the mix. +- **Double quotes** in JS and CSS (`'/api'` → `"/api"`, `content: '▶'` → `content: "▶"`). +- **Self-closing void elements** in HTML (`` → ``), lowercase ``. +- **One selector per line** in CSS (`*, *::before, *::after` and `@keyframes` stops). +- **Expanded one-line rules** (`.message-content h1 { font-size: 1.5rem; }` → block form). +- **Long attribute lists wrapped** — the `data-question` buttons and the send-button ``. +- `"` inside a `data-question` attribute became a single-quoted attribute holding literal + `"` (identical after HTML parsing). +- Trailing newline added to `index.html`, which previously had none. +- Trailing whitespace and stray blank lines removed throughout. + +Backend `.py` files were reformatted too (13 files), which is where the rest of the overall diff +comes from. + +## Configuration + +`.prettierrc.json`: + +```json +{ + "printWidth": 100, + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "arrowParens": "always", + "endOfLine": "lf", + "htmlWhitespaceSensitivity": "css" +} +``` + +- `printWidth` 100 (120 for HTML via an override) — the existing markup is attribute-heavy, and + the default 80 would have shattered nearly every tag onto multiple lines. +- `endOfLine: "lf"` keeps diffs stable on Windows, which is the primary dev platform here. +- `htmlWhitespaceSensitivity: "css"` lets Prettier re-indent only where CSS `display` makes + whitespace insignificant, so re-wrapping the ` - - - - - + + + + + + Course Materials Assistant - - - + + + +
-
-

Course Materials Assistant

-

Ask questions about courses, instructors, and content

-
+ + -
- - +
+ +
-
+
+
+ Loading... +
+
+
+ + + + + + + +
+
+
+
+ + +
+
+
+ + - - - \ No newline at end of file + + + diff --git a/frontend/script.js b/frontend/script.js index 562a8a363..3018a4e16 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -1,104 +1,159 @@ // API base URL - use relative path to work from any host -const API_URL = '/api'; +const API_URL = "/api"; // Global state let currentSessionId = null; // DOM elements -let chatMessages, chatInput, sendButton, totalCourses, courseTitles; +let chatMessages, chatInput, sendButton, totalCourses, courseTitles, newChatButton, themeToggle; // 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(); +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"); + newChatButton = document.getElementById("newChatButton"); + themeToggle = document.getElementById("themeToggle"); + + initTheme(); + setupEventListeners(); + createNewSession(); + loadCourseStats(); }); // Event Listeners function setupEventListeners() { - // Chat functionality - sendButton.addEventListener('click', sendMessage); - chatInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') sendMessage(); - }); - - - // 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 + newChatButton.addEventListener("click", createNewSession); + + // Theme toggle (native