diff --git a/.env.example b/.env.example index 18b34cb7e..626f1db98 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,19 @@ # 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 + +# API Provider Configuration +# Options: "anthropic" (default) or "openrouter" +API_PROVIDER=anthropic + +# API Key - Works for both Anthropic and OpenRouter +# For Anthropic: Get your key at https://console.anthropic.com/ +# For OpenRouter: Get your key at https://openrouter.ai/keys +ANTHROPIC_API_KEY=your-api-key-here + +# Model Configuration +# For Anthropic (when API_PROVIDER=anthropic): +ANTHROPIC_MODEL=claude-sonnet-4-20250514 + +# For OpenRouter (when API_PROVIDER=openrouter): +# Available models: anthropic/claude-3.5-sonnet, anthropic/claude-3-opus, etc. +# See https://openrouter.ai/models for full list +OPENROUTER_MODEL=anthropic/claude-3.5-sonnet \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..647f4e3de --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,176 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## ⚠️ CRITICAL: Package Management + +**ALWAYS use `uv` for ALL dependency management operations. NEVER use `pip` directly.** + +This project uses `uv` as its package manager. All Python commands must be run through `uv run`: + +```bash +# ✅ CORRECT - Use uv +uv sync # Install/sync dependencies +uv run python script.py # Run Python scripts +uv run uvicorn app:app --reload # Run servers +uv add package-name # Add new dependency +uv remove package-name # Remove dependency + +# ❌ WRONG - Do NOT use +pip install package-name # Don't use pip +python script.py # Don't run Python directly +``` + +## Project Overview + +This is a **RAG (Retrieval-Augmented Generation) system** for course materials. It allows users to query educational content and receive AI-powered responses backed by semantic search across course documents. + +**Tech Stack:** +- Backend: FastAPI + Python 3.13 +- Vector Database: ChromaDB with sentence-transformers embeddings +- AI: Anthropic Claude API (claude-sonnet-4-20250514) +- Frontend: Vanilla HTML/CSS/JavaScript +- **Package Manager: uv** (not pip) + +## Development Commands + +### Running the Application + +```bash +# Quick start (recommended) +./run.sh + +# Manual start +cd backend +uv run uvicorn app:app --reload --port 8000 +``` + +Application URLs: +- Frontend: `http://localhost:8000` +- API docs: `http://localhost:8000/docs` + +### Package Management + +See **"⚠️ CRITICAL: Package Management"** section at the top of this file. + +All dependency operations use `uv` exclusively: +- Install dependencies: `uv sync` +- Add packages: `uv add ` +- Remove packages: `uv remove ` +- Run commands: `uv run ` + +**Adding New Dependencies:** +When adding new packages to this project, always use `uv add`: +```bash +uv add anthropic # Add to project dependencies +uv add --dev pytest # Add development dependency +``` + +This updates both `pyproject.toml` and `uv.lock` automatically. + +### Environment Setup + +Create `.env` in project root: +``` +ANTHROPIC_API_KEY=your_key_here +``` + +## Architecture + +### Core Components (backend/) + +The system follows a modular architecture with clear separation of concerns: + +1. **app.py** - FastAPI application entry point + - Serves static frontend files + - Exposes `/api/query` and `/api/courses` endpoints + - Initializes RAGSystem and loads documents from `../docs` on startup + +2. **rag_system.py** - Main orchestrator + - Coordinates all components (document processor, vector store, AI generator, session manager) + - `add_course_document()`: Process and add single course + - `add_course_folder()`: Batch process all documents in a folder + - `query()`: Execute RAG query using tool-based search + +3. **vector_store.py** - ChromaDB interface + - Two collections: `course_catalog` (course metadata) and `course_content` (chunked text) + - `search()`: Unified search interface with course name resolution and content filtering + - Uses semantic matching for course names (partial matches work) + +4. **ai_generator.py** - Claude API integration + - Uses Anthropic's tool calling for structured search + - `generate_response()`: Handles tool execution flow + - Temperature: 0, Max tokens: 800 + +5. **document_processor.py** - Course document parsing + - Expects specific format: Course metadata (title/link/instructor) followed by lessons + - `chunk_text()`: Sentence-based chunking with configurable overlap + - Adds contextual prefixes to chunks (e.g., "Course X Lesson Y content:") + +6. **search_tools.py** - Tool-based architecture + - `CourseSearchTool`: Implements semantic search as an Anthropic tool + - `ToolManager`: Registers and executes tools, tracks sources + - Follows abstract Tool interface pattern for extensibility + +7. **session_manager.py** - Conversation history + - Tracks user sessions for multi-turn conversations + - Configurable history length (MAX_HISTORY=2) + +### Data Models (models.py) + +- **Course**: Container for course metadata and lessons +- **Lesson**: Individual lesson with number, title, optional link +- **CourseChunk**: Text chunk with course/lesson metadata for vector storage + +### Configuration (config.py) + +Key settings in `Config` dataclass: +- `CHUNK_SIZE=800`, `CHUNK_OVERLAP=100`: Text chunking parameters +- `MAX_RESULTS=5`: Number of search results returned +- `MAX_HISTORY=2`: Conversation history length +- `EMBEDDING_MODEL="all-MiniLM-L6-v2"`: Sentence transformer model +- `CHROMA_PATH="./chroma_db"`: Vector database location + +### Document Format + +Course documents in `docs/` folder should follow this structure: + +``` +Course Title: [title] +Course Link: [url] +Course Instructor: [name] + +Lesson 0: [lesson title] +Lesson Link: [optional url] +[lesson content...] + +Lesson 1: [lesson title] +... +``` + +Supported formats: `.pdf`, `.docx`, `.txt` + +### RAG Query Flow + +1. User submits query → FastAPI endpoint +2. RAGSystem creates session if needed +3. AI Generator (Claude) receives query + tool definitions +4. Claude decides whether to use CourseSearchTool +5. If tool used: VectorStore performs semantic search (course name resolution → content search) +6. Tool returns formatted results with sources +7. Claude synthesizes final answer +8. Response + sources returned to user + +### Vector Store Architecture + +**Two-collection design:** +- **course_catalog**: Course-level metadata for course name resolution via semantic search + - ID: course title + - Stores: instructor, course_link, lessons (as JSON) + +- **course_content**: Chunked course content + - ID: `{course_title}_{chunk_index}` + - Metadata: course_title, lesson_number, chunk_index + - Used for actual content retrieval + +This separation enables fuzzy course name matching while maintaining efficient content filtering. diff --git a/backend-tool-refactor.md b/backend-tool-refactor.md new file mode 100644 index 000000000..e69de29bb diff --git a/backend/ai_generator.py b/backend/ai_generator.py index 0363ca90c..215658248 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -1,25 +1,40 @@ import anthropic +from openai import OpenAI from typing import List, Optional, Dict, Any +import json class AIGenerator: - """Handles interactions with Anthropic's Claude API for generating responses""" - + """Handles interactions with Claude API via Anthropic or OpenRouter""" + # 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 comprehensive search and outline tools for course information. + +Tool Selection Guidelines: +- **Course Outline Tool** (`get_course_outline`): Use for questions about: + - Course structure, topics, or overview ("What's in this course?", "What topics are covered?") + - Lesson list or organization ("How many lessons?", "What lessons are included?") + - Course metadata (instructor, course details) + - General course navigation questions + +- **Content Search Tool** (`search_course_content`): Use for questions about: + - Specific content within lessons ("What does lesson 3 say about...?") + - Detailed technical information from course materials + - Code examples, explanations, or specific concepts taught in lessons -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 +- **Sequential tool use allowed**: You may use tools across multiple turns + - Maximum 2 rounds of tool calls per query + - Use first round to gather context, second to get specifics + - Example: get_course_outline → then search_course_content for details +- Synthesize tool results into accurate, fact-based responses +- If 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 structure questions**: Use outline tool first, then answer +- **Course content questions**: Use search tool 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" - + - Provide direct answers only — no reasoning process, tool usage explanations, or question-type analysis + - Do not mention "based on the search results" or "using the outline tool" All responses must be: 1. **Brief, Concise and focused** - Get to the point quickly @@ -28,16 +43,26 @@ 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) + + def __init__(self, api_key: str, model: str, provider: str = "anthropic", base_url: Optional[str] = None, max_tool_rounds: int = 2): + self.provider = provider.lower() self.model = model - + self.max_tool_rounds = max_tool_rounds + + # Initialize appropriate client based on provider + if self.provider == "openrouter": + self.client = OpenAI( + api_key=api_key, + base_url=base_url or "https://openrouter.ai/api/v1" + ) + else: # anthropic (default) + self.client = anthropic.Anthropic(api_key=api_key) + # Pre-build base API parameters self.base_params = { "model": self.model, "temperature": 0, - "max_tokens": 800 + "max_tokens": 500 # Reduced to fit within OpenRouter credit limits } def generate_response(self, query: str, @@ -46,90 +71,191 @@ def generate_response(self, query: str, tool_manager=None) -> str: """ Generate AI response with optional tool usage and conversation context. - + Args: query: The user's question or request conversation_history: Previous messages for context tools: Available tools the AI can use tool_manager: Manager to execute tools - + Returns: Generated response as string """ - + if self.provider == "openrouter": + return self._generate_openrouter(query, conversation_history, tools, tool_manager) + else: + return self._generate_anthropic(query, conversation_history, tools, tool_manager) + + def _generate_anthropic(self, query: str, conversation_history: Optional[str], + tools: Optional[List], tool_manager) -> str: + """Generate response using Anthropic SDK with loop-based sequential tool calling""" # 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 - } - - # Add tools if available - if tools: - api_params["tools"] = tools - api_params["tool_choice"] = {"type": "auto"} - - # Get response from Claude - response = self.client.messages.create(**api_params) - - # Handle tool execution if needed - if response.stop_reason == "tool_use" and tool_manager: - return self._handle_tool_execution(response, api_params, tool_manager) - - # Return direct response - return response.content[0].text - - def _handle_tool_execution(self, initial_response, base_params: Dict[str, Any], tool_manager): - """ - 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 - ) - - 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 + + # Initialize messages with user query + messages = [{"role": "user", "content": query}] + + # Sequential tool calling loop + for iteration in range(self.max_tool_rounds): + # Prepare API call parameters + api_params = { + **self.base_params, + "messages": messages, + "system": system_content + } + + # Add tools if available + if tools: + api_params["tools"] = tools + api_params["tool_choice"] = {"type": "auto"} + + # Get response from Claude + response = self.client.messages.create(**api_params) + + # Termination condition: No tool use - Claude gave final answer + if response.stop_reason != "tool_use": + return response.content[0].text + + # Tool use detected - execute tools if tool_manager available + if not tool_manager: + # No tool manager, make final call without tools + break + + # Add assistant's tool use response to messages + messages.append({"role": "assistant", "content": response.content}) + + # Execute all tool calls and collect results + tool_results = [] + for content_block in response.content: + if content_block.type == "tool_use": + try: + tool_result = tool_manager.execute_tool( + content_block.name, + **content_block.input + ) + except Exception as e: + tool_result = f"Error executing tool: {str(e)}" + print(f"Tool execution error: {e}") + + 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}) + + # Continue to next iteration (or exit if max rounds reached) + + # Max iterations reached - make final call without tools final_params = { **self.base_params, "messages": messages, - "system": base_params["system"] + "system": system_content } - - # Get final response + final_response = self.client.messages.create(**final_params) - return final_response.content[0].text \ No newline at end of file + return final_response.content[0].text + + def _generate_openrouter(self, query: str, conversation_history: Optional[str], + tools: Optional[List], tool_manager) -> str: + """Generate response using OpenRouter (OpenAI-compatible) with loop-based sequential tool calling""" + # Build system content + system_content = ( + f"{self.SYSTEM_PROMPT}\n\nPrevious conversation:\n{conversation_history}" + if conversation_history + else self.SYSTEM_PROMPT + ) + + # Initialize messages with system prompt and user query + messages = [ + {"role": "system", "content": system_content}, + {"role": "user", "content": query} + ] + + # Convert Anthropic tool format to OpenAI format if tools provided + openai_tools = self._convert_tools_to_openai(tools) if tools else None + + # Sequential tool calling loop + for iteration in range(self.max_tool_rounds): + # Prepare API call parameters + api_params = { + **self.base_params, + "messages": messages + } + + # Add tools if available + if openai_tools: + api_params["tools"] = openai_tools + api_params["tool_choice"] = "auto" + + # Get response from OpenRouter + response = self.client.chat.completions.create(**api_params) + + # Termination condition: No tool calls - Claude gave final answer + message = response.choices[0].message + if not message.tool_calls: + return message.content + + # Tool calls detected - execute tools if tool_manager available + if not tool_manager: + # No tool manager, make final call without tools + break + + # Add assistant's tool call message + messages.append(message) + + # Execute all tool calls + for tool_call in message.tool_calls: + # Parse arguments + args = json.loads(tool_call.function.arguments) + + # Execute tool + try: + tool_result = tool_manager.execute_tool(tool_call.function.name, **args) + except Exception as e: + tool_result = f"Error executing tool: {str(e)}" + print(f"Tool execution error: {e}") + + # Add tool response + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": tool_result + }) + + # Continue to next iteration (or exit if max rounds reached) + + # Max iterations reached - make final call without tools + # MUST include system prompt for context + final_response = self.client.chat.completions.create( + model=self.base_params["model"], + messages=[ + {"role": "system", "content": system_content}, + *messages[1:] # Skip original system message to avoid duplication + ], + temperature=self.base_params["temperature"], + max_tokens=self.base_params["max_tokens"] + ) + + return final_response.choices[0].message.content + + def _convert_tools_to_openai(self, anthropic_tools: List[Dict]) -> List[Dict]: + """Convert Anthropic tool format to OpenAI function calling format""" + openai_tools = [] + for tool in anthropic_tools: + openai_tools.append({ + "type": "function", + "function": { + "name": tool["name"], + "description": tool["description"], + "parameters": tool["input_schema"] + } + }) + return openai_tools \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index 5a69d741d..b2d7c14b1 100644 --- a/backend/app.py +++ b/backend/app.py @@ -11,6 +11,7 @@ from config import config from rag_system import RAGSystem +from models import Source # Initialize FastAPI app app = FastAPI(title="Course Materials RAG System", root_path="") @@ -43,7 +44,7 @@ class QueryRequest(BaseModel): class QueryResponse(BaseModel): """Response model for course queries""" answer: str - sources: List[str] + sources: List[Source] session_id: str class CourseStats(BaseModel): diff --git a/backend/config.py b/backend/config.py index d9f6392ef..361c2c4a3 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,16 +1,27 @@ import os from dataclasses import dataclass from dotenv import load_dotenv +from pathlib import Path -# Load environment variables from .env file -load_dotenv() +# Load environment variables from .env file in parent directory +env_path = Path(__file__).parent.parent / '.env' +load_dotenv(dotenv_path=env_path) @dataclass class Config: """Configuration settings for the RAG system""" - # Anthropic API settings + # API Provider settings + API_PROVIDER: str = os.getenv("API_PROVIDER", "anthropic") # "anthropic" or "openrouter" + + # API Key (works for both Anthropic and OpenRouter) ANTHROPIC_API_KEY: str = os.getenv("ANTHROPIC_API_KEY", "") - ANTHROPIC_MODEL: str = "claude-sonnet-4-20250514" + + # Model settings + ANTHROPIC_MODEL: str = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514") + + # OpenRouter specific settings + OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1" + OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "anthropic/claude-3.5-sonnet") # Embedding model settings EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" @@ -20,6 +31,7 @@ class Config: 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 + MAX_TOOL_ROUNDS: int = 2 # Maximum sequential tool calling rounds # Database paths CHROMA_PATH: str = "./chroma_db" # ChromaDB storage location diff --git a/backend/models.py b/backend/models.py index 7f7126fa3..9fe982198 100644 --- a/backend/models.py +++ b/backend/models.py @@ -19,4 +19,10 @@ class CourseChunk(BaseModel): 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 + chunk_index: int # Position of this chunk in the document + +class Source(BaseModel): + """Represents a clickable source citation""" + text: str # Display text: "Course - Lesson 5" + link: Optional[str] = None # URL to course/lesson page + type: str = "lesson" # "lesson" or "course" \ No newline at end of file diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8e..f22a4de85 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: @@ -16,13 +16,36 @@ def __init__(self, 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) + + # Initialize AI generator with appropriate provider + if config.API_PROVIDER == "openrouter": + self.ai_generator = AIGenerator( + api_key=config.ANTHROPIC_API_KEY, + model=config.OPENROUTER_MODEL, + provider="openrouter", + base_url=config.OPENROUTER_BASE_URL, + max_tool_rounds=config.MAX_TOOL_ROUNDS + ) + else: + self.ai_generator = AIGenerator( + api_key=config.ANTHROPIC_API_KEY, + model=config.ANTHROPIC_MODEL, + provider="anthropic", + max_tool_rounds=config.MAX_TOOL_ROUNDS + ) + self.session_manager = SessionManager(config.MAX_HISTORY) # Initialize search tools self.tool_manager = ToolManager() + + # Register content search tool self.search_tool = CourseSearchTool(self.vector_store) self.tool_manager.register_tool(self.search_tool) + + # Register course outline tool + self.outline_tool = CourseOutlineTool(self.vector_store) + self.tool_manager.register_tool(self.outline_tool) def add_course_document(self, file_path: str) -> Tuple[Course, int]: """ diff --git a/backend/search_tools.py b/backend/search_tools.py index adfe82352..3e07d83c7 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -89,30 +89,126 @@ def _format_results(self, results: SearchResults) -> str: """Format search results with course and lesson context""" formatted = [] sources = [] # Track sources for the UI - + for doc, meta in zip(results.documents, results.metadata): course_title = meta.get('course_title', 'unknown') lesson_num = meta.get('lesson_number') - + # Build context header header = f"[{course_title}" if lesson_num is not None: header += f" - Lesson {lesson_num}" header += "]" - - # Track source for the UI - source = course_title + + # Track source for the UI - now with link + source_text = course_title if lesson_num is not None: - source += f" - Lesson {lesson_num}" - sources.append(source) - + source_text += f" - Lesson {lesson_num}" + + # Get lesson or course link + link = None + source_type = "course" + try: + if lesson_num is not None: + link = self.store.get_lesson_link(course_title, lesson_num) + source_type = "lesson" + + # Fallback to course link if lesson link unavailable + if not link: + link = self.store.get_course_link(course_title) + source_type = "course" + except Exception as e: + print(f"Error retrieving link for {course_title}: {e}") + link = None # Fallback to None + + # Create structured source object + sources.append({ + "text": source_text, + "link": link, + "type": source_type + }) + 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 complete course outlines and lesson structures""" + + def __init__(self, vector_store: VectorStore): + self.store = vector_store + self.last_sources = [] # Track sources for UI display + + def get_tool_definition(self) -> Dict[str, Any]: + """Return Anthropic tool definition for course outline retrieval""" + return { + "name": "get_course_outline", + "description": "Retrieve the complete structure and lesson list for a specific course. Use this for questions about course topics, lesson structure, what's covered in a course, or course overview questions. DO NOT use for searching lesson content.", + "input_schema": { + "type": "object", + "properties": { + "course_name": { + "type": "string", + "description": "Course title or partial name (e.g., 'MCP', 'Introduction to Python'). Fuzzy matching is supported." + } + }, + "required": ["course_name"] + } + } + + def execute(self, course_name: str) -> str: + """ + Execute the outline tool to retrieve course structure. + + Args: + course_name: Course title or partial match + + Returns: + Formatted course outline or error message + """ + # Get course outline from vector store + outline = self.store.get_course_outline(course_name) + + # Handle course not found + if not outline: + return f"No course found matching '{course_name}'. Please check the course name and try again." + + # Format and return results + return self._format_outline(outline) + + def _format_outline(self, outline: Dict[str, Any]) -> str: + """Format course outline for AI consumption""" + # Build header with course metadata + formatted_parts = [ + f"Course: {outline['title']}" + ] + + if outline.get('instructor'): + formatted_parts.append(f"Instructor: {outline['instructor']}") + + formatted_parts.append(f"Total Lessons: {outline['lesson_count']}") + formatted_parts.append("") # Blank line + formatted_parts.append("Lesson Structure:") + + # Add each lesson + for lesson in outline.get('lessons', []): + lesson_line = f" Lesson {lesson['lesson_number']}: {lesson['lesson_title']}" + formatted_parts.append(lesson_line) + + # Track source for UI display + self.last_sources = [{ + "text": outline['title'], + "link": outline.get('course_link'), + "type": "course" + }] + + return "\n".join(formatted_parts) + + 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 000000000..e69de29bb diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 000000000..aceb142ff --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,117 @@ +""" +Pytest configuration and fixtures for RAG system tests +""" +import pytest +import sys +from pathlib import Path +from unittest.mock import Mock, MagicMock + +# Add backend directory to path for imports +backend_path = Path(__file__).parent.parent +sys.path.insert(0, str(backend_path)) + +from vector_store import SearchResults +from models import Course, Lesson + + +@pytest.fixture +def mock_vector_store(): + """Mock VectorStore with common test methods""" + store = Mock() + + # Default successful search results + store.search.return_value = SearchResults( + documents=["Test content from lesson 1"], + metadata=[{"course_title": "Test Course", "lesson_number": 1, "chunk_index": 0}], + distances=[0.5] + ) + + # Default link retrieval + store.get_lesson_link.return_value = "https://example.com/lesson/1" + store.get_course_link.return_value = "https://example.com/course" + + # Default course outline + store.get_course_outline.return_value = { + "title": "Test Course", + "instructor": "Test Instructor", + "course_link": "https://example.com/course", + "lesson_count": 3, + "lessons": [ + {"lesson_number": 0, "lesson_title": "Introduction"}, + {"lesson_number": 1, "lesson_title": "Getting Started"}, + {"lesson_number": 2, "lesson_title": "Advanced Topics"} + ] + } + + return store + + +@pytest.fixture +def mock_tool_manager(): + """Mock ToolManager with tool execution""" + manager = Mock() + manager.execute_tool.return_value = "[Test Course - Lesson 1]\nTest content" + manager.get_last_sources.return_value = [{ + "text": "Test Course - Lesson 1", + "link": "https://example.com/lesson/1", + "type": "lesson" + }] + manager.reset_sources.return_value = None + return manager + + +@pytest.fixture +def mock_anthropic_client(): + """Mock Anthropic API client""" + client = Mock() + + # Mock successful response without tool use + response = Mock() + response.content = [Mock(type="text", text="Test response")] + response.stop_reason = "end_turn" + client.messages.create.return_value = response + + return client + + +@pytest.fixture +def mock_openai_client(): + """Mock OpenAI/OpenRouter API client""" + client = Mock() + + # Mock successful response + response = Mock() + response.choices = [Mock()] + response.choices[0].message = Mock() + response.choices[0].message.content = "Test response" + response.choices[0].message.tool_calls = None + client.chat.completions.create.return_value = response + + return client + + +@pytest.fixture +def sample_course(): + """Sample course object for testing""" + return Course( + title="Introduction to Testing", + course_link="https://example.com/testing-course", + instructor="Test Instructor", + lessons=[ + Lesson(lesson_number=0, title="Course Overview", lesson_link="https://example.com/lesson/0"), + Lesson(lesson_number=1, title="Getting Started", lesson_link="https://example.com/lesson/1"), + Lesson(lesson_number=2, title="Advanced Topics", lesson_link="https://example.com/lesson/2"), + ] + ) + + +@pytest.fixture +def empty_search_results(): + """Empty search results for testing edge cases""" + return SearchResults(documents=[], metadata=[], distances=[]) + + +@pytest.fixture +def search_results_with_error(): + """Search results with error for testing error handling""" + return SearchResults.empty("Search failed: Connection timeout") diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py new file mode 100644 index 000000000..86bf63ae9 --- /dev/null +++ b/backend/tests/test_ai_generator.py @@ -0,0 +1,607 @@ +""" +Tests for AIGenerator - Focus on tool calling and OpenRouter system prompt bug +""" +import pytest +from unittest.mock import Mock, MagicMock, patch, call +from ai_generator import AIGenerator + + +class TestAnthropicToolCalling: + """Test Anthropic provider tool calling""" + + def test_anthropic_without_tools(self, mock_anthropic_client, mock_tool_manager): + """Test basic response without tool use""" + generator = AIGenerator(api_key="test-key", model="claude-sonnet-4", provider="anthropic") + generator.client = mock_anthropic_client + + response = generator.generate_response( + query="What is Python?", + conversation_history=None, + tools=None, + tool_manager=None + ) + + assert response == "Test response" + mock_anthropic_client.messages.create.assert_called_once() + + def test_anthropic_with_tool_use(self, mock_tool_manager): + """Test Anthropic tool calling flow""" + # Create mock client with tool use response + mock_client = Mock() + + # First response: tool use + tool_use_response = Mock() + tool_use_block = Mock() + tool_use_block.type = "tool_use" + tool_use_block.name = "search_course_content" + tool_use_block.id = "tool_123" + tool_use_block.input = {"query": "test"} + tool_use_response.content = [tool_use_block] + tool_use_response.stop_reason = "tool_use" + + # Second response: final answer + final_response = Mock() + final_text = Mock() + final_text.type = "text" + final_text.text = "Final answer with tool results" + final_response.content = [final_text] + final_response.stop_reason = "end_turn" + + mock_client.messages.create.side_effect = [tool_use_response, final_response] + + generator = AIGenerator(api_key="test-key", model="claude-sonnet-4", provider="anthropic") + generator.client = mock_client + + response = generator.generate_response( + query="What's in the MCP course?", + conversation_history=None, + tools=[{"name": "search_course_content"}], + tool_manager=mock_tool_manager + ) + + # Should call API twice (tool use + final response) + assert mock_client.messages.create.call_count == 2 + # Should execute tool + mock_tool_manager.execute_tool.assert_called_once() + assert response == "Final answer with tool results" + + +class TestOpenRouterToolCalling: + """Test OpenRouter provider tool calling - CRITICAL BUG #1""" + + def test_openrouter_system_prompt_in_final_call(self, mock_tool_manager): + """ + CRITICAL TEST: Tests Bug #1 - Missing system prompt in OpenRouter final call + + This test will FAIL with current code because: + - Line 213-216 in ai_generator.py doesn't include system prompt in final API call + - The final call uses **self.base_params which doesn't include system message + - OpenRouter models need system prompt to maintain context + + Expected: System prompt should be included in the final API call after tool execution + """ + # Create mock OpenRouter client + mock_client = Mock() + + # First response: tool use + tool_call_response = Mock() + tool_call_message = Mock() + tool_call = Mock() + tool_call.function.name = "search_course_content" + tool_call.function.arguments = '{"query": "test"}' + tool_call.id = "call_123" + tool_call_message.tool_calls = [tool_call] + tool_call_response.choices = [Mock(message=tool_call_message)] + + # Second response: final answer + final_response = Mock() + final_message = Mock() + final_message.content = "Final answer" + final_message.tool_calls = None + final_response.choices = [Mock(message=final_message)] + + mock_client.chat.completions.create.side_effect = [tool_call_response, final_response] + + generator = AIGenerator( + api_key="test-key", + model="anthropic/claude-3.5-sonnet", + provider="openrouter", + base_url="https://openrouter.ai/api/v1" + ) + generator.client = mock_client + + response = generator.generate_response( + query="What's in the MCP course?", + conversation_history=None, + tools=[{ + "name": "search_course_content", + "description": "Search course content", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + } + }], + tool_manager=mock_tool_manager + ) + + # Verify final API call includes system prompt + assert mock_client.chat.completions.create.call_count == 2 + + # Get the second (final) API call + final_call_kwargs = mock_client.chat.completions.create.call_args_list[1][1] + + # CRITICAL CHECK: System prompt must be in messages for final call + messages = final_call_kwargs.get('messages', []) + + # Should have system message at the start + system_messages = [msg for msg in messages if msg.get('role') == 'system'] + + # THIS WILL FAIL - current code doesn't include system prompt in final call + assert len(system_messages) > 0, "System prompt missing in OpenRouter final call!" + assert generator.SYSTEM_PROMPT in system_messages[0]['content'] + + def test_openrouter_basic_response(self, mock_openai_client): + """Test basic OpenRouter response without tools""" + generator = AIGenerator( + api_key="test-key", + model="anthropic/claude-3.5-sonnet", + provider="openrouter", + base_url="https://openrouter.ai/api/v1" + ) + generator.client = mock_openai_client + + response = generator.generate_response( + query="What is Python?", + conversation_history=None, + tools=None, + tool_manager=None + ) + + assert response == "Test response" + + +class TestToolExecutionErrorHandling: + """Test error handling in tool execution - Bug #5""" + + def test_tool_execution_exception_anthropic(self): + """ + Tests Bug #5 - No error handling for tool execution + + Current code doesn't wrap tool_manager.execute_tool() in try-except + If tool execution fails, the entire query crashes + + Expected: Should catch exceptions and return error message to model + """ + mock_client = Mock() + + # Tool use response + tool_use_response = Mock() + tool_use_block = Mock() + tool_use_block.type = "tool_use" + tool_use_block.name = "search_course_content" + tool_use_block.id = "tool_123" + tool_use_block.input = {"query": "test"} + tool_use_response.content = [tool_use_block] + tool_use_response.stop_reason = "tool_use" + + # Final response + final_response = Mock() + final_text = Mock() + final_text.type = "text" + final_text.text = "Error handled response" + final_response.content = [final_text] + final_response.stop_reason = "end_turn" + + mock_client.messages.create.side_effect = [tool_use_response, final_response] + + # Tool manager that raises exception + mock_tool_manager = Mock() + mock_tool_manager.execute_tool.side_effect = Exception("Tool execution failed!") + + generator = AIGenerator(api_key="test-key", model="claude-sonnet-4", provider="anthropic") + generator.client = mock_client + + # THIS WILL FAIL - current code doesn't handle tool execution exceptions + # Should not raise exception, should handle gracefully + response = generator.generate_response( + query="test", + conversation_history=None, + tools=[{"name": "search_course_content"}], + tool_manager=mock_tool_manager + ) + + # Should complete despite tool error + assert response is not None + + def test_tool_execution_exception_openrouter(self): + """Test OpenRouter tool execution error handling""" + mock_client = Mock() + + # Tool call response + tool_call_response = Mock() + tool_call_message = Mock() + tool_call = Mock() + tool_call.function.name = "search_course_content" + tool_call.function.arguments = '{"query": "test"}' + tool_call.id = "call_123" + tool_call_message.tool_calls = [tool_call] + tool_call_response.choices = [Mock(message=tool_call_message)] + + # Final response + final_response = Mock() + final_message = Mock() + final_message.content = "Error handled" + final_message.tool_calls = None + final_response.choices = [Mock(message=final_message)] + + mock_client.chat.completions.create.side_effect = [tool_call_response, final_response] + + # Tool manager that raises exception + mock_tool_manager = Mock() + mock_tool_manager.execute_tool.side_effect = Exception("Tool execution failed!") + + generator = AIGenerator( + api_key="test-key", + model="anthropic/claude-3.5-sonnet", + provider="openrouter", + base_url="https://openrouter.ai/api/v1" + ) + generator.client = mock_client + + # Should not raise exception + response = generator.generate_response( + query="test", + conversation_history=None, + tools=[{ + "name": "search_course_content", + "description": "Search course content", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + } + }], + tool_manager=mock_tool_manager + ) + + assert response is not None + + +class TestConversationHistory: + """Test conversation history handling""" + + def test_with_conversation_history(self, mock_anthropic_client): + """Test that conversation history is included in system prompt""" + generator = AIGenerator(api_key="test-key", model="claude-sonnet-4", provider="anthropic") + generator.client = mock_anthropic_client + + # Format history as string (same format as SessionManager.get_conversation_history) + history = "User: What is MCP?\nAssistant: MCP is Model Context Protocol\nUser: Tell me more\nAssistant: It's a way to..." + + generator.generate_response( + query="What's the latest?", + conversation_history=history, + tools=None, + tool_manager=None + ) + + # Verify history was included in system prompt + call_kwargs = mock_anthropic_client.messages.create.call_args.kwargs + system_content = call_kwargs['system'] + + # History should be in system prompt + assert "What is MCP?" in system_content + assert "MCP is Model Context Protocol" in system_content + + +class TestSequentialToolCalling: + """Test sequential tool calling with loop-based implementation""" + + def test_single_round_backward_compatibility(self, mock_tool_manager): + """Test that single-round tool calling still works (backward compatibility)""" + mock_client = Mock() + + # Single tool use response, then final answer + tool_use_response = Mock() + tool_use_block = Mock() + tool_use_block.type = "tool_use" + tool_use_block.name = "search_course_content" + tool_use_block.id = "tool_123" + tool_use_block.input = {"query": "test"} + tool_use_response.content = [tool_use_block] + tool_use_response.stop_reason = "tool_use" + + # Second call returns final answer (no more tool use) + final_response = Mock() + final_text = Mock() + final_text.type = "text" + final_text.text = "Final answer" + final_response.content = [final_text] + final_response.stop_reason = "end_turn" + + mock_client.messages.create.side_effect = [tool_use_response, final_response] + + generator = AIGenerator(api_key="test-key", model="claude-sonnet-4", provider="anthropic", max_tool_rounds=2) + generator.client = mock_client + + response = generator.generate_response( + query="What's in the course?", + conversation_history=None, + tools=[{"name": "search_course_content"}], + tool_manager=mock_tool_manager + ) + + # Should call API twice (tool use + final) + assert mock_client.messages.create.call_count == 2 + # Should execute tool once + assert mock_tool_manager.execute_tool.call_count == 1 + assert response == "Final answer" + + def test_two_sequential_rounds_anthropic(self, mock_tool_manager): + """Test Anthropic provider with 2 sequential tool calls""" + mock_client = Mock() + + # First response: tool use + tool_use_1 = Mock() + tool_use_block_1 = Mock() + tool_use_block_1.type = "tool_use" + tool_use_block_1.name = "get_course_outline" + tool_use_block_1.id = "tool_1" + tool_use_block_1.input = {"course_name": "MCP"} + tool_use_1.content = [tool_use_block_1] + tool_use_1.stop_reason = "tool_use" + + # Second response: another tool use + tool_use_2 = Mock() + tool_use_block_2 = Mock() + tool_use_block_2.type = "tool_use" + tool_use_block_2.name = "search_course_content" + tool_use_block_2.id = "tool_2" + tool_use_block_2.input = {"query": "lesson 3", "course_name": "MCP"} + tool_use_2.content = [tool_use_block_2] + tool_use_2.stop_reason = "tool_use" + + # Third response: final answer + final_response = Mock() + final_text = Mock() + final_text.type = "text" + final_text.text = "Lesson 3 covers..." + final_response.content = [final_text] + final_response.stop_reason = "end_turn" + + mock_client.messages.create.side_effect = [tool_use_1, tool_use_2, final_response] + + generator = AIGenerator(api_key="test-key", model="claude-sonnet-4", provider="anthropic", max_tool_rounds=2) + generator.client = mock_client + + response = generator.generate_response( + query="What does lesson 3 of MCP course cover?", + conversation_history=None, + tools=[{"name": "get_course_outline"}, {"name": "search_course_content"}], + tool_manager=mock_tool_manager + ) + + # Should call API 3 times (tool1 + tool2 + final) + assert mock_client.messages.create.call_count == 3 + # Should execute tools twice + assert mock_tool_manager.execute_tool.call_count == 2 + assert response == "Lesson 3 covers..." + + def test_max_depth_enforcement_anthropic(self, mock_tool_manager): + """Test that Anthropic stops at max_tool_rounds=2""" + mock_client = Mock() + + # All responses are tool uses (Claude keeps requesting tools) + tool_use_response = Mock() + tool_use_block = Mock() + tool_use_block.type = "tool_use" + tool_use_block.name = "search_course_content" + tool_use_block.id = "tool_123" + tool_use_block.input = {"query": "test"} + tool_use_response.content = [tool_use_block] + tool_use_response.stop_reason = "tool_use" + + # Final response after max rounds + final_response = Mock() + final_text = Mock() + final_text.type = "text" + final_text.text = "Final answer after max rounds" + final_response.content = [final_text] + + # Claude tries to use tools 3 times, but we stop at 2 + mock_client.messages.create.side_effect = [ + tool_use_response, # Round 1 + tool_use_response, # Round 2 + final_response # Final call without tools + ] + + generator = AIGenerator(api_key="test-key", model="claude-sonnet-4", provider="anthropic", max_tool_rounds=2) + generator.client = mock_client + + response = generator.generate_response( + query="Test query", + conversation_history=None, + tools=[{"name": "search_course_content"}], + tool_manager=mock_tool_manager + ) + + # Should call API exactly 3 times (2 tool rounds + 1 final) + assert mock_client.messages.create.call_count == 3 + # Should execute tools exactly twice (max_tool_rounds=2) + assert mock_tool_manager.execute_tool.call_count == 2 + assert response == "Final answer after max rounds" + + def test_two_sequential_rounds_openrouter(self, mock_tool_manager): + """Test OpenRouter provider with 2 sequential tool calls""" + mock_client = Mock() + + # First response: tool call + tool_call_1 = Mock() + tool_call_1.function.name = "get_course_outline" + tool_call_1.function.arguments = '{"course_name": "MCP"}' + tool_call_1.id = "call_1" + + message_1 = Mock() + message_1.tool_calls = [tool_call_1] + message_1.content = None + response_1 = Mock() + response_1.choices = [Mock(message=message_1)] + + # Second response: another tool call + tool_call_2 = Mock() + tool_call_2.function.name = "search_course_content" + tool_call_2.function.arguments = '{"query": "lesson 3", "course_name": "MCP"}' + tool_call_2.id = "call_2" + + message_2 = Mock() + message_2.tool_calls = [tool_call_2] + message_2.content = None + response_2 = Mock() + response_2.choices = [Mock(message=message_2)] + + # Third response: final answer + final_message = Mock() + final_message.tool_calls = None + final_message.content = "Lesson 3 covers..." + final_response = Mock() + final_response.choices = [Mock(message=final_message)] + + mock_client.chat.completions.create.side_effect = [response_1, response_2, final_response] + + generator = AIGenerator( + api_key="test-key", + model="anthropic/claude-3.5-sonnet", + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + max_tool_rounds=2 + ) + generator.client = mock_client + + # Complete tool definitions with required fields + tools = [ + {"name": "get_course_outline", "description": "Get course outline", "input_schema": {}}, + {"name": "search_course_content", "description": "Search course content", "input_schema": {}} + ] + + response = generator.generate_response( + query="What does lesson 3 of MCP course cover?", + conversation_history=None, + tools=tools, + tool_manager=mock_tool_manager + ) + + # Should call API 3 times + assert mock_client.chat.completions.create.call_count == 3 + # Should execute tools twice + assert mock_tool_manager.execute_tool.call_count == 2 + assert response == "Lesson 3 covers..." + + def test_max_depth_enforcement_openrouter(self, mock_tool_manager): + """Test that OpenRouter stops at max_tool_rounds=2""" + mock_client = Mock() + + # All responses have tool calls (Claude keeps requesting tools) + tool_call = Mock() + tool_call.function.name = "search_course_content" + tool_call.function.arguments = '{"query": "test"}' + tool_call.id = "call_123" + + message_with_tools = Mock() + message_with_tools.tool_calls = [tool_call] + message_with_tools.content = None + response_with_tools = Mock() + response_with_tools.choices = [Mock(message=message_with_tools)] + + # Final response + final_message = Mock() + final_message.content = "Final answer after max rounds" + final_response = Mock() + final_response.choices = [Mock(message=final_message)] + + mock_client.chat.completions.create.side_effect = [ + response_with_tools, # Round 1 + response_with_tools, # Round 2 + final_response # Final call + ] + + generator = AIGenerator( + api_key="test-key", + model="anthropic/claude-3.5-sonnet", + provider="openrouter", + max_tool_rounds=2 + ) + generator.client = mock_client + + # Complete tool definition with required fields + tools = [{"name": "search_course_content", "description": "Search content", "input_schema": {}}] + + response = generator.generate_response( + query="Test query", + conversation_history=None, + tools=tools, + tool_manager=mock_tool_manager + ) + + # Should call API exactly 3 times + assert mock_client.chat.completions.create.call_count == 3 + # Should execute tools exactly twice + assert mock_tool_manager.execute_tool.call_count == 2 + assert response == "Final answer after max rounds" + + def test_message_history_preserved_sequential(self, mock_tool_manager): + """Test that message history is preserved across sequential tool rounds""" + mock_client = Mock() + + # Two tool use rounds + tool_use_1 = Mock() + tool_use_block_1 = Mock() + tool_use_block_1.type = "tool_use" + tool_use_block_1.name = "get_course_outline" + tool_use_block_1.id = "tool_1" + tool_use_block_1.input = {"course_name": "MCP"} + tool_use_1.content = [tool_use_block_1] + tool_use_1.stop_reason = "tool_use" + + tool_use_2 = Mock() + tool_use_block_2 = Mock() + tool_use_block_2.type = "tool_use" + tool_use_block_2.name = "search_course_content" + tool_use_block_2.id = "tool_2" + tool_use_block_2.input = {"query": "lesson 3"} + tool_use_2.content = [tool_use_block_2] + tool_use_2.stop_reason = "tool_use" + + final_response = Mock() + final_text = Mock() + final_text.type = "text" + final_text.text = "Final answer" + final_response.content = [final_text] + final_response.stop_reason = "end_turn" + + mock_client.messages.create.side_effect = [tool_use_1, tool_use_2, final_response] + + generator = AIGenerator(api_key="test-key", model="claude-sonnet-4", provider="anthropic", max_tool_rounds=2) + generator.client = mock_client + + response = generator.generate_response( + query="What does lesson 3 cover?", + conversation_history=None, + tools=[{"name": "get_course_outline"}, {"name": "search_course_content"}], + tool_manager=mock_tool_manager + ) + + # Verify sequential tool calling occurred correctly + # Should make 3 API calls total (2 tool rounds + 1 final) + assert mock_client.messages.create.call_count == 3 + + # Should execute both tools + assert mock_tool_manager.execute_tool.call_count == 2 + + # Verify final response + assert response == "Final answer" + + # Verify that system prompt was included in all calls + for call in mock_client.messages.create.call_args_list: + call_kwargs = call[1] + assert 'system' in call_kwargs, "System prompt should be in all API calls" diff --git a/backend/tests/test_course_search_tool.py b/backend/tests/test_course_search_tool.py new file mode 100644 index 000000000..d5b02c0fc --- /dev/null +++ b/backend/tests/test_course_search_tool.py @@ -0,0 +1,187 @@ +""" +Tests for CourseSearchTool - Focus on error handling and link retrieval +""" +import pytest +from unittest.mock import Mock, patch +from search_tools import CourseSearchTool +from vector_store import SearchResults + + +class TestCourseSearchTool: + """Test CourseSearchTool.execute() method""" + + def test_execute_successful_search(self, mock_vector_store): + """Test normal search returns formatted results""" + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="test query") + + assert "Test Course" in result + assert "Lesson 1" in result or "lesson 1" in result + assert isinstance(result, str) + + def test_execute_empty_results(self, mock_vector_store): + """Test empty search returns 'No relevant content found' message""" + # Configure mock to return empty results + mock_vector_store.search.return_value = SearchResults( + documents=[], + metadata=[], + distances=[] + ) + + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="nonexistent content") + + assert "No relevant content found" in result + + def test_execute_with_error(self, mock_vector_store): + """Test error propagation from vector store""" + # Configure mock to return error + error_results = SearchResults.empty("Database connection failed") + mock_vector_store.search.return_value = error_results + + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="test") + + assert "Database connection failed" in result + + def test_execute_with_course_filter(self, mock_vector_store): + """Test course_name filter is passed correctly""" + tool = CourseSearchTool(mock_vector_store) + + tool.execute(query="test", course_name="MCP") + + # Verify search was called with course_name + mock_vector_store.search.assert_called_once() + call_kwargs = mock_vector_store.search.call_args.kwargs + assert call_kwargs['course_name'] == "MCP" + + def test_execute_with_lesson_filter(self, mock_vector_store): + """Test lesson_number filter is passed correctly""" + tool = CourseSearchTool(mock_vector_store) + + tool.execute(query="test", lesson_number=2) + + # Verify search was called with lesson_number + call_kwargs = mock_vector_store.search.call_args.kwargs + assert call_kwargs['lesson_number'] == 2 + + +class TestFormatResultsLinkRetrieval: + """Test _format_results() link retrieval - Bug #4""" + + def test_format_results_with_valid_links(self, mock_vector_store): + """Test successful link retrieval""" + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="test") + + # Should have populated last_sources with links + assert len(tool.last_sources) > 0 + assert tool.last_sources[0]['link'] is not None + assert 'https://' in tool.last_sources[0]['link'] + + def test_format_results_link_retrieval_exception(self, mock_vector_store): + """ + CRITICAL TEST: Tests Bug #4 - No error handling in _format_results() + + This test will FAIL with current code because: + - get_lesson_link() and get_course_link() calls are not wrapped in try-except + - If they raise exceptions, the entire search fails + + Expected: Should catch exceptions and fallback to None for links + """ + # Configure mock to raise exception when getting links + mock_vector_store.get_lesson_link.side_effect = Exception("Link retrieval failed") + mock_vector_store.get_course_link.side_effect = Exception("Link retrieval failed") + + tool = CourseSearchTool(mock_vector_store) + + # This should NOT raise exception - should handle gracefully + result = tool.execute(query="test") + + # Should still return results, just without links + assert "Test Course" in result + # Links should be None in sources + assert tool.last_sources[0]['link'] is None + + def test_format_results_missing_lesson_link(self, mock_vector_store): + """Test graceful handling when lesson link is None""" + # Lesson link returns None, should fallback to course link + mock_vector_store.get_lesson_link.return_value = None + mock_vector_store.get_course_link.return_value = "https://example.com/course" + + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="test") + + # Should have course link as fallback + assert tool.last_sources[0]['link'] == "https://example.com/course" + assert tool.last_sources[0]['type'] == "course" + + def test_format_results_all_links_none(self, mock_vector_store): + """Test when both lesson and course links are None""" + mock_vector_store.get_lesson_link.return_value = None + mock_vector_store.get_course_link.return_value = None + + tool = CourseSearchTool(mock_vector_store) + + result = tool.execute(query="test") + + # Should handle gracefully with None links + assert tool.last_sources[0]['link'] is None + + def test_source_tracking(self, mock_vector_store): + """Test that sources are properly tracked""" + # Configure specific metadata + mock_vector_store.search.return_value = SearchResults( + documents=["Content 1", "Content 2"], + metadata=[ + {"course_title": "Course A", "lesson_number": 1}, + {"course_title": "Course B", "lesson_number": 2} + ], + distances=[0.3, 0.5] + ) + + tool = CourseSearchTool(mock_vector_store) + + tool.execute(query="test") + + # Should have 2 sources + assert len(tool.last_sources) == 2 + assert tool.last_sources[0]['text'] == "Course A - Lesson 1" + assert tool.last_sources[1]['text'] == "Course B - Lesson 2" + + def test_source_without_lesson_number(self, mock_vector_store): + """Test source formatting when lesson_number is None""" + mock_vector_store.search.return_value = SearchResults( + documents=["Content"], + metadata=[{"course_title": "Test Course"}], # No lesson_number + distances=[0.5] + ) + + tool = CourseSearchTool(mock_vector_store) + + tool.execute(query="test") + + # Should just have course title without lesson number + assert tool.last_sources[0]['text'] == "Test Course" + assert tool.last_sources[0]['type'] == "course" + + +class TestToolDefinition: + """Test tool definition for Anthropic API""" + + def test_get_tool_definition(self, mock_vector_store): + """Test that tool definition is properly formatted""" + tool = CourseSearchTool(mock_vector_store) + + definition = tool.get_tool_definition() + + assert definition['name'] == 'search_course_content' + assert 'description' in definition + assert 'input_schema' in definition + assert 'query' in definition['input_schema']['properties'] + assert definition['input_schema']['required'] == ['query'] diff --git a/backend/tests/test_rag_sequential_integration.py b/backend/tests/test_rag_sequential_integration.py new file mode 100644 index 000000000..18aa96d3e --- /dev/null +++ b/backend/tests/test_rag_sequential_integration.py @@ -0,0 +1,190 @@ +""" +Integration test for sequential tool calling through the RAG system +Tests the complete flow from user query to AI response with multiple tool rounds +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from rag_system import RAGSystem +from config import Config + + +class TestRAGSequentialIntegration: + """Integration tests for sequential tool calling through RAG system""" + + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_sequential_tool_calling_integration(self, mock_doc_processor, mock_vector_store_class): + """ + E2E integration test: User query triggers sequential tool calls + + Simulates: "What does lesson 3 of the MCP course cover?" + Expected flow: + 1. Claude uses get_course_outline to get course structure + 2. Claude sees lesson 3 exists, uses search_course_content to get details + 3. Claude synthesizes final answer + """ + # Mock configuration + config = Config() + config.API_PROVIDER = "anthropic" + config.ANTHROPIC_API_KEY = "test-key" + config.MAX_TOOL_ROUNDS = 2 + + # Mock vector store instance + mock_vector_store = Mock() + mock_vector_store_class.return_value = mock_vector_store + + # Mock course outline response + mock_vector_store.get_course_outline.return_value = { + 'title': 'Introduction to MCP', + 'instructor': 'Test Instructor', + 'course_link': 'https://example.com/mcp', + 'lesson_count': 5, + 'lessons': [ + {'lesson_number': 1, 'lesson_title': 'Getting Started', 'lesson_link': 'https://example.com/lesson1'}, + {'lesson_number': 2, 'lesson_title': 'Basic Concepts', 'lesson_link': 'https://example.com/lesson2'}, + {'lesson_number': 3, 'lesson_title': 'Building Your First Server', 'lesson_link': 'https://example.com/lesson3'}, + ] + } + + # Mock search results for lesson 3 + from vector_store import SearchResults + mock_search_results = SearchResults( + documents=["Lesson 3 covers building your first MCP server with Python..."], + metadata=[{'course_title': 'Introduction to MCP', 'lesson_number': 3, 'chunk_index': 0}], + distances=[0.15] + ) + mock_vector_store.search.return_value = mock_search_results + mock_vector_store.get_lesson_link.return_value = 'https://example.com/lesson3' + mock_vector_store.get_course_link.return_value = 'https://example.com/mcp' + + # Initialize RAG system + with patch('rag_system.anthropic.Anthropic') as mock_anthropic: + # Mock Anthropic client responses + mock_client = Mock() + mock_anthropic.return_value = mock_client + + # First API call: Claude requests course outline + outline_tool_use = Mock() + outline_tool_use.type = "tool_use" + outline_tool_use.name = "get_course_outline" + outline_tool_use.id = "tool_1" + outline_tool_use.input = {"course_name": "MCP"} + + outline_response = Mock() + outline_response.content = [outline_tool_use] + outline_response.stop_reason = "tool_use" + + # Second API call: Claude requests lesson content search + search_tool_use = Mock() + search_tool_use.type = "tool_use" + search_tool_use.name = "search_course_content" + search_tool_use.id = "tool_2" + search_tool_use.input = { + "query": "Building Your First Server", + "course_name": "MCP", + "lesson_number": 3 + } + + search_response = Mock() + search_response.content = [search_tool_use] + search_response.stop_reason = "tool_use" + + # Third API call: Claude returns final answer + final_text = Mock() + final_text.type = "text" + final_text.text = "Lesson 3 of the MCP course covers building your first server with Python. It provides step-by-step instructions and examples." + + final_response = Mock() + final_response.content = [final_text] + final_response.stop_reason = "end_turn" + + # Set up API call sequence + mock_client.messages.create.side_effect = [ + outline_response, # Round 1: get outline + search_response, # Round 2: search content + final_response # Final response + ] + + # Create RAG system and execute query + rag = RAGSystem(config) + response, sources = rag.query("What does lesson 3 of the MCP course cover?") + + # Verify sequential tool calling occurred + assert mock_client.messages.create.call_count == 3, "Should make 3 API calls (2 tools + final)" + + # Verify tools were called in correct order + assert mock_vector_store.get_course_outline.call_count == 1, "Should call get_course_outline once" + assert mock_vector_store.search.call_count == 1, "Should call search once" + + # Verify final response is returned + assert "Lesson 3" in response + assert "building your first server" in response.lower() + + # Verify sources are provided + assert len(sources) > 0, "Should have sources from the search" + assert sources[0]['text'] == "Introduction to MCP - Lesson 3" + assert sources[0]['link'] == 'https://example.com/lesson3' + + @patch('rag_system.VectorStore') + @patch('rag_system.DocumentProcessor') + def test_max_rounds_enforcement_integration(self, mock_doc_processor, mock_vector_store_class): + """ + Integration test: Verify system stops at MAX_TOOL_ROUNDS + + Simulates a scenario where Claude keeps requesting tools, + but system enforces the 2-round limit + """ + config = Config() + config.API_PROVIDER = "anthropic" + config.ANTHROPIC_API_KEY = "test-key" + config.MAX_TOOL_ROUNDS = 2 + + mock_vector_store = Mock() + mock_vector_store_class.return_value = mock_vector_store + + # Mock returns for tools + from vector_store import SearchResults + mock_vector_store.search.return_value = SearchResults( + documents=["Test content"], + metadata=[{'course_title': 'Test Course', 'lesson_number': 1}], + distances=[0.1] + ) + mock_vector_store.get_lesson_link.return_value = 'https://example.com/lesson1' + + with patch('rag_system.anthropic.Anthropic') as mock_anthropic: + mock_client = Mock() + mock_anthropic.return_value = mock_client + + # Claude keeps requesting tools (simulating greedy behavior) + tool_use = Mock() + tool_use.type = "tool_use" + tool_use.name = "search_course_content" + tool_use.id = "tool_123" + tool_use.input = {"query": "test"} + + tool_response = Mock() + tool_response.content = [tool_use] + tool_response.stop_reason = "tool_use" + + # Final response + final_text = Mock() + final_text.type = "text" + final_text.text = "Final answer after max rounds" + + final_response = Mock() + final_response.content = [final_text] + + # Claude tries to use tools repeatedly, but we limit to 2 rounds + mock_client.messages.create.side_effect = [ + tool_response, # Round 1 + tool_response, # Round 2 + final_response # Final call (forced) + ] + + rag = RAGSystem(config) + response, sources = rag.query("Test query") + + # Verify max rounds enforced + assert mock_client.messages.create.call_count == 3, "Should stop at 2 tool rounds + 1 final call" + assert mock_vector_store.search.call_count == 2, "Should execute tools exactly twice" + assert response == "Final answer after max rounds" diff --git a/backend/tests/test_vector_store.py b/backend/tests/test_vector_store.py new file mode 100644 index 000000000..4737db8f3 --- /dev/null +++ b/backend/tests/test_vector_store.py @@ -0,0 +1,194 @@ +""" +Tests for VectorStore class - Focus on edge cases and error handling +""" +import pytest +import json +from unittest.mock import Mock, patch +from vector_store import SearchResults, VectorStore + + +class TestSearchResults: + """Test SearchResults.from_chroma() method for IndexError bug""" + + def test_from_chroma_empty_metadatas_bug(self): + """ + CRITICAL TEST: Tests Bug #3 - IndexError in SearchResults.from_chroma() + + This test will FAIL with current code due to: + metadata=chroma_results['metadatas'][0] if chroma_results['metadatas'][0] else [], + + When metadatas is an empty list [], the condition tries to access [0] + which raises IndexError. + """ + chroma_results = { + 'documents': [[]], # Empty documents + 'metadatas': [[]], # Empty metadatas list + 'distances': [[]] + } + + # This should NOT raise IndexError + result = SearchResults.from_chroma(chroma_results) + + assert result.documents == [] + assert result.metadata == [] + assert result.distances == [] + + def test_from_chroma_none_metadatas(self): + """Test handling when metadatas is None""" + chroma_results = { + 'documents': [['doc1']], + 'metadatas': None, + 'distances': [[0.5]] + } + + result = SearchResults.from_chroma(chroma_results) + + # Should handle None gracefully + assert result.documents == ['doc1'] + assert result.metadata == [] + + def test_from_chroma_successful(self): + """Test normal case with valid data""" + chroma_results = { + 'documents': [['doc1', 'doc2']], + 'metadatas': [[{'course_title': 'Test'}, {'course_title': 'Test2'}]], + 'distances': [[0.5, 0.7]] + } + + result = SearchResults.from_chroma(chroma_results) + + assert len(result.documents) == 2 + assert len(result.metadata) == 2 + assert result.metadata[0]['course_title'] == 'Test' + + def test_empty_search_results(self): + """Test creating empty search results with error message""" + result = SearchResults.empty("Test error message") + + assert result.is_empty() + assert result.error == "Test error message" + assert result.documents == [] + + +class TestVectorStoreLinkRetrieval: + """Test link retrieval methods for explicit return bug""" + + @patch('chromadb.Client') + def test_get_lesson_link_exception_handling(self, mock_chroma_client): + """ + Tests Bug #2 - Missing explicit return in exception handler + + Current code implicitly returns None when exception occurs. + Should explicitly return None for clarity. + """ + # Create VectorStore instance with mocked ChromaDB + mock_collection = Mock() + mock_chroma_client.return_value.get_or_create_collection.return_value = mock_collection + + # Simulate ChromaDB raising an exception + mock_collection.get.side_effect = Exception("Database connection lost") + + store = VectorStore(chroma_path="./test_db", embedding_model="test-model", max_results=5) + + # Should return None, not raise exception + result = store.get_lesson_link("Test Course", 1) + + # Currently this works but we want EXPLICIT return None in the code + assert result is None + + @patch('chromadb.Client') + def test_get_course_link_exception_handling(self, mock_chroma_client): + """ + Tests Bug #2 - Missing explicit return in get_course_link exception handler + """ + mock_collection = Mock() + mock_chroma_client.return_value.get_or_create_collection.return_value = mock_collection + + mock_collection.get.side_effect = Exception("Database error") + + store = VectorStore(chroma_path="./test_db", embedding_model="test-model", max_results=5) + + result = store.get_course_link("Test Course") + + assert result is None + + @patch('chromadb.Client') + def test_get_lesson_link_json_parse_error(self, mock_chroma_client): + """Test handling of corrupted lessons_json data""" + mock_collection = Mock() + mock_chroma_client.return_value.get_or_create_collection.return_value = mock_collection + + # Return corrupted JSON + mock_collection.get.return_value = { + 'metadatas': [{'lessons_json': 'invalid json {{{'}] + } + + store = VectorStore(chroma_path="./test_db", embedding_model="test-model", max_results=5) + + # Should handle JSON parse error gracefully + result = store.get_lesson_link("Test Course", 1) + + assert result is None + + @patch('chromadb.Client') + def test_get_lesson_link_successful(self, mock_chroma_client): + """Test successful lesson link retrieval""" + mock_collection = Mock() + mock_chroma_client.return_value.get_or_create_collection.return_value = mock_collection + + lessons_data = [ + {"lesson_number": 1, "lesson_title": "Intro", "lesson_link": "https://example.com/lesson/1"}, + {"lesson_number": 2, "lesson_title": "Advanced", "lesson_link": "https://example.com/lesson/2"} + ] + + mock_collection.get.return_value = { + 'metadatas': [{'lessons_json': json.dumps(lessons_data)}] + } + + store = VectorStore(chroma_path="./test_db", embedding_model="test-model", max_results=5) + + result = store.get_lesson_link("Test Course", 1) + + assert result == "https://example.com/lesson/1" + + @patch('chromadb.Client') + def test_get_lesson_link_not_found(self, mock_chroma_client): + """Test when lesson number doesn't exist""" + mock_collection = Mock() + mock_chroma_client.return_value.get_or_create_collection.return_value = mock_collection + + lessons_data = [ + {"lesson_number": 1, "lesson_title": "Intro", "lesson_link": "https://example.com/lesson/1"} + ] + + mock_collection.get.return_value = { + 'metadatas': [{'lessons_json': json.dumps(lessons_data)}] + } + + store = VectorStore(chroma_path="./test_db", embedding_model="test-model", max_results=5) + + # Request lesson 99 which doesn't exist + result = store.get_lesson_link("Test Course", 99) + + assert result is None + + +class TestVectorStoreSearchEdgeCases: + """Test search method edge cases""" + + @patch('chromadb.Client') + def test_search_with_chroma_exception(self, mock_chroma_client): + """Test search error handling""" + mock_collection = Mock() + mock_chroma_client.return_value.get_or_create_collection.return_value = mock_collection + + # Simulate ChromaDB query failure + mock_collection.query.side_effect = Exception("ChromaDB query failed") + + store = VectorStore(chroma_path="./test_db", embedding_model="test-model", max_results=5) + + result = store.search(query="test query") + + # Should return error in SearchResults, not raise exception + assert result.is_empty() + assert "Search error" in result.error diff --git a/backend/vector_store.py b/backend/vector_store.py index 390abe71c..4dd95b60d 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -17,9 +17,9 @@ class 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.get('documents') and len(chroma_results['documents']) > 0 else [], + metadata=chroma_results['metadatas'][0] if chroma_results.get('metadatas') and len(chroma_results['metadatas']) > 0 and chroma_results['metadatas'][0] else [], + distances=chroma_results['distances'][0] if chroma_results.get('distances') and len(chroma_results['distances']) > 0 else [] ) @classmethod @@ -233,6 +233,55 @@ def get_all_courses_metadata(self) -> List[Dict[str, Any]]: print(f"Error getting courses metadata: {e}") return [] + def get_course_outline(self, course_name: str) -> Optional[Dict[str, Any]]: + """ + Get complete outline for a single course with fuzzy name matching. + + Args: + course_name: Course title or partial match (e.g., 'MCP', 'Introduction') + + Returns: + Dictionary with course metadata and lessons, or None if not found + Structure: { + 'title': str, + 'instructor': str, + 'course_link': str, + 'lesson_count': int, + 'lessons': List[Dict] with lesson_number, lesson_title, lesson_link + } + """ + import json + try: + # Step 1: Use fuzzy matching to find the course + resolved_title = self._resolve_course_name(course_name) + if not resolved_title: + return None + + # Step 2: Get full metadata for the resolved course + results = self.course_catalog.get(ids=[resolved_title]) + if not results or not results['metadatas'] or not results['metadatas'][0]: + return None + + # Step 3: Parse and return structured data + metadata = results['metadatas'][0] + course_data = { + 'title': metadata.get('title'), + 'instructor': metadata.get('instructor'), + 'course_link': metadata.get('course_link'), + 'lesson_count': metadata.get('lesson_count', 0), + 'lessons': [] + } + + # Parse lessons JSON + if 'lessons_json' in metadata: + course_data['lessons'] = json.loads(metadata['lessons_json']) + + return course_data + + except Exception as e: + print(f"Error getting course outline: {e}") + return None + def get_course_link(self, course_title: str) -> Optional[str]: """Get course link for a given course title""" try: @@ -264,4 +313,5 @@ 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}") + return None \ No newline at end of file diff --git a/docs/query-flow-diagram.md b/docs/query-flow-diagram.md new file mode 100644 index 000000000..f0485e4d7 --- /dev/null +++ b/docs/query-flow-diagram.md @@ -0,0 +1,600 @@ +# Query Flow Diagram - RAG Chatbot System + +## Complete User Query Flow: Frontend → Backend → Response + +This document illustrates the complete journey of a user query through the RAG chatbot system. + +--- + +## Sequence Diagram + +```mermaid +sequenceDiagram + participant User + participant Frontend as Frontend
(script.js) + participant API as FastAPI
(app.py) + participant RAG as RAG System
(rag_system.py) + participant Session as Session Manager + participant AI as AI Generator
(ai_generator.py) + participant Claude as Claude API
(Anthropic) + participant Tools as Tool Manager
(search_tools.py) + participant Vector as Vector Store
(vector_store.py) + participant Chroma as ChromaDB + + User->>Frontend: Types question & clicks send + activate Frontend + Frontend->>Frontend: Disable input
Show loading animation + Frontend->>Frontend: Display user message + + Frontend->>API: POST /api/query
{query, session_id} + activate API + + API->>API: Validate request + API->>Session: Create/get session + Session-->>API: session_id + + API->>RAG: query(query, session_id) + activate RAG + + RAG->>Session: Get conversation history + Session-->>RAG: Previous messages + + RAG->>AI: generate_response()
(query, history, tools) + activate AI + + AI->>AI: Build system prompt
with conversation context + + AI->>Claude: API Call #1
messages.create()
(with tools) + activate Claude + + Note over Claude: Claude analyzes query
Decides: Search or answer directly? + + alt Course-specific question + Claude-->>AI: stop_reason: "tool_use"
tool: search_course_content + deactivate Claude + + AI->>Tools: execute_tool()
(query, course_name, lesson_number) + activate Tools + + Tools->>Vector: search()
(query, filters) + activate Vector + + alt Course name provided + Vector->>Chroma: Query course_catalog
(semantic course resolution) + Chroma-->>Vector: Best matching course title + end + + Vector->>Vector: Build metadata filters
(course_title, lesson_number) + + Vector->>Chroma: Query course_content
(semantic search with filters) + Chroma-->>Vector: Top 5 relevant chunks
(with embeddings) + + Vector-->>Tools: SearchResults
(documents, metadata, distances) + deactivate Vector + + Tools->>Tools: Format results
Add course/lesson context
Track sources + + Tools-->>AI: Formatted search results + deactivate Tools + + AI->>AI: Build messages with
tool results + + AI->>Claude: API Call #2
messages.create()
(with tool results) + activate Claude + + Note over Claude: Claude synthesizes answer
from retrieved context + + Claude-->>AI: Final response text + deactivate Claude + + else General knowledge question + Claude-->>AI: Direct answer
(no tool use) + deactivate Claude + end + + AI-->>RAG: Generated answer + deactivate AI + + RAG->>Tools: get_last_sources() + Tools-->>RAG: Source list + + RAG->>Tools: reset_sources() + + RAG->>Session: add_exchange()
(query, response) + + RAG-->>API: (answer, sources) + deactivate RAG + + API->>API: Format QueryResponse
{answer, sources, session_id} + + API-->>Frontend: JSON Response + deactivate API + + Frontend->>Frontend: Remove loading animation + Frontend->>Frontend: Render markdown answer + Frontend->>Frontend: Display sources (collapsible) + Frontend->>Frontend: Re-enable input + + Frontend-->>User: Display answer with sources + deactivate Frontend +``` + +--- + +## Architecture Flow Diagram + +```mermaid +flowchart TD + Start([User Types Question]) --> Input[Frontend Input Handler] + + Input --> Disable[Disable Input & Show Loading] + Disable --> Display[Display User Message] + Display --> POST[POST /api/query] + + POST --> Validate[FastAPI: Validate Request] + Validate --> CheckSession{Session Exists?} + + CheckSession -->|No| CreateSession[Create New Session] + CheckSession -->|Yes| GetHistory[Get Conversation History] + CreateSession --> GetHistory + + GetHistory --> RAGQuery[RAG System: query method] + + RAGQuery --> AIGen[AI Generator: generate_response] + + AIGen --> BuildPrompt[Build System Prompt + Context] + BuildPrompt --> ClaudeCall1[Claude API Call #1
with Tools] + + ClaudeCall1 --> Decision{Claude Decision} + + Decision -->|General Question| DirectAnswer[Return Direct Answer] + Decision -->|Needs Search| ToolUse[Tool Use: search_course_content] + + ToolUse --> ParseParams[Parse: query, course_name, lesson_number] + ParseParams --> SearchExec[Execute Search Tool] + + SearchExec --> ResolveCourse{Course Name
Provided?} + + ResolveCourse -->|Yes| SemanticCourse[Semantic Search in course_catalog
Find best matching course] + ResolveCourse -->|No| BuildFilter[Build Metadata Filter] + SemanticCourse --> BuildFilter + + BuildFilter --> VectorSearch[ChromaDB Vector Search
on course_content] + + VectorSearch --> EmbedQuery[Generate Query Embedding
all-MiniLM-L6-v2] + EmbedQuery --> FindSimilar[Find Top 5 Similar Chunks
Cosine Similarity] + + FindSimilar --> FormatResults[Format Results with
Course & Lesson Context] + FormatResults --> TrackSources[Track Sources] + + TrackSources --> ReturnResults[Return Formatted Results] + ReturnResults --> ClaudeCall2[Claude API Call #2
with Tool Results] + + ClaudeCall2 --> Synthesize[Claude Synthesizes Answer
from Retrieved Context] + + Synthesize --> FinalAnswer[Return Final Answer] + DirectAnswer --> Merge[Merge Paths] + FinalAnswer --> Merge + + Merge --> ExtractSources[Extract Sources from Tools] + ExtractSources --> UpdateHistory[Update Conversation History] + + UpdateHistory --> ReturnRAG[Return answer + sources] + ReturnRAG --> FormatJSON[Format JSON Response] + + FormatJSON --> SendResponse[Send Response to Frontend] + SendResponse --> RemoveLoading[Remove Loading Animation] + + RemoveLoading --> RenderMarkdown[Render Markdown Answer] + RenderMarkdown --> ShowSources[Display Sources Collapsible] + ShowSources --> EnableInput[Re-enable Input] + + EnableInput --> End([User Sees Answer]) + + style Start fill:#e1f5e1 + style End fill:#e1f5e1 + style ClaudeCall1 fill:#fff3cd + style ClaudeCall2 fill:#fff3cd + style VectorSearch fill:#d1ecf1 + style SemanticCourse fill:#d1ecf1 + style POST fill:#f8d7da + style SendResponse fill:#f8d7da +``` + +--- + +## Component Architecture + +```mermaid +graph TB + subgraph Frontend ["🖥️ Frontend Layer"] + UI[HTML/CSS UI] + JS[JavaScript
script.js] + Marked[Marked.js
Markdown Rendering] + end + + subgraph API ["🚪 API Layer"] + FastAPI[FastAPI Server
app.py] + CORS[CORS Middleware] + Routes[API Routes
/api/query
/api/courses] + end + + subgraph Core ["🎯 Core RAG System"] + RAGSystem[RAG System
rag_system.py] + Config[Configuration
config.py] + Models[Data Models
models.py] + end + + subgraph Processing ["⚙️ Processing Components"] + DocProc[Document Processor
document_processor.py] + VectorStore[Vector Store
vector_store.py] + AIGen[AI Generator
ai_generator.py] + SessionMgr[Session Manager
session_manager.py] + ToolMgr[Tool Manager
search_tools.py] + end + + subgraph External ["🌐 External Services"] + Claude[Anthropic Claude
Sonnet 4] + ChromaDB[(ChromaDB
Vector Database)] + SentenceT[Sentence Transformers
all-MiniLM-L6-v2] + end + + subgraph Data ["📚 Data Storage"] + Catalog[(course_catalog
Collection)] + Content[(course_content
Collection)] + Sessions[(Session History
In-Memory)] + end + + %% Frontend connections + UI <--> JS + JS <--> Marked + JS -->|HTTP POST| FastAPI + + %% API connections + FastAPI --> CORS + FastAPI --> Routes + Routes --> RAGSystem + + %% Core connections + RAGSystem --> Config + RAGSystem --> Models + RAGSystem --> DocProc + RAGSystem --> VectorStore + RAGSystem --> AIGen + RAGSystem --> SessionMgr + RAGSystem --> ToolMgr + + %% Processing connections + AIGen -->|API Calls| Claude + ToolMgr --> VectorStore + VectorStore --> ChromaDB + VectorStore --> SentenceT + DocProc --> Models + + %% Data connections + ChromaDB --> Catalog + ChromaDB --> Content + SessionMgr --> Sessions + VectorStore -.->|Read/Write| Catalog + VectorStore -.->|Read/Write| Content + + %% Styling + classDef frontend fill:#e1f5e1 + classDef api fill:#fff3cd + classDef core fill:#f8d7da + classDef processing fill:#d1ecf1 + classDef external fill:#e7d4f5 + classDef data fill:#ffd6a5 + + class UI,JS,Marked frontend + class FastAPI,CORS,Routes api + class RAGSystem,Config,Models core + class DocProc,VectorStore,AIGen,SessionMgr,ToolMgr processing + class Claude,ChromaDB,SentenceT external + class Catalog,Content,Sessions data +``` + +--- + +## Data Flow by Stage + +### Stage 1: User Input → API Request +``` +User Input + ↓ +Frontend validates input + ↓ +Display user message + ↓ +Show loading animation + ↓ +HTTP POST /api/query + { + "query": "What is MCP?", + "session_id": "abc123" or null + } +``` + +### Stage 2: API Processing +``` +FastAPI receives request + ↓ +Validate request body (Pydantic) + ↓ +Check/Create session_id + ↓ +Pass to RAG System +``` + +### Stage 3: RAG System Orchestration +``` +RAG System receives query + ↓ +Get conversation history (if session exists) + ↓ +Prepare tools (CourseSearchTool) + ↓ +Pass to AI Generator with context +``` + +### Stage 4: AI Decision & Execution +``` +AI Generator → Claude API + ↓ +Claude analyzes query + history + available tools + ↓ +Decision Branch: + │ + ├─→ [General Knowledge] + │ Claude answers directly + │ No tool use + │ + └─→ [Course-Specific] + Claude decides to use search tool + Returns: tool_use block + ↓ + Tool Manager executes search + ↓ + Vector Store performs search + ↓ + ChromaDB returns results + ↓ + Tool formats results + ↓ + Results sent back to Claude + ↓ + Claude synthesizes final answer +``` + +### Stage 5: Vector Search Details (When Tool Used) +``` +Search Tool receives parameters + { + "query": "explain MCP", + "course_name": "Introduction to MCP", + "lesson_number": 1 + } + ↓ +Vector Store processes: + 1. Resolve course name (semantic) + - Query course_catalog collection + - Find best matching course title + - Example: "Intro MCP" → "Introduction to MCP" + + 2. Build metadata filter + - course_title: "Introduction to MCP" + - lesson_number: 1 + + 3. Generate query embedding + - Use all-MiniLM-L6-v2 + - Convert query to 384-dim vector + + 4. Search course_content + - Cosine similarity search + - Filter by metadata + - Return top 5 chunks + + 5. Format results + - Add course/lesson context + - Track sources for UI +``` + +### Stage 6: Response Assembly +``` +AI Generator returns final answer + ↓ +RAG System extracts sources + ↓ +Update conversation history + ↓ +Return (answer, sources) tuple + ↓ +FastAPI formats JSON response + { + "answer": "MCP stands for...", + "sources": [ + "Introduction to MCP - Lesson 1", + "Introduction to MCP - Lesson 2" + ], + "session_id": "abc123" + } +``` + +### Stage 7: Frontend Rendering +``` +Frontend receives JSON response + ↓ +Remove loading animation + ↓ +Parse markdown in answer + (using marked.js) + ↓ +Render answer in chat + ↓ +Display sources in collapsible section + ↓ +Re-enable input field + ↓ +Auto-scroll to bottom + ↓ +User sees complete answer +``` + +--- + +## Key Technologies & Their Roles + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| **Frontend** | Vanilla JavaScript | User interface & HTTP requests | +| **API Server** | FastAPI (Python) | HTTP endpoints & request handling | +| **AI Model** | Claude Sonnet 4 | Natural language understanding & generation | +| **Tool System** | Anthropic Tool Calling | Enables AI to search when needed | +| **Vector DB** | ChromaDB | Stores & searches document embeddings | +| **Embeddings** | all-MiniLM-L6-v2 | Converts text to 384-dim vectors | +| **Chunking** | Sentence-based | 800 chars with 100 char overlap | +| **Session** | In-memory dict | Maintains conversation context | +| **Rendering** | Marked.js | Markdown to HTML conversion | + +--- + +## Performance Characteristics + +### Latency Breakdown (Typical Query) + +``` +Frontend Processing: ~50ms + - Input validation + - UI updates + - HTTP request prep + +Network (to server): ~50ms + - Depends on connection + +Backend Processing: ~100ms + - FastAPI routing + - Session management + - RAG system setup + +AI Processing: ~2-4 seconds + - First Claude call: ~1-2s + - Tool execution: ~500ms + - Second Claude call: ~1-2s + +Vector Search: ~200-500ms + - Course resolution: ~100ms + - Content search: ~200ms + - Result formatting: ~50ms + +Response Assembly: ~50ms + - Source extraction + - History update + - JSON formatting + +Network (to client): ~50ms + +Frontend Rendering: ~100ms + - Markdown parsing + - DOM updates + - Source display + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Total User-Perceived Time: ~3-5 seconds +``` + +### Bottlenecks & Optimizations + +1. **AI API Calls** (Largest bottleneck) + - Sequential tool calling requires 2 API calls + - Anthropic API latency varies by load + - Optimization: Caching common responses + +2. **Vector Search** (Usually fast) + - ChromaDB is optimized for similarity search + - In-memory for small datasets + - Optimization: Pre-computed embeddings + +3. **Network Latency** (Variable) + - Depends on user connection + - Multiple round trips + - Optimization: Response streaming (future) + +--- + +## Configuration Values + +From `config.py`: + +```python +CHUNK_SIZE = 800 # Characters per chunk +CHUNK_OVERLAP = 100 # Overlapping characters +MAX_RESULTS = 5 # Top-K search results +MAX_HISTORY = 2 # Messages to remember +EMBEDDING_MODEL = "all-MiniLM-L6-v2" # Sentence transformer +ANTHROPIC_MODEL = "claude-sonnet-4-20250514" # AI model +``` + +These values balance: +- **Chunk size**: Large enough for context, small enough for precision +- **Overlap**: Prevents context loss at boundaries +- **Max results**: Enough variety without overwhelming the AI +- **History**: Recent context without token bloat + +--- + +## Error Handling Flow + +```mermaid +flowchart TD + Start[Query Initiated] --> Try{Try Block} + + Try -->|Success| Normal[Normal Flow] + Try -->|Error| Catch[Catch Exception] + + Catch --> ErrorType{Error Type} + + ErrorType -->|Network| NetError[Network Error
Display: Connection failed] + ErrorType -->|API 500| ServerError[Server Error
Display: Server error] + ErrorType -->|Validation| ValidError[Validation Error
Display: Invalid input] + ErrorType -->|Tool| ToolError[Tool Error
Display: Search failed] + ErrorType -->|AI| AIError[AI Error
Display: AI service error] + + NetError --> Recovery[Frontend Recovery] + ServerError --> Recovery + ValidError --> Recovery + ToolError --> Recovery + AIError --> Recovery + + Recovery --> RemoveLoad[Remove Loading Animation] + RemoveLoad --> ShowError[Display Error Message] + ShowError --> EnableInput[Re-enable Input] + + Normal --> Success[Display Answer] + EnableInput --> Ready[Ready for Next Query] + Success --> Ready + + style Start fill:#e1f5e1 + style Ready fill:#e1f5e1 + style Catch fill:#f8d7da + style NetError fill:#f8d7da + style ServerError fill:#f8d7da + style ValidError fill:#fff3cd + style ToolError fill:#fff3cd + style AIError fill:#f8d7da +``` + +--- + +## Summary + +This RAG chatbot system implements a sophisticated query flow that: + +1. ✅ **Maintains Conversation Context** - Session management for multi-turn dialogues +2. ✅ **Makes Smart Decisions** - AI decides when to search vs. answer directly +3. ✅ **Performs Semantic Search** - Vector similarity for relevant content retrieval +4. ✅ **Handles Flexible Queries** - Fuzzy course name matching, optional filters +5. ✅ **Provides Source Citations** - Tracks and displays where answers come from +6. ✅ **Delivers Fast Responses** - Optimized pipeline with parallel operations +7. ✅ **Handles Errors Gracefully** - Comprehensive error handling throughout + +The system demonstrates modern RAG architecture with tool-calling AI, semantic search, and clean separation of concerns across frontend, API, and backend layers. diff --git a/frontend/index.html b/frontend/index.html index f8e25a62f..fc95a0972 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,6 +19,14 @@

Course Materials Assistant

+ +
+ +
+
diff --git a/frontend/script.js b/frontend/script.js index 562a8a363..cb79cecbc 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -30,6 +30,12 @@ function setupEventListeners() { }); + // NEW: New Chat button + const newChatButton = document.getElementById('newChatButton'); + if (newChatButton) { + newChatButton.addEventListener('click', handleNewChat); + } + // Suggested questions document.querySelectorAll('.suggested-item').forEach(button => { button.addEventListener('click', (e) => { @@ -71,7 +77,10 @@ async function sendMessage() { }) }); - if (!response.ok) throw new Error('Query failed'); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Query failed' })); + throw new Error(errorData.detail || 'Query failed'); + } const data = await response.json(); @@ -115,25 +124,36 @@ function addMessage(content, type, sources = null, isWelcome = false) { 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) { + // Create clickable source links + const sourceLinks = sources.map(source => { + if (source.link) { + // Create clickable link with security attributes + return `${escapeHtml(source.text)}`; + } else { + // No link available, render as plain text + return `${escapeHtml(source.text)}`; + } + }).join(''); + html += `
Sources -
${sources.join(', ')}
+
${sourceLinks}
`; } - + messageDiv.innerHTML = html; chatMessages.appendChild(messageDiv); chatMessages.scrollTop = chatMessages.scrollHeight; - + return messageId; } @@ -152,6 +172,21 @@ async function createNewSession() { 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); } +function handleNewChat() { + // Call existing session reset logic + createNewSession(); + + // Focus on input for immediate typing + chatInput.focus(); + + // Optional: Add visual feedback (brief flash or animation) + const newChatButton = document.getElementById('newChatButton'); + newChatButton.style.transform = 'scale(0.95)'; + setTimeout(() => { + newChatButton.style.transform = ''; + }, 150); +} + // Load course statistics async function loadCourseStats() { try { diff --git a/frontend/style.css b/frontend/style.css index 825d03675..b310c6414 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -111,6 +111,39 @@ header h1 { margin-bottom: 0; } +/* New Chat Button */ +.new-chat-button { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: color 0.2s ease; + text-align: left; +} + +.new-chat-button:hover { + color: var(--primary-color); +} + +.new-chat-button:active { + transform: scale(0.98); +} + +.new-chat-icon { + font-size: 0.875rem; + font-weight: 600; + line-height: 1; +} + /* Main Chat Area */ .chat-main { flex: 1; @@ -241,8 +274,65 @@ header h1 { } .sources-content { - padding: 0 0.5rem 0.25rem 1.5rem; + padding: 0.5rem 0.5rem 0.25rem 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* Source Links - Modern Badge Style */ +.source-link { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.75rem; + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 6px; + color: var(--primary-color); + text-decoration: none; + font-size: 0.8rem; + font-weight: 500; + transition: all 0.2s ease; + cursor: pointer; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-link:hover { + background: rgba(59, 130, 246, 0.2); + border-color: rgba(59, 130, 246, 0.5); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); +} + +.source-link:active { + transform: translateY(0); +} + +.source-link:focus { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +.source-link::before { + content: "📚"; + margin-right: 0.4rem; + font-size: 0.9rem; +} + +/* Source without link - plain text badge styling */ +.source-text { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.75rem; + background: rgba(148, 163, 184, 0.1); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 6px; color: var(--text-secondary); + font-size: 0.8rem; + font-weight: 500; } /* Markdown formatting styles */ diff --git a/pyproject.toml b/pyproject.toml index 3f05e2de0..47f89b5ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,4 +12,21 @@ dependencies = [ "uvicorn==0.35.0", "python-multipart==0.0.20", "python-dotenv==1.1.1", + "openai>=2.14.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-mock>=3.12.0", + "pytest-cov>=4.1.0", +] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", ] diff --git a/test_sequential.py b/test_sequential.py new file mode 100644 index 000000000..76cd9eb2b --- /dev/null +++ b/test_sequential.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Test script for sequential tool calling""" +import requests +import json + +def test_query(query): + url = "http://localhost:8000/api/query" + payload = {"query": query} + + print(f"\n{'='*80}") + print(f"QUERY: {query}") + print(f"{'='*80}\n") + + response = requests.post(url, json=payload) + + if response.status_code == 200: + data = response.json() + print("FULL RESPONSE DATA:") + print(json.dumps(data, indent=2)) + print("\n" + "="*80) + if 'response' in data: + print("\nRESPONSE:") + print(data['response']) + print("\nSOURCES:") + for source in data.get('sources', []): + print(f" - {source['text']}") + if source.get('link'): + print(f" Link: {source['link']}") + else: + print(f"Error: {response.status_code}") + print(response.text) + +if __name__ == "__main__": + # Test 1: Query that should trigger sequential tool calling + # Expected: get_course_outline (to find lesson 3) → search_course_content (to get details) + test_query("What does lesson 3 of the MCP course cover?") + + # Test 2: Another sequential query + # Expected: get_course_outline (to see structure) → possibly search for specifics + test_query("Compare what's taught in lesson 1 versus lesson 2 of the MCP course") diff --git a/uv.lock b/uv.lock index 9ae65c557..ab5f701ab 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -239,6 +239,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -470,6 +531,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" @@ -860,6 +930,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/15/d75fd66aba116ce3732bb1050401394c5ec52074c4f7ee18db8838dd4667/onnxruntime-1.22.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7e823624b015ea879d976cbef8bfaed2f7e2cc233d7506860a76dd37f8f381", size = 16477261, upload-time = "2025-07-10T19:16:03.226Z" }, ] +[[package]] +name = "openai" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/b1/12fe1c196bea326261718eb037307c1c1fe1dedc2d2d4de777df822e6238/openai-2.14.0.tar.gz", hash = "sha256:419357bedde9402d23bf8f2ee372fca1985a73348debba94bddff06f19459952", size = 626938, upload-time = "2025-12-19T03:28:45.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183", size = 1067558, upload-time = "2025-12-19T03:28:43.727Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.35.0" @@ -1038,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 = "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 +1305,60 @@ 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 = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1555,22 +1707,53 @@ dependencies = [ { name = "anthropic" }, { name = "chromadb" }, { name = "fastapi" }, + { name = "openai" }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "sentence-transformers" }, { name = "uvicorn" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + [package.metadata] requires-dist = [ { name = "anthropic", specifier = "==0.58.2" }, { name = "chromadb", specifier = "==1.0.15" }, { name = "fastapi", specifier = "==0.116.1" }, + { name = "openai", specifier = ">=2.14.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, { name = "python-dotenv", specifier = "==1.1.1" }, { name = "python-multipart", specifier = "==0.0.20" }, { name = "sentence-transformers", specifier = "==5.0.0" }, { name = "uvicorn", specifier = "==0.35.0" }, ] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, +] [[package]] name = "sympy"