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/.flake8 b/.flake8 new file mode 100644 index 000000000..a23955597 --- /dev/null +++ b/.flake8 @@ -0,0 +1,16 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203, E266, E501, W503 +exclude = + .git, + __pycache__, + .venv, + venv, + .eggs, + *.egg, + dist, + build, + chroma_db +max-complexity = 10 +per-file-ignores = + __init__.py:F401 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..ce6e79c74 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + quality-checks: + name: Code Quality & Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install dependencies + run: | + uv sync + + - name: Run Black (formatting check) + run: | + uv run black --check backend/ *.py + continue-on-error: false + + - name: Run isort (import sorting check) + run: | + uv run isort --check-only backend/ *.py + continue-on-error: false + + - name: Run flake8 (linting) + run: | + uv run flake8 backend/ *.py + continue-on-error: false + + - name: Run mypy (type checking) + run: | + uv run mypy backend/ *.py + continue-on-error: true # Allow type checking to fail without blocking + + - name: Run pytest (tests with coverage) + run: | + # Set API provider based on available secrets + if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then + export API_PROVIDER=anthropic + export ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" + elif [ -n "${{ secrets.OPENROUTER_API_KEY }}" ]; then + export API_PROVIDER=openrouter + export ANTHROPIC_API_KEY="${{ secrets.OPENROUTER_API_KEY }}" + export OPENROUTER_MODEL="anthropic/claude-3.5-sonnet" + fi + + uv run pytest backend/tests/ -v --cov=backend --cov-report=term-missing --cov-report=xml + + - name: Upload coverage reports + uses: codecov/codecov-action@v4 + if: always() + with: + file: ./coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + summary: + name: CI Summary + runs-on: ubuntu-latest + needs: [quality-checks] + if: always() + + steps: + - name: Check CI Status + run: | + if [ "${{ needs.quality-checks.result }}" == "success" ]; then + echo "✅ All quality checks and tests passed!" + exit 0 + else + echo "❌ Some checks failed. Please review the logs above." + exit 1 + fi diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..8452b0f2f --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,57 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + diff --git a/.github/workflows/claude-code.yml b/.github/workflows/claude-code.yml new file mode 100644 index 000000000..6b82f4124 --- /dev/null +++ b/.github/workflows/claude-code.yml @@ -0,0 +1,124 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + claude-code: + # Only run on comments that mention @claude code + if: | + github.event.comment.body != null && + contains(github.event.comment.body, '@claude code') + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get PR branch + id: get-branch + if: github.event.issue.pull_request + run: | + PR_NUMBER=${{ github.event.issue.number }} + BRANCH=$(gh pr view $PR_NUMBER --json headRefName --jq '.headRefName') + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Checkout PR branch + if: github.event.issue.pull_request + run: | + git fetch origin ${{ steps.get-branch.outputs.branch }} + git checkout ${{ steps.get-branch.outputs.branch }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync + + - name: Extract task from comment + id: extract-task + run: | + COMMENT_BODY="${{ github.event.comment.body }}" + # Remove @claude code prefix and extract the actual task + TASK=$(echo "$COMMENT_BODY" | sed 's/@claude code//g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + echo "task=$TASK" >> $GITHUB_OUTPUT + + - name: Add reaction to comment + run: | + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + -f content='eyes' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Post processing status + run: | + gh pr comment ${{ github.event.issue.number }} \ + --body "🤖 Claude Code is processing your request: \`${{ steps.extract-task.outputs.task }}\`" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Run Claude Code task + id: claude-task + run: | + # Set API provider based on available secrets + if [ -n "${{ secrets.ANTHROPIC_API_KEY }}" ]; then + export API_PROVIDER=anthropic + export ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" + elif [ -n "${{ secrets.OPENROUTER_API_KEY }}" ]; then + export API_PROVIDER=openrouter + export ANTHROPIC_API_KEY="${{ secrets.OPENROUTER_API_KEY }}" + export OPENROUTER_MODEL="anthropic/claude-3.5-sonnet" + fi + + # This is a placeholder - you would integrate with Claude Code CLI here + echo "Task: ${{ steps.extract-task.outputs.task }}" + echo "Using API Provider: ${API_PROVIDER:-none}" + echo "Note: Full Claude Code integration requires additional setup" + + - name: Commit and push changes + if: success() + run: | + git config user.name "Claude Code Bot" + git config user.email "claude-code-bot@users.noreply.github.com" + + if [[ -n $(git status -s) ]]; then + git add . + git commit -m "🤖 Claude Code: ${{ steps.extract-task.outputs.task }}" + git push + + gh pr comment ${{ github.event.issue.number }} \ + --body "✅ Changes have been committed to this PR" + else + gh pr comment ${{ github.event.issue.number }} \ + --body "â„šī¸ No changes were made" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Handle errors + if: failure() + run: | + gh pr comment ${{ github.event.issue.number }} \ + --body "❌ Claude Code encountered an error processing your request. Please check the workflow logs for details." + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 000000000..214c26fc7 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,125 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + claude-review: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch base branch + run: | + git fetch origin ${{ github.base_ref }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync + + - name: Get changed files + id: changed-files + run: | + git diff --name-only origin/${{ github.base_ref }}...HEAD > changed_files.txt + echo "Changed files:" + cat changed_files.txt + + - name: Analyze code changes + id: analyze + run: | + echo "## 🤖 Claude Code Review" > review.md + echo "" >> review.md + + # Count changes + CHANGED_FILES=$(wc -l < changed_files.txt) + echo "### Summary" >> review.md + echo "- **Files changed**: $CHANGED_FILES" >> review.md + echo "" >> review.md + + # Analyze Python files + PYTHON_FILES=$(grep '\.py$' changed_files.txt || true) + if [ -n "$PYTHON_FILES" ]; then + echo "### Python Files Analysis" >> review.md + echo "" >> review.md + + while IFS= read -r file; do + if [ -f "$file" ]; then + echo "#### \`$file\`" >> review.md + LINES=$(wc -l < "$file") + echo "- Lines of code: $LINES" >> review.md + echo "" >> review.md + fi + done <<< "$PYTHON_FILES" + fi + + # Check for common issues + echo "### Code Quality Checks" >> review.md + echo "" >> review.md + + # Run quick linting checks + if echo "$PYTHON_FILES" | xargs -I {} test -f {} && uv run flake8 $PYTHON_FILES --count --exit-zero --max-line-length=100 > flake8_output.txt 2>&1; then + ISSUES=$(cat flake8_output.txt | tail -n 1) + if [ "$ISSUES" != "0" ]; then + echo "âš ī¸ **Flake8 found $ISSUES issues**" >> review.md + echo '```' >> review.md + cat flake8_output.txt >> review.md + echo '```' >> review.md + else + echo "✅ No linting issues found" >> review.md + fi + fi + echo "" >> review.md + + # Check test coverage + if [ -d "backend/tests" ]; then + echo "### Test Coverage Reminder" >> review.md + echo "" >> review.md + echo "📝 Please ensure you've added tests for new functionality:" >> review.md + echo "- Unit tests in \`backend/tests/\`" >> review.md + echo "- Run tests with: \`uv run pytest backend/tests/\`" >> review.md + echo "" >> review.md + fi + + # Add suggestions + echo "### Recommendations" >> review.md + echo "" >> review.md + echo "- ✅ Run \`./scripts/check.sh\` before committing" >> review.md + echo "- ✅ Ensure all tests pass locally" >> review.md + echo "- ✅ Update documentation if needed" >> review.md + echo "" >> review.md + echo "---" >> review.md + echo "*This review was automatically generated by Claude Code*" >> review.md + + - name: Post review comment + run: | + gh pr comment ${{ github.event.pull_request.number }} \ + --body-file review.md + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Add review reaction + run: | + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews \ + -f event='COMMENT' \ + -f body='🤖 Automated code review completed. Check the comments above for details.' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..d300267f1 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..b36f54686 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,226 @@ +# 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 +``` + +### Code Quality Tools + +This project uses several code quality tools to maintain consistent, high-quality code: + +**Tools:** +- **black**: Code formatter (line length: 100) +- **isort**: Import sorter (compatible with black) +- **flake8**: Linting and style checking +- **mypy**: Static type checking + +**Quality Check Scripts:** + +```bash +# Format code (auto-fix) +./scripts/format.sh + +# Run linting and type checking +./scripts/lint.sh + +# Run all checks (format check, lint, type check, tests) +./scripts/check.sh +``` + +**Manual Commands:** + +```bash +# Format code +uv run black backend/ *.py +uv run isort backend/ *.py + +# Check formatting without modifying +uv run black --check backend/ *.py +uv run isort --check-only backend/ *.py + +# Run linting +uv run flake8 backend/ *.py + +# Run type checking +uv run mypy backend/ *.py +``` + +**Configuration:** +- Tool settings are in `pyproject.toml` ([tool.black], [tool.isort], [tool.mypy]) +- Flake8 settings are in `.flake8` (doesn't support pyproject.toml) +- All tools configured to use 100-character line length +- Excludes: `.venv`, `venv`, `chroma_db` + +**Pre-commit Checklist:** +Before committing code, run `./scripts/check.sh` to ensure all quality checks pass. + +## 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..f09e6445a 100644 --- a/backend/ai_generator.py +++ b/backend/ai_generator.py @@ -1,25 +1,42 @@ +import json +from typing import Dict, List, Optional + import anthropic -from typing import List, Optional, Dict, Any +from openai import OpenAI + 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 -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 +- **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 + +- **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,108 +45,220 @@ 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": 300, # Llama free model has higher limits } - - def generate_response(self, query: str, - conversation_history: Optional[str] = None, - tools: Optional[List] = None, - tool_manager=None) -> str: + + def generate_response( + self, + query: str, + conversation_history: Optional[str] = None, + tools: Optional[List] = None, + tool_manager=None, + ) -> str: """ Generate AI response with optional tool usage and conversation context. - + Args: query: The user's question or request conversation_history: Previous messages for context tools: Available tools the AI can use tool_manager: Manager to execute tools - + Returns: Generated response as string """ - + 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 - final_params = { - **self.base_params, - "messages": messages, - "system": base_params["system"] - } - - # Get final response + + # 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": system_content} + 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 diff --git a/backend/app.py b/backend/app.py index 5a69d741d..79d6797a6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,25 +1,25 @@ +import os import warnings -warnings.filterwarnings("ignore", message="resource_tracker: There appear to be.*") +from typing import List, Optional +from config import config from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from fastapi.middleware.trustedhost import TrustedHostMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from models import Source from pydantic import BaseModel -from typing import List, Optional -import os - -from config import config from rag_system import RAGSystem +# Filter resource tracker warnings +warnings.filterwarnings("ignore", message="resource_tracker: There appear to be.*") + # Initialize FastAPI app app = FastAPI(title="Course Materials RAG System", root_path="") # Add trusted host middleware for proxy -app.add_middleware( - TrustedHostMiddleware, - allowed_hosts=["*"] -) +app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"]) # Enable CORS with proper settings for proxy app.add_middleware( @@ -34,25 +34,33 @@ # Initialize RAG system rag_system = RAGSystem(config) + # Pydantic models for request/response class QueryRequest(BaseModel): """Request model for course queries""" + query: str session_id: Optional[str] = None + class QueryResponse(BaseModel): """Response model for course queries""" + answer: str - sources: List[str] + sources: List[Source] session_id: str + class CourseStats(BaseModel): """Response model for course statistics""" + total_courses: int course_titles: List[str] + # API Endpoints + @app.post("/api/query", response_model=QueryResponse) async def query_documents(request: QueryRequest): """Process a query and return response with sources""" @@ -61,30 +69,27 @@ async def query_documents(request: QueryRequest): session_id = request.session_id if not session_id: session_id = rag_system.session_manager.create_session() - + # Process query using RAG system answer, sources = rag_system.query(request.query, session_id) - - return QueryResponse( - answer=answer, - sources=sources, - session_id=session_id - ) + + return QueryResponse(answer=answer, sources=sources, session_id=session_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @app.get("/api/courses", response_model=CourseStats) async def get_course_stats(): """Get course analytics and statistics""" try: analytics = rag_system.get_course_analytics() return CourseStats( - total_courses=analytics["total_courses"], - course_titles=analytics["course_titles"] + total_courses=analytics["total_courses"], course_titles=analytics["course_titles"] ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @app.on_event("startup") async def startup_event(): """Load initial documents on startup""" @@ -97,13 +102,8 @@ async def startup_event(): except Exception as e: print(f"Error loading documents: {e}") -# Custom static file handler with no-cache headers for development -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse -import os -from pathlib import Path - +# Custom static file handler with no-cache headers for development class DevStaticFiles(StaticFiles): async def get_response(self, path: str, scope): response = await super().get_response(path, scope) @@ -113,7 +113,7 @@ async def get_response(self, path: str, scope): response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response - - + + # Serve static files for the frontend -app.mount("/", StaticFiles(directory="../frontend", html=True), name="static") \ No newline at end of file +app.mount("/", StaticFiles(directory="../frontend", html=True), name="static") diff --git a/backend/config.py b/backend/config.py index d9f6392ef..8bfee8faf 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,29 +1,43 @@ import os from dataclasses import dataclass +from pathlib import Path + from dotenv import load_dotenv -# 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" - + # Document processing settings - CHUNK_SIZE: int = 800 # Size of text chunks for vector storage - CHUNK_OVERLAP: int = 100 # Characters to overlap between chunks - MAX_RESULTS: int = 5 # Maximum search results to return - MAX_HISTORY: int = 2 # Number of conversation messages to remember - + CHUNK_SIZE: int = 800 # Size of text chunks for vector storage + CHUNK_OVERLAP: int = 100 # Characters to overlap between chunks + MAX_RESULTS: int = 5 # Maximum search results to return + MAX_HISTORY: int = 2 # Number of conversation messages to remember + MAX_TOOL_ROUNDS: int = 2 # Maximum sequential tool calling rounds + # Database paths CHROMA_PATH: str = "./chroma_db" # ChromaDB storage location -config = Config() - +config = Config() diff --git a/backend/document_processor.py b/backend/document_processor.py index 266e85904..321b72f5d 100644 --- a/backend/document_processor.py +++ b/backend/document_processor.py @@ -1,83 +1,85 @@ import os import re from typing import List, Tuple -from models import Course, Lesson, CourseChunk + +from models import Course, CourseChunk, Lesson + class DocumentProcessor: """Processes course documents and extracts structured information""" - + def __init__(self, chunk_size: int, chunk_overlap: int): self.chunk_size = chunk_size self.chunk_overlap = chunk_overlap - + def read_file(self, file_path: str) -> str: """Read content from file with UTF-8 encoding""" try: - with open(file_path, 'r', encoding='utf-8') as file: + with open(file_path, "r", encoding="utf-8") as file: return file.read() except UnicodeDecodeError: # If UTF-8 fails, try with error handling - with open(file_path, 'r', encoding='utf-8', errors='ignore') as file: + with open(file_path, "r", encoding="utf-8", errors="ignore") as file: return file.read() - - def chunk_text(self, text: str) -> List[str]: """Split text into sentence-based chunks with overlap using config settings""" - + # Clean up the text - text = re.sub(r'\s+', ' ', text.strip()) # Normalize whitespace - + text = re.sub(r"\s+", " ", text.strip()) # Normalize whitespace + # Better sentence splitting that handles abbreviations # This regex looks for periods followed by whitespace and capital letters # but ignores common abbreviations - sentence_endings = re.compile(r'(? self.chunk_size and current_chunk: break - + current_chunk.append(sentence) current_size += total_addition - + # Add chunk if we have content if current_chunk: - chunks.append(' '.join(current_chunk)) - + chunks.append(" ".join(current_chunk)) + # Calculate overlap for next chunk - if hasattr(self, 'chunk_overlap') and self.chunk_overlap > 0: + if hasattr(self, "chunk_overlap") and self.chunk_overlap > 0: # Find how many sentences to overlap overlap_size = 0 overlap_sentences = 0 - + # Count backwards from end of current chunk for k in range(len(current_chunk) - 1, -1, -1): - sentence_len = len(current_chunk[k]) + (1 if k < len(current_chunk) - 1 else 0) + sentence_len = len(current_chunk[k]) + ( + 1 if k < len(current_chunk) - 1 else 0 + ) if overlap_size + sentence_len <= self.chunk_overlap: overlap_size += sentence_len overlap_sentences += 1 else: break - + # Move start position considering overlap next_start = i + len(current_chunk) - overlap_sentences i = max(next_start, i + 1) # Ensure we make progress @@ -87,64 +89,65 @@ def chunk_text(self, text: str) -> List[str]: else: # No sentences fit, move to next i += 1 - - return chunks - - + return chunks - - def process_course_document(self, file_path: str) -> Tuple[Course, List[CourseChunk]]: + def process_course_document( # noqa: C901 + self, file_path: str + ) -> Tuple[Course, List[CourseChunk]]: """ Process a course document with expected format: Line 1: Course Title: [title] Line 2: Course Link: [url] Line 3: Course Instructor: [instructor] Following lines: Lesson markers and content + + Note: Function complexity is high due to sequential document parsing logic. + Consider refactoring into smaller helper methods in future iterations. """ content = self.read_file(file_path) filename = os.path.basename(file_path) - - lines = content.strip().split('\n') - + + lines = content.strip().split("\n") + # Extract course metadata from first three lines course_title = filename # Default fallback course_link = None instructor_name = "Unknown" - + # Parse course title from first line if len(lines) >= 1 and lines[0].strip(): - title_match = re.match(r'^Course Title:\s*(.+)$', lines[0].strip(), re.IGNORECASE) + title_match = re.match(r"^Course Title:\s*(.+)$", lines[0].strip(), re.IGNORECASE) if title_match: course_title = title_match.group(1).strip() else: course_title = lines[0].strip() - + # Parse remaining lines for course metadata for i in range(1, min(len(lines), 4)): # Check first 4 lines for metadata line = lines[i].strip() if not line: continue - + # Try to match course link - link_match = re.match(r'^Course Link:\s*(.+)$', line, re.IGNORECASE) + link_match = re.match(r"^Course Link:\s*(.+)$", line, re.IGNORECASE) if link_match: course_link = link_match.group(1).strip() continue - + # Try to match instructor - instructor_match = re.match(r'^Course Instructor:\s*(.+)$', line, re.IGNORECASE) + instructor_match = re.match(r"^Course Instructor:\s*(.+)$", line, re.IGNORECASE) if instructor_match: instructor_name = instructor_match.group(1).strip() continue - + # Create course object with title as ID course = Course( title=course_title, course_link=course_link, - instructor=instructor_name if instructor_name != "Unknown" else None + instructor=instructor_name if instructor_name != "Unknown" else None, ) - + # Process lessons and create chunks course_chunks = [] current_lesson = None @@ -152,32 +155,32 @@ def process_course_document(self, file_path: str) -> Tuple[Course, List[CourseCh lesson_link = None lesson_content = [] chunk_counter = 0 - + # Start processing from line 4 (after metadata) start_index = 3 if len(lines) > 3 and not lines[3].strip(): start_index = 4 # Skip empty line after instructor - + i = start_index while i < len(lines): line = lines[i] - + # Check for lesson markers (e.g., "Lesson 0: Introduction") - lesson_match = re.match(r'^Lesson\s+(\d+):\s*(.+)$', line.strip(), re.IGNORECASE) - + lesson_match = re.match(r"^Lesson\s+(\d+):\s*(.+)$", line.strip(), re.IGNORECASE) + if lesson_match: # Process previous lesson if it exists if current_lesson is not None and lesson_content: - lesson_text = '\n'.join(lesson_content).strip() + lesson_text = "\n".join(lesson_content).strip() if lesson_text: # Add lesson to course lesson = Lesson( lesson_number=current_lesson, title=lesson_title, - lesson_link=lesson_link + lesson_link=lesson_link, ) course.lessons.append(lesson) - + # Create chunks for this lesson chunks = self.chunk_text(lesson_text) for idx, chunk in enumerate(chunks): @@ -186,74 +189,72 @@ def process_course_document(self, file_path: str) -> Tuple[Course, List[CourseCh chunk_with_context = f"Lesson {current_lesson} content: {chunk}" else: chunk_with_context = chunk - + course_chunk = CourseChunk( content=chunk_with_context, course_title=course.title, lesson_number=current_lesson, - chunk_index=chunk_counter + chunk_index=chunk_counter, ) course_chunks.append(course_chunk) chunk_counter += 1 - + # Start new lesson current_lesson = int(lesson_match.group(1)) lesson_title = lesson_match.group(2).strip() lesson_link = None - + # Check if next line is a lesson link if i + 1 < len(lines): next_line = lines[i + 1].strip() - link_match = re.match(r'^Lesson Link:\s*(.+)$', next_line, re.IGNORECASE) + link_match = re.match(r"^Lesson Link:\s*(.+)$", next_line, re.IGNORECASE) if link_match: lesson_link = link_match.group(1).strip() i += 1 # Skip the link line so it's not added to content - + lesson_content = [] else: # Add line to current lesson content lesson_content.append(line) - + i += 1 - + # Process the last lesson if current_lesson is not None and lesson_content: - lesson_text = '\n'.join(lesson_content).strip() + lesson_text = "\n".join(lesson_content).strip() if lesson_text: lesson = Lesson( - lesson_number=current_lesson, - title=lesson_title, - lesson_link=lesson_link + lesson_number=current_lesson, title=lesson_title, lesson_link=lesson_link ) course.lessons.append(lesson) - + chunks = self.chunk_text(lesson_text) for idx, chunk in enumerate(chunks): # For any chunk of each lesson, add lesson context & course title - - chunk_with_context = f"Course {course_title} Lesson {current_lesson} content: {chunk}" - + + chunk_with_context = ( + f"Course {course_title} Lesson {current_lesson} content: {chunk}" + ) + course_chunk = CourseChunk( content=chunk_with_context, course_title=course.title, lesson_number=current_lesson, - chunk_index=chunk_counter + chunk_index=chunk_counter, ) course_chunks.append(course_chunk) chunk_counter += 1 - + # If no lessons found, treat entire content as one document if not course_chunks and len(lines) > 2: - remaining_content = '\n'.join(lines[start_index:]).strip() + remaining_content = "\n".join(lines[start_index:]).strip() if remaining_content: chunks = self.chunk_text(remaining_content) for chunk in chunks: course_chunk = CourseChunk( - content=chunk, - course_title=course.title, - chunk_index=chunk_counter + content=chunk, course_title=course.title, chunk_index=chunk_counter ) course_chunks.append(course_chunk) chunk_counter += 1 - + return course, course_chunks diff --git a/backend/models.py b/backend/models.py index 7f7126fa3..9c623f8c1 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,22 +1,37 @@ -from typing import List, Dict, Optional +from typing import List, Optional + from pydantic import BaseModel + class Lesson(BaseModel): """Represents a lesson within a course""" + lesson_number: int # Sequential lesson number (1, 2, 3, etc.) - title: str # Lesson title + title: str # Lesson title lesson_link: Optional[str] = None # URL link to the lesson + class Course(BaseModel): """Represents a complete course with its lessons""" - title: str # Full course title (used as unique identifier) + + title: str # Full course title (used as unique identifier) course_link: Optional[str] = None # URL link to the course instructor: Optional[str] = None # Course instructor name (optional metadata) - lessons: List[Lesson] = [] # List of lessons in this course + lessons: List[Lesson] = [] # List of lessons in this course + class CourseChunk(BaseModel): """Represents a text chunk from a course for vector storage""" - content: str # The actual text content - course_title: str # Which course this chunk belongs to - lesson_number: Optional[int] = None # Which lesson this chunk is from - chunk_index: int # Position of this chunk in the document \ No newline at end of file + + content: str # The actual text content + course_title: str # Which course this chunk belongs to + lesson_number: Optional[int] = None # Which lesson this chunk is from + chunk_index: int # Position of this chunk in the document + + +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" diff --git a/backend/rag_system.py b/backend/rag_system.py index 50d848c8e..981a87d74 100644 --- a/backend/rag_system.py +++ b/backend/rag_system.py @@ -1,89 +1,118 @@ -from typing import List, Tuple, Optional, Dict import os -from document_processor import DocumentProcessor -from vector_store import VectorStore +from typing import Dict, List, Optional, Tuple + from ai_generator import AIGenerator +from document_processor import DocumentProcessor +from models import Course +from search_tools import CourseOutlineTool, CourseSearchTool, ToolManager from session_manager import SessionManager -from search_tools import ToolManager, CourseSearchTool -from models import Course, Lesson, CourseChunk +from vector_store import VectorStore + class RAGSystem: """Main orchestrator for the Retrieval-Augmented Generation system""" - + def __init__(self, config): self.config = config - + # Initialize core components self.document_processor = DocumentProcessor(config.CHUNK_SIZE, config.CHUNK_OVERLAP) - self.vector_store = VectorStore(config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS) - self.ai_generator = AIGenerator(config.ANTHROPIC_API_KEY, config.ANTHROPIC_MODEL) + self.vector_store = VectorStore( + config.CHROMA_PATH, config.EMBEDDING_MODEL, config.MAX_RESULTS + ) + + # 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]: """ Add a single course document to the knowledge base. - + Args: file_path: Path to the course document - + Returns: Tuple of (Course object, number of chunks created) """ try: # Process the document course, course_chunks = self.document_processor.process_course_document(file_path) - + # Add course metadata to vector store for semantic search self.vector_store.add_course_metadata(course) - + # Add course content chunks to vector store self.vector_store.add_course_content(course_chunks) - + return course, len(course_chunks) except Exception as e: print(f"Error processing course document {file_path}: {e}") return None, 0 - + def add_course_folder(self, folder_path: str, clear_existing: bool = False) -> Tuple[int, int]: """ Add all course documents from a folder. - + Args: folder_path: Path to folder containing course documents clear_existing: Whether to clear existing data first - + Returns: Tuple of (total courses added, total chunks created) """ total_courses = 0 total_chunks = 0 - + # Clear existing data if requested if clear_existing: print("Clearing existing data for fresh rebuild...") self.vector_store.clear_all_data() - + if not os.path.exists(folder_path): print(f"Folder {folder_path} does not exist") return 0, 0 - + # Get existing course titles to avoid re-processing existing_course_titles = set(self.vector_store.get_existing_course_titles()) - + # Process each file in the folder for file_name in os.listdir(folder_path): file_path = os.path.join(folder_path, file_name) - if os.path.isfile(file_path) and file_name.lower().endswith(('.pdf', '.docx', '.txt')): + if os.path.isfile(file_path) and file_name.lower().endswith((".pdf", ".docx", ".txt")): try: # Check if this course might already exist # We'll process the document to get the course ID, but only add if new - course, course_chunks = self.document_processor.process_course_document(file_path) - + course, course_chunks = self.document_processor.process_course_document( + file_path + ) + if course and course.title not in existing_course_titles: # This is a new course - add it to the vector store self.vector_store.add_course_metadata(course) @@ -96,52 +125,52 @@ def add_course_folder(self, folder_path: str, clear_existing: bool = False) -> T print(f"Course already exists: {course.title} - skipping") except Exception as e: print(f"Error processing {file_name}: {e}") - + return total_courses, total_chunks - + def query(self, query: str, session_id: Optional[str] = None) -> Tuple[str, List[str]]: """ Process a user query using the RAG system with tool-based search. - + Args: query: User's question session_id: Optional session ID for conversation context - + Returns: Tuple of (response, sources list - empty for tool-based approach) """ # Create prompt for the AI with clear instructions prompt = f"""Answer this question about course materials: {query}""" - + # Get conversation history if session exists history = None if session_id: history = self.session_manager.get_conversation_history(session_id) - + # Generate response using AI with tools response = self.ai_generator.generate_response( query=prompt, conversation_history=history, tools=self.tool_manager.get_tool_definitions(), - tool_manager=self.tool_manager + tool_manager=self.tool_manager, ) - + # Get sources from the search tool sources = self.tool_manager.get_last_sources() # Reset sources after retrieving them self.tool_manager.reset_sources() - + # Update conversation history if session_id: self.session_manager.add_exchange(session_id, query, response) - + # Return response with sources from tool searches return response, sources - + def get_course_analytics(self) -> Dict: """Get analytics about the course catalog""" return { "total_courses": self.vector_store.get_course_count(), - "course_titles": self.vector_store.get_existing_course_titles() - } \ No newline at end of file + "course_titles": self.vector_store.get_existing_course_titles(), + } diff --git a/backend/search_tools.py b/backend/search_tools.py index adfe82352..332848a09 100644 --- a/backend/search_tools.py +++ b/backend/search_tools.py @@ -1,16 +1,17 @@ -from typing import Dict, Any, Optional, Protocol from abc import ABC, abstractmethod -from vector_store import VectorStore, SearchResults +from typing import Any, Dict, Optional + +from vector_store import SearchResults, VectorStore class Tool(ABC): """Abstract base class for all tools""" - + @abstractmethod def get_tool_definition(self) -> Dict[str, Any]: """Return Anthropic tool definition for this tool""" pass - + @abstractmethod def execute(self, **kwargs) -> str: """Execute the tool with given parameters""" @@ -19,11 +20,11 @@ def execute(self, **kwargs) -> str: class CourseSearchTool(Tool): """Tool for searching course content with semantic course name matching""" - + def __init__(self, vector_store: VectorStore): self.store = vector_store self.last_sources = [] # Track sources from last search - + def get_tool_definition(self) -> Dict[str, Any]: """Return Anthropic tool definition for this tool""" return { @@ -33,46 +34,46 @@ def get_tool_definition(self) -> Dict[str, Any]: "type": "object", "properties": { "query": { - "type": "string", - "description": "What to search for in the course content" + "type": "string", + "description": "What to search for in the course content", }, "course_name": { "type": "string", - "description": "Course title (partial matches work, e.g. 'MCP', 'Introduction')" + "description": "Course title (partial matches work, e.g. 'MCP', 'Introduction')", }, "lesson_number": { "type": "integer", - "description": "Specific lesson number to search within (e.g. 1, 2, 3)" - } + "description": "Specific lesson number to search within (e.g. 1, 2, 3)", + }, }, - "required": ["query"] - } + "required": ["query"], + }, } - - def execute(self, query: str, course_name: Optional[str] = None, lesson_number: Optional[int] = None) -> str: + + def execute( + self, query: str, course_name: Optional[str] = None, lesson_number: Optional[int] = None + ) -> str: """ Execute the search tool with given parameters. - + Args: query: What to search for course_name: Optional course filter lesson_number: Optional lesson filter - + Returns: Formatted search results or error message """ - + # Use the vector store's unified search interface results = self.store.search( - query=query, - course_name=course_name, - lesson_number=lesson_number + query=query, course_name=course_name, lesson_number=lesson_number ) - + # Handle errors if results.error: return results.error - + # Handle empty results if results.is_empty(): filter_info = "" @@ -81,44 +82,132 @@ def execute(self, query: str, course_name: Optional[str] = None, lesson_number: if lesson_number: filter_info += f" in lesson {lesson_number}" return f"No relevant content found{filter_info}." - + # Format and return results return self._format_results(results) - + def _format_results(self, results: SearchResults) -> str: """Format search results with course and lesson context""" formatted = [] sources = [] # Track sources for the UI - + for doc, meta in zip(results.documents, results.metadata): - course_title = meta.get('course_title', 'unknown') - lesson_num = meta.get('lesson_number') - + course_title = meta.get("course_title", "unknown") + lesson_num = meta.get("lesson_number") + # Build context header header = f"[{course_title}" if lesson_num is not None: header += f" - Lesson {lesson_num}" header += "]" - - # Track source for the UI - source = course_title + + # 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""" - + def __init__(self): self.tools = {} - + def register_tool(self, tool: Tool): """Register any tool that implements the Tool interface""" tool_def = tool.get_tool_definition() @@ -127,28 +216,27 @@ def register_tool(self, tool: Tool): raise ValueError("Tool must have a 'name' in its definition") self.tools[tool_name] = tool - def get_tool_definitions(self) -> list: """Get all tool definitions for Anthropic tool calling""" return [tool.get_tool_definition() for tool in self.tools.values()] - + def execute_tool(self, tool_name: str, **kwargs) -> str: """Execute a tool by name with given parameters""" if tool_name not in self.tools: return f"Tool '{tool_name}' not found" - + return self.tools[tool_name].execute(**kwargs) - + def get_last_sources(self) -> list: """Get sources from the last search operation""" # Check all tools for last_sources attribute for tool in self.tools.values(): - if hasattr(tool, 'last_sources') and tool.last_sources: + if hasattr(tool, "last_sources") and tool.last_sources: return tool.last_sources return [] def reset_sources(self): """Reset sources from all tools that track sources""" for tool in self.tools.values(): - if hasattr(tool, 'last_sources'): - tool.last_sources = [] \ No newline at end of file + if hasattr(tool, "last_sources"): + tool.last_sources = [] diff --git a/backend/session_manager.py b/backend/session_manager.py index a5a96b1a1..306632868 100644 --- a/backend/session_manager.py +++ b/backend/session_manager.py @@ -1,61 +1,64 @@ -from typing import Dict, List, Optional from dataclasses import dataclass +from typing import Dict, List, Optional + @dataclass class Message: """Represents a single message in a conversation""" - role: str # "user" or "assistant" + + role: str # "user" or "assistant" content: str # The message content + class SessionManager: """Manages conversation sessions and message history""" - + def __init__(self, max_history: int = 5): self.max_history = max_history self.sessions: Dict[str, List[Message]] = {} self.session_counter = 0 - + def create_session(self) -> str: """Create a new conversation session""" self.session_counter += 1 session_id = f"session_{self.session_counter}" self.sessions[session_id] = [] return session_id - + def add_message(self, session_id: str, role: str, content: str): """Add a message to the conversation history""" if session_id not in self.sessions: self.sessions[session_id] = [] - + message = Message(role=role, content=content) self.sessions[session_id].append(message) - + # Keep conversation history within limits if len(self.sessions[session_id]) > self.max_history * 2: - self.sessions[session_id] = self.sessions[session_id][-self.max_history * 2:] - + self.sessions[session_id] = self.sessions[session_id][-self.max_history * 2 :] + def add_exchange(self, session_id: str, user_message: str, assistant_message: str): """Add a complete question-answer exchange""" self.add_message(session_id, "user", user_message) self.add_message(session_id, "assistant", assistant_message) - + def get_conversation_history(self, session_id: Optional[str]) -> Optional[str]: """Get formatted conversation history for a session""" if not session_id or session_id not in self.sessions: return None - + messages = self.sessions[session_id] if not messages: return None - + # Format messages for context formatted_messages = [] for msg in messages: formatted_messages.append(f"{msg.role.title()}: {msg.content}") - + return "\n".join(formatted_messages) - + def clear_session(self, session_id: str): """Clear all messages from a session""" if session_id in self.sessions: - self.sessions[session_id] = [] \ No newline at end of file + self.sessions[session_id] = [] 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..6bc5b3014 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,253 @@ +""" +Pytest configuration and fixtures for RAG system tests +""" + +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest + +# Add backend directory to path for imports +backend_path = Path(__file__).parent.parent +sys.path.insert(0, str(backend_path)) + +from fastapi.testclient import TestClient # noqa: E402 +from models import Course, Lesson, Source # noqa: E402 +from vector_store import SearchResults # noqa: E402 + + +@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") + + +@pytest.fixture +def mock_session_manager(): + """Mock SessionManager for API testing""" + manager = Mock() + manager.create_session.return_value = "test-session-123" + manager.add_message.return_value = None + manager.get_conversation_history.return_value = [] + return manager + + +@pytest.fixture +def mock_rag_system(mock_session_manager): + """Mock RAGSystem for API testing""" + rag = Mock() + rag.session_manager = mock_session_manager + + # Mock query method + rag.query.return_value = ( + "Test answer from RAG system", + [ + Source( + text="Test Course - Lesson 1", link="https://example.com/lesson/1", type="lesson" + ), + Source( + text="Test Course - Lesson 2", link="https://example.com/lesson/2", type="lesson" + ), + ], + ) + + # Mock get_course_analytics method + rag.get_course_analytics.return_value = { + "total_courses": 2, + "course_titles": ["Introduction to Testing", "Advanced Testing Techniques"], + } + + # Mock add_course_folder method + rag.add_course_folder.return_value = (2, 50) + + return rag + + +@pytest.fixture +def test_app(mock_rag_system): + """Create test FastAPI app without static file mounting""" + from typing import List, Optional + + from fastapi import FastAPI, HTTPException + from fastapi.middleware.cors import CORSMiddleware + + # Import models + from models import Source + from pydantic import BaseModel + + # Create test app + app = FastAPI(title="Course Materials RAG System - Test", root_path="") + + # Add CORS middleware + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Pydantic models + class QueryRequest(BaseModel): + query: str + session_id: Optional[str] = None + + class QueryResponse(BaseModel): + answer: str + sources: List[Source] + session_id: str + + class CourseStats(BaseModel): + total_courses: int + course_titles: List[str] + + # API Endpoints (same as app.py but without static files) + @app.post("/api/query", response_model=QueryResponse) + async def query_documents(request: QueryRequest): + """Process a query and return response with sources""" + try: + session_id = request.session_id + if not session_id: + session_id = mock_rag_system.session_manager.create_session() + + answer, sources = mock_rag_system.query(request.query, session_id) + + return QueryResponse(answer=answer, sources=sources, session_id=session_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/api/courses", response_model=CourseStats) + async def get_course_stats(): + """Get course analytics and statistics""" + try: + analytics = mock_rag_system.get_course_analytics() + return CourseStats( + total_courses=analytics["total_courses"], course_titles=analytics["course_titles"] + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + return app + + +@pytest.fixture +def client(test_app): + """Create TestClient for API testing""" + return TestClient(test_app) + + +@pytest.fixture +def sample_query_request(): + """Sample query request data""" + return {"query": "What is test-driven development?", "session_id": None} + + +@pytest.fixture +def sample_sources(): + """Sample source list for testing""" + return [ + Source(text="Test Course - Lesson 1", link="https://example.com/lesson/1", type="lesson"), + Source(text="Test Course - Lesson 2", link="https://example.com/lesson/2", type="lesson"), + ] diff --git a/backend/tests/test_ai_generator.py b/backend/tests/test_ai_generator.py new file mode 100644 index 000000000..44320e149 --- /dev/null +++ b/backend/tests/test_ai_generator.py @@ -0,0 +1,617 @@ +""" +Tests for AIGenerator - Focus on tool calling and OpenRouter system prompt bug +""" + +from unittest.mock import Mock + +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( # noqa: F841 + 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 api_call in mock_client.messages.create.call_args_list: + call_kwargs = api_call[1] + assert "system" in call_kwargs, "System prompt should be in all API calls" diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 000000000..a85660f96 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,335 @@ +""" +Tests for FastAPI endpoints + +Tests the HTTP API layer including: +- POST /api/query - Query processing endpoint +- GET /api/courses - Course statistics endpoint +- Request/response validation +- Error handling +""" + +import pytest + + +@pytest.mark.api +class TestQueryEndpoint: + """Tests for POST /api/query endpoint""" + + def test_query_with_session_id(self, client, mock_rag_system): + """Test query with explicit session_id""" + # Arrange + request_data = { + "query": "What is test-driven development?", + "session_id": "existing-session-456", + } + + # Act + response = client.post("/api/query", json=request_data) + + # Assert + assert response.status_code == 200 + data = response.json() + + # Verify response structure + assert "answer" in data + assert "sources" in data + assert "session_id" in data + + # Verify values + assert data["answer"] == "Test answer from RAG system" + assert data["session_id"] == "existing-session-456" + assert len(data["sources"]) == 2 + + # Verify source structure + assert data["sources"][0]["text"] == "Test Course - Lesson 1" + assert data["sources"][0]["link"] == "https://example.com/lesson/1" + assert data["sources"][0]["type"] == "lesson" + + # Verify RAG system was called correctly + mock_rag_system.query.assert_called_once_with( + "What is test-driven development?", "existing-session-456" + ) + + def test_query_without_session_id(self, client, mock_rag_system): + """Test query without session_id creates new session""" + # Arrange + request_data = {"query": "How do I write unit tests?"} + + # Act + response = client.post("/api/query", json=request_data) + + # Assert + assert response.status_code == 200 + data = response.json() + + # Should have created new session + assert data["session_id"] == "test-session-123" + mock_rag_system.session_manager.create_session.assert_called_once() + + # Verify query was called with new session + mock_rag_system.query.assert_called_once_with( + "How do I write unit tests?", "test-session-123" + ) + + def test_query_with_empty_string(self, client, mock_rag_system): + """Test query with empty query string""" + # Arrange + request_data = {"query": "", "session_id": "test-session"} + + # Act + response = client.post("/api/query", json=request_data) + + # Assert - Should still process but might return empty results + assert response.status_code == 200 + data = response.json() + assert "answer" in data + assert "sources" in data + + def test_query_missing_required_field(self, client): + """Test query without required 'query' field""" + # Arrange + request_data = {"session_id": "test-session"} + + # Act + response = client.post("/api/query", json=request_data) + + # Assert - Should return validation error + assert response.status_code == 422 # Unprocessable Entity + + def test_query_invalid_json(self, client): + """Test query with invalid JSON""" + # Act + response = client.post( + "/api/query", data="invalid json{", headers={"Content-Type": "application/json"} + ) + + # Assert + assert response.status_code == 422 + + def test_query_rag_system_error(self, client, mock_rag_system): + """Test error handling when RAG system fails""" + # Arrange + mock_rag_system.query.side_effect = Exception("Vector store connection failed") + request_data = {"query": "What is testing?", "session_id": "test-session"} + + # Act + response = client.post("/api/query", json=request_data) + + # Assert + assert response.status_code == 500 + data = response.json() + assert "detail" in data + assert "Vector store connection failed" in data["detail"] + + def test_query_returns_empty_sources(self, client, mock_rag_system): + """Test query that returns no sources""" + # Arrange + mock_rag_system.query.return_value = ("No relevant information found", []) + request_data = {"query": "Nonexistent topic", "session_id": "test-session"} + + # Act + response = client.post("/api/query", json=request_data) + + # Assert + assert response.status_code == 200 + data = response.json() + assert data["answer"] == "No relevant information found" + assert data["sources"] == [] + + def test_query_response_schema(self, client): + """Test that response matches expected schema""" + # Arrange + request_data = {"query": "Test query", "session_id": "test-session"} + + # Act + response = client.post("/api/query", json=request_data) + + # Assert + assert response.status_code == 200 + data = response.json() + + # Required fields + required_fields = ["answer", "sources", "session_id"] + for field in required_fields: + assert field in data, f"Missing required field: {field}" + + # Field types + assert isinstance(data["answer"], str) + assert isinstance(data["sources"], list) + assert isinstance(data["session_id"], str) + + # Source structure if sources exist + if data["sources"]: + source = data["sources"][0] + assert "text" in source + assert "link" in source + assert "type" in source + + +@pytest.mark.api +class TestCoursesEndpoint: + """Tests for GET /api/courses endpoint""" + + def test_get_courses_success(self, client, mock_rag_system): + """Test successful retrieval of course statistics""" + # Act + response = client.get("/api/courses") + + # Assert + assert response.status_code == 200 + data = response.json() + + # Verify response structure + assert "total_courses" in data + assert "course_titles" in data + + # Verify values + assert data["total_courses"] == 2 + assert len(data["course_titles"]) == 2 + assert "Introduction to Testing" in data["course_titles"] + assert "Advanced Testing Techniques" in data["course_titles"] + + # Verify RAG system was called + mock_rag_system.get_course_analytics.assert_called_once() + + def test_get_courses_empty_database(self, client, mock_rag_system): + """Test course stats when no courses exist""" + # Arrange + mock_rag_system.get_course_analytics.return_value = { + "total_courses": 0, + "course_titles": [], + } + + # Act + response = client.get("/api/courses") + + # Assert + assert response.status_code == 200 + data = response.json() + assert data["total_courses"] == 0 + assert data["course_titles"] == [] + + def test_get_courses_error_handling(self, client, mock_rag_system): + """Test error handling when analytics retrieval fails""" + # Arrange + mock_rag_system.get_course_analytics.side_effect = Exception("Database connection error") + + # Act + response = client.get("/api/courses") + + # Assert + assert response.status_code == 500 + data = response.json() + assert "detail" in data + assert "Database connection error" in data["detail"] + + def test_get_courses_response_schema(self, client): + """Test that response matches expected schema""" + # Act + response = client.get("/api/courses") + + # Assert + assert response.status_code == 200 + data = response.json() + + # Required fields + assert "total_courses" in data + assert "course_titles" in data + + # Field types + assert isinstance(data["total_courses"], int) + assert isinstance(data["course_titles"], list) + + # Ensure all course titles are strings + for title in data["course_titles"]: + assert isinstance(title, str) + + def test_get_courses_large_dataset(self, client, mock_rag_system): + """Test course stats with large number of courses""" + # Arrange + course_titles = [f"Course {i}" for i in range(100)] + mock_rag_system.get_course_analytics.return_value = { + "total_courses": 100, + "course_titles": course_titles, + } + + # Act + response = client.get("/api/courses") + + # Assert + assert response.status_code == 200 + data = response.json() + assert data["total_courses"] == 100 + assert len(data["course_titles"]) == 100 + + +@pytest.mark.api +class TestMiddleware: + """Tests for middleware and general API behavior""" + + def test_basic_request_response(self, client): + """Test basic request/response flow works""" + # Act + response = client.get("/api/courses") + + # Assert - Basic response works + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + def test_json_content_type(self, client): + """Test that responses are JSON formatted""" + # Act + response = client.post("/api/query", json={"query": "test"}) + + # Assert + assert response.status_code == 200 + assert "application/json" in response.headers["content-type"] + + +@pytest.mark.api +class TestAPIIntegration: + """Integration tests for API workflows""" + + def test_multi_turn_conversation(self, client, mock_rag_system): + """Test multiple queries in same session""" + # First query + response1 = client.post("/api/query", json={"query": "What is testing?"}) + assert response1.status_code == 200 + session_id = response1.json()["session_id"] + + # Second query in same session + response2 = client.post( + "/api/query", json={"query": "Tell me more about unit tests", "session_id": session_id} + ) + assert response2.status_code == 200 + assert response2.json()["session_id"] == session_id + + # Verify both queries were processed + assert mock_rag_system.query.call_count == 2 + + def test_concurrent_sessions(self, client, mock_rag_system): + """Test handling multiple concurrent sessions""" + # Create multiple sessions + sessions = [] + for i in range(3): + response = client.post("/api/query", json={"query": f"Query {i}"}) + assert response.status_code == 200 + sessions.append(response.json()["session_id"]) + + # All sessions should be unique (in real implementation) + # In our mock, they'll all be "test-session-123", but in reality they'd differ + assert len(sessions) == 3 + + def test_query_then_get_courses(self, client, mock_rag_system): + """Test querying then getting course stats""" + # First, submit a query + query_response = client.post("/api/query", json={"query": "What courses are available?"}) + assert query_response.status_code == 200 + + # Then get course statistics + courses_response = client.get("/api/courses") + assert courses_response.status_code == 200 + + # Both should succeed independently + assert "answer" in query_response.json() + assert "total_courses" in courses_response.json() diff --git a/backend/tests/test_course_search_tool.py b/backend/tests/test_course_search_tool.py new file mode 100644 index 000000000..c0d6c2f1e --- /dev/null +++ b/backend/tests/test_course_search_tool.py @@ -0,0 +1,184 @@ +""" +Tests for CourseSearchTool - Focus on error handling and link retrieval +""" + +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") # noqa: F841 + + # 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") # noqa: F841 + + # 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") # noqa: F841 + + # 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..2a8531629 --- /dev/null +++ b/backend/tests/test_rag_sequential_integration.py @@ -0,0 +1,213 @@ +""" +Integration test for sequential tool calling through the RAG system +Tests the complete flow from user query to AI response with multiple tool rounds +""" + +from unittest.mock import Mock, patch + +from config import Config +from rag_system import RAGSystem + + +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("ai_generator.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("ai_generator.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..116c5cfaa --- /dev/null +++ b/backend/tests/test_vector_store.py @@ -0,0 +1,207 @@ +""" +Tests for VectorStore class - Focus on edge cases and error handling +""" + +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.utils.embedding_functions.SentenceTransformerEmbeddingFunction") + @patch("chromadb.PersistentClient") + def test_get_lesson_link_exception_handling(self, mock_chroma_client, mock_embedding_func): + """ + 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.utils.embedding_functions.SentenceTransformerEmbeddingFunction") + @patch("chromadb.PersistentClient") + def test_get_course_link_exception_handling(self, mock_chroma_client, mock_embedding_func): + """ + 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.utils.embedding_functions.SentenceTransformerEmbeddingFunction") + @patch("chromadb.PersistentClient") + def test_get_lesson_link_json_parse_error(self, mock_chroma_client, mock_embedding_func): + """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.utils.embedding_functions.SentenceTransformerEmbeddingFunction") + @patch("chromadb.PersistentClient") + def test_get_lesson_link_successful(self, mock_chroma_client, mock_embedding_func): + """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.utils.embedding_functions.SentenceTransformerEmbeddingFunction") + @patch("chromadb.PersistentClient") + def test_get_lesson_link_not_found(self, mock_chroma_client, mock_embedding_func): + """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.utils.embedding_functions.SentenceTransformerEmbeddingFunction") + @patch("chromadb.PersistentClient") + def test_search_with_chroma_exception(self, mock_chroma_client, mock_embedding_func): + """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..ea61f8f50 100644 --- a/backend/vector_store.py +++ b/backend/vector_store.py @@ -1,77 +1,96 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + import chromadb from chromadb.config import Settings -from typing import List, Dict, Any, Optional -from dataclasses import dataclass from models import Course, CourseChunk -from sentence_transformers import SentenceTransformer + @dataclass class SearchResults: """Container for search results with metadata""" + documents: List[str] metadata: List[Dict[str, Any]] distances: List[float] error: Optional[str] = None - + @classmethod - def from_chroma(cls, chroma_results: Dict) -> 'SearchResults': + def from_chroma(cls, chroma_results: Dict) -> "SearchResults": """Create SearchResults from ChromaDB query results""" return cls( - documents=chroma_results['documents'][0] if chroma_results['documents'] else [], - metadata=chroma_results['metadatas'][0] if chroma_results['metadatas'] else [], - distances=chroma_results['distances'][0] if chroma_results['distances'] else [] + documents=( + chroma_results["documents"][0] + if chroma_results.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 - def empty(cls, error_msg: str) -> 'SearchResults': + def empty(cls, error_msg: str) -> "SearchResults": """Create empty results with error message""" return cls(documents=[], metadata=[], distances=[], error=error_msg) - + def is_empty(self) -> bool: """Check if results are empty""" return len(self.documents) == 0 + class VectorStore: """Vector storage using ChromaDB for course content and metadata""" - + def __init__(self, chroma_path: str, embedding_model: str, max_results: int = 5): self.max_results = max_results # Initialize ChromaDB client self.client = chromadb.PersistentClient( - path=chroma_path, - settings=Settings(anonymized_telemetry=False) + path=chroma_path, settings=Settings(anonymized_telemetry=False) ) - + # Set up sentence transformer embedding function - self.embedding_function = chromadb.utils.embedding_functions.SentenceTransformerEmbeddingFunction( - model_name=embedding_model + self.embedding_function = ( + chromadb.utils.embedding_functions.SentenceTransformerEmbeddingFunction( + model_name=embedding_model + ) ) - + # Create collections for different types of data self.course_catalog = self._create_collection("course_catalog") # Course titles/instructors self.course_content = self._create_collection("course_content") # Actual course material - + def _create_collection(self, name: str): """Create or get a ChromaDB collection""" return self.client.get_or_create_collection( - name=name, - embedding_function=self.embedding_function + name=name, embedding_function=self.embedding_function ) - - def search(self, - query: str, - course_name: Optional[str] = None, - lesson_number: Optional[int] = None, - limit: Optional[int] = None) -> SearchResults: + + def search( + self, + query: str, + course_name: Optional[str] = None, + lesson_number: Optional[int] = None, + limit: Optional[int] = None, + ) -> SearchResults: """ Main search interface that handles course resolution and content search. - + Args: query: What to search for in course content course_name: Optional course name/title to filter by lesson_number: Optional lesson number to filter by limit: Maximum results to return - + Returns: SearchResults object with documents and metadata """ @@ -81,104 +100,101 @@ def search(self, course_title = self._resolve_course_name(course_name) if not course_title: return SearchResults.empty(f"No course found matching '{course_name}'") - + # Step 2: Build filter for content search filter_dict = self._build_filter(course_title, lesson_number) - + # Step 3: Search course content # Use provided limit or fall back to configured max_results search_limit = limit if limit is not None else self.max_results - + try: results = self.course_content.query( - query_texts=[query], - n_results=search_limit, - where=filter_dict + query_texts=[query], n_results=search_limit, where=filter_dict ) return SearchResults.from_chroma(results) except Exception as e: return SearchResults.empty(f"Search error: {str(e)}") - + def _resolve_course_name(self, course_name: str) -> Optional[str]: """Use vector search to find best matching course by name""" try: - results = self.course_catalog.query( - query_texts=[course_name], - n_results=1 - ) - - if results['documents'][0] and results['metadatas'][0]: + results = self.course_catalog.query(query_texts=[course_name], n_results=1) + + if results["documents"][0] and results["metadatas"][0]: # Return the title (which is now the ID) - return results['metadatas'][0][0]['title'] + return results["metadatas"][0][0]["title"] except Exception as e: print(f"Error resolving course name: {e}") - + return None - - def _build_filter(self, course_title: Optional[str], lesson_number: Optional[int]) -> Optional[Dict]: + + def _build_filter( + self, course_title: Optional[str], lesson_number: Optional[int] + ) -> Optional[Dict]: """Build ChromaDB filter from search parameters""" if not course_title and lesson_number is None: return None - + # Handle different filter combinations if course_title and lesson_number is not None: - return {"$and": [ - {"course_title": course_title}, - {"lesson_number": lesson_number} - ]} - + return {"$and": [{"course_title": course_title}, {"lesson_number": lesson_number}]} + if course_title: return {"course_title": course_title} - + return {"lesson_number": lesson_number} - + def add_course_metadata(self, course: Course): """Add course information to the catalog for semantic search""" import json course_text = course.title - + # Build lessons metadata and serialize as JSON string lessons_metadata = [] for lesson in course.lessons: - lessons_metadata.append({ - "lesson_number": lesson.lesson_number, - "lesson_title": lesson.title, - "lesson_link": lesson.lesson_link - }) - + lessons_metadata.append( + { + "lesson_number": lesson.lesson_number, + "lesson_title": lesson.title, + "lesson_link": lesson.lesson_link, + } + ) + self.course_catalog.add( documents=[course_text], - metadatas=[{ - "title": course.title, - "instructor": course.instructor, - "course_link": course.course_link, - "lessons_json": json.dumps(lessons_metadata), # Serialize as JSON string - "lesson_count": len(course.lessons) - }], - ids=[course.title] + metadatas=[ + { + "title": course.title, + "instructor": course.instructor, + "course_link": course.course_link, + "lessons_json": json.dumps(lessons_metadata), # Serialize as JSON string + "lesson_count": len(course.lessons), + } + ], + ids=[course.title], ) - + def add_course_content(self, chunks: List[CourseChunk]): """Add course content chunks to the vector store""" if not chunks: return - + documents = [chunk.content for chunk in chunks] - metadatas = [{ - "course_title": chunk.course_title, - "lesson_number": chunk.lesson_number, - "chunk_index": chunk.chunk_index - } for chunk in chunks] + metadatas = [ + { + "course_title": chunk.course_title, + "lesson_number": chunk.lesson_number, + "chunk_index": chunk.chunk_index, + } + for chunk in chunks + ] # Use title with chunk index for unique IDs ids = [f"{chunk.course_title.replace(' ', '_')}_{chunk.chunk_index}" for chunk in chunks] - - self.course_content.add( - documents=documents, - metadatas=metadatas, - ids=ids - ) - + + self.course_content.add(documents=documents, metadatas=metadatas, ids=ids) + def clear_all_data(self): """Clear all data from both collections""" try: @@ -189,43 +205,44 @@ def clear_all_data(self): self.course_content = self._create_collection("course_content") except Exception as e: print(f"Error clearing data: {e}") - + def get_existing_course_titles(self) -> List[str]: """Get all existing course titles from the vector store""" try: # Get all documents from the catalog results = self.course_catalog.get() - if results and 'ids' in results: - return results['ids'] + if results and "ids" in results: + return results["ids"] return [] except Exception as e: print(f"Error getting existing course titles: {e}") return [] - + def get_course_count(self) -> int: """Get the total number of courses in the vector store""" try: results = self.course_catalog.get() - if results and 'ids' in results: - return len(results['ids']) + if results and "ids" in results: + return len(results["ids"]) return 0 except Exception as e: print(f"Error getting course count: {e}") return 0 - + def get_all_courses_metadata(self) -> List[Dict[str, Any]]: """Get metadata for all courses in the vector store""" import json + try: results = self.course_catalog.get() - if results and 'metadatas' in results: + if results and "metadatas" in results: # Parse lessons JSON for each course parsed_metadata = [] - for metadata in results['metadatas']: + for metadata in results["metadatas"]: course_meta = metadata.copy() - if 'lessons_json' in course_meta: - course_meta['lessons'] = json.loads(course_meta['lessons_json']) - del course_meta['lessons_json'] # Remove the JSON string version + if "lessons_json" in course_meta: + course_meta["lessons"] = json.loads(course_meta["lessons_json"]) + del course_meta["lessons_json"] # Remove the JSON string version parsed_metadata.append(course_meta) return parsed_metadata return [] @@ -233,35 +250,86 @@ 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: # Get course by ID (title is the ID) results = self.course_catalog.get(ids=[course_title]) - if results and 'metadatas' in results and results['metadatas']: - metadata = results['metadatas'][0] - return metadata.get('course_link') + if results and "metadatas" in results and results["metadatas"]: + metadata = results["metadatas"][0] + return metadata.get("course_link") return None except Exception as e: print(f"Error getting course link: {e}") return None - + def get_lesson_link(self, course_title: str, lesson_number: int) -> Optional[str]: """Get lesson link for a given course title and lesson number""" import json + try: # Get course by ID (title is the ID) results = self.course_catalog.get(ids=[course_title]) - if results and 'metadatas' in results and results['metadatas']: - metadata = results['metadatas'][0] - lessons_json = metadata.get('lessons_json') + if results and "metadatas" in results and results["metadatas"]: + metadata = results["metadatas"][0] + lessons_json = metadata.get("lessons_json") if lessons_json: lessons = json.loads(lessons_json) # Find the lesson with matching number for lesson in lessons: - if lesson.get('lesson_number') == lesson_number: - return lesson.get('lesson_link') + if lesson.get("lesson_number") == lesson_number: + return lesson.get("lesson_link") return None except Exception as e: print(f"Error getting lesson link: {e}") - \ No newline at end of file + return None 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..97fc85272 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,6 +10,24 @@ + + +

Course Materials Assistant

@@ -19,6 +37,14 @@

Course Materials Assistant

+ +
+ +
+
diff --git a/frontend/script.js b/frontend/script.js index 562a8a363..6d09a8eae 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -5,7 +5,30 @@ const API_URL = '/api'; let currentSessionId = null; // DOM elements -let chatMessages, chatInput, sendButton, totalCourses, courseTitles; +let chatMessages, chatInput, sendButton, totalCourses, courseTitles, themeToggle; + +// Theme Management +const THEME_KEY = 'course-assistant-theme'; + +function initTheme() { + // Load saved theme or default to dark + const savedTheme = localStorage.getItem(THEME_KEY) || 'dark'; + applyTheme(savedTheme); +} + +function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(THEME_KEY, theme); +} + +function toggleTheme() { + const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark'; + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + applyTheme(newTheme); +} + +// Initialize theme before DOM loads to prevent flash +initTheme(); // Initialize document.addEventListener('DOMContentLoaded', () => { @@ -15,7 +38,8 @@ document.addEventListener('DOMContentLoaded', () => { sendButton = document.getElementById('sendButton'); totalCourses = document.getElementById('totalCourses'); courseTitles = document.getElementById('courseTitles'); - + themeToggle = document.getElementById('themeToggle'); + setupEventListeners(); createNewSession(); loadCourseStats(); @@ -28,8 +52,25 @@ function setupEventListeners() { chatInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); }); - - + + // Theme toggle + if (themeToggle) { + themeToggle.addEventListener('click', toggleTheme); + // Keyboard accessibility + themeToggle.addEventListener('keypress', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleTheme(); + } + }); + } + + // 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 +112,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 +159,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 +207,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..52204b37b 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -5,7 +5,7 @@ padding: 0; } -/* CSS Variables */ +/* CSS Variables - Dark Theme (Default) */ :root { --primary-color: #2563eb; --primary-hover: #1d4ed8; @@ -24,6 +24,24 @@ --welcome-border: #2563eb; } +/* Light Theme */ +[data-theme="light"] { + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --background: #f8fafc; + --surface: #ffffff; + --surface-hover: #f1f5f9; + --text-primary: #0f172a; + --text-secondary: #64748b; + --border-color: #e2e8f0; + --user-message: #2563eb; + --assistant-message: #f1f5f9; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --focus-ring: rgba(37, 99, 235, 0.15); + --welcome-bg: #eff6ff; + --welcome-border: #2563eb; +} + /* Base Styles */ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; @@ -34,6 +52,79 @@ body { overflow: hidden; margin: 0; padding: 0; + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* Theme Toggle Button */ +.theme-toggle { + position: fixed; + top: 1.5rem; + right: 1.5rem; + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--surface); + border: 1px solid var(--border-color); + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + transition: all 0.3s ease; + box-shadow: var(--shadow); +} + +.theme-toggle:hover { + background: var(--surface-hover); + transform: translateY(-2px); + box-shadow: 0 6px 12px -2px rgba(0, 0, 0, 0.2); +} + +.theme-toggle:active { + transform: translateY(0); +} + +.theme-toggle:focus { + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring), var(--shadow); +} + +/* Icon animations */ +.theme-toggle .sun-icon, +.theme-toggle .moon-icon { + position: absolute; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +/* Dark theme: show moon, hide sun */ +.theme-toggle .sun-icon { + transform: rotate(0deg) scale(0); + opacity: 0; +} + +.theme-toggle .moon-icon { + transform: rotate(0deg) scale(1); + opacity: 1; +} + +/* Light theme: show sun, hide moon */ +[data-theme="light"] .theme-toggle .sun-icon { + transform: rotate(180deg) scale(1); + opacity: 1; +} + +[data-theme="light"] .theme-toggle .moon-icon { + transform: rotate(180deg) scale(0); + opacity: 0; +} + +/* Smooth theme transitions for all elements */ +*, *::before, *::after { + transition: background-color 0.3s ease, + color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; } /* Container - Full Screen */ @@ -111,6 +202,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 +365,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 */ @@ -284,6 +465,10 @@ header h1 { font-size: 0.875em; } +[data-theme="light"] .message-content code { + background-color: rgba(0, 0, 0, 0.08); +} + .message-content pre { background-color: rgba(0, 0, 0, 0.2); padding: 0.75rem; @@ -292,6 +477,11 @@ header h1 { margin: 0.5rem 0; } +[data-theme="light"] .message-content pre { + background-color: rgba(0, 0, 0, 0.05); + border: 1px solid var(--border-color); +} + .message-content pre code { background-color: transparent; padding: 0; @@ -636,10 +826,17 @@ details[open] .suggested-header::before { /* Responsive Design */ @media (max-width: 768px) { + .theme-toggle { + top: 1rem; + right: 1rem; + width: 44px; + height: 44px; + } + .main-content { flex-direction: column; } - + .sidebar { width: 100%; border-right: none; diff --git a/pyproject.toml b/pyproject.toml index 3f05e2de0..3dab98932 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,4 +12,101 @@ 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 = [ + "black>=25.12.0", + "flake8>=7.3.0", + "isort>=7.0.0", + "mypy>=1.19.1", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] + +[tool.black] +line-length = 100 +target-version = ['py313'] +include = '\.pyi?$' +extend-exclude = ''' +/( + \.git + | \.venv + | venv + | \.eggs + | \.tox + | dist + | chroma_db +)/ +''' + +[tool.isort] +profile = "black" +line_length = 100 +skip_gitignore = true +skip = [".venv", "venv", "chroma_db"] + +[tool.mypy] +python_version = "3.13" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +disallow_incomplete_defs = false +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +follow_imports = "silent" +ignore_missing_imports = true +exclude = [ + "venv", + ".venv", + "chroma_db", +] + +[tool.pytest.ini_options] +# Test discovery +testpaths = ["backend/tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +# Output and reporting +addopts = [ + "-v", # Verbose output + "--strict-markers", # Strict marker validation + "--tb=short", # Short traceback format + "--cov=backend", # Coverage for backend directory + "--cov-report=term-missing", # Show missing lines in coverage + "--cov-report=html", # Generate HTML coverage report + "--asyncio-mode=auto", # Auto-detect async tests +] + +# Markers for categorizing tests +markers = [ + "unit: Unit tests for individual components", + "integration: Integration tests for multiple components", + "api: API endpoint tests", + "slow: Tests that take longer to run", +] + +# Asyncio configuration +asyncio_default_fixture_loop_scope = "function" + +# Warnings +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", ] diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 000000000..c745ad73d --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Run all quality checks: formatting, linting, and tests + +set -e + +echo "🚀 Running all quality checks..." +echo "" + +# Check formatting (without modifying files) +echo "1ī¸âƒŖ Checking code formatting..." +uv run black --check backend/ *.py +uv run isort --check-only backend/ *.py +echo "✅ Format check passed!" +echo "" + +# Run linting +echo "2ī¸âƒŖ Running linting..." +uv run flake8 backend/ *.py +echo "✅ Flake8 passed!" +echo "" + +# Run type checking +echo "3ī¸âƒŖ Running type checking..." +uv run mypy backend/ *.py +echo "✅ Type checking passed!" +echo "" + +# Run tests +echo "4ī¸âƒŖ Running tests..." +cd backend +uv run pytest +cd .. +echo "✅ Tests passed!" +echo "" + +echo "🎉 All quality checks passed successfully!" diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 000000000..74429cd8b --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Format Python code using isort and black + +set -e + +echo "🔧 Formatting Python code..." + +echo "đŸ“Ļ Running isort..." +uv run isort backend/ *.py + +echo "🎨 Running black..." +uv run black backend/ *.py + +echo "✅ Code formatting complete!" diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 000000000..903c9b022 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Run linting and type checking + +set -e + +echo "🔍 Running code quality checks..." + +echo "📋 Running flake8..." +uv run flake8 backend/ *.py + +echo "đŸ”Ŧ Running mypy..." +uv run mypy backend/ *.py + +echo "✅ All linting checks passed!" diff --git a/test-claude.md b/test-claude.md new file mode 100644 index 000000000..a923adad0 --- /dev/null +++ b/test-claude.md @@ -0,0 +1 @@ +# Claude Code Test diff --git a/test_sequential.py b/test_sequential.py new file mode 100644 index 000000000..3680abd71 --- /dev/null +++ b/test_sequential.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Test script for sequential tool calling""" +import json + +import requests + + +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..513119353 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -110,6 +110,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, ] +[[package]] +name = "black" +version = "25.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" }, + { url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" }, + { url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" }, + { url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" }, + { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, +] + [[package]] name = "build" version = "1.2.2.post1" @@ -239,6 +266,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" @@ -280,6 +368,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + [[package]] name = "flatbuffers" version = "25.2.10" @@ -470,6 +572,24 @@ 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 = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -576,6 +696,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/43/d9bebfc3db7dea6ec80df5cb2aad8d274dd18ec2edd6c4f21f32c237cbbb/kubernetes-33.1.0-py2.py3-none-any.whl", hash = "sha256:544de42b24b64287f7e0aa9513c93cb503f7f40eea39b20f66810011a86eabc5", size = 1941335, upload-time = "2025-06-09T21:57:56.327Z" }, ] +[[package]] +name = "librt" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/8a/071f6628363d83e803d4783e0cd24fb9c5b798164300fcfaaa47c30659c0/librt-0.7.5.tar.gz", hash = "sha256:de4221a1181fa9c8c4b5f35506ed6f298948f44003d84d2a8b9885d7e01e6cfa", size = 145868, upload-time = "2025-12-25T03:53:16.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/9a/8f61e16de0ff76590af893cfb5b1aa5fa8b13e5e54433d0809c7033f59ed/librt-0.7.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1795c4b2789b458fa290059062c2f5a297ddb28c31e704d27e161386469691a", size = 55750, upload-time = "2025-12-25T03:52:26.975Z" }, + { url = "https://files.pythonhosted.org/packages/05/7c/a8a883804851a066f301e0bad22b462260b965d5c9e7fe3c5de04e6f91f8/librt-0.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2fcbf2e135c11f721193aa5f42ba112bb1046afafbffd407cbc81d8d735c74d0", size = 57170, upload-time = "2025-12-25T03:52:27.948Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/b3b47facf5945be294cf8a835b03589f70ee0e791522f99ec6782ed738b3/librt-0.7.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c039bbf79a9a2498404d1ae7e29a6c175e63678d7a54013a97397c40aee026c5", size = 165834, upload-time = "2025-12-25T03:52:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b6/b26910cd0a4e43e5d02aacaaea0db0d2a52e87660dca08293067ee05601a/librt-0.7.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3919c9407faeeee35430ae135e3a78acd4ecaaaa73767529e2c15ca1d73ba325", size = 174820, upload-time = "2025-12-25T03:52:30.463Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a3/81feddd345d4c869b7a693135a462ae275f964fcbbe793d01ea56a84c2ee/librt-0.7.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26b46620e1e0e45af510d9848ea0915e7040605dd2ae94ebefb6c962cbb6f7ec", size = 189609, upload-time = "2025-12-25T03:52:31.492Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/31310796ef4157d1d37648bf4a3b84555319f14cee3e9bad7bdd7bfd9a35/librt-0.7.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9bbb8facc5375476d392990dd6a71f97e4cb42e2ac66f32e860f6e47299d5e89", size = 184589, upload-time = "2025-12-25T03:52:32.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/22/da3900544cb0ac6ab7a2857850158a0a093b86f92b264aa6c4a4f2355ff3/librt-0.7.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e9e9c988b5ffde7be02180f864cbd17c0b0c1231c235748912ab2afa05789c25", size = 178251, upload-time = "2025-12-25T03:52:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/db/77/78e02609846e78b9b8c8e361753b3dbac9a07e6d5b567fe518de9e074ab0/librt-0.7.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:edf6b465306215b19dbe6c3fb63cf374a8f3e1ad77f3b4c16544b83033bbb67b", size = 199852, upload-time = "2025-12-25T03:52:34.826Z" }, + { url = "https://files.pythonhosted.org/packages/2a/25/05706f6b346429c951582f1b3561f4d5e1418d0d7ba1a0c181237cd77b3b/librt-0.7.5-cp313-cp313-win32.whl", hash = "sha256:060bde69c3604f694bd8ae21a780fe8be46bb3dbb863642e8dfc75c931ca8eee", size = 43250, upload-time = "2025-12-25T03:52:35.905Z" }, + { url = "https://files.pythonhosted.org/packages/d9/59/c38677278ac0b9ae1afc611382ef6c9ea87f52ad257bd3d8d65f0eacdc6a/librt-0.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:a82d5a0ee43aeae2116d7292c77cc8038f4841830ade8aa922e098933b468b9e", size = 49421, upload-time = "2025-12-25T03:52:36.895Z" }, + { url = "https://files.pythonhosted.org/packages/c0/47/1d71113df4a81de5fdfbd3d7244e05d3d67e89f25455c3380ca50b92741e/librt-0.7.5-cp313-cp313-win_arm64.whl", hash = "sha256:3c98a8d0ac9e2a7cb8ff8c53e5d6e8d82bfb2839abf144fdeaaa832f2a12aa45", size = 42827, upload-time = "2025-12-25T03:52:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/97/ae/8635b4efdc784220f1378be640d8b1a794332f7f6ea81bb4859bf9d18aa7/librt-0.7.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9937574e6d842f359b8585903d04f5b4ab62277a091a93e02058158074dc52f2", size = 55191, upload-time = "2025-12-25T03:52:38.839Z" }, + { url = "https://files.pythonhosted.org/packages/52/11/ed7ef6955dc2032af37db9b0b31cd5486a138aa792e1bb9e64f0f4950e27/librt-0.7.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5cd3afd71e9bc146203b6c8141921e738364158d4aa7cdb9a874e2505163770f", size = 56894, upload-time = "2025-12-25T03:52:39.805Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/02921d4a66a1b5dcd0493b89ce76e2762b98c459fe2ad04b67b2ea6fdd39/librt-0.7.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cffa3ef0af29687455161cb446eff059bf27607f95163d6a37e27bcb37180f6", size = 163726, upload-time = "2025-12-25T03:52:40.79Z" }, + { url = "https://files.pythonhosted.org/packages/65/87/27df46d2756fcb7a82fa7f6ca038a0c6064c3e93ba65b0b86fbf6a4f76a2/librt-0.7.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f3f088482e2229387eadf8215c03f7726d56f69cce8c0c40f0795aebc9b361", size = 172470, upload-time = "2025-12-25T03:52:42.226Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a9/e65a35e5d423639f4f3d8e17301ff13cc41c2ff97677fe9c361c26dbfbb7/librt-0.7.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7aa33153a5bb0bac783d2c57885889b1162823384e8313d47800a0e10d0070e", size = 186807, upload-time = "2025-12-25T03:52:43.688Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b0/ac68aa582a996b1241773bd419823290c42a13dc9f494704a12a17ddd7b6/librt-0.7.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:265729b551a2dd329cc47b323a182fb7961af42abf21e913c9dd7d3331b2f3c2", size = 181810, upload-time = "2025-12-25T03:52:45.095Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/03f6717677f20acd2d690813ec2bbe12a2de305f32c61479c53f7b9413bc/librt-0.7.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:168e04663e126416ba712114050f413ac306759a1791d87b7c11d4428ba75760", size = 175599, upload-time = "2025-12-25T03:52:46.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/d7/f976ff4c07c59b69bb5eec7e5886d43243075bbef834428124b073471c86/librt-0.7.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:553dc58987d1d853adda8aeadf4db8e29749f0b11877afcc429a9ad892818ae2", size = 196506, upload-time = "2025-12-25T03:52:47.327Z" }, + { url = "https://files.pythonhosted.org/packages/b7/74/004f068b8888e61b454568b5479f88018fceb14e511ac0609cccee7dd227/librt-0.7.5-cp314-cp314-win32.whl", hash = "sha256:263f4fae9eba277513357c871275b18d14de93fd49bf5e43dc60a97b81ad5eb8", size = 39747, upload-time = "2025-12-25T03:52:48.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/b1/ea3ec8fcf5f0a00df21f08972af77ad799604a306db58587308067d27af8/librt-0.7.5-cp314-cp314-win_amd64.whl", hash = "sha256:85f485b7471571e99fab4f44eeb327dc0e1f814ada575f3fa85e698417d8a54e", size = 45970, upload-time = "2025-12-25T03:52:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/5d/30/5e3fb7ac4614a50fc67e6954926137d50ebc27f36419c9963a94f931f649/librt-0.7.5-cp314-cp314-win_arm64.whl", hash = "sha256:49c596cd18e90e58b7caa4d7ca7606049c1802125fcff96b8af73fa5c3870e4d", size = 39075, upload-time = "2025-12-25T03:52:50.395Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7f/0af0a9306a06c2aabee3a790f5aa560c50ec0a486ab818a572dd3db6c851/librt-0.7.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:54d2aef0b0f5056f130981ad45081b278602ff3657fe16c88529f5058038e802", size = 57375, upload-time = "2025-12-25T03:52:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/57/1f/c85e510baf6572a3d6ef40c742eacedc02973ed2acdb5dba2658751d9af8/librt-0.7.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b4791202296ad51ac09a3ff58eb49d9da8e3a4009167a6d76ac418a974e5fd4", size = 59234, upload-time = "2025-12-25T03:52:52.687Z" }, + { url = "https://files.pythonhosted.org/packages/49/b1/bb6535e4250cd18b88d6b18257575a0239fa1609ebba925f55f51ae08e8e/librt-0.7.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e860909fea75baef941ee6436e0453612505883b9d0d87924d4fda27865b9a2", size = 183873, upload-time = "2025-12-25T03:52:53.705Z" }, + { url = "https://files.pythonhosted.org/packages/8e/49/ad4a138cca46cdaa7f0e15fa912ce3ccb4cc0d4090bfeb8ccc35766fa6d5/librt-0.7.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f02c4337bf271c4f06637f5ff254fad2238c0b8e32a3a480ebb2fc5e26f754a5", size = 194609, upload-time = "2025-12-25T03:52:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2d/3b3cb933092d94bb2c1d3c9b503d8775f08d806588c19a91ee4d1495c2a8/librt-0.7.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f51ffe59f4556243d3cc82d827bde74765f594fa3ceb80ec4de0c13ccd3416", size = 206777, upload-time = "2025-12-25T03:52:55.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/52/6e7611d3d1347812233dabc44abca4c8065ee97b83c9790d7ecc3f782bc8/librt-0.7.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b7f080ba30601dfa3e3deed3160352273e1b9bc92e652f51103c3e9298f7899", size = 203208, upload-time = "2025-12-25T03:52:57.036Z" }, + { url = "https://files.pythonhosted.org/packages/27/aa/466ae4654bd2d45903fbf180815d41e3ae8903e5a1861f319f73c960a843/librt-0.7.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb565b4219abc8ea2402e61c7ba648a62903831059ed3564fa1245cc245d58d7", size = 196698, upload-time = "2025-12-25T03:52:58.481Z" }, + { url = "https://files.pythonhosted.org/packages/97/8f/424f7e4525bb26fe0d3e984d1c0810ced95e53be4fd867ad5916776e18a3/librt-0.7.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a3cfb15961e7333ea6ef033dc574af75153b5c230d5ad25fbcd55198f21e0cf", size = 217194, upload-time = "2025-12-25T03:52:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/9e/33/13a4cb798a171b173f3c94db23adaf13a417130e1493933dc0df0d7fb439/librt-0.7.5-cp314-cp314t-win32.whl", hash = "sha256:118716de5ad6726332db1801bc90fa6d94194cd2e07c1a7822cebf12c496714d", size = 40282, upload-time = "2025-12-25T03:53:01.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/62b136301796399d65dad73b580f4509bcbd347dff885a450bff08e80cb6/librt-0.7.5-cp314-cp314t-win_amd64.whl", hash = "sha256:3dd58f7ce20360c6ce0c04f7bd9081c7f9c19fc6129a3c705d0c5a35439f201d", size = 46764, upload-time = "2025-12-25T03:53:02.381Z" }, + { url = "https://files.pythonhosted.org/packages/49/cb/940431d9410fda74f941f5cd7f0e5a22c63be7b0c10fa98b2b7022b48cb1/librt-0.7.5-cp314-cp314t-win_arm64.whl", hash = "sha256:08153ea537609d11f774d2bfe84af39d50d5c9ca3a4d061d946e0c9d8bce04a1", size = 39728, upload-time = "2025-12-25T03:53:03.306Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -616,6 +777,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -658,6 +828,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "networkx" version = "3.5" @@ -860,6 +1066,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" @@ -983,6 +1208,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "pillow" version = "11.3.0" @@ -1038,6 +1272,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[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" @@ -1131,6 +1383,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/6a/8ec0e4461bf89ef0499ef6c746b081f3520a1e710aeb58730bae693e0681/pybase64-1.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b3635e5873707906e72963c447a67969cfc6bac055432a57a91d7a4d5164fdf", size = 29961, upload-time = "2025-03-02T11:12:21.908Z" }, ] +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -1174,6 +1435,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1207,6 +1477,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" @@ -1237,6 +1561,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "pytokens" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1555,22 +1888,61 @@ 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 = "black" }, + { name = "flake8" }, + { name = "isort" }, + { name = "mypy" }, + { 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 = "black", specifier = ">=25.12.0" }, + { name = "flake8", specifier = ">=7.3.0" }, + { name = "isort", specifier = ">=7.0.0" }, + { name = "mypy", specifier = ">=1.19.1" }, + { 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"