From 190ed76335445613bb576dc21ae70fc86d4388bb Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Wed, 29 Apr 2026 17:38:20 -0300 Subject: [PATCH 1/8] add CLAUDE.md with architecture overview and development commands Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..af1d1b0a8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Run the application +./run.sh +# or manually: +cd backend && uv run uvicorn app:app --reload --port 8000 + +# Install dependencies +uv sync + +# Run a specific backend file directly (useful for quick testing) +cd backend && uv run python .py +``` + +The app is served at `http://localhost:8000`. FastAPI's auto-generated API docs are at `http://localhost:8000/docs`. + +## Architecture + +This is a RAG (Retrieval-Augmented Generation) chatbot that answers questions about course materials. FastAPI serves both the API and the static frontend from a single process. + +**Request flow:** +1. Frontend (`frontend/script.js`) POSTs `{ query, session_id }` to `/api/query` +2. `app.py` routes to `RAGSystem.query()` — the main orchestrator in `rag_system.py` +3. `RAGSystem` fetches conversation history from `SessionManager`, then calls `AIGenerator` +4. `AIGenerator` calls Claude (claude-sonnet-4) with a `search_course_content` tool available +5. If Claude invokes the tool, `CourseSearchTool` runs a semantic search against ChromaDB and returns formatted chunks +6. Claude makes a second API call to synthesize a final answer from the retrieved chunks +7. Sources and response are returned up the chain to the frontend + +**Key design decisions:** +- Claude drives retrieval via tool use — it decides whether to search and what to search for, rather than always retrieving before generating +- Two ChromaDB collections: `course_catalog` (one entry per course, used for fuzzy course-name resolution) and `course_content` (chunked text, used for semantic search) +- Conversation history is stored in-memory in `SessionManager` — it is lost on server restart +- The session ID is minted server-side on first request and returned to the frontend, which holds it in `currentSessionId` for the rest of the browser session + +**Document format** (`docs/*.txt`): +``` +Course Title: ... +Course Link: ... +Course Instructor: ... +Lesson 1: Title +Lesson Link: ... + +``` +`DocumentProcessor` parses this format and chunks each lesson's content into ~800-character overlapping segments. Course documents are loaded at startup via `app.py`'s `startup_event`. + +**Configuration** (`backend/config.py`): +- `ANTHROPIC_MODEL` — Claude model used for generation +- `EMBEDDING_MODEL` — SentenceTransformer model used for ChromaDB embeddings (`all-MiniLM-L6-v2`) +- `CHUNK_SIZE` / `CHUNK_OVERLAP` — control document chunking +- `MAX_HISTORY` — number of conversation exchanges retained per session +- `CHROMA_PATH` — local path for persisted ChromaDB data From 56b6be9f169ddbb926948a1cb7070f4bd29735dc Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Wed, 29 Apr 2026 18:00:08 -0300 Subject: [PATCH 2/8] feat: render source citations as clickable lesson link chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sources returned by the search tool now include the lesson URL (looked up from the course catalog at query time). The API returns structured {label, url} objects instead of plain strings, and the frontend renders them as pill-shaped chips — blue and clickable when a URL is available, gray otherwise. Co-Authored-By: Claude Sonnet 4.6 --- backend/app.py | 4 ++-- backend/search_tools.py | 26 +++++++++++++------------- frontend/script.js | 10 +++++++++- frontend/style.css | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/backend/app.py b/backend/app.py index 5a69d741d..40a77d640 100644 --- a/backend/app.py +++ b/backend/app.py @@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.middleware.trustedhost import TrustedHostMiddleware from pydantic import BaseModel -from typing import List, Optional +from typing import Any, List, Optional import os from config import config @@ -43,7 +43,7 @@ class QueryRequest(BaseModel): class QueryResponse(BaseModel): """Response model for course queries""" answer: str - sources: List[str] + sources: List[Any] session_id: str class CourseStats(BaseModel): diff --git a/backend/search_tools.py b/backend/search_tools.py index adfe82352..45b05ce45 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -88,29 +88,29 @@ def execute(self, query: str, course_name: Optional[str] = None, lesson_number: def _format_results(self, results: SearchResults) -> str: """Format search results with course and lesson context""" formatted = [] - sources = [] # Track sources for the UI - + 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 + + label = course_title if lesson_num is not None: - source += f" - Lesson {lesson_num}" - sources.append(source) - + 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 ToolManager: diff --git a/frontend/script.js b/frontend/script.js index 562a8a363..df7bf29ad 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -122,10 +122,18 @@ function addMessage(content, type, sources = null, isWelcome = false) { let html = `
${displayContent}
`; if (sources && sources.length > 0) { + const chipsHtml = sources.map(src => { + if (src && src.url) { + return `${src.label} ↗`; + } + const label = src && src.label ? src.label : String(src); + return `${label}`; + }).join(''); + html += `
Sources -
${sources.join(', ')}
+
${chipsHtml}
`; } diff --git a/frontend/style.css b/frontend/style.css index 825d03675..7a2e007de 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -241,8 +241,40 @@ header h1 { } .sources-content { - padding: 0 0.5rem 0.25rem 1.5rem; + padding: 0.25rem 0.5rem 0.25rem 0.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.source-chip { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.65rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; + background: var(--surface); + border: 1px solid var(--border-color); color: var(--text-secondary); + white-space: nowrap; + transition: all 0.2s ease; + text-decoration: none; +} + +.source-chip--link { + color: var(--primary-color); + border-color: rgba(37, 99, 235, 0.3); + background: rgba(37, 99, 235, 0.08); + cursor: pointer; +} + +.source-chip--link:hover { + background: rgba(37, 99, 235, 0.18); + border-color: var(--primary-color); + color: var(--text-primary); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25); } /* Markdown formatting styles */ From 4b17dda420281aa38b2c8243355b986af6787ca1 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Wed, 29 Apr 2026 18:44:36 -0300 Subject: [PATCH 3/8] feat: add dark/light mode toggle to header Reveals the previously hidden header as a slim top bar with the app title and a sun/moon toggle button. Theme preference is persisted in localStorage and falls back to the OS prefers-color-scheme setting. Co-Authored-By: Claude Sonnet 4.6 --- frontend/index.html | 13 +++++--- frontend/script.js | 23 +++++++++++++- frontend/style.css | 73 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index f8e25a62f..6a4184572 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,13 +7,18 @@ Course Materials Assistant - +
-

Course Materials Assistant

-

Ask questions about courses, instructors, and content

+
+

Course Materials Assistant

+

Ask questions about courses, instructors, and content

+
+
@@ -76,6 +81,6 @@

Course Materials Assistant

- + \ No newline at end of file diff --git a/frontend/script.js b/frontend/script.js index df7bf29ad..e110b2ab4 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -7,6 +7,25 @@ let currentSessionId = null; // DOM elements let chatMessages, chatInput, sendButton, totalCourses, courseTitles; +// Theme management +function initTheme() { + const saved = localStorage.getItem('theme'); + const preferred = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + setTheme(saved || preferred); +} + +function setTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + const icon = document.getElementById('themeToggle')?.querySelector('.theme-icon'); + if (icon) icon.textContent = theme === 'dark' ? '☀️' : '🌙'; +} + +function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + setTheme(current === 'dark' ? 'light' : 'dark'); +} + // Initialize document.addEventListener('DOMContentLoaded', () => { // Get DOM elements after page loads @@ -15,7 +34,9 @@ document.addEventListener('DOMContentLoaded', () => { sendButton = document.getElementById('sendButton'); totalCourses = document.getElementById('totalCourses'); courseTitles = document.getElementById('courseTitles'); - + + initTheme(); + document.getElementById('themeToggle').addEventListener('click', toggleTheme); setupEventListeners(); createNewSession(); loadCourseStats(); diff --git a/frontend/style.css b/frontend/style.css index 7a2e007de..2f29c617f 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -46,25 +46,66 @@ body { padding: 0; } -/* Header - Hidden */ +/* Header */ header { - display: none; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: var(--surface); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; + gap: 1rem; +} + +.header-brand { + display: flex; + align-items: baseline; + gap: 1rem; + min-width: 0; } header h1 { - font-size: 1.75rem; + font-size: 1.1rem; 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; + white-space: nowrap; } .subtitle { - font-size: 0.95rem; + font-size: 0.8rem; color: var(--text-secondary); - margin-top: 0.5rem; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.theme-toggle { + background: var(--surface-hover); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.4rem 0.6rem; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + flex-shrink: 0; + transition: all 0.2s ease; + color: var(--text-primary); +} + +.theme-toggle:hover { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.theme-toggle:focus { + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring); } /* Main Content Area with Sidebar */ @@ -748,3 +789,25 @@ details[open] .suggested-header::before { width: 280px; } } + +/* Light mode */ +[data-theme="light"] { + --background: #f8fafc; + --surface: #ffffff; + --surface-hover: #f1f5f9; + --text-primary: #0f172a; + --text-secondary: #64748b; + --border-color: #e2e8f0; + --assistant-message: #f1f5f9; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08); + --welcome-bg: #eff6ff; +} + +[data-theme="light"] .message-content code, +[data-theme="light"] .message-content pre { + background-color: rgba(0, 0, 0, 0.05); +} + +[data-theme="light"] .message.welcome-message .message-content { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); +} From 53d30a7ee8cde7136a00eebfbbcafe27bf128f55 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira Date: Wed, 29 Apr 2026 19:13:18 -0300 Subject: [PATCH 4/8] feat: add Gemini model selector alongside Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sidebar dropdown to switch between Claude (Sonnet) and Gemini (2.5 Flash) at query time. Model choice persists in localStorage and is sent with each request. GeminiGenerator mirrors AIGenerator's interface with two-turn tool-call flow and Anthropic→Gemini tool definition conversion. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 3 +- backend/app.py | 3 +- backend/config.py | 4 + backend/gemini_generator.py | 132 ++++++++++++++++++++++++++++ backend/rag_system.py | 14 ++- frontend/index.html | 9 ++ frontend/script.js | 14 ++- frontend/style.css | 43 ++++++++++ pyproject.toml | 1 + uv.lock | 167 ++++++++++++++++++++++++++++++------ 10 files changed, 357 insertions(+), 33 deletions(-) create mode 100644 backend/gemini_generator.py diff --git a/.env.example b/.env.example index 18b34cb7e..f88aa79c3 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ # Copy this file to .env and add your actual API key -ANTHROPIC_API_KEY=your-anthropic-api-key-here \ No newline at end of file +ANTHROPIC_API_KEY=your-anthropic-api-key-here +GEMINI_API_KEY=your-gemini-api-key-here \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index 40a77d640..83107a977 100644 --- a/backend/app.py +++ b/backend/app.py @@ -39,6 +39,7 @@ class QueryRequest(BaseModel): """Request model for course queries""" query: str session_id: Optional[str] = None + model: str = "claude" class QueryResponse(BaseModel): """Response model for course queries""" @@ -63,7 +64,7 @@ async def query_documents(request: QueryRequest): session_id = rag_system.session_manager.create_session() # Process query using RAG system - answer, sources = rag_system.query(request.query, session_id) + answer, sources = rag_system.query(request.query, session_id, request.model) return QueryResponse( answer=answer, diff --git a/backend/config.py b/backend/config.py index d9f6392ef..017cb7e5c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -11,6 +11,10 @@ class Config: # Anthropic API settings ANTHROPIC_API_KEY: str = os.getenv("ANTHROPIC_API_KEY", "") ANTHROPIC_MODEL: str = "claude-sonnet-4-20250514" + + # Gemini API settings + GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "") + GEMINI_MODEL: str = "gemini-2.5-flash" # Embedding model settings EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" diff --git a/backend/gemini_generator.py b/backend/gemini_generator.py new file mode 100644 index 000000000..26f2672e4 --- /dev/null +++ b/backend/gemini_generator.py @@ -0,0 +1,132 @@ +from google import genai +from google.genai import types +from typing import List, Optional, Dict, Any + +TYPE_MAP = { + "object": types.Type.OBJECT, + "string": types.Type.STRING, + "integer": types.Type.INTEGER, + "number": types.Type.NUMBER, + "boolean": types.Type.BOOLEAN, + "array": types.Type.ARRAY, +} + + +class GeminiGenerator: + SYSTEM_PROMPT = """ You are an AI assistant specialized in course materials and educational content with access to a comprehensive search tool for course information. + +Search Tool Usage: +- Use the search tool **only** for questions about specific course content or detailed educational materials +- **One search per query maximum** +- Synthesize search results into accurate, fact-based responses +- If search yields no results, state this clearly without offering alternatives + +Response Protocol: +- **General knowledge questions**: Answer using existing knowledge without searching +- **Course-specific questions**: Search first, then answer +- **No meta-commentary**: + - Provide direct answers only — no reasoning process, search explanations, or question-type analysis + - Do not mention "based on the search results" + + +All responses must be: +1. **Brief, Concise and focused** - Get to the point quickly +2. **Educational** - Maintain instructional value +3. **Clear** - Use accessible language +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 = genai.Client(api_key=api_key) + self.model = model + + def generate_response( + self, + query: str, + conversation_history: Optional[str] = None, + tools: Optional[List] = None, + tool_manager=None, + ) -> str: + system = ( + f"{self.SYSTEM_PROMPT}\n\nPrevious conversation:\n{conversation_history}" + if conversation_history + else self.SYSTEM_PROMPT + ) + contents = [{"role": "user", "parts": [{"text": query}]}] + config = types.GenerateContentConfig( + system_instruction=system, + temperature=0, + max_output_tokens=800, + tools=self._convert_tools(tools) if tools else None, + ) + response = self.client.models.generate_content( + model=self.model, contents=contents, config=config + ) + if self._has_function_call(response) and tool_manager: + return self._handle_tool_execution(response, contents, system, tool_manager) + return response.text + + def _handle_tool_execution(self, initial_response, contents, system, tool_manager) -> str: + contents = contents + [ + {"role": "model", "parts": initial_response.candidates[0].content.parts} + ] + result_parts = [] + for part in initial_response.candidates[0].content.parts: + if part.function_call: + result = tool_manager.execute_tool( + part.function_call.name, **dict(part.function_call.args) + ) + result_parts.append( + types.Part.from_function_response( + name=part.function_call.name, + response={"result": result}, + ) + ) + contents = contents + [{"role": "user", "parts": result_parts}] + final = self.client.models.generate_content( + model=self.model, + contents=contents, + config=types.GenerateContentConfig( + system_instruction=system, temperature=0, max_output_tokens=800 + ), + ) + return final.text + + def _has_function_call(self, response) -> bool: + try: + return any( + p.function_call for p in response.candidates[0].content.parts + ) + except (AttributeError, IndexError): + return False + + def _convert_tools(self, anthropic_tools: List[Dict]) -> List: + return [ + types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name=t["name"], + description=t["description"], + parameters=self._convert_schema(t["input_schema"]), + ) + for t in anthropic_tools + ] + ) + ] + + def _convert_schema(self, schema: Dict) -> types.Schema: + kwargs: Dict[str, Any] = { + "type": TYPE_MAP.get(schema.get("type", "").lower(), types.Type.STRING) + } + if "description" in schema: + kwargs["description"] = schema["description"] + if "properties" in schema: + kwargs["properties"] = { + k: self._convert_schema(v) for k, v in schema["properties"].items() + } + if "required" in schema: + kwargs["required"] = schema["required"] + if "items" in schema: + kwargs["items"] = self._convert_schema(schema["items"]) + return types.Schema(**kwargs) diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8e..8d5db2548 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -3,6 +3,7 @@ from document_processor import DocumentProcessor from vector_store import VectorStore from ai_generator import AIGenerator +from gemini_generator import GeminiGenerator from session_manager import SessionManager from search_tools import ToolManager, CourseSearchTool from models import Course, Lesson, CourseChunk @@ -17,6 +18,7 @@ def __init__(self, config): 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.gemini_generator = GeminiGenerator(config.GEMINI_API_KEY, config.GEMINI_MODEL) self.session_manager = SessionManager(config.MAX_HISTORY) # Initialize search tools @@ -99,7 +101,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, model: str = "claude") -> Tuple[str, List[str]]: """ Process a user query using the RAG system with tool-based search. @@ -118,8 +120,16 @@ def query(self, query: str, session_id: Optional[str] = None) -> Tuple[str, List if session_id: history = self.session_manager.get_conversation_history(session_id) + # Select generator based on model choice + if model == "gemini": + if not self.config.GEMINI_API_KEY: + raise ValueError("Gemini API key is not configured.") + generator = self.gemini_generator + else: + generator = self.ai_generator + # Generate response using AI with tools - response = self.ai_generator.generate_response( + response = generator.generate_response( query=prompt, conversation_history=history, tools=self.tool_manager.get_tool_definitions(), diff --git a/frontend/index.html b/frontend/index.html index 6a4184572..6ade1481f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,6 +24,15 @@

Course Materials Assistant