From c76615cf96dce97174f9bb7726edc7f75f1f1b3f Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 14 Sep 2026 22:29:09 -0700 Subject: [PATCH 01/13] Add CLAUDE.md Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7675ede7a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# 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. + +```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 test suite, linter, or build step in this repo. + +## 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. + +**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. From 09e83e2feae98f69d7d14f755e0cb64da7ceb8b0 Mon Sep 17 00:00:00 2001 From: unknown <tim.erdmann@mail.de> Date: Mon, 14 Sep 2026 22:37:30 -0700 Subject: [PATCH 02/13] Require uv run instead of plain python commands Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7675ede7a..a72be61f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,8 @@ A Retrieval-Augmented Generation (RAG) system for answering questions about cour 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 ``` From b9bf935b524696d9bf49afa5311081aca5efd05e Mon Sep 17 00:00:00 2001 From: unknown <tim.erdmann@mail.de> Date: Tue, 15 Sep 2026 00:00:08 -0700 Subject: [PATCH 03/13] Fix Anthropic API config for model compatibility claude-sonnet-4-20250514 was returning a 404 (not accessible), and the hardcoded temperature=0 param is deprecated on newer models. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- backend/ai_generator.py | 1 - backend/config.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 0363ca90c..374f6b6fc 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -36,7 +36,6 @@ def __init__(self, api_key: str, model: str): # Pre-build base API parameters self.base_params = { "model": self.model, - "temperature": 0, "max_tokens": 800 } diff --git a/backend/config.py b/backend/config.py index d9f6392ef..c4ba3712b 100644 --- a/backend/config.py +++ b/backend/config.py @@ -10,7 +10,7 @@ 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" From fc0c11273350bd3a95549d40e046d59ce5327c83 Mon Sep 17 00:00:00 2001 From: unknown <tim.erdmann@mail.de> Date: Tue, 15 Sep 2026 00:00:29 -0700 Subject: [PATCH 04/13] Document the RAG pipeline in detail Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- CLAUDE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a72be61f8..49e074647 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,27 @@ The key design point: retrieval is **agentic, not a fixed pipeline step**. `RAGS - `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: <title> From 71024a7a1d7992ddae012940929db617a591837a Mon Sep 17 00:00:00 2001 From: Tim Erdmann <tim.erdmann@mail.de> Date: Tue, 15 Sep 2026 01:43:24 -0700 Subject: [PATCH 05/13] Make source citations clickable links to lesson videos Sources now carry an optional link resolved from the vector store (lesson link, falling back to course link), rendered as a numbered list with each entry wrapped in an anchor when a link is available. Also dedupes sources by course/lesson so repeated chunks from the same lesson only produce one citation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- backend/app.py | 9 +++++++-- backend/rag_system.py | 4 ++-- backend/search_tools.py | 34 +++++++++++++++++++++++++--------- frontend/script.js | 10 +++++++++- frontend/style.css | 14 ++++++++++++++ 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/backend/app.py b/backend/app.py index 5a69d741d..90739d2b1 100644 --- a/backend/app.py +++ b/backend/app.py @@ -40,10 +40,15 @@ class QueryRequest(BaseModel): 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): @@ -67,7 +72,7 @@ async def query_documents(request: QueryRequest): return QueryResponse( answer=answer, - sources=sources, + sources=[SourceItem(text=s.text, link=s.link) for s in sources], session_id=session_id ) except Exception as e: diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8e..1bed9b3a5 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -4,7 +4,7 @@ from vector_store import VectorStore from ai_generator import AIGenerator from session_manager import SessionManager -from search_tools import ToolManager, CourseSearchTool +from search_tools import ToolManager, CourseSearchTool, Source from models import Course, Lesson, CourseChunk class RAGSystem: @@ -99,7 +99,7 @@ def add_course_folder(self, folder_path: str, clear_existing: bool = False) -> T 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. diff --git a/backend/search_tools.py b/backend/search_tools.py index adfe82352..b734804a0 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -1,8 +1,16 @@ from typing import Dict, Any, Optional, Protocol from abc import ABC, abstractmethod +from dataclasses import dataclass from vector_store import VectorStore, SearchResults +@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""" @@ -89,23 +97,31 @@ 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') - + # 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 diff --git a/frontend/script.js b/frontend/script.js index 562a8a363..2785a59a3 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -122,10 +122,18 @@ function addMessage(content, type, sources = null, isWelcome = false) { let html = `<div class="message-content">${displayContent}</div>`; if (sources && sources.length > 0) { + const sourceHtml = sources.map(source => { + const safeText = escapeHtml(source.text); + if (source.link) { + return `<li><a href="${escapeHtml(source.link)}" target="_blank" rel="noopener noreferrer" class="source-link">${safeText}</a></li>`; + } + return `<li><span class="source-item">${safeText}</span></li>`; + }).join(''); + html += ` <details class="sources-collapsible"> <summary class="sources-header">Sources</summary> - <div class="sources-content">${sources.join(', ')}</div> + <ol class="sources-content">${sourceHtml}</ol> </details> `; } diff --git a/frontend/style.css b/frontend/style.css index 825d03675..71cdf511d 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -245,6 +245,20 @@ header h1 { color: var(--text-secondary); } +.source-link { + color: var(--text-secondary); + text-decoration: none; +} + +.source-link:hover { + color: var(--primary-color); + text-decoration: underline; +} + +.source-item { + color: var(--text-secondary); +} + /* Markdown formatting styles */ .message-content h1, .message-content h2, From 060fcba4fe50c74e3797b05a168d6c72155b17aa Mon Sep 17 00:00:00 2001 From: Tim Erdmann <tim.erdmann@mail.de> Date: Tue, 15 Sep 2026 01:43:29 -0700 Subject: [PATCH 06/13] Fix empty responses caused by extended thinking on claude-sonnet-5 claude-sonnet-5 has extended thinking enabled by default, and would occasionally end its turn after a thinking block with no text at all, leaving the chatbot with a blank answer. Disable thinking explicitly, extract text by scanning for a text block instead of assuming content[0] is one (thinking blocks have no .text), and retry once if a response still comes back with no text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- backend/ai_generator.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 374f6b6fc..86db09b80 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -36,7 +36,8 @@ def __init__(self, api_key: str, model: str): # Pre-build base API parameters self.base_params = { "model": self.model, - "max_tokens": 800 + "max_tokens": 800, + "thinking": {"type": "disabled"} } def generate_response(self, query: str, @@ -83,7 +84,7 @@ def generate_response(self, query: str, return self._handle_tool_execution(response, api_params, tool_manager) # Return direct response - return response.content[0].text + return self._extract_text_with_retry(response, api_params) def _handle_tool_execution(self, initial_response, base_params: Dict[str, Any], tool_manager): """ @@ -131,4 +132,19 @@ def _handle_tool_execution(self, initial_response, base_params: Dict[str, Any], # 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 \ No newline at end of file From 5b8ab4a68f3f60580fa361c9b48bd48cb738144c Mon Sep 17 00:00:00 2001 From: Tim Erdmann <tim.erdmann@mail.de> Date: Tue, 15 Sep 2026 01:52:01 -0700 Subject: [PATCH 07/13] Add "+ New Chat" button to start a fresh conversation Clears the chat window and starts a new session client-side without a page reload, and ends the previous session on the backend via a new DELETE /api/session/{id} endpoint so its stored history is freed rather than left orphaned in memory. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- backend/app.py | 9 +++++++++ backend/session_manager.py | 6 +++++- frontend/index.html | 5 +++++ frontend/script.js | 22 ++++++++++++++++++---- frontend/style.css | 16 +++++++++++++--- 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/backend/app.py b/backend/app.py index 90739d2b1..5e101c791 100644 --- a/backend/app.py +++ b/backend/app.py @@ -78,6 +78,15 @@ async def query_documents(request: QueryRequest): 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""" diff --git a/backend/session_manager.py b/backend/session_manager.py index a5a96b1a1..14ac7792b 100644 --- a/backend/session_manager.py +++ b/backend/session_manager.py @@ -58,4 +58,8 @@ def get_conversation_history(self, session_id: Optional[str]) -> Optional[str]: 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) \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index f8e25a62f..adcbf2204 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,6 +19,11 @@ <h1>Course Materials Assistant</h1> <div class="main-content"> <!-- Left Sidebar --> <aside class="sidebar"> + <!-- New Chat --> + <div class="sidebar-section"> + <button id="newChatButton" class="new-chat-button">+ New Chat</button> + </div> + <!-- Course Stats --> <div class="sidebar-section"> <details class="stats-collapsible"> diff --git a/frontend/script.js b/frontend/script.js index 2785a59a3..701973dc7 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -5,7 +5,7 @@ const API_URL = '/api'; let currentSessionId = null; // DOM elements -let chatMessages, chatInput, sendButton, totalCourses, courseTitles; +let chatMessages, chatInput, sendButton, totalCourses, courseTitles, newChatButton; // Initialize document.addEventListener('DOMContentLoaded', () => { @@ -15,7 +15,8 @@ document.addEventListener('DOMContentLoaded', () => { sendButton = document.getElementById('sendButton'); totalCourses = document.getElementById('totalCourses'); courseTitles = document.getElementById('courseTitles'); - + newChatButton = document.getElementById('newChatButton'); + setupEventListeners(); createNewSession(); loadCourseStats(); @@ -28,8 +29,10 @@ function setupEventListeners() { chatInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); }); - - + + // New chat + newChatButton.addEventListener('click', createNewSession); + // Suggested questions document.querySelectorAll('.suggested-item').forEach(button => { button.addEventListener('click', (e) => { @@ -155,9 +158,20 @@ function escapeHtml(text) { // Removed removeMessage function - no longer needed since we handle loading differently async function createNewSession() { + const oldSessionId = currentSessionId; currentSessionId = null; chatMessages.innerHTML = ''; addMessage('Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know?', 'assistant', null, true); + chatInput.focus(); + + // End the previous session on the backend so its history is freed + if (oldSessionId) { + try { + await fetch(`${API_URL}/session/${oldSessionId}`, { method: 'DELETE' }); + } catch (error) { + console.error('Error ending previous session:', error); + } + } } // Load course statistics diff --git a/frontend/style.css b/frontend/style.css index 71cdf511d..b152cdf06 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -461,7 +461,8 @@ header h1 { /* Sidebar Headers */ .stats-header, -.suggested-header { +.suggested-header, +.new-chat-button { font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); @@ -476,13 +477,22 @@ header h1 { letter-spacing: 0.5px; } +.new-chat-button { + display: block; + width: 100%; + text-align: left; + font-family: inherit; +} + .stats-header:focus, -.suggested-header:focus { +.suggested-header:focus, +.new-chat-button:focus { color: var(--primary-color); } .stats-header:hover, -.suggested-header:hover { +.suggested-header:hover, +.new-chat-button:hover { color: var(--primary-color); } From 19c928e7f965542e031c396c23a11c5e2a72ad8a Mon Sep 17 00:00:00 2001 From: Tim Erdmann <tim.erdmann@mail.de> Date: Tue, 15 Sep 2026 03:36:50 -0700 Subject: [PATCH 08/13] Add course outline tool and sort source links alphabetically Adds a get_course_outline tool alongside the existing content-search tool so the AI can answer structure/syllabus questions (course title, link, and full lesson list) from course_catalog metadata instead of approximating from content chunks. Sharpens the system prompt to route outline-style queries reliably to the new tool, and sorts returned sources alphabetically by label. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- backend/ai_generator.py | 22 +++++++++-------- backend/rag_system.py | 4 +++- backend/search_tools.py | 52 +++++++++++++++++++++++++++++++++++++++-- backend/vector_store.py | 26 +++++++++++++++++++++ 4 files changed, 91 insertions(+), 13 deletions(-) diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 86db09b80..8aba6f401 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -5,20 +5,22 @@ 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: diff --git a/backend/rag_system.py b/backend/rag_system.py index 1bed9b3a5..a22ff8b08 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -4,7 +4,7 @@ from vector_store import VectorStore from ai_generator import AIGenerator from session_manager import SessionManager -from search_tools import ToolManager, CourseSearchTool, Source +from search_tools import ToolManager, CourseSearchTool, CourseOutlineTool, Source from models import Course, Lesson, CourseChunk class RAGSystem: @@ -22,7 +22,9 @@ def __init__(self, config): # Initialize search tools self.tool_manager = ToolManager() self.search_tool = CourseSearchTool(self.vector_store) + self.outline_tool = CourseOutlineTool(self.vector_store) self.tool_manager.register_tool(self.search_tool) + self.tool_manager.register_tool(self.outline_tool) def add_course_document(self, file_path: str) -> Tuple[Course, int]: """ diff --git a/backend/search_tools.py b/backend/search_tools.py index b734804a0..c0c8430e2 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -129,6 +129,54 @@ def _format_results(self, results: SearchResults) -> str: 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""" @@ -156,11 +204,11 @@ def execute_tool(self, tool_name: str, **kwargs) -> str: 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 + return sorted(tool.last_sources, key=lambda source: source.text.lower()) return [] def reset_sources(self): diff --git a/backend/vector_store.py b/backend/vector_store.py index 390abe71c..ded8e10d4 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -264,4 +264,30 @@ def get_lesson_link(self, course_title: str, lesson_number: int) -> Optional[str return None except Exception as e: print(f"Error getting lesson link: {e}") + + 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 \ No newline at end of file From 7b39151bc045db29bfe2da4d21adefe9d20f07fa Mon Sep 17 00:00:00 2001 From: Tim Erdmann <tim.erdmann@mail.de> Date: Tue, 15 Sep 2026 03:52:27 -0700 Subject: [PATCH 09/13] Add diagnostic test suite; fix lesson_number=0 dropped from no-results message Adds pytest coverage for CourseSearchTool formatting/sources, AIGenerator's tool-calling flow, and RAGSystem's content-query handling (against the real chroma_db, with the Anthropic client scripted). The suite caught a real bug: CourseSearchTool.execute used a truthy check on lesson_number, so a lesson-0 scoped search with no results silently dropped the "in lesson 0" qualifier since lesson 0 is a valid lesson in every course. Fixed by checking `is not None` instead, matching the pattern already used in _format_results. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- backend/search_tools.py | 2 +- backend/tests/__init__.py | 0 backend/tests/conftest.py | 136 ++++++++++++ backend/tests/test_ai_generator.py | 186 ++++++++++++++++ backend/tests/test_course_search_tool.py | 209 ++++++++++++++++++ .../tests/test_rag_system_content_queries.py | 128 +++++++++++ pyproject.toml | 9 + uv.lock | 44 +++- 8 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_ai_generator.py create mode 100644 backend/tests/test_course_search_tool.py create mode 100644 backend/tests/test_rag_system_content_queries.py diff --git a/backend/search_tools.py b/backend/search_tools.py index c0c8430e2..d17e45598 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -86,7 +86,7 @@ def execute(self, query: str, course_name: Optional[str] = None, lesson_number: 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}." 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..1a79332eb --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,136 @@ +"""Shared fixtures for the backend diagnostic test suite.""" +from pathlib import Path +from dataclasses import dataclass +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from vector_store import VectorStore, SearchResults +from search_tools import ToolManager +from ai_generator import AIGenerator +from rag_system import RAGSystem + + +# --------------------------------------------------------------------------- +# 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 diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py new file mode 100644 index 000000000..f0aa548bc --- /dev/null +++ b/backend/tests/test_ai_generator.py @@ -0,0 +1,186 @@ +""" +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_course_search_tool.py b/backend/tests/test_course_search_tool.py new file mode 100644 index 000000000..b890ece97 --- /dev/null +++ b/backend/tests/test_course_search_tool.py @@ -0,0 +1,209 @@ +""" +Objective 1: pure unit tests for CourseSearchTool and ToolManager. +VectorStore is fully mocked -- no real Chroma, no network. +""" +import pytest + +from search_tools import CourseSearchTool, CourseOutlineTool, ToolManager, Source, Tool + + +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..47668645e --- /dev/null +++ b/backend/tests/test_rag_system_content_queries.py @@ -0,0 +1,128 @@ +""" +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 + + +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/pyproject.toml b/pyproject.toml index 3f05e2de0..99ca9e4ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,3 +13,12 @@ dependencies = [ "python-multipart==0.0.20", "python-dotenv==1.1.1", ] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] + +[tool.pytest.ini_options] +pythonpath = ["backend"] +testpaths = ["backend/tests"] diff --git a/uv.lock b/uv.lock index 9ae65c557..8c5a41afe 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -470,6 +470,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1038,6 +1047,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "posthog" version = "5.4.0" @@ -1207,6 +1225,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1561,6 +1595,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "anthropic", specifier = "==0.58.2" }, @@ -1572,6 +1611,9 @@ requires-dist = [ { name = "uvicorn", specifier = "==0.35.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + [[package]] name = "sympy" version = "1.14.0" From 7403675171787bb75f4b82bee62ca429c75f6ab1 Mon Sep 17 00:00:00 2001 From: Tim Erdmann <tim.erdmann@mail.de> Date: Tue, 15 Sep 2026 03:58:44 -0700 Subject: [PATCH 10/13] Move .claude config into the repo; gitignore Playwright MCP output .claude/ and .playwright-mcp/ had been created one directory up, outside this git repo, so nothing there was version-tracked. Moves .claude/ (the /implement-feature command and local settings) in so it travels with the project, and gitignores .playwright-mcp/ since it's disposable browser-automation output (screenshots, snapshots, console logs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- .claude/commands/implement-feature.md | 7 +++++++ .claude/settings.local.json | 7 +++++++ .gitignore | 3 +++ 3 files changed, 17 insertions(+) create mode 100644 .claude/commands/implement-feature.md create mode 100644 .claude/settings.local.json 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..1166c3f21 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ uploads/ *.swp *.swo +# Playwright MCP output (screenshots, snapshots, console logs) +.playwright-mcp/ + # OS .DS_Store Thumbs.db \ No newline at end of file From d591fb4799f651d686d5b17e3bd5aa4b553afac9 Mon Sep 17 00:00:00 2001 From: Tim Erdmann <tim.erdmann@mail.de> Date: Tue, 15 Sep 2026 05:05:47 -0700 Subject: [PATCH 11/13] Add FastAPI endpoint tests for /api/query, /api/courses, and session deletion Adds 29 endpoint tests plus shared fixtures/app factory in conftest.py, marks the real-Chroma content-query tests as `slow` so the default run stays fast, and adds httpx as a dev dependency for the FastAPI TestClient. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- backend/tests/conftest.py | 189 ++++++++++++- backend/tests/test_api_endpoints.py | 258 ++++++++++++++++++ .../tests/test_rag_system_content_queries.py | 7 + frontend-changes.md | 124 +++++++++ pyproject.toml | 28 +- uv.lock | 6 +- 6 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_api_endpoints.py create mode 100644 frontend-changes.md diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1a79332eb..f05a1be13 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,12 +2,19 @@ from pathlib import Path from dataclasses import dataclass 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 vector_store import VectorStore, SearchResults -from search_tools import ToolManager +from search_tools import ToolManager, Source from ai_generator import AIGenerator from rag_system import RAGSystem @@ -134,3 +141,183 @@ def _isolate_rag_system(request): 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_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_rag_system_content_queries.py b/backend/tests/test_rag_system_content_queries.py index 47668645e..44e28ad94 100644 --- a/backend/tests/test_rag_system_content_queries.py +++ b/backend/tests/test_rag_system_content_queries.py @@ -11,6 +11,13 @@ """ 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( diff --git a/frontend-changes.md b/frontend-changes.md new file mode 100644 index 000000000..7ae9b8ba4 --- /dev/null +++ b/frontend-changes.md @@ -0,0 +1,124 @@ +# Changes: API testing infrastructure + +> **Scope note:** the requested feature is backend/test-infrastructure work, not a +> frontend change. No files under `frontend/` were modified. What these tests *do* +> cover is the HTTP contract `frontend/script.js` depends on — the exact shape of +> `/api/query`, `/api/courses`, `DELETE /api/session/{id}`, and the static mount +> that serves the frontend itself — so a breaking change to the API surface the UI +> consumes now fails a test instead of only breaking in the browser. + +## Summary + +Added API endpoint tests, shared fixtures, and pytest configuration to the +existing backend test suite. + +| File | Change | +| --- | --- | +| `backend/tests/test_api_endpoints.py` | **New.** 29 endpoint tests. | +| `backend/tests/conftest.py` | Added an inline FastAPI app factory + API fixtures. | +| `backend/tests/test_rag_system_content_queries.py` | Marked `slow`. | +| `pyproject.toml` | Expanded `[tool.pytest.ini_options]`; added `httpx` dev dep. | + +--- + +## 1. `backend/tests/test_api_endpoints.py` (new) + +29 tests, all marked `api`, grouped by endpoint: + +**`POST /api/query`** (12 tests) +- 200 response shape is exactly `{answer, sources, session_id}` +- `Source` dataclasses serialize to `{text, link}`, including `link: null` +- a session is created when `session_id` is omitted or `null` +- a supplied `session_id` is reused and `create_session` is *not* called +- `rag.query` is called with `(query, session_id)` positionally +- empty `sources` list is valid (general-knowledge answers) +- 422 on missing `query`, wrong `query` type, and malformed JSON +- unknown body fields are ignored rather than rejected +- a `RuntimeError` from the RAG system becomes a 500 with the message in `detail` +- `GET` on the route is 405 + +**`GET /api/courses`** (3 tests) — stats payload, empty catalog, 500 on failure. + +**`DELETE /api/session/{session_id}`** (3 tests) — deletes the named session, +stays 200 for an unknown id (the manager's `pop` is a no-op), 500 on failure. +This is what the frontend's "+ New Chat" button calls. + +**`GET /` static mount** (6 tests) — `index.html` at `/`, `style.css`/`script.js` +served, the no-cache headers `DevStaticFiles` adds, 404 for a missing asset, and +that the `"/"` mount does not shadow `/api/*`. + +**Cross-cutting** (5 tests) — CORS simple + preflight headers, the OpenAPI schema +listing all three API paths, and app-factory isolation. + +## 2. `backend/tests/conftest.py` + +`backend/app.py` **cannot be imported under test**: at import time it constructs a +real `RAGSystem` (loading the sentence-transformer model and Chroma) and calls +`app.mount("/", StaticFiles(directory="../frontend"))` — a cwd-relative path that +does not resolve when pytest runs from the repo root per `testpaths`. + +So the endpoints are re-declared inline via a factory: + +- `create_test_app(rag_system, static_dir=None)` — mirrors `app.py`'s routes, + Pydantic models (`QueryRequest`/`SourceItem`/`QueryResponse`/`CourseStats`), + CORS middleware, and `_NoCacheStaticFiles` (a copy of `DevStaticFiles`). + The static mount is **opt-in**, so API-only tests need no frontend on disk. + *This is a deliberate duplicate — keep it in sync with `backend/app.py`.* + +New fixtures: + +| Fixture | Purpose | +| --- | --- | +| `mock_rag` | Stand-in RAG system. No Chroma, no embedding model, no Anthropic. | +| `sample_sources` | Two `Source` records — one with a lesson link, one without. | +| `sample_analytics` | A `get_course_analytics()`-shaped payload. | +| `static_dir` | Throwaway `tmp_path` frontend with `index.html`/`style.css`/`script.js`. | +| `api_client` | `TestClient` over the API routes only. | +| `full_client` | `TestClient` over API routes **plus** the static mount. | + +`mock_rag` is a plain `MagicMock`, not `MagicMock(spec=RAGSystem)`, because +`session_manager` is an instance attribute and would not survive spec +introspection. + +## 3. `pyproject.toml` + +```toml +[tool.pytest.ini_options] +pythonpath = ["backend", "backend/tests"] # "backend/tests" lets tests import the app factory from conftest +testpaths = ["backend/tests"] +python_files / python_classes / python_functions # explicit discovery rules +addopts = ["-q", "--tb=short", "-ra", "--strict-markers", "--strict-config"] +markers = ["api", "slow"] +filterwarnings = [...] # silences chromadb/pydantic deprecation noise +``` + +`--strict-markers` means a typo'd marker is an error, not a silent no-op. +Added `httpx>=0.28.1` to the dev group — `TestClient` requires it, and it was +previously only present as a transitive dependency of `anthropic`. + +## 4. `test_rag_system_content_queries.py` marked `slow` + +These integration tests read the real `backend/chroma_db` and only pass once +`./run.sh` has ingested `docs/`. Marking them `slow` makes a clean fast run +possible. + +--- + +## Running + +```bash +uv run pytest # everything +uv run pytest -m "not slow" # fast run, no Chroma/embedding model -> 54 passed in 0.67s +uv run pytest -m api # endpoint tests only -> 29 passed in 0.33s +``` + +**Current state:** 57 passed, 3 failed. The 3 failures are pre-existing and +environmental — `test_rag_system_content_queries.py` needs a populated +`backend/chroma_db`, and this worktree's store is empty (0 courses). They pass +once documents have been ingested, and are excluded by `-m "not slow"`. + +On Windows/OneDrive, `uv sync` fails to hardlink into the uv cache (`os error 396`). +`pyproject.toml` now sets `[tool.uv] link-mode = "copy"`, so no environment +variable is needed. (This could *not* go in `.env` — uv only reads a `.env` when +`--env-file`/`UV_ENV_FILE` is given, and then only to populate the child command's +environment, not uv's own install behavior.) diff --git a/pyproject.toml b/pyproject.toml index 99ca9e4ef..658600b59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,34 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=9.1.1", + "httpx>=0.28.1", ] +[tool.uv] +# The repo lives under OneDrive, where hardlinking into the uv cache fails with +# "The cloud operation cannot be performed on a file with incompatible hardlinks +# (os error 396)". Copying is slower but works. Equivalent to UV_LINK_MODE=copy. +link-mode = "copy" + [tool.pytest.ini_options] -pythonpath = ["backend"] +pythonpath = ["backend", "backend/tests"] testpaths = ["backend/tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-q", + "--tb=short", + "-ra", + "--strict-markers", + "--strict-config", +] +markers = [ + "api: FastAPI endpoint tests (no Chroma, no Anthropic, no network)", + "slow: tests that load the embedding model or the real Chroma store", +] +filterwarnings = [ + "ignore:resource_tracker:UserWarning", + "ignore::DeprecationWarning:chromadb.*", + "ignore::DeprecationWarning:pydantic.*", +] diff --git a/uv.lock b/uv.lock index 8c5a41afe..968093a19 100644 --- a/uv.lock +++ b/uv.lock @@ -1597,6 +1597,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "pytest" }, ] @@ -1612,7 +1613,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.1.1" }] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.1.1" }, +] [[package]] name = "sympy" From 8a35d7479c6f6111afcb72bdea733716dcc42827 Mon Sep 17 00:00:00 2001 From: Tim Erdmann Date: Tue, 15 Sep 2026 05:05:50 -0700 Subject: [PATCH 12/13] Add code quality tooling: black/isort for backend, Prettier for frontend Adds format/check/quality npm-free scripts (scripts/*.sh) so both halves of the codebase can be formatted and verified consistently; docs/ is excluded from Prettier since DocumentProcessor parses it against an exact line format. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 4 + .prettierignore | 13 + .prettierrc.json | 20 + CLAUDE.md | 18 +- README.md | 15 + backend/ai_generator.py | 86 +- backend/app.py | 53 +- backend/config.py | 20 +- backend/document_processor.py | 154 ++-- backend/models.py | 23 +- backend/rag_system.py | 108 ++- backend/search_tools.py | 87 +- backend/session_manager.py | 35 +- backend/tests/conftest.py | 17 +- backend/tests/test_ai_generator.py | 101 ++- backend/tests/test_course_search_tool.py | 40 +- .../tests/test_rag_system_content_queries.py | 75 +- backend/vector_store.py | 263 +++--- frontend-changes.md | 121 +++ frontend/index.html | 170 ++-- frontend/script.js | 318 +++---- frontend/style.css | 831 +++++++++--------- package-lock.json | 30 + package.json | 13 + pyproject.toml | 22 + scripts/check.sh | 30 + scripts/format.sh | 23 + scripts/quality.sh | 23 + uv.lock | 121 ++- 29 files changed, 1760 insertions(+), 1074 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 frontend-changes.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/check.sh create mode 100644 scripts/format.sh create mode 100644 scripts/quality.sh diff --git a/.gitignore b/.gitignore index 1166c3f21..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/ 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 index 49e074647..a7b9caa58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,23 @@ cd backend && uv run uvicorn app:app --reload --port 8000 - Web UI: http://localhost:8000 - API docs: http://localhost:8000/docs -There is no test suite, linter, or build step in this repo. +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 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 8aba6f401..cedbffa7d 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -1,9 +1,11 @@ +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 tools for searching course content and retrieving course outlines. @@ -30,108 +32,114 @@ 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, "max_tokens": 800, - "thinking": {"type": "disabled"} + "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 self._extract_text_with_retry(response, api_params) - - def _handle_tool_execution(self, initial_response, base_params: Dict[str, Any], tool_manager): + + 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 self._extract_text_with_retry(final_response, final_params) @@ -149,4 +157,4 @@ def _extract_text_with_retry(self, response, api_params: Dict[str, Any]) -> str: if not text: response = self.client.messages.create(**api_params) text = self._extract_text(response) - return text \ No newline at end of file + return text diff --git a/backend/app.py b/backend/app.py index 5e101c791..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,30 +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[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""" @@ -66,18 +75,19 @@ 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=[SourceItem(text=s.text, link=s.link) for s in sources], - session_id=session_id + 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""" @@ -87,6 +97,7 @@ async def delete_session(session_id: str): 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""" @@ -94,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""" @@ -106,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): @@ -127,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 c4ba3712b..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-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'(? 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 a22ff8b08..a064b84ba 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -1,149 +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, CourseOutlineTool, Source -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[Source]]: + + 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 d17e45598..6f8a405d1 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -1,24 +1,26 @@ -from typing import Dict, Any, Optional, Protocol from abc import ABC, abstractmethod from dataclasses import dataclass -from vector_store import VectorStore, SearchResults +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""" @@ -27,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 { @@ -41,46 +43,49 @@ 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 = "" @@ -89,10 +94,10 @@ def execute(self, query: str, course_name: Optional[str] = None, 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 = [] @@ -100,8 +105,8 @@ def _format_results(self, results: SearchResults) -> str: 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}" @@ -123,12 +128,13 @@ def _format_results(self, results: SearchResults) -> str: 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""" @@ -145,11 +151,11 @@ def get_tool_definition(self) -> Dict[str, Any]: "properties": { "course_title": { "type": "string", - "description": "Course title (partial matches work, e.g. 'MCP', 'Introduction')" + "description": "Course title (partial matches work, e.g. 'MCP', 'Introduction')", } }, - "required": ["course_title"] - } + "required": ["course_title"], + }, } def execute(self, course_title: str) -> str: @@ -179,10 +185,10 @@ def execute(self, course_title: str) -> str: 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() @@ -191,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, 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: + 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 14ac7792b..916ab42b3 100644 --- a/backend/session_manager.py +++ b/backend/session_manager.py @@ -1,60 +1,65 @@ -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: @@ -62,4 +67,4 @@ def clear_session(self, session_id: str): def delete_session(self, session_id: str): """Remove a session entirely, freeing its stored history""" - self.sessions.pop(session_id, None) \ No newline at end of file + self.sessions.pop(session_id, None) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1a79332eb..0f4b17c3e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,21 +1,22 @@ """Shared fixtures for the backend diagnostic test suite.""" -from pathlib import Path + from dataclasses import dataclass +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from vector_store import VectorStore, SearchResults -from search_tools import ToolManager from ai_generator import AIGenerator from rag_system import RAGSystem - +from search_tools import 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.""" @@ -25,6 +26,7 @@ def mock_vector_store(): @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 [], @@ -32,6 +34,7 @@ def _make(documents=None, metadata=None, distances=None, error=None): distances=distances or [], error=error, ) + return _make @@ -39,6 +42,7 @@ def _make(documents=None, metadata=None, distances=None, error=None): # 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.""" @@ -55,11 +59,13 @@ def mock_tool_manager(): @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 @@ -69,6 +75,7 @@ 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( @@ -80,6 +87,7 @@ def _make(tool_calls, stop_reason="tool_use"): for i, call in enumerate(tool_calls) ] return SimpleNamespace(stop_reason=stop_reason, content=blocks) + return _make @@ -99,6 +107,7 @@ class _TestConfig: './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" diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py index f0aa548bc..c1845a298 100644 --- a/backend/tests/test_ai_generator.py +++ b/backend/tests/test_ai_generator.py @@ -3,6 +3,7 @@ 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 @@ -10,13 +11,15 @@ 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", - } - ]) + 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" @@ -35,53 +38,87 @@ def test_tool_use_triggers_execute_tool_with_exact_kwargs( 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"} - ]) + 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 + 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 + 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"} + { + "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"} - ]) + 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 + 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 + 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"}, - ]) + 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"] @@ -91,7 +128,9 @@ def test_multiple_parallel_tool_use_blocks_all_executed( ) assert mock_tool_manager.execute_tool.call_count == 2 - second_call_kwargs = ai_generator.client.messages.create.call_args_list[1].kwargs + 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"}, @@ -100,7 +139,9 @@ def test_multiple_parallel_tool_use_blocks_all_executed( class TestNoToolUseFlow: - def test_no_tools_passed_omits_tools_and_tool_choice_keys(self, ai_generator, text_response): + 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") @@ -139,7 +180,9 @@ def test_retries_exactly_once_when_first_response_has_no_text_block( 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): + 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] @@ -152,7 +195,9 @@ def test_returns_empty_string_without_looping_if_retry_is_also_textless(self, ai class TestSystemPromptAndParams: - def test_conversation_history_is_appended_to_system_prompt(self, ai_generator, text_response): + 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( @@ -163,7 +208,9 @@ def test_conversation_history_is_appended_to_system_prompt(self, ai_generator, t 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): + 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") diff --git a/backend/tests/test_course_search_tool.py b/backend/tests/test_course_search_tool.py index b890ece97..b30adfd37 100644 --- a/backend/tests/test_course_search_tool.py +++ b/backend/tests/test_course_search_tool.py @@ -2,9 +2,10 @@ Objective 1: pure unit tests for CourseSearchTool and ToolManager. VectorStore is fully mocked -- no real Chroma, no network. """ + import pytest -from search_tools import CourseSearchTool, CourseOutlineTool, ToolManager, Source, Tool +from search_tools import CourseOutlineTool, CourseSearchTool, Source, Tool, ToolManager class TestCourseSearchToolFormatting: @@ -25,8 +26,7 @@ def test_successful_search_formats_headers_and_joins_with_blank_line( result = tool.execute(query="test") assert result == ( - "[Course A - Lesson 1]\ndoc1 text\n\n" - "[Course A - Lesson 2]\ndoc2 text" + "[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 @@ -49,8 +49,12 @@ def test_sources_tracked_and_deduped_by_course_and_lesson( 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") + 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 @@ -87,7 +91,9 @@ def test_empty_with_course_name_only(self, mock_vector_store, make_results): assert result == "No relevant content found in course 'Foo'." - def test_empty_with_positive_lesson_number_only(self, mock_vector_store, make_results): + 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) @@ -113,8 +119,12 @@ def test_empty_with_neither_filter(self, mock_vector_store, make_results): 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'") + 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") @@ -142,6 +152,7 @@ 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" @@ -162,7 +173,10 @@ def test_get_last_sources_sorted_alphabetically_by_text( manager.register_tool(tool) mock_vector_store.search.return_value = make_results( documents=["a", "b"], - metadata=[{"course_title": "Zebra Course"}, {"course_title": "Alpha Course"}], + metadata=[ + {"course_title": "Zebra Course"}, + {"course_title": "Alpha Course"}, + ], ) mock_vector_store.get_course_link.return_value = None tool.execute(query="x") @@ -171,7 +185,9 @@ def test_get_last_sources_sorted_alphabetically_by_text( 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): + 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) @@ -186,7 +202,9 @@ def test_reset_sources_clears_all_registered_tools(self, mock_vector_store, make assert manager.get_last_sources() == [] - def test_get_last_sources_only_surfaces_first_tool_with_sources(self, mock_vector_store): + 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 diff --git a/backend/tests/test_rag_system_content_queries.py b/backend/tests/test_rag_system_content_queries.py index 47668645e..4aa67f180 100644 --- a/backend/tests/test_rag_system_content_queries.py +++ b/backend/tests/test_rag_system_content_queries.py @@ -9,6 +9,7 @@ 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 @@ -16,14 +17,18 @@ 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.") + 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?") @@ -36,17 +41,19 @@ def test_content_query_returns_scripted_answer_with_real_resolvable_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", - } - ]) + 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] @@ -56,7 +63,9 @@ def test_lesson_zero_scoped_search_finds_real_content_not_the_bug_path( 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): + 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( @@ -71,16 +80,22 @@ def test_nonexistent_course_name_degrades_gracefully_without_crashing(self, rag_ 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", - } - ]) + 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] + rag_system.ai_generator.client.messages.create.side_effect = [ + initial, + final1, + final2, + ] _, sources1 = rag_system.query("What is computer use?") assert len(sources1) > 0 @@ -106,7 +121,9 @@ def test_two_queries_same_session_record_both_exchanges_and_pass_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 + 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"] diff --git a/backend/vector_store.py b/backend/vector_store.py index ded8e10d4..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,29 +265,30 @@ def get_course_link(self, course_title: str) -> Optional[str]: try: # Get course by ID (title is the ID) results = self.course_catalog.get(ids=[course_title]) - if results and 'metadatas' in results and results['metadatas']: - metadata = results['metadatas'][0] - return metadata.get('course_link') + if results and "metadatas" in results and results["metadatas"]: + metadata = results["metadatas"][0] + return metadata.get("course_link") return None except Exception as e: print(f"Error getting course link: {e}") return None - + def get_lesson_link(self, course_title: str, lesson_number: int) -> Optional[str]: """Get lesson link for a given course title and lesson number""" import json + try: # Get course by ID (title is the ID) results = self.course_catalog.get(ids=[course_title]) - if results and 'metadatas' in results and results['metadatas']: - metadata = results['metadatas'][0] - lessons_json = metadata.get('lessons_json') + if results and "metadatas" in results and results["metadatas"]: + metadata = results["metadatas"][0] + lessons_json = metadata.get("lessons_json") if lessons_json: lessons = json.loads(lessons_json) # Find the lesson with matching number for lesson in lessons: - if lesson.get('lesson_number') == lesson_number: - return lesson.get('lesson_link') + if lesson.get("lesson_number") == lesson_number: + return lesson.get("lesson_link") return None except Exception as e: print(f"Error getting lesson link: {e}") @@ -268,26 +296,29 @@ def get_lesson_link(self, course_title: str, lesson_number: int) -> Optional[str 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']: + if not results or "metadatas" not in results or not results["metadatas"]: return None - metadata = results['metadatas'][0] - lessons_json = metadata.get('lessons_json') + 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)) + lessons.sort(key=lambda l: l.get("lesson_number", 0)) return { - "title": metadata.get('title', course_title), - "course_link": metadata.get('course_link'), + "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')} + { + "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 - \ No newline at end of file diff --git a/frontend-changes.md b/frontend-changes.md new file mode 100644 index 000000000..39bc59e22 --- /dev/null +++ b/frontend-changes.md @@ -0,0 +1,121 @@ +# 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

-
+
+

Course Materials Assistant

+

Ask questions about courses, instructors, and content

+
-
- -
- -
-
-
-
- - -
-
-
-
- + + + + +
+
+
+
+ + +
+
+
+ + - - \ No newline at end of file + + diff --git a/frontend/script.js b/frontend/script.js index 701973dc7..51e0660f1 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -1,5 +1,5 @@ // API base URL - use relative path to work from any host -const API_URL = '/api'; +const API_URL = "/api"; // Global state let currentSessionId = null; @@ -8,100 +8,98 @@ let currentSessionId = null; let chatMessages, chatInput, sendButton, totalCourses, courseTitles, newChatButton; // 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'); - newChatButton = document.getElementById('newChatButton'); - - 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"); + + setupEventListeners(); + createNewSession(); + loadCourseStats(); }); // Event Listeners function setupEventListeners() { - // Chat functionality - sendButton.addEventListener('click', sendMessage); - chatInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') sendMessage(); - }); - - // New chat - newChatButton.addEventListener('click', createNewSession); - - // 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); + + // Suggested questions + document.querySelectorAll(".suggested-item").forEach((button) => { + button.addEventListener("click", (e) => { + const question = e.target.getAttribute("data-question"); + chatInput.value = question; + sendMessage(); }); + }); } - // Chat Functions async function sendMessage() { - const query = chatInput.value.trim(); - if (!query) return; - - // Disable input - chatInput.value = ''; - chatInput.disabled = true; - sendButton.disabled = true; - - // Add user message - addMessage(query, 'user'); - - // Add loading message - create a unique container for it - const loadingMessage = createLoadingMessage(); - chatMessages.appendChild(loadingMessage); - chatMessages.scrollTop = chatMessages.scrollHeight; + const query = chatInput.value.trim(); + if (!query) return; + + // Disable input + chatInput.value = ""; + chatInput.disabled = true; + sendButton.disabled = true; + + // Add user message + addMessage(query, "user"); + + // Add loading message - create a unique container for it + const loadingMessage = createLoadingMessage(); + chatMessages.appendChild(loadingMessage); + chatMessages.scrollTop = chatMessages.scrollHeight; + + try { + const response = await fetch(`${API_URL}/query`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: query, + session_id: currentSessionId, + }), + }); - try { - const response = await fetch(`${API_URL}/query`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: query, - session_id: currentSessionId - }) - }); - - if (!response.ok) throw new Error('Query failed'); - - const data = await response.json(); - - // Update session ID if new - if (!currentSessionId) { - currentSessionId = data.session_id; - } + if (!response.ok) throw new Error("Query failed"); - // Replace loading message with response - loadingMessage.remove(); - addMessage(data.answer, 'assistant', data.sources); + const data = await response.json(); - } catch (error) { - // Replace loading message with error - loadingMessage.remove(); - addMessage(`Error: ${error.message}`, 'assistant'); - } finally { - chatInput.disabled = false; - sendButton.disabled = false; - chatInput.focus(); + // Update session ID if new + if (!currentSessionId) { + currentSessionId = data.session_id; } + + // Replace loading message with response + loadingMessage.remove(); + addMessage(data.answer, "assistant", data.sources); + } catch (error) { + // Replace loading message with error + loadingMessage.remove(); + addMessage(`Error: ${error.message}`, "assistant"); + } finally { + chatInput.disabled = false; + sendButton.disabled = false; + chatInput.focus(); + } } function createLoadingMessage() { - const messageDiv = document.createElement('div'); - messageDiv.className = 'message assistant'; - messageDiv.innerHTML = ` + const messageDiv = document.createElement("div"); + messageDiv.className = "message assistant"; + messageDiv.innerHTML = `
@@ -110,104 +108,110 @@ function createLoadingMessage() {
`; - return messageDiv; + return messageDiv; } function addMessage(content, type, sources = null, isWelcome = false) { - const messageId = Date.now(); - const messageDiv = document.createElement('div'); - messageDiv.className = `message ${type}${isWelcome ? ' welcome-message' : ''}`; - messageDiv.id = `message-${messageId}`; - - // Convert markdown to HTML for assistant messages - const displayContent = type === 'assistant' ? marked.parse(content) : escapeHtml(content); - - let html = `
${displayContent}
`; - - if (sources && sources.length > 0) { - const sourceHtml = sources.map(source => { - const safeText = escapeHtml(source.text); - if (source.link) { - return `
  • ${safeText}
  • `; - } - return `
  • ${safeText}
  • `; - }).join(''); - - html += ` + const messageId = Date.now(); + const messageDiv = document.createElement("div"); + messageDiv.className = `message ${type}${isWelcome ? " welcome-message" : ""}`; + messageDiv.id = `message-${messageId}`; + + // Convert markdown to HTML for assistant messages + const displayContent = type === "assistant" ? marked.parse(content) : escapeHtml(content); + + let html = `
    ${displayContent}
    `; + + if (sources && sources.length > 0) { + const sourceHtml = sources + .map((source) => { + const safeText = escapeHtml(source.text); + if (source.link) { + return `
  • ${safeText}
  • `; + } + return `
  • ${safeText}
  • `; + }) + .join(""); + + html += `
    Sources
      ${sourceHtml}
    `; - } - - messageDiv.innerHTML = html; - chatMessages.appendChild(messageDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - - return messageId; + } + + messageDiv.innerHTML = html; + chatMessages.appendChild(messageDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + + return messageId; } // Helper function to escape HTML for user messages function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; } // Removed removeMessage function - no longer needed since we handle loading differently async function createNewSession() { - const oldSessionId = currentSessionId; - currentSessionId = null; - chatMessages.innerHTML = ''; - addMessage('Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know?', 'assistant', null, true); - chatInput.focus(); - - // End the previous session on the backend so its history is freed - if (oldSessionId) { - try { - await fetch(`${API_URL}/session/${oldSessionId}`, { method: 'DELETE' }); - } catch (error) { - console.error('Error ending previous session:', error); - } + const oldSessionId = currentSessionId; + currentSessionId = null; + chatMessages.innerHTML = ""; + addMessage( + "Welcome to the Course Materials Assistant! I can help you with questions about courses, lessons and specific content. What would you like to know?", + "assistant", + null, + true + ); + chatInput.focus(); + + // End the previous session on the backend so its history is freed + if (oldSessionId) { + try { + await fetch(`${API_URL}/session/${oldSessionId}`, { method: "DELETE" }); + } catch (error) { + console.error("Error ending previous session:", error); } + } } // Load course statistics async function loadCourseStats() { - try { - console.log('Loading course stats...'); - const response = await fetch(`${API_URL}/courses`); - if (!response.ok) throw new Error('Failed to load course stats'); - - const data = await response.json(); - console.log('Course data received:', data); - - // Update stats in UI - if (totalCourses) { - totalCourses.textContent = data.total_courses; - } - - // Update course titles - if (courseTitles) { - if (data.course_titles && data.course_titles.length > 0) { - courseTitles.innerHTML = data.course_titles - .map(title => `
    ${title}
    `) - .join(''); - } else { - courseTitles.innerHTML = 'No courses available'; - } - } - - } catch (error) { - console.error('Error loading course stats:', error); - // Set default values on error - if (totalCourses) { - totalCourses.textContent = '0'; - } - if (courseTitles) { - courseTitles.innerHTML = 'Failed to load courses'; - } + try { + console.log("Loading course stats..."); + const response = await fetch(`${API_URL}/courses`); + if (!response.ok) throw new Error("Failed to load course stats"); + + const data = await response.json(); + console.log("Course data received:", data); + + // Update stats in UI + if (totalCourses) { + totalCourses.textContent = data.total_courses; } -} \ No newline at end of file + + // Update course titles + if (courseTitles) { + if (data.course_titles && data.course_titles.length > 0) { + courseTitles.innerHTML = data.course_titles + .map((title) => `
    ${title}
    `) + .join(""); + } else { + courseTitles.innerHTML = 'No courses available'; + } + } + } catch (error) { + console.error("Error loading course stats:", error); + // Set default values on error + if (totalCourses) { + totalCourses.textContent = "0"; + } + if (courseTitles) { + courseTitles.innerHTML = 'Failed to load courses'; + } + } +} diff --git a/frontend/style.css b/frontend/style.css index b152cdf06..f434cad97 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1,262 +1,265 @@ /* Modern CSS Reset */ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; } /* CSS Variables */ :root { - --primary-color: #2563eb; - --primary-hover: #1d4ed8; - --background: #0f172a; - --surface: #1e293b; - --surface-hover: #334155; - --text-primary: #f1f5f9; - --text-secondary: #94a3b8; - --border-color: #334155; - --user-message: #2563eb; - --assistant-message: #374151; - --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); - --radius: 12px; - --focus-ring: rgba(37, 99, 235, 0.2); - --welcome-bg: #1e3a5f; - --welcome-border: #2563eb; + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --background: #0f172a; + --surface: #1e293b; + --surface-hover: #334155; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --border-color: #334155; + --user-message: #2563eb; + --assistant-message: #374151; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); + --radius: 12px; + --focus-ring: rgba(37, 99, 235, 0.2); + --welcome-bg: #1e3a5f; + --welcome-border: #2563eb; } /* Base Styles */ body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background-color: var(--background); - color: var(--text-primary); - line-height: 1.6; - height: 100vh; - overflow: hidden; - margin: 0; - padding: 0; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: var(--background); + color: var(--text-primary); + line-height: 1.6; + height: 100vh; + overflow: hidden; + margin: 0; + padding: 0; } /* Container - Full Screen */ .container { - height: 100vh; - width: 100vw; - display: flex; - flex-direction: column; - margin: 0; - padding: 0; + height: 100vh; + width: 100vw; + display: flex; + flex-direction: column; + margin: 0; + padding: 0; } /* Header - Hidden */ header { - display: none; + display: none; } header h1 { - font-size: 1.75rem; - font-weight: 700; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - margin: 0; + font-size: 1.75rem; + font-weight: 700; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0; } .subtitle { - font-size: 0.95rem; - color: var(--text-secondary); - margin-top: 0.5rem; + font-size: 0.95rem; + color: var(--text-secondary); + margin-top: 0.5rem; } /* Main Content Area with Sidebar */ .main-content { - flex: 1; - display: flex; - overflow: hidden; - background: var(--background); + flex: 1; + display: flex; + overflow: hidden; + background: var(--background); } /* Left Sidebar */ .sidebar { - width: 320px; - background: var(--surface); - border-right: 1px solid var(--border-color); - padding: 1.5rem; - overflow-y: auto; - flex-shrink: 0; + width: 320px; + background: var(--surface); + border-right: 1px solid var(--border-color); + padding: 1.5rem; + overflow-y: auto; + flex-shrink: 0; } /* Custom Scrollbar for Sidebar */ .sidebar::-webkit-scrollbar { - width: 8px; + width: 8px; } .sidebar::-webkit-scrollbar-track { - background: var(--surface); + background: var(--surface); } .sidebar::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 4px; + background: var(--border-color); + border-radius: 4px; } .sidebar::-webkit-scrollbar-thumb:hover { - background: var(--text-secondary); + background: var(--text-secondary); } .sidebar-section { - margin-bottom: 1.5rem; + margin-bottom: 1.5rem; } .sidebar-section:last-child { - margin-bottom: 0; + margin-bottom: 0; } /* Main Chat Area */ .chat-main { - flex: 1; - display: flex; - justify-content: center; - overflow: hidden; - padding: 0; - background: var(--background); + flex: 1; + display: flex; + justify-content: center; + overflow: hidden; + padding: 0; + background: var(--background); } /* Chat Container - Centered with Max Width */ .chat-container { - flex: 1; - display: flex; - flex-direction: column; - background: var(--background); - overflow: hidden; - width: 100%; - max-width: 800px; - margin: 0; + flex: 1; + display: flex; + flex-direction: column; + background: var(--background); + overflow: hidden; + width: 100%; + max-width: 800px; + margin: 0; } /* Chat Messages */ .chat-messages { - flex: 1; - overflow-y: auto; - padding: 2rem; - display: flex; - flex-direction: column; - gap: 1rem; - background: var(--background); + flex: 1; + overflow-y: auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1rem; + background: var(--background); } /* Custom Scrollbar */ .chat-messages::-webkit-scrollbar { - width: 8px; + width: 8px; } .chat-messages::-webkit-scrollbar-track { - background: var(--surface); + background: var(--surface); } .chat-messages::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 4px; + background: var(--border-color); + border-radius: 4px; } .chat-messages::-webkit-scrollbar-thumb:hover { - background: var(--text-secondary); + background: var(--text-secondary); } /* Message Styles */ .message { - max-width: 85%; - animation: fadeIn 0.3s ease-out; + max-width: 85%; + animation: fadeIn 0.3s ease-out; } @keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .message.user { - align-self: flex-end; + align-self: flex-end; } .message.assistant { - align-self: flex-start; + align-self: flex-start; } .message-content { - padding: 0.75rem 1.25rem; - border-radius: 18px; - word-wrap: break-word; - line-height: 1.5; + padding: 0.75rem 1.25rem; + border-radius: 18px; + word-wrap: break-word; + line-height: 1.5; } .message.user .message-content { - background: var(--user-message); - color: white; - border-bottom-right-radius: 4px; + background: var(--user-message); + color: white; + border-bottom-right-radius: 4px; } .message.assistant .message-content { - background: var(--surface); - color: var(--text-primary); - border-bottom-left-radius: 4px; + background: var(--surface); + color: var(--text-primary); + border-bottom-left-radius: 4px; } /* Message metadata */ .message-meta { - font-size: 0.75rem; - color: var(--text-secondary); - margin-top: 0.25rem; - padding: 0 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 0.25rem; + padding: 0 0.5rem; } .message.user .message-meta { - text-align: right; + text-align: right; } /* Collapsible Sources */ .sources-collapsible { - margin-top: 0.5rem; - font-size: 0.75rem; - color: var(--text-secondary); + margin-top: 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary); } .sources-collapsible summary { - cursor: pointer; - padding: 0.25rem 0.5rem; - user-select: none; - font-weight: 500; + cursor: pointer; + padding: 0.25rem 0.5rem; + user-select: none; + font-weight: 500; } .sources-collapsible summary:hover { - color: var(--text-primary); + color: var(--text-primary); } .sources-collapsible[open] summary { - margin-bottom: 0.25rem; + margin-bottom: 0.25rem; } .sources-content { - padding: 0 0.5rem 0.25rem 1.5rem; - color: var(--text-secondary); + padding: 0 0.5rem 0.25rem 1.5rem; + color: var(--text-secondary); } .source-link { - color: var(--text-secondary); - text-decoration: none; + color: var(--text-secondary); + text-decoration: none; } .source-link:hover { - color: var(--primary-color); - text-decoration: underline; + color: var(--primary-color); + text-decoration: underline; } .source-item { - color: var(--text-secondary); + color: var(--text-secondary); } /* Markdown formatting styles */ @@ -266,477 +269,487 @@ header h1 { .message-content h4, .message-content h5, .message-content h6 { - margin: 0.5rem 0; - font-weight: 600; + margin: 0.5rem 0; + font-weight: 600; } -.message-content h1 { font-size: 1.5rem; } -.message-content h2 { font-size: 1.3rem; } -.message-content h3 { font-size: 1.1rem; } +.message-content h1 { + font-size: 1.5rem; +} +.message-content h2 { + font-size: 1.3rem; +} +.message-content h3 { + font-size: 1.1rem; +} .message-content p { - margin: 0.5rem 0; - line-height: 1.6; + margin: 0.5rem 0; + line-height: 1.6; } .message-content ul, .message-content ol { - margin: 0.5rem 0; - padding-left: 1.5rem; + margin: 0.5rem 0; + padding-left: 1.5rem; } .message-content li { - margin: 0.25rem 0; - line-height: 1.6; + margin: 0.25rem 0; + line-height: 1.6; } .message-content code { - background-color: rgba(0, 0, 0, 0.2); - padding: 0.125rem 0.25rem; - border-radius: 3px; - font-family: 'Fira Code', 'Consolas', monospace; - font-size: 0.875em; + background-color: rgba(0, 0, 0, 0.2); + padding: 0.125rem 0.25rem; + border-radius: 3px; + font-family: "Fira Code", "Consolas", monospace; + font-size: 0.875em; } .message-content pre { - background-color: rgba(0, 0, 0, 0.2); - padding: 0.75rem; - border-radius: 4px; - overflow-x: auto; - margin: 0.5rem 0; + background-color: rgba(0, 0, 0, 0.2); + padding: 0.75rem; + border-radius: 4px; + overflow-x: auto; + margin: 0.5rem 0; } .message-content pre code { - background-color: transparent; - padding: 0; + background-color: transparent; + padding: 0; } .message-content blockquote { - border-left: 3px solid var(--primary); - padding-left: 1rem; - margin: 0.5rem 0; - color: var(--text-secondary); + border-left: 3px solid var(--primary); + padding-left: 1rem; + margin: 0.5rem 0; + color: var(--text-secondary); } /* Welcome message special styling */ .message.welcome-message .message-content { - background: var(--surface); - border: 2px solid var(--border-color); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); - position: relative; + background: var(--surface); + border: 2px solid var(--border-color); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + position: relative; } .message-content strong { - font-weight: 600; + font-weight: 600; } .message-content em { - font-style: italic; + font-style: italic; } .message-content hr { - border: none; - border-top: 1px solid var(--border-color); - margin: 1rem 0; + border: none; + border-top: 1px solid var(--border-color); + margin: 1rem 0; } /* Chat Input Container */ .chat-input-container { - display: flex; - gap: 0.75rem; - padding: 1.5rem 2rem; - background: var(--background); - border-top: 1px solid var(--border-color); - flex-shrink: 0; + display: flex; + gap: 0.75rem; + padding: 1.5rem 2rem; + background: var(--background); + border-top: 1px solid var(--border-color); + flex-shrink: 0; } /* Chat Input */ #chatInput { - flex: 1; - padding: 0.875rem 1.25rem; - background: var(--surface); - border: 1px solid var(--border-color); - border-radius: 24px; - color: var(--text-primary); - font-size: 0.95rem; - transition: all 0.2s ease; + flex: 1; + padding: 0.875rem 1.25rem; + background: var(--surface); + border: 1px solid var(--border-color); + border-radius: 24px; + color: var(--text-primary); + font-size: 0.95rem; + transition: all 0.2s ease; } #chatInput:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--focus-ring); + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--focus-ring); } #chatInput::placeholder { - color: var(--text-secondary); + color: var(--text-secondary); } /* Send Button */ #sendButton { - padding: 0.75rem 1.25rem; - background: var(--primary-color); - color: white; - border: none; - border-radius: 24px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - min-width: 52px; + padding: 0.75rem 1.25rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: 24px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + min-width: 52px; } #sendButton:focus { - outline: none; - box-shadow: 0 0 0 3px var(--focus-ring); + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring); } #sendButton:hover:not(:disabled) { - background: var(--primary-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); } #sendButton:active:not(:disabled) { - transform: translateY(0); + transform: translateY(0); } #sendButton:disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } /* Loading Animation */ .loading { - display: inline-flex; - gap: 4px; - padding: 0.75rem 1.25rem; + display: inline-flex; + gap: 4px; + padding: 0.75rem 1.25rem; } .loading span { - width: 8px; - height: 8px; - background: var(--text-secondary); - border-radius: 50%; - animation: bounce 1.4s infinite ease-in-out both; + width: 8px; + height: 8px; + background: var(--text-secondary); + border-radius: 50%; + animation: bounce 1.4s infinite ease-in-out both; } .loading span:nth-child(1) { - animation-delay: -0.32s; + animation-delay: -0.32s; } .loading span:nth-child(2) { - animation-delay: -0.16s; + animation-delay: -0.16s; } @keyframes bounce { - 0%, 80%, 100% { - transform: scale(0); - } - 40% { - transform: scale(1); - } + 0%, + 80%, + 100% { + transform: scale(0); + } + 40% { + transform: scale(1); + } } /* Error Message */ .error-message { - background: rgba(239, 68, 68, 0.1); - color: #f87171; - padding: 0.75rem 1.25rem; - border-radius: 8px; - border: 1px solid rgba(239, 68, 68, 0.2); - margin: 0.5rem 0; + background: rgba(239, 68, 68, 0.1); + color: #f87171; + padding: 0.75rem 1.25rem; + border-radius: 8px; + border: 1px solid rgba(239, 68, 68, 0.2); + margin: 0.5rem 0; } /* Success Message */ .success-message { - background: rgba(34, 197, 94, 0.1); - color: #4ade80; - padding: 0.75rem 1.25rem; - border-radius: 8px; - border: 1px solid rgba(34, 197, 94, 0.2); - margin: 0.5rem 0; + background: rgba(34, 197, 94, 0.1); + color: #4ade80; + padding: 0.75rem 1.25rem; + border-radius: 8px; + border: 1px solid rgba(34, 197, 94, 0.2); + margin: 0.5rem 0; } /* Sidebar Headers */ .stats-header, .suggested-header, .new-chat-button { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-secondary); - cursor: pointer; - padding: 0.5rem 0; - border: none; - background: none; - list-style: none; - outline: none; - transition: color 0.2s ease; - text-transform: uppercase; - letter-spacing: 0.5px; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary); + cursor: pointer; + padding: 0.5rem 0; + border: none; + background: none; + list-style: none; + outline: none; + transition: color 0.2s ease; + text-transform: uppercase; + letter-spacing: 0.5px; } .new-chat-button { - display: block; - width: 100%; - text-align: left; - font-family: inherit; + display: block; + width: 100%; + text-align: left; + font-family: inherit; } .stats-header:focus, .suggested-header:focus, .new-chat-button:focus { - color: var(--primary-color); + color: var(--primary-color); } .stats-header:hover, .suggested-header:hover, .new-chat-button:hover { - color: var(--primary-color); + color: var(--primary-color); } .stats-header::-webkit-details-marker, .suggested-header::-webkit-details-marker { - display: none; + display: none; } .stats-header::before, .suggested-header::before { - content: '▶'; - display: inline-block; - margin-right: 0.5rem; - transition: transform 0.2s ease; - font-size: 0.75rem; + content: "▶"; + display: inline-block; + margin-right: 0.5rem; + transition: transform 0.2s ease; + font-size: 0.75rem; } details[open] .stats-header::before, details[open] .suggested-header::before { - transform: rotate(90deg); + transform: rotate(90deg); } /* Course Stats in Sidebar */ .course-stats { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 0.75rem 0; - background: transparent; - border: none; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0.75rem 0; + background: transparent; + border: none; } .stat-item { - text-align: left; - padding: 0.75rem; - background: var(--background); - border-radius: 8px; - border: 1px solid var(--border-color); - margin-bottom: 0.75rem; + text-align: left; + padding: 0.75rem; + background: var(--background); + border-radius: 8px; + border: 1px solid var(--border-color); + margin-bottom: 0.75rem; } .stat-item:last-child { - margin-bottom: 0; + margin-bottom: 0; } .stat-value { - display: inline-block; - font-size: 0.875rem; - font-weight: 600; - color: var(--primary-color); - margin-left: 0.5rem; + display: inline-block; + font-size: 0.875rem; + font-weight: 600; + color: var(--primary-color); + margin-left: 0.5rem; } .stat-label { - display: inline-block; - font-size: 0.875rem; - color: var(--text-secondary); - font-weight: 600; + display: inline-block; + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 600; } .stat-item:last-child .stat-label { - display: block; - margin-bottom: 0.5rem; + display: block; + margin-bottom: 0.5rem; } /* Course titles collapsible */ .course-titles-collapsible { - width: 100%; + width: 100%; } .course-titles-header { - cursor: pointer; - font-size: 0.875rem; - color: var(--text-secondary); - font-weight: 600; - padding: 0.5rem 0; - list-style: none; - display: block; - user-select: none; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 600; + padding: 0.5rem 0; + list-style: none; + display: block; + user-select: none; } .course-titles-header:focus { - outline: none; - color: var(--primary-color); + outline: none; + color: var(--primary-color); } .course-titles-header::-webkit-details-marker { - display: none; + display: none; } .course-titles-header::before { - content: '▶'; - display: inline-block; - margin-right: 0.5rem; - transition: transform 0.2s ease; - font-size: 0.75rem; + content: "▶"; + display: inline-block; + margin-right: 0.5rem; + transition: transform 0.2s ease; + font-size: 0.75rem; } .course-titles-collapsible[open] .course-titles-header::before { - transform: rotate(90deg); + transform: rotate(90deg); } /* Course titles display */ .course-titles { - margin-top: 0.5rem; - /* Remove max-height to show all titles without scrolling */ + margin-top: 0.5rem; + /* Remove max-height to show all titles without scrolling */ } .course-title-item { - font-size: 0.85rem; - color: var(--text-primary); - padding: 0.5rem 0.25rem; - border-bottom: 1px solid var(--border-color); - text-transform: none; - line-height: 1.4; + font-size: 0.85rem; + color: var(--text-primary); + padding: 0.5rem 0.25rem; + border-bottom: 1px solid var(--border-color); + text-transform: none; + line-height: 1.4; } .course-title-item:last-child { - border-bottom: none; + border-bottom: none; } .course-title-item:first-child { - padding-top: 0.25rem; + padding-top: 0.25rem; } -.no-courses, .loading, .error { - font-size: 0.85rem; - color: var(--text-secondary); - font-style: italic; - text-transform: none; +.no-courses, +.loading, +.error { + font-size: 0.85rem; + color: var(--text-secondary); + font-style: italic; + text-transform: none; } /* Suggested Questions in Sidebar */ .suggested-items { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 0.75rem 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 0; } .suggested-item { - padding: 0.75rem 1rem; - background: var(--background); - border: 1px solid var(--border-color); - border-radius: 8px; - color: var(--text-primary); - font-size: 0.875rem; - cursor: pointer; - transition: all 0.2s ease; - text-align: left; - width: 100%; + padding: 0.75rem 1rem; + background: var(--background); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + width: 100%; } .suggested-item:focus { - outline: none; - box-shadow: 0 0 0 3px var(--focus-ring); + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring); } .suggested-item:hover { - background: var(--surface-hover); - border-color: var(--primary-color); - color: var(--primary-color); - transform: translateX(2px); + background: var(--surface-hover); + border-color: var(--primary-color); + color: var(--primary-color); + transform: translateX(2px); } /* Responsive Design */ @media (max-width: 768px) { - .main-content { - flex-direction: column; - } - - .sidebar { - width: 100%; - border-right: none; - border-bottom: 1px solid var(--border-color); - padding: 1rem; - order: 2; - max-height: 40vh; - } - - .sidebar::-webkit-scrollbar { - width: 8px; - } - - .sidebar::-webkit-scrollbar-track { - background: var(--surface); - } - - .sidebar::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 4px; - } - - .sidebar::-webkit-scrollbar-thumb:hover { - background: var(--text-secondary); - } - - .chat-main { - order: 1; - } - - header { - padding: 1rem; - } - - header h1 { - font-size: 1.5rem; - } - - .chat-messages { - padding: 1rem; - } - - .message { - max-width: 90%; - } - - .chat-input-container { - padding: 1rem; - gap: 0.5rem; - } - - #chatInput { - padding: 0.75rem 1rem; - font-size: 0.9rem; - } - - #sendButton { - padding: 0.75rem 1rem; - min-width: 48px; - } - - .stat-value { - font-size: 1.25rem; - } - - .suggested-item { - padding: 0.5rem 0.75rem; - font-size: 0.8rem; - } + .main-content { + flex-direction: column; + } + + .sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border-color); + padding: 1rem; + order: 2; + max-height: 40vh; + } + + .sidebar::-webkit-scrollbar { + width: 8px; + } + + .sidebar::-webkit-scrollbar-track { + background: var(--surface); + } + + .sidebar::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; + } + + .sidebar::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); + } + + .chat-main { + order: 1; + } + + header { + padding: 1rem; + } + + header h1 { + font-size: 1.5rem; + } + + .chat-messages { + padding: 1rem; + } + + .message { + max-width: 90%; + } + + .chat-input-container { + padding: 1rem; + gap: 0.5rem; + } + + #chatInput { + padding: 0.75rem 1rem; + font-size: 0.9rem; + } + + #sendButton { + padding: 0.75rem 1rem; + min-width: 48px; + } + + .stat-value { + font-size: 1.25rem; + } + + .suggested-item { + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + } } @media (max-width: 1024px) { - .sidebar { - width: 280px; - } + .sidebar { + width: 280px; + } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..01c94dd45 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "course-materials-rag-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "course-materials-rag-frontend", + "version": "0.1.0", + "devDependencies": { + "prettier": "^3.6.2" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..987421432 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "course-materials-rag-frontend", + "version": "0.1.0", + "private": true, + "description": "Front-end tooling for the Course Materials RAG System (formatting only, no build step)", + "scripts": { + "format": "prettier --write \"frontend/**/*.{html,css,js}\"", + "format:check": "prettier --check \"frontend/**/*.{html,css,js}\"" + }, + "devDependencies": { + "prettier": "^3.6.2" + } +} diff --git a/pyproject.toml b/pyproject.toml index 99ca9e4ef..a760a4a22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,30 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=9.1.1", + "black>=25.1.0", + "isort>=6.0.1", ] [tool.pytest.ini_options] pythonpath = ["backend"] testpaths = ["backend/tests"] + +[tool.black] +line-length = 88 +target-version = ["py313"] + +[tool.isort] +# Keep isort's output byte-identical to what black would produce. +profile = "black" +line_length = 88 +known_first_party = [ + "ai_generator", + "app", + "config", + "document_processor", + "models", + "rag_system", + "search_tools", + "session_manager", + "vector_store", +] diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100644 index 000000000..0febada03 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Verify formatting without writing any files. Exits non-zero if anything is unformatted. +# Usage: ./scripts/check.sh +set -uo pipefail + +cd "$(dirname "$0")/.." + +failed=0 + +echo "==> isort --check-only (backend)" +uv run isort --check-only --diff backend main.py || failed=1 + +echo "==> black --check (backend)" +uv run black --check --diff backend main.py || failed=1 + +if [ ! -d node_modules ]; then + echo "==> installing frontend dev dependencies" + npm install --silent +fi + +echo "==> prettier --check (frontend)" +npm run --silent format:check || failed=1 + +echo +if [ "$failed" -ne 0 ]; then + echo "Formatting check FAILED. Run ./scripts/format.sh to fix." + exit 1 +fi + +echo "Formatting check passed." diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100644 index 000000000..8f20b1f2c --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Apply all formatters in place: isort + black for the backend, Prettier for the frontend. +# Usage: ./scripts/format.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> isort (backend)" +uv run isort backend main.py + +echo "==> black (backend)" +uv run black backend main.py + +if [ ! -d node_modules ]; then + echo "==> installing frontend dev dependencies" + npm install --silent +fi + +echo "==> prettier (frontend)" +npm run --silent format + +echo +echo "All formatters applied." diff --git a/scripts/quality.sh b/scripts/quality.sh new file mode 100644 index 000000000..199ae071e --- /dev/null +++ b/scripts/quality.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Full quality gate: formatting checks followed by the test suite. +# This is the one to run before committing (and the one CI should call). +# Usage: ./scripts/quality.sh +set -uo pipefail + +cd "$(dirname "$0")/.." + +failed=0 + +./scripts/check.sh || failed=1 + +echo +echo "==> pytest" +uv run pytest || failed=1 + +echo +if [ "$failed" -ne 0 ]; then + echo "Quality gate FAILED." + exit 1 +fi + +echo "Quality gate passed." diff --git a/uv.lock b/uv.lock index 8c5a41afe..0e07ba65d 100644 --- a/uv.lock +++ b/uv.lock @@ -110,6 +110,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, ] +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + [[package]] name = "build" version = "1.2.2.post1" @@ -479,6 +506,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isort" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/43/067e17bfa10b6486b408d5294105ac894149a9abb94b338568b1f53a73c9/isort-9.0.1.tar.gz", hash = "sha256:ba23db109e3e93ef1999f7209a651214994cd807801addd16ac485982eb4edd7", size = 667724, upload-time = "2026-08-27T20:54:26.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/65/aba682b6d0853de85f5bb5c18f0269d2980760783f5144dfcded6b8a1f14/isort-9.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:67680927f739d4b48d67d8b7430faa92c95b02fb6075ca0351c6446214f6c7bb", size = 1021693, upload-time = "2026-08-27T20:53:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bb/7025f3606b42f7db084fa234d61462a73443af166ecf7c1a0e8e3b448433/isort-9.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3727eb33a9759649346481cf2a9287d656a170c31ed7c105856f9c6f5b539756", size = 1648575, upload-time = "2026-08-27T20:53:50.701Z" }, + { url = "https://files.pythonhosted.org/packages/47/78/a92b09507565f8bd27eb72a7df3ba59a1ea0a5baacbf155c587d0be51a13/isort-9.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:466b0c3f156a21c10edefba697e641666bc26ffb0122bf08b42caa3d464c20aa", size = 1639624, upload-time = "2026-08-27T20:53:52.22Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/f5efd28563bc751792a7a1f64ae73db57dcbfa0563dc3e0cbbd825a48252/isort-9.0.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:5832683294dd61c59d00cd043a68d42f6ecd7dc7d04b73ac777f7f90a534d6ae", size = 457708, upload-time = "2026-08-27T20:53:53.837Z" }, + { url = "https://files.pythonhosted.org/packages/53/d6/a776ea214de5403f038b182e1d5fd2211d0cee41f2c62ac9f2f9a0ecc463/isort-9.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:7281cdf538f682b8d75fa44bcdad1b299036bbc440855f7d61412b3b85d5727d", size = 874790, upload-time = "2026-08-27T20:53:56.154Z" }, + { url = "https://files.pythonhosted.org/packages/db/42/d75a674a7fcbbf4374cd358b8529535d3c59e0ab6acc164b6862acb95058/isort-9.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7ea5f505b152fedd2b990b39d8b76108a48b355da874025aad4982e8ceeb0f3d", size = 1020950, upload-time = "2026-08-27T20:53:57.744Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b6/04d74c7c42b9af1c0d47f34a79722dbbcff71bae3765c9250376ebd44ec5/isort-9.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:873cf1b6371d41e2a74d57d7c0176d311822f0415441abf8251ad074c9fe4a66", size = 1653678, upload-time = "2026-08-27T20:53:59.292Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/621969c0cde272b7a183484589cc9f28132775d78fd708c231fd292c7abd/isort-9.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99b7bc28b1f05f7e3267629043a99c6c479a750df3689327a10324e396827f94", size = 1645589, upload-time = "2026-08-27T20:54:00.911Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/e3db1ae5d5fbe9e2dff0c6a0b2b9dda07a742ede20f8fbc7d78d5d73f472/isort-9.0.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:89ebbcdbdd9d66cc14909bbac36acb9db29f37325606113c9f270242f8a1f896", size = 457029, upload-time = "2026-08-27T20:54:02.551Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f2/b9cf816c8dfd5d9066b65a2f738f8d03fb0f6001bcffa3d436cfdbe1b421/isort-9.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:2057236a764f31c78dac78f7343057621fcc2fd40461ce61061f34fd09066f46", size = 894580, upload-time = "2026-08-27T20:54:03.985Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d7/e5e9e477ff7ec9f75b4c07b25aef10814775200c1e90bc9acbb788def54a/isort-9.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5e72a7063570f1d740f0284c7ae5739dc34c6a2d9f1049b13027a5bdadb56682", size = 1088685, upload-time = "2026-08-27T20:54:05.376Z" }, + { url = "https://files.pythonhosted.org/packages/96/6f/915effa62cdcdc757bd0c567ee4d611c7a73ce7da097c820b3b2f340c2f7/isort-9.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2525606f62742fc4ed9f8ca89043b9522ac3e6f9c9892e6cb16f4870d937f38", size = 1863445, upload-time = "2026-08-27T20:54:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d4/af84d711cda084ddcb7a01a3d6a2608542c16ae7d418d99169b7deb775a4/isort-9.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e3a2697ebcb54b51af4833de44447dbf31ddf081c5f163772092d21c0267483b", size = 1878639, upload-time = "2026-08-27T20:54:08.618Z" }, + { url = "https://files.pythonhosted.org/packages/58/d0/d77ec7d1c648a0b92ea6a8da3591a342550404af98a338106278a872ea1a/isort-9.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd326823ddbe338357ba1823b7f96481d4421d54c83ebd43c92f1b51314a24ae", size = 919565, upload-time = "2026-08-27T20:54:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/9963610d22fa55cbbd3c141a350f7011dd1023be9d3ba06212053c3639c6/isort-9.0.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:5022b332ac91ccb39dc28bb206d5ae96ae7f8d45e710b072cb039b2fcda6602a", size = 1020254, upload-time = "2026-08-27T20:54:11.732Z" }, + { url = "https://files.pythonhosted.org/packages/11/20/9f22f2574d94cc2b86f7c1764a186843ed19e7215666b57b10935335614f/isort-9.0.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:930879e4cfab3264f1d7346abeec10726b5382dc4be9f4251c25ec7fa057926b", size = 1667500, upload-time = "2026-08-27T20:54:13.193Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/5a46b814d7fa49fc778e65a6703e4bb4d60149e01574afd7366b5ac7a1dc/isort-9.0.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:825c05d2d63a1b9c608c352503c10b6411a3c6e12bcacc97b306774ee379786f", size = 1655914, upload-time = "2026-08-27T20:54:14.827Z" }, + { url = "https://files.pythonhosted.org/packages/a7/09/b575e1837f3b182c97f937cf39cb55b3942f44dedfec4303403e4aa9bbd0/isort-9.0.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:cc9814ce2ee42c17007d822455e4db55e32e589808ecfc2665d51c848d0bb30a", size = 457120, upload-time = "2026-08-27T20:54:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/3c73139b71b7031caf54aa098bc589b18374533b5f96f31c541b36112896/isort-9.0.1-cp315-cp315-win_amd64.whl", hash = "sha256:1b8d6c836fb83232f5f4c1c037d332caf743bb24dca63167bad9174ae13e150e", size = 894525, upload-time = "2026-08-27T20:54:17.895Z" }, + { url = "https://files.pythonhosted.org/packages/e5/27/5c17d4c8239a33a876f91e52426cc0d3fc75eb8438f34c40f403998057ac/isort-9.0.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2fb33e0c0f9f87821acf6d82c83f0a0c7e54680fdf3fe4131409d2b95901f00a", size = 1088576, upload-time = "2026-08-27T20:54:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/32/9b/9e80dee125b3d1208fb89afa380cae6fdfac660ad682eb0139a8fd8d790a/isort-9.0.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cdf765657edb2bcccbb1b20d26e710acbcb27379c0a407c6cb376e5619059a7b", size = 1853379, upload-time = "2026-08-27T20:54:21.119Z" }, + { url = "https://files.pythonhosted.org/packages/34/43/f9f456c223ba34293ab28f736f00e26875b26913f24451e5a2938a8c4e02/isort-9.0.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23d3b6657763f9be1b15bb9664b016abfce34849d6215a46a42af7945d4acd68", size = 1871162, upload-time = "2026-08-27T20:54:22.593Z" }, + { url = "https://files.pythonhosted.org/packages/38/5c/17fedc2bee564bb13b50ed3cb2ce7d66dd8e09184fb45d7a263ebaaaa97e/isort-9.0.1-cp315-cp315t-win_amd64.whl", hash = "sha256:8f490acc182253d07071cc8255b57a281855e2e027b929a89eaa7c797f7b213e", size = 918269, upload-time = "2026-08-27T20:54:23.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/4ec84f19b008864656b9f158bad006aa6346e754694aff47a7fa9ee65a19/isort-9.0.1-py3-none-any.whl", hash = "sha256:5aac7263b7a7f9f647f94fb6df2761ff5b60a7168eb492ff39dd30443207fa19", size = 103487, upload-time = "2026-08-27T20:54:25.395Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -667,6 +729,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "networkx" version = "3.5" @@ -992,6 +1063,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pillow" version = "11.3.0" @@ -1047,6 +1127,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] +[[package]] +name = "platformdirs" +version = "4.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc", size = 37182, upload-time = "2026-09-08T22:20:42.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1271,6 +1360,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1597,6 +1710,8 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "black" }, + { name = "isort" }, { name = "pytest" }, ] @@ -1612,7 +1727,11 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.1.1" }] +dev = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "isort", specifier = ">=6.0.1" }, + { name = "pytest", specifier = ">=9.1.1" }, +] [[package]] name = "sympy" From 6c217030d3d697a646335ebd458dbfcbd580984e Mon Sep 17 00:00:00 2001 From: Tim Erdmann Date: Tue, 15 Sep 2026 05:06:43 -0700 Subject: [PATCH 13/13] Add dark/light theme toggle to frontend Icon-based toggle button (top-right) switches between the existing dark theme and a new light theme, with the choice persisted in localStorage and falling back to the OS prefers-color-scheme setting. Frontend only. Co-Authored-By: Claude Sonnet 5 --- frontend-changes.md | 91 +++++++++++++++++++++++++ frontend/index.html | 44 +++++++++++- frontend/script.js | 55 ++++++++++++++- frontend/style.css | 158 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 338 insertions(+), 10 deletions(-) create mode 100644 frontend-changes.md diff --git a/frontend-changes.md b/frontend-changes.md new file mode 100644 index 000000000..89ac86807 --- /dev/null +++ b/frontend-changes.md @@ -0,0 +1,91 @@ +# Frontend Changes — Dark / Light Theme Toggle + +Adds a theme toggle button that switches the UI between the existing dark theme and a +new light theme. Frontend only — no backend files were touched. + +## Summary + +- Icon-based toggle button pinned to the **top-right** of the viewport. +- Sun icon in dark mode, moon icon in light mode, cross-fading with a rotate + scale animation. +- Full light-theme palette driven entirely by CSS variables. +- Choice persists in `localStorage`; falls back to the OS `prefers-color-scheme` setting. +- Accessible: native ` +

    Course Materials Assistant

    Ask questions about courses, instructors, and content

    @@ -81,6 +121,6 @@

    Course Materials Assistant

    - + \ No newline at end of file diff --git a/frontend/script.js b/frontend/script.js index 701973dc7..88562db05 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -5,7 +5,7 @@ const API_URL = '/api'; let currentSessionId = null; // DOM elements -let chatMessages, chatInput, sendButton, totalCourses, courseTitles, newChatButton; +let chatMessages, chatInput, sendButton, totalCourses, courseTitles, newChatButton, themeToggle; // Initialize document.addEventListener('DOMContentLoaded', () => { @@ -16,7 +16,9 @@ document.addEventListener('DOMContentLoaded', () => { totalCourses = document.getElementById('totalCourses'); courseTitles = document.getElementById('courseTitles'); newChatButton = document.getElementById('newChatButton'); + themeToggle = document.getElementById('themeToggle'); + initTheme(); setupEventListeners(); createNewSession(); loadCourseStats(); @@ -33,6 +35,9 @@ function setupEventListeners() { // New chat newChatButton.addEventListener('click', createNewSession); + // Theme toggle (native