diff --git a/.gitignore b/.gitignore index 41b4384b..c1cc4f93 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ uploads/ .idea/ *.swp *.swo +.env +.env.* # OS .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..129bd501 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Install dependencies +uv sync + +# Start the development server (from repo root) +cd backend && uv run uvicorn app:app --reload --port 8000 +# or use the helper script: +bash run.sh + +# App available at http://localhost:8000 +# OpenAPI docs at http://localhost:8000/docs +``` + +There are no test or lint commands configured in this project. + +## Architecture + +This is a **RAG (Retrieval-Augmented Generation) chatbot** that answers questions about course content using Claude's tool-calling API + ChromaDB semantic search. + +### Stack + +- **Backend**: FastAPI (Python 3.13), managed with `uv` +- **AI**: Anthropic Claude API (`claude-sonnet-4-20250514`) with tool calling +- **Vector DB**: ChromaDB (persistent, stored at `backend/chroma_db/`) +- **Embeddings**: `sentence-transformers` (`all-MiniLM-L6-v2`) +- **Frontend**: Vanilla HTML/CSS/JS served statically by FastAPI + +### Key Data Flows + +**Document ingestion** (runs automatically on startup via `app.py`): +``` +docs/*.txt → DocumentProcessor → CourseChunk objects → VectorStore (ChromaDB) +``` +Course docs follow a specific format: `Course Title:`, `Course Link:`, `Course Instructor:`, then `Lesson N:` sections. + +**Query flow**: +``` +POST /api/query → RAGSystem.query() → AIGenerator.generate_response() + → Claude calls `search_course_content` tool (in search_tools.py) + → VectorStore.search() (semantic search, optional course/lesson filters) + → Claude synthesizes answer → response + sources back to frontend +``` + +Claude decides autonomously when to invoke the search tool vs. answer from general knowledge — this is not prompt-injected RAG, it uses Claude's native tool-calling agentic loop. + +### Module Responsibilities + +| File | Responsibility | +|------|---------------| +| `backend/app.py` | FastAPI routes, startup document loading | +| `backend/rag_system.py` | Orchestrates query pipeline; coordinates all other modules | +| `backend/ai_generator.py` | All Anthropic API calls; handles tool execution loop | +| `backend/vector_store.py` | ChromaDB management; two collections: `course_catalog` and `course_content` | +| `backend/document_processor.py` | Parses `.txt` course files into `Course` + `CourseChunk` objects | +| `backend/search_tools.py` | Tool schema for Claude + search execution; tracks sources for UI | +| `backend/session_manager.py` | In-memory conversation history per session | +| `backend/models.py` | Pydantic models: `Lesson`, `Course`, `CourseChunk` | +| `backend/config.py` | Central config loaded from `.env` (chunk size, model, max results, etc.) | + +### Configuration + +All tunable parameters live in `backend/config.py` and are sourced from `.env`: + +- `ANTHROPIC_MODEL` — Claude model ID +- `CHUNK_SIZE` / `CHUNK_OVERLAP` — text chunking (default 800 / 100 chars) +- `MAX_RESULTS` — semantic search results returned per tool call (default 5) +- `MAX_HISTORY` — conversation turns kept in session (default 2) +- `CHROMA_PATH` — path to ChromaDB persistence directory + +Copy `.env.example` to `.env` and add your `ANTHROPIC_API_KEY` to run the app. diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 0363ca90..69927f27 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -8,7 +8,8 @@ class AIGenerator: SYSTEM_PROMPT = """ You are an AI assistant specialized in course materials and educational content with access to a comprehensive search tool for course information. Search Tool Usage: -- Use the search tool **only** for questions about specific course content or detailed educational materials +- Use **get_course_outline** when the user asks for a course outline, structure, syllabus, or lesson list. Always include the course title, course link, and each lesson's number and title in your response. +- Use **search_course_content** 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 @@ -132,4 +133,6 @@ 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 + if not final_response.content: + return "I found relevant information but was unable to generate a response. Please try rephrasing your question." + return final_response.content[0].text diff --git a/backend/app.py b/backend/app.py index 5a69d741..b19fd8d1 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 returned with a query response""" + label: str + url: 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): @@ -73,6 +78,12 @@ 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): + """Clear conversation history for a session""" + rag_system.session_manager.clear_session(session_id) + return {"status": "ok"} + @app.get("/api/courses", response_model=CourseStats) async def get_course_stats(): """Get course analytics and statistics""" diff --git a/backend/config.py b/backend/config.py index d9f6392e..29c4f468 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,7 +22,7 @@ class Config: MAX_HISTORY: int = 2 # Number of conversation messages to remember # Database paths - CHROMA_PATH: str = "./chroma_db" # ChromaDB storage location + CHROMA_PATH: str = os.path.join(os.path.dirname(__file__), "chroma_db") # ChromaDB storage location config = Config() diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8..443649f0 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -4,7 +4,7 @@ from vector_store import VectorStore from ai_generator import AIGenerator from session_manager import SessionManager -from search_tools import ToolManager, CourseSearchTool +from search_tools import ToolManager, CourseSearchTool, CourseOutlineTool from models import Course, Lesson, CourseChunk class RAGSystem: @@ -23,6 +23,8 @@ def __init__(self, config): self.tool_manager = ToolManager() self.search_tool = CourseSearchTool(self.vector_store) self.tool_manager.register_tool(self.search_tool) + self.outline_tool = CourseOutlineTool(self.vector_store) + self.tool_manager.register_tool(self.outline_tool) def add_course_document(self, file_path: str) -> Tuple[Course, int]: """ diff --git a/backend/search_tools.py b/backend/search_tools.py index adfe8235..6c2725a9 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -89,30 +89,74 @@ def _format_results(self, results: SearchResults) -> str: """Format search results with course and lesson context""" formatted = [] sources = [] # Track sources for the UI - + seen = set() # Deduplicate sources + 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 (deduplicated) + source_key = (course_title, lesson_num) + if source_key not in seen: + seen.add(source_key) + label = course_title + if lesson_num is not None: + label += f" - Lesson {lesson_num}" + url = None + if lesson_num is not None: + url = self.store.get_lesson_link(course_title, lesson_num) + sources.append({"label": label, "url": url}) + formatted.append(f"{header}\n{doc}") - + # Store sources for retrieval self.last_sources = sources - + return "\n\n".join(formatted) +class CourseOutlineTool(Tool): + """Tool for retrieving a course's full outline from the course catalog""" + + def __init__(self, vector_store: VectorStore): + self.store = vector_store + + def get_tool_definition(self) -> Dict[str, Any]: + return { + "name": "get_course_outline", + "description": "Get the full outline of a course: its title, link, and ordered list of lessons with their numbers and titles. Use this for questions about course structure, outline, syllabus, or lesson list.", + "input_schema": { + "type": "object", + "properties": { + "course_title": { + "type": "string", + "description": "The name or partial name of the course to look up" + } + }, + "required": ["course_title"] + } + } + + def execute(self, course_title: str) -> str: + data = self.store.get_course_metadata_by_name(course_title) + if not data: + return f"No course found matching '{course_title}'." + + lines = [ + f"Course: {data['title']}", + f"Link: {data['course_link']}", + "Lessons:" + ] + for lesson in data["lessons"]: + lines.append(f" Lesson {lesson['lesson_number']}: {lesson['lesson_title']}") + return "\n".join(lines) + + class ToolManager: """Manages available tools for the AI""" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..a115e798 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,46 @@ +"""Shared fixtures for backend tests.""" +import sys +import os + +# Ensure backend directory is on the path so imports work +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from config import config +from vector_store import VectorStore +from search_tools import CourseSearchTool, CourseOutlineTool, ToolManager +from ai_generator import AIGenerator +from rag_system import RAGSystem + + +@pytest.fixture(scope="session") +def vector_store(): + return VectorStore(config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS) + + +@pytest.fixture(scope="session") +def search_tool(vector_store): + return CourseSearchTool(vector_store) + + +@pytest.fixture(scope="session") +def ai_generator(): + return AIGenerator(config.ANTHROPIC_API_KEY, config.ANTHROPIC_MODEL) + + +@pytest.fixture(scope="session") +def outline_tool(vector_store): + return CourseOutlineTool(vector_store) + + +@pytest.fixture(scope="session") +def tool_manager(search_tool, outline_tool): + tm = ToolManager() + tm.register_tool(search_tool) + tm.register_tool(outline_tool) + return tm + + +@pytest.fixture(scope="session") +def rag_system(): + return RAGSystem(config) diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py new file mode 100644 index 00000000..f3a6b857 --- /dev/null +++ b/backend/tests/test_ai_generator.py @@ -0,0 +1,115 @@ +""" +Tests for AIGenerator in ai_generator.py. + +Verifies that: +- The generator calls the search tool for course-specific questions +- The generator does NOT call the search tool for general knowledge questions +- Tool execution results are incorporated into the final response +- The two-turn agentic loop works correctly +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from unittest.mock import MagicMock, patch +from ai_generator import AIGenerator +from search_tools import CourseSearchTool, ToolManager +from vector_store import VectorStore +from config import config + + +@pytest.fixture(scope="module") +def generator(): + return AIGenerator(config.ANTHROPIC_API_KEY, config.ANTHROPIC_MODEL) + + +@pytest.fixture(scope="module") +def real_tool_manager(): + """Tool manager backed by real vector store.""" + store = VectorStore(config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS) + tool = CourseSearchTool(store) + tm = ToolManager() + tm.register_tool(tool) + return tm + + +# --------------------------------------------------------------------------- +# Tool definitions are passed correctly +# --------------------------------------------------------------------------- + +def test_tool_definitions_non_empty(real_tool_manager): + """ToolManager must expose at least one tool definition to Claude.""" + defs = real_tool_manager.get_tool_definitions() + assert isinstance(defs, list) and len(defs) > 0, "No tool definitions registered" + names = [d["name"] for d in defs] + assert "search_course_content" in names, f"search_course_content missing from: {names}" + + +# --------------------------------------------------------------------------- +# General knowledge — should NOT invoke a tool +# --------------------------------------------------------------------------- + +def test_general_question_does_not_use_tool(generator, real_tool_manager): + """A general question like 'what is Python?' should be answered without tool use.""" + real_tool_manager.reset_sources() + response = generator.generate_response( + query="What is Python programming language?", + tools=real_tool_manager.get_tool_definitions(), + tool_manager=real_tool_manager + ) + assert isinstance(response, str) and len(response) > 0, "Response should not be empty" + sources = real_tool_manager.get_last_sources() + assert sources == [], ( + f"General question should not trigger tool use, but got sources: {sources}" + ) + + +# --------------------------------------------------------------------------- +# Course-specific question — should invoke the search tool +# --------------------------------------------------------------------------- + +def test_course_specific_question_uses_tool(generator, real_tool_manager): + """A course-specific question should trigger search_course_content tool use.""" + real_tool_manager.reset_sources() + response = generator.generate_response( + query="Answer this question about course materials: What topics are covered in the MCP course?", + tools=real_tool_manager.get_tool_definitions(), + tool_manager=real_tool_manager + ) + assert isinstance(response, str) and len(response) > 0, ( + f"Response should not be empty, got: {response!r}" + ) + + +def test_generate_response_returns_string_not_exception(generator, real_tool_manager): + """generate_response() must never raise — always return a string.""" + real_tool_manager.reset_sources() + try: + result = generator.generate_response( + query="Answer this question about course materials: Explain RAG systems", + tools=real_tool_manager.get_tool_definitions(), + tool_manager=real_tool_manager + ) + assert isinstance(result, str), f"Expected str, got {type(result)}: {result!r}" + except Exception as e: + pytest.fail(f"generate_response() raised an exception: {e}") + + +# --------------------------------------------------------------------------- +# Tool manager execute_tool dispatching +# --------------------------------------------------------------------------- + +def test_tool_manager_executes_search_tool(real_tool_manager): + """ToolManager.execute_tool() should dispatch to search_course_content.""" + result = real_tool_manager.execute_tool( + "search_course_content", + query="chromadb embeddings" + ) + assert isinstance(result, str), f"Expected str from execute_tool, got: {type(result)}" + + +def test_tool_manager_unknown_tool_returns_error(real_tool_manager): + """Calling a non-existent tool should return an error string, not raise.""" + result = real_tool_manager.execute_tool("nonexistent_tool", query="test") + assert "not found" in result.lower(), f"Expected 'not found' message, got: {result!r}" diff --git a/backend/tests/test_outline_tool.py b/backend/tests/test_outline_tool.py new file mode 100644 index 00000000..5b536992 --- /dev/null +++ b/backend/tests/test_outline_tool.py @@ -0,0 +1,57 @@ +""" +Tests for CourseOutlineTool.execute() in search_tools.py. +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from search_tools import CourseOutlineTool +from vector_store import VectorStore +from config import config + + +@pytest.fixture(scope="module") +def tool(): + store = VectorStore(config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS) + return CourseOutlineTool(store) + + +def test_tool_definition_shape(tool): + """get_tool_definition() must return a valid Anthropic tool schema.""" + defn = tool.get_tool_definition() + assert defn["name"] == "get_course_outline" + assert "description" in defn + assert defn["input_schema"]["required"] == ["course_title"] + + +def test_known_course_returns_title_and_lessons(tool): + """A known course name should return its title and at least one lesson.""" + result = tool.execute(course_title="MCP") + assert isinstance(result, str), f"Expected str, got {type(result)}" + assert "Course:" in result, f"Missing 'Course:' header: {result!r}" + assert "Lesson" in result, f"Expected lesson list, got: {result!r}" + + +def test_known_course_includes_link(tool): + """Result for a known course should include a course link.""" + result = tool.execute(course_title="MCP") + assert "Link:" in result, f"Expected 'Link:' in result: {result!r}" + + +def test_unknown_course_returns_error_string(tool): + """An unknown course name must return an informative error string, not raise.""" + result = tool.execute(course_title="ZZZ_TOTALLY_NONEXISTENT_COURSE_XYZ") + assert isinstance(result, str) + assert "no course found" in result.lower(), ( + f"Expected 'No course found' message, got: {result!r}" + ) + + +def test_execute_never_raises(tool): + """execute() must not raise for any input.""" + try: + result = tool.execute(course_title="asdfjkl qwerty") + assert isinstance(result, str) + except Exception as e: + pytest.fail(f"CourseOutlineTool.execute() raised: {e}") diff --git a/backend/tests/test_rag_system.py b/backend/tests/test_rag_system.py new file mode 100644 index 00000000..a63ab588 --- /dev/null +++ b/backend/tests/test_rag_system.py @@ -0,0 +1,127 @@ +""" +Tests for RAGSystem.query() in rag_system.py. + +Verifies the full end-to-end pipeline for content-related queries: +- Query returns a (response, sources) tuple without raising +- Response is a non-empty string +- Sources is a list +- Specific content questions return relevant answers +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from rag_system import RAGSystem +from config import config + + +@pytest.fixture(scope="module") +def rag(): + return RAGSystem(config) + + +# --------------------------------------------------------------------------- +# Return type contract +# --------------------------------------------------------------------------- + +def test_query_returns_tuple(rag): + """query() must return a (str, list) tuple.""" + result = rag.query("What is RAG?") + assert isinstance(result, tuple) and len(result) == 2, ( + f"Expected (str, list) tuple, got: {result!r}" + ) + answer, sources = result + assert isinstance(answer, str), f"answer must be str, got {type(answer)}" + assert isinstance(sources, list), f"sources must be list, got {type(sources)}" + + +def test_query_answer_nonempty(rag): + """query() must return a non-empty answer for any reasonable question.""" + answer, _ = rag.query("What is RAG?") + assert len(answer.strip()) > 0, "Answer should not be empty" + + +# --------------------------------------------------------------------------- +# Content-related queries (the failing case) +# --------------------------------------------------------------------------- + +def test_content_query_does_not_crash(rag): + """A content-specific question must not raise an exception.""" + try: + answer, sources = rag.query( + "What topics are covered in the MCP course?" + ) + assert isinstance(answer, str), f"Expected str answer, got: {type(answer)}" + except Exception as e: + pytest.fail(f"RAGSystem.query() raised an exception: {e}") + + +def test_content_query_no_query_failed_message(rag): + """A content query should not produce a 'query failed' or error response.""" + answer, _ = rag.query("What is covered in lesson 1 of the MCP course?") + lower = answer.lower() + assert "query failed" not in lower, ( + f"Got 'query failed' in response: {answer!r}" + ) + assert "error" not in lower or len(answer) > 50, ( + f"Response looks like an error message: {answer!r}" + ) + + +def test_rag_query_with_known_course(rag): + """Querying about a course that exists in ChromaDB should return content.""" + answer, sources = rag.query( + "Tell me about the Advanced Retrieval for AI course" + ) + assert isinstance(answer, str) and len(answer) > 20, ( + f"Expected substantive answer, got: {answer!r}" + ) + + +def test_rag_query_returns_sources_for_course_content(rag): + """A course-content query should populate sources.""" + _, sources = rag.query("What does lesson 2 of the MCP course cover?") + # sources may be empty if tool wasn't invoked, but must be a list + assert isinstance(sources, list), f"sources must be a list, got: {type(sources)}" + + +# --------------------------------------------------------------------------- +# Session handling +# --------------------------------------------------------------------------- + +def test_query_with_session_id(rag): + """query() with a session_id must not crash and must return valid results.""" + session_id = rag.session_manager.create_session() + answer, sources = rag.query("What is ChromaDB?", session_id=session_id) + assert isinstance(answer, str) and len(answer) > 0 + + +def test_multi_turn_conversation(rag): + """A second query in the same session should work without error.""" + session_id = rag.session_manager.create_session() + rag.query("What is RAG?", session_id=session_id) + answer, _ = rag.query("Can you give me an example?", session_id=session_id) + assert isinstance(answer, str) and len(answer) > 0, ( + f"Second turn failed, got: {answer!r}" + ) + + +# --------------------------------------------------------------------------- +# Vector store connectivity +# --------------------------------------------------------------------------- + +def test_courses_are_loaded(rag): + """ChromaDB should have courses loaded — if 0, ingestion failed.""" + count = rag.vector_store.get_course_count() + assert count > 0, ( + f"No courses in vector store! Ingestion may have failed. count={count}" + ) + + +def test_course_search_tool_registered(rag): + """search_course_content tool must be registered in the tool manager.""" + names = [d["name"] for d in rag.tool_manager.get_tool_definitions()] + assert "search_course_content" in names, ( + f"search_course_content not registered. Registered: {names}" + ) diff --git a/backend/tests/test_search_tool.py b/backend/tests/test_search_tool.py new file mode 100644 index 00000000..23226770 --- /dev/null +++ b/backend/tests/test_search_tool.py @@ -0,0 +1,93 @@ +""" +Tests for CourseSearchTool.execute() in search_tools.py. + +These are integration tests against the real ChromaDB vector store. +They verify that the tool returns usable results (or graceful errors) +for the kinds of queries the RAG chatbot receives. +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from search_tools import CourseSearchTool +from vector_store import VectorStore +from config import config + + +@pytest.fixture(scope="module") +def tool(): + store = VectorStore(config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS) + return CourseSearchTool(store) + + +# --------------------------------------------------------------------------- +# Basic execute() smoke tests +# --------------------------------------------------------------------------- + +def test_execute_returns_string(tool): + """execute() must always return a string, never raise.""" + result = tool.execute(query="what is RAG?") + assert isinstance(result, str), f"Expected str, got {type(result)}" + + +def test_execute_nonempty_for_known_topic(tool): + """A broad topic query should return content, not an empty result message.""" + result = tool.execute(query="RAG retrieval augmented generation") + assert "No relevant content found" not in result, ( + f"Expected real results but got: {result!r}" + ) + + +def test_execute_with_course_name_filter(tool): + """Filtering by a known partial course name should narrow results to that course.""" + result = tool.execute(query="lesson content", course_name="MCP") + # Should either find results or report course not found — must not crash + assert isinstance(result, str) + if "No relevant content found" not in result and "No course found" not in result: + assert "MCP" in result or "lesson" in result.lower(), ( + f"Expected MCP-related content, got: {result[:300]}" + ) + + +def test_execute_with_lesson_number_filter(tool): + """Filtering by lesson number should return content for that lesson.""" + result = tool.execute(query="introduction overview", lesson_number=1) + assert isinstance(result, str) + + +def test_execute_nonexistent_course_returns_error_string(tool): + """An unknown course name must return an error string, not raise.""" + result = tool.execute(query="anything", course_name="ZZZ_NONEXISTENT_COURSE_XYZ") + assert isinstance(result, str) + assert len(result) > 0 + + +def test_execute_garbage_query_does_not_crash(tool): + """Completely irrelevant query must not raise — may return no results.""" + result = tool.execute(query="asdfjkl qwerty zxcvbnm 12345") + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# Sources tracking +# --------------------------------------------------------------------------- + +def test_sources_populated_after_successful_search(tool): + """After a successful search, last_sources should be a non-empty list.""" + tool.last_sources = [] # Reset + result = tool.execute(query="chromadb vector database") + if "No relevant content found" not in result: + assert isinstance(tool.last_sources, list), "last_sources should be a list" + assert len(tool.last_sources) > 0, "last_sources should not be empty after a hit" + first = tool.last_sources[0] + assert "label" in first, f"Source entry missing 'label': {first}" + + +def test_sources_is_list_after_any_search(tool): + """last_sources must always be a list after any search (semantic search always finds nearest match).""" + tool.last_sources = [] + tool.execute(query="some query", course_name="some course") + assert isinstance(tool.last_sources, list), ( + f"last_sources should always be a list, got: {type(tool.last_sources)}" + ) diff --git a/backend/vector_store.py b/backend/vector_store.py index 390abe71..42874366 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -1,4 +1,5 @@ import chromadb +import json from chromadb.config import Settings from typing import List, Dict, Any, Optional from dataclasses import dataclass @@ -264,4 +265,24 @@ 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}") - \ No newline at end of file + + def get_course_metadata_by_name(self, course_name: str) -> Optional[Dict[str, Any]]: + """Resolve a course name via semantic search and return its full metadata.""" + try: + results = self.course_catalog.query( + query_texts=[course_name], + n_results=1, + include=["metadatas"] + ) + if not results["metadatas"] or not results["metadatas"][0]: + return None + meta = results["metadatas"][0][0] + lessons = json.loads(meta.get("lessons_json", "[]")) + return { + "title": meta.get("title", ""), + "course_link": meta.get("course_link", ""), + "lessons": lessons + } + except Exception as e: + print(f"Error getting course metadata by name: {e}") + return None diff --git a/frontend/index.html b/frontend/index.html index f8e25a62..2bcd76dd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,10 @@ Course Materials Assistant - + + + +
@@ -19,6 +22,23 @@

Course Materials Assistant

+ +
+
+ + + + + + +
+ Course Assistant +
+ +
+ +
+
diff --git a/frontend/script.js b/frontend/script.js index 562a8a36..daeaa850 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,6 +15,7 @@ document.addEventListener('DOMContentLoaded', () => { sendButton = document.getElementById('sendButton'); totalCourses = document.getElementById('totalCourses'); courseTitles = document.getElementById('courseTitles'); + newChatButton = document.getElementById('newChatButton'); setupEventListeners(); createNewSession(); @@ -30,6 +31,9 @@ function setupEventListeners() { }); + // New chat button + newChatButton.addEventListener('click', startNewChat); + // Suggested questions document.querySelectorAll('.suggested-item').forEach(button => { button.addEventListener('click', (e) => { @@ -122,10 +126,16 @@ function addMessage(content, type, sources = null, isWelcome = false) { let html = `
${displayContent}
`; if (sources && sources.length > 0) { + const sourceChips = sources.map(s => { + if (s.url) { + return `${escapeHtml(s.label)}`; + } + return `${escapeHtml(s.label)}`; + }).join(''); html += `
- Sources -
${sources.join(', ')}
+ 📄 Sources ${sources.length} +
${sourceChips}
`; } @@ -146,6 +156,18 @@ function escapeHtml(text) { // Removed removeMessage function - no longer needed since we handle loading differently +async function startNewChat() { + // Clean up old session on backend + if (currentSessionId) { + try { + await fetch(`${API_URL}/session/${currentSessionId}`, { method: 'DELETE' }); + } catch (err) { + console.warn('Failed to clear session on backend:', err); + } + } + createNewSession(); +} + async function createNewSession() { currentSessionId = null; chatMessages.innerHTML = ''; diff --git a/frontend/style.css b/frontend/style.css index 825d0367..012345f1 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -7,43 +7,39 @@ /* 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); + --primary: #6366f1; + --primary-hover: #4f46e5; + --primary-glow: rgba(99, 102, 241, 0.25); + --background: #0d1117; + --surface: #161b22; + --surface-2: #21262d; + --surface-hover: #2d333b; + --text-primary: #e6edf3; + --text-secondary: #7d8590; + --border-color: #30363d; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); --radius: 12px; - --focus-ring: rgba(37, 99, 235, 0.2); - --welcome-bg: #1e3a5f; - --welcome-border: #2563eb; + --focus-ring: rgba(99, 102, 241, 0.3); + --sidebar-width: 280px; } /* Base Styles */ body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 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 */ .container { height: 100vh; width: 100vw; display: flex; flex-direction: column; - margin: 0; - padding: 0; } /* Header - Hidden */ @@ -51,251 +47,282 @@ header { 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; -} - -.subtitle { - 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); } -/* Left Sidebar */ +/* ────────────────────────────── + Sidebar +────────────────────────────── */ .sidebar { - width: 320px; + width: var(--sidebar-width); background: var(--surface); border-right: 1px solid var(--border-color); - padding: 1.5rem; - overflow-y: auto; + display: flex; + flex-direction: column; flex-shrink: 0; + overflow-y: auto; } -/* Custom Scrollbar for Sidebar */ -.sidebar::-webkit-scrollbar { - width: 8px; +/* Brand */ +.sidebar-brand { + padding: 1.1rem 1.25rem 1rem; + display: flex; + align-items: center; + gap: 0.625rem; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; } -.sidebar::-webkit-scrollbar-track { - background: var(--surface); +.sidebar-logo { + width: 30px; + height: 30px; + background: linear-gradient(135deg, var(--primary) 0%, #8b5cf6 100%); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 0 14px rgba(99, 102, 241, 0.35); } -.sidebar::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 4px; +.sidebar-logo svg { + width: 16px; + height: 16px; + stroke: white; } -.sidebar::-webkit-scrollbar-thumb:hover { - background: var(--text-secondary); +.sidebar-title { + font-size: 0.875rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; + white-space: nowrap; } +/* Custom Scrollbar for Sidebar */ +.sidebar::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-track { background: transparent; } +.sidebar::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; } +.sidebar::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } + .sidebar-section { - margin-bottom: 1.5rem; + margin-bottom: 1.25rem; + padding: 0 1.25rem; } -.sidebar-section:last-child { - margin-bottom: 0; +.sidebar-brand + .sidebar-section { + margin-top: 1rem; } -/* Main Chat Area */ +/* ────────────────────────────── + Main Chat Area +────────────────────────────── */ .chat-main { flex: 1; display: flex; justify-content: center; overflow: hidden; - padding: 0; background: var(--background); + background-image: radial-gradient(circle, rgba(255, 255, 255, 0.025) 1px, transparent 1px); + background-size: 28px 28px; } -/* Chat Container - Centered with Max Width */ +/* Chat Container */ .chat-container { flex: 1; display: flex; flex-direction: column; - background: var(--background); overflow: hidden; width: 100%; - max-width: 800px; - margin: 0; + max-width: 840px; } /* Chat Messages */ .chat-messages { flex: 1; overflow-y: auto; - padding: 2rem; + padding: 2rem 1.5rem; display: flex; flex-direction: column; - gap: 1rem; - background: var(--background); + gap: 1.25rem; } /* Custom Scrollbar */ -.chat-messages::-webkit-scrollbar { - width: 8px; -} - -.chat-messages::-webkit-scrollbar-track { - background: var(--surface); -} - -.chat-messages::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 4px; -} - -.chat-messages::-webkit-scrollbar-thumb:hover { - background: var(--text-secondary); -} - -/* Message Styles */ +.chat-messages::-webkit-scrollbar { width: 4px; } +.chat-messages::-webkit-scrollbar-track { background: transparent; } +.chat-messages::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; } +.chat-messages::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } + +/* ────────────────────────────── + Message Styles +────────────────────────────── */ .message { max-width: 85%; - animation: fadeIn 0.3s ease-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } + animation: fadeSlideIn 0.25s ease-out; } -.message.user { - align-self: flex-end; +@keyframes fadeSlideIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } } -.message.assistant { - align-self: flex-start; -} +.message.user { align-self: flex-end; } +.message.assistant { align-self: flex-start; } .message-content { - padding: 0.75rem 1.25rem; - border-radius: 18px; + padding: 0.8rem 1.15rem; + border-radius: 16px; word-wrap: break-word; - line-height: 1.5; + line-height: 1.6; + font-size: 0.925rem; } .message.user .message-content { - background: var(--user-message); + background: linear-gradient(135deg, var(--primary) 0%, #8b5cf6 100%); color: white; border-bottom-right-radius: 4px; + box-shadow: 0 2px 14px rgba(99, 102, 241, 0.3); } .message.assistant .message-content { background: var(--surface); color: var(--text-primary); border-bottom-left-radius: 4px; + border: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); } /* Message metadata */ .message-meta { - font-size: 0.75rem; + font-size: 0.7rem; color: var(--text-secondary); - margin-top: 0.25rem; - padding: 0 0.5rem; + margin-top: 0.3rem; + padding: 0 0.4rem; } -.message.user .message-meta { - text-align: right; -} +.message.user .message-meta { text-align: right; } -/* Collapsible Sources */ +/* ────────────────────────────── + Collapsible Sources +────────────────────────────── */ .sources-collapsible { - margin-top: 0.5rem; + margin-top: 0.75rem; font-size: 0.75rem; - color: var(--text-secondary); + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: 0.5rem; } .sources-collapsible summary { cursor: pointer; - padding: 0.25rem 0.5rem; + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0.4rem; + border-radius: 6px; user-select: none; - font-weight: 500; + font-weight: 600; + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-secondary); + width: fit-content; + transition: background 0.15s, color 0.15s; } .sources-collapsible summary:hover { + background: rgba(255, 255, 255, 0.05); color: var(--text-primary); } -.sources-collapsible[open] summary { - margin-bottom: 0.25rem; +.sources-icon { font-size: 0.85rem; } + +.sources-count { + background: rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + border-radius: 999px; + padding: 0 0.45em; + font-size: 0.65rem; + font-weight: 700; + line-height: 1.6; } +.sources-collapsible[open] summary { margin-bottom: 0.5rem; } + .sources-content { - padding: 0 0.5rem 0.25rem 1.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0 0.25rem 0.25rem; +} + +.source-chip { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 500; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); color: var(--text-secondary); + text-decoration: none; + transition: all 0.15s; + white-space: nowrap; +} + +a.source-chip:hover { + background: rgba(99, 102, 241, 0.15); + border-color: rgba(99, 102, 241, 0.4); + color: #a5b4fc; } -/* Markdown formatting styles */ +/* ────────────────────────────── + Markdown Formatting +────────────────────────────── */ .message-content h1, .message-content h2, .message-content h3, .message-content h4, .message-content h5, .message-content h6 { - margin: 0.5rem 0; + margin: 0.75rem 0 0.4rem; 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.4rem; } +.message-content h2 { font-size: 1.2rem; } +.message-content h3 { font-size: 1.05rem; } -.message-content p { - margin: 0.5rem 0; - line-height: 1.6; -} +.message-content p { margin: 0.5rem 0; line-height: 1.65; } .message-content ul, -.message-content ol { - margin: 0.5rem 0; - padding-left: 1.5rem; -} +.message-content ol { margin: 0.5rem 0; padding-left: 1.5rem; } -.message-content li { - margin: 0.25rem 0; - line-height: 1.6; -} +.message-content li { 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: rgba(0, 0, 0, 0.3); + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace; + font-size: 0.85em; + border: 1px solid rgba(255, 255, 255, 0.07); } .message-content pre { - background-color: rgba(0, 0, 0, 0.2); - padding: 0.75rem; - border-radius: 4px; + background: rgba(0, 0, 0, 0.35); + padding: 1rem; + border-radius: 8px; overflow-x: auto; - margin: 0.5rem 0; + margin: 0.75rem 0; + border: 1px solid rgba(255, 255, 255, 0.07); } -.message-content pre code { - background-color: transparent; - padding: 0; -} +.message-content pre code { background: transparent; padding: 0; border: none; } .message-content blockquote { border-left: 3px solid var(--primary); @@ -304,21 +331,8 @@ header h1 { 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; -} - -.message-content strong { - font-weight: 600; -} - -.message-content em { - font-style: italic; -} +.message-content strong { font-weight: 600; } +.message-content em { font-style: italic; } .message-content hr { border: none; @@ -326,393 +340,328 @@ header h1 { margin: 1rem 0; } -/* Chat Input Container */ +/* Welcome message */ +.message.welcome-message .message-content { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.07), rgba(139, 92, 246, 0.04)); + border: 1px solid rgba(99, 102, 241, 0.25); + box-shadow: 0 0 28px rgba(99, 102, 241, 0.08); +} + +/* ────────────────────────────── + Chat Input +────────────────────────────── */ .chat-input-container { display: flex; gap: 0.75rem; - padding: 1.5rem 2rem; + padding: 1rem 1.5rem 1.5rem; 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; + border-radius: 12px; color: var(--text-primary); - font-size: 0.95rem; + font-size: 0.925rem; + font-family: inherit; transition: all 0.2s ease; } #chatInput:focus { outline: none; - border-color: var(--primary-color); + border-color: var(--primary); box-shadow: 0 0 0 3px var(--focus-ring); + background: var(--surface-2); } -#chatInput::placeholder { - color: var(--text-secondary); -} +#chatInput::placeholder { color: var(--text-secondary); } -/* Send Button */ #sendButton { - padding: 0.75rem 1.25rem; - background: var(--primary-color); + padding: 0 1.25rem; + background: linear-gradient(135deg, var(--primary), #8b5cf6); color: white; border: none; - border-radius: 24px; + border-radius: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; min-width: 52px; + box-shadow: 0 2px 10px rgba(99, 102, 241, 0.35); } -#sendButton:focus { - outline: none; - box-shadow: 0 0 0 3px var(--focus-ring); -} +#sendButton:focus { 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); + box-shadow: 0 4px 18px rgba(99, 102, 241, 0.45); + filter: brightness(1.1); } -#sendButton:active:not(:disabled) { - transform: translateY(0); -} +#sendButton:active:not(:disabled) { transform: translateY(0); } #sendButton:disabled { - opacity: 0.5; + opacity: 0.4; cursor: not-allowed; + box-shadow: none; } -/* Loading Animation */ +/* ────────────────────────────── + Loading Animation +────────────────────────────── */ .loading { display: inline-flex; - gap: 4px; - padding: 0.75rem 1.25rem; + gap: 5px; + padding: 0.5rem 0.25rem; + align-items: center; } .loading span { - width: 8px; - height: 8px; - background: var(--text-secondary); + width: 7px; + height: 7px; + background: var(--primary); border-radius: 50%; - animation: bounce 1.4s infinite ease-in-out both; + animation: bounce 1.3s infinite ease-in-out both; + opacity: 0.7; } -.loading span:nth-child(1) { - animation-delay: -0.32s; -} - -.loading span:nth-child(2) { - animation-delay: -0.16s; -} +.loading span:nth-child(1) { animation-delay: -0.32s; } +.loading span:nth-child(2) { animation-delay: -0.16s; } @keyframes bounce { - 0%, 80%, 100% { - transform: scale(0); - } - 40% { - transform: scale(1); - } + 0%, 80%, 100% { transform: scale(0.5); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } } -/* Error Message */ +/* Error / Success */ .error-message { - background: rgba(239, 68, 68, 0.1); + background: rgba(239, 68, 68, 0.08); color: #f87171; padding: 0.75rem 1.25rem; - border-radius: 8px; + border-radius: 10px; border: 1px solid rgba(239, 68, 68, 0.2); margin: 0.5rem 0; + font-size: 0.9rem; } -/* Success Message */ .success-message { - background: rgba(34, 197, 94, 0.1); + background: rgba(34, 197, 94, 0.08); color: #4ade80; padding: 0.75rem 1.25rem; - border-radius: 8px; + border-radius: 10px; border: 1px solid rgba(34, 197, 94, 0.2); margin: 0.5rem 0; + font-size: 0.9rem; } -/* Sidebar Headers */ +/* ────────────────────────────── + Sidebar – Section Headers +────────────────────────────── */ .stats-header, .suggested-header { - font-size: 0.875rem; + font-size: 0.7rem; font-weight: 600; color: var(--text-secondary); cursor: pointer; - padding: 0.5rem 0; + padding: 0.4rem 0; border: none; background: none; list-style: none; outline: none; transition: color 0.2s ease; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.07em; } .stats-header:focus, -.suggested-header:focus { - color: var(--primary-color); -} - +.suggested-header:focus, .stats-header:hover, .suggested-header:hover { - color: var(--primary-color); + color: var(--primary); } .stats-header::-webkit-details-marker, -.suggested-header::-webkit-details-marker { - display: none; -} +.suggested-header::-webkit-details-marker { display: none; } .stats-header::before, .suggested-header::before { - content: '▶'; + content: '›'; display: inline-block; - margin-right: 0.5rem; + margin-right: 0.4rem; transition: transform 0.2s ease; - font-size: 0.75rem; + font-size: 1.1rem; + line-height: 1; + vertical-align: middle; } details[open] .stats-header::before, -details[open] .suggested-header::before { - transform: rotate(90deg); +details[open] .suggested-header::before { transform: rotate(90deg); } + +/* ────────────────────────────── + New Chat Button +────────────────────────────── */ +.new-chat-btn { + width: 100%; + background: rgba(99, 102, 241, 0.08); + border: 1px solid rgba(99, 102, 241, 0.22); + padding: 0.6rem 0.875rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--primary); + letter-spacing: 0.02em; + text-align: left; + cursor: pointer; + border-radius: 8px; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 0.5rem; + font-family: inherit; +} + +.new-chat-btn::before { + content: '+'; + font-size: 1.25rem; + line-height: 1; + font-weight: 300; } -/* Course Stats in Sidebar */ +.new-chat-btn:hover { + background: rgba(99, 102, 241, 0.15); + border-color: rgba(99, 102, 241, 0.45); + box-shadow: 0 2px 10px rgba(99, 102, 241, 0.15); +} + +/* ────────────────────────────── + Course Stats in Sidebar +────────────────────────────── */ .course-stats { display: flex; flex-direction: column; - gap: 1rem; - padding: 0.75rem 0; + gap: 0.5rem; + padding: 0.5rem 0; background: transparent; border: none; } .stat-item { - text-align: left; - padding: 0.75rem; + padding: 0.625rem 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; } .stat-value { display: inline-block; font-size: 0.875rem; - font-weight: 600; - color: var(--primary-color); - margin-left: 0.5rem; + font-weight: 700; + color: var(--primary); + margin-left: 0.4rem; } .stat-label { display: inline-block; - font-size: 0.875rem; + font-size: 0.8rem; color: var(--text-secondary); - font-weight: 600; + font-weight: 500; } .stat-item:last-child .stat-label { display: block; - margin-bottom: 0.5rem; -} - -/* Course titles collapsible */ -.course-titles-collapsible { - 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; -} - -.course-titles-header:focus { - outline: none; - color: var(--primary-color); -} - -.course-titles-header::-webkit-details-marker { - display: none; -} - -.course-titles-header::before { - 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); -} - -/* Course titles display */ -.course-titles { - margin-top: 0.5rem; - /* Remove max-height to show all titles without scrolling */ + margin-bottom: 0.4rem; } +/* Course titles */ .course-title-item { - font-size: 0.85rem; + font-size: 0.8rem; color: var(--text-primary); - padding: 0.5rem 0.25rem; + padding: 0.4rem 0.25rem; border-bottom: 1px solid var(--border-color); - text-transform: none; line-height: 1.4; + opacity: 0.85; } -.course-title-item:last-child { - border-bottom: none; -} - -.course-title-item:first-child { - padding-top: 0.25rem; -} +.course-title-item:last-child { border-bottom: none; } +.course-title-item:first-child { padding-top: 0.2rem; } .no-courses, .loading, .error { - font-size: 0.85rem; + font-size: 0.8rem; color: var(--text-secondary); font-style: italic; text-transform: none; } -/* Suggested Questions in Sidebar */ +/* ────────────────────────────── + Suggested Questions +────────────────────────────── */ .suggested-items { display: flex; flex-direction: column; - gap: 0.5rem; - padding: 0.75rem 0; + gap: 0.4rem; + padding: 0.5rem 0; } .suggested-item { - padding: 0.75rem 1rem; + padding: 0.6rem 0.875rem; background: var(--background); border: 1px solid var(--border-color); border-radius: 8px; - color: var(--text-primary); - font-size: 0.875rem; + color: var(--text-secondary); + font-size: 0.8rem; cursor: pointer; transition: all 0.2s ease; text-align: left; width: 100%; + font-family: inherit; + line-height: 1.4; } .suggested-item:focus { outline: none; - box-shadow: 0 0 0 3px var(--focus-ring); + box-shadow: 0 0 0 2px var(--focus-ring); } .suggested-item:hover { - background: var(--surface-hover); - border-color: var(--primary-color); - color: var(--primary-color); - transform: translateX(2px); + background: rgba(99, 102, 241, 0.08); + border-color: rgba(99, 102, 241, 0.3); + color: var(--text-primary); + transform: translateX(3px); +} + +/* ────────────────────────────── + Responsive +────────────────────────────── */ +@media (max-width: 1024px) { + :root { --sidebar-width: 260px; } } -/* Responsive Design */ @media (max-width: 768px) { - .main-content { - flex-direction: column; - } - + .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; - } + .chat-main { order: 1; } + + .chat-messages { padding: 1rem; } + + .message { max-width: 92%; } + + .chat-input-container { padding: 0.75rem 1rem 1rem; gap: 0.5rem; } + + #chatInput { padding: 0.75rem 1rem; font-size: 0.9rem; } + + #sendButton { padding: 0.75rem 1rem; min-width: 48px; } + + .suggested-item { padding: 0.5rem 0.75rem; font-size: 0.775rem; } } diff --git a/pyproject.toml b/pyproject.toml index 3f05e2de..98e7f98e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,3 +13,8 @@ dependencies = [ "python-multipart==0.0.20", "python-dotenv==1.1.1", ] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/uv.lock b/uv.lock index 9ae65c55..4030b663 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -470,6 +470,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1038,6 +1047,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "posthog" version = "5.4.0" @@ -1207,6 +1225,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1561,6 +1595,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "anthropic", specifier = "==0.58.2" }, @@ -1572,6 +1611,9 @@ requires-dist = [ { name = "uvicorn", specifier = "==0.35.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + [[package]] name = "sympy" version = "1.14.0"