diff --git a/.DS_Store b/.DS_Store index f58e55b..9187e12 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.coverage b/.coverage deleted file mode 100644 index 292dd35..0000000 Binary files a/.coverage and /dev/null differ diff --git a/.env.example b/.env.example index f83fd47..9e0bc5c 100644 --- a/.env.example +++ b/.env.example @@ -36,4 +36,9 @@ ALLOWED_ORIGINS=["*"] HOST=0.0.0.0 PORT=8000 -LIGHTRAG_API_URL=http://localhost:9621 +# MinIO Configuration +MINIO_HOST=localhost:9000 +MINIO_ACCESS=minioadmin +MINIO_SECRET=minioadmin +MINIO_BUCKET=raganything +MINIO_SECURE=false diff --git a/.env.lightrag.server.example b/.env.lightrag.server.example deleted file mode 100644 index 3322569..0000000 --- a/.env.lightrag.server.example +++ /dev/null @@ -1,30 +0,0 @@ -# Light RAG Server Configuration -PORT=9621 - -# PostgreSQL Configuration (matches docker-compose service) -POSTGRES_USER=raganything -POSTGRES_PASSWORD=raganything -POSTGRES_DATABASE=raganything -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 - -# LLM Configuration -LLM_BINDING=openai -LLM_MODEL=gpt-4o-mini -LLM_BINDING_HOST=https://openrouter.ai/api/v1 -LLM_BINDING_API_KEY=apikey - -# Embedding Configuration (avec Ollama) -EMBEDDING_BINDING=openai -EMBEDDING_BINDING_HOST=https://openrouter.ai/api/v1 -EMBEDDING_MODEL=text-embedding-3-small -OPENAI_API_KEY=apikey -# Settings -TIMEOUT=150 -MAX_ASYNC=4 -MAX_PARALLEL_INSERT=2 - -LIGHTRAG_KV_STORAGE=PGKVStorage -LIGHTRAG_VECTOR_STORAGE=PGVectorStorage -LIGHTRAG_GRAPH_STORAGE=PGGraphStorage -LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index e583945..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,191 +0,0 @@ -# Copilot Instructions: FastAPI Backend with Hexagonal Architecture - -You are a Python/FastAPI expert. Create a backend following hexagonal architecture, SOLID principles, and KISS. - -## 🏗️ Project Structure - -``` -├── .github/workflows/ # CI/CD (tests, linting, deploy) -├── src/ -│ ├── main.py # FastAPI app -│ ├── config.py # Pydantic Settings -│ ├── dependencies.py # Dependency injection -│ ├── domain/ # Business core (pure Python) -│ │ ├── entities.py # Business entities (dataclasses) -│ │ ├── ports.py # Interfaces (ABC) -│ │ └── services.py # Business services (optional) -│ ├── application/ -│ │ ├── requests.py # Input DTOs (Pydantic) -│ │ ├── use_cases.py # Application logic -│ │ └── api.py # FastAPI routes -│ └── infrastructure/ # One folder = one implementation -│ ├── postgres/ # adapter.py + models.py -│ ├── mongodb/ # adapter.py + models.py -│ └── email/ # adapter.py -└── tests/ - ├── unit/ # Unit tests - └── doubles/ # Test doubles (fakes) -``` - -## 🎯 Core Principles - -### Hexagonal Architecture -- **Domain**: Completely independent, ZERO external imports (no FastAPI, SQLAlchemy, etc.) -- **Application**: Orchestrates use cases, depends only on domain -- **Infrastructure**: Implements ports (adapters), can depend on everything - -### SOLID -- **SRP**: 1 class = 1 responsibility (1 use case = 1 business action) -- **OCP**: Extension via new adapters, never modify ports -- **LSP**: All implementations respect their port's contract -- **ISP**: Specific and targeted interfaces (no ports with 20 methods) -- **DIP**: Use cases depend on ports (abstractions), never on concrete adapters - -### KISS (Keep It Simple, Stupid) -- Direct transformations: `Class(**other.__dict__)` or `Class(**model.model_dump())` -- **No methods** like `create()`, `from_entity()`, `to_entity()` → too complex -- **No Response DTOs** → return domain entities directly in API -- Readable code > "clever" code -- One file per concept (no unnecessary subdirectories) - -## 📋 Code Rules - -### Python Best Practices -- **Python 3.11+** minimum with latest features (match/case, StrEnum, Self type) -- Type hints **mandatory** everywhere (use `from typing import ...`) -- Async/await for all I/O operations (database, HTTP calls, file operations) -- **Dataclasses** for domain entities (simple, immutable when possible with `frozen=True`) -- **ABC (Abstract Base Classes)** for ports/interfaces -- Google-style docstrings for all public methods/classes -- **Error handling**: Custom exceptions hierarchy (inherit from base domain exception) -- **Naming conventions**: - - Classes: `PascalCase` - - Functions/variables: `snake_case` - - Constants: `UPPER_SNAKE_CASE` - - Private attributes: `_leading_underscore` -- Use **context managers** (`async with`) for resource management -- Prefer **composition over inheritance** -- **No mutable default arguments** (use `None` and initialize in function body) -- Use **Enum** for constants/status values -- **List/Dict comprehensions** over loops when readable -- Dependency manager: **UV** (not Poetry/pip) - -### FastAPI Best Practices -- **Dependency injection** via `Depends()` for all services, repositories, and configurations -- **Annotated types** for cleaner dependency injection: `Annotated[Service, Depends(get_service)]` -- **Pydantic V2** for all request/response validation with Field constraints -- **Explicit HTTP status codes**: Use `status.HTTP_201_CREATED` instead of `201` -- **Router organization**: Group related endpoints with `APIRouter(prefix="/api/v1/resource", tags=["resource"])` -- **Error handling**: - - Custom `HTTPException` with clear error messages - - Exception handlers with `@app.exception_handler()` - - Consistent error response format -- **Request/Response models**: - - Separate models for input (Request) and output (Response) when needed - - Use `response_model` parameter to control what gets returned - - Use `response_model_exclude_unset=True` to skip null values -- **Validation**: - - Use Pydantic `Field()` with validators: `min_length`, `max_length`, `ge`, `le`, `regex` - - Custom validators with `@field_validator` - - Model validators with `@model_validator` -- **Documentation**: - - Docstrings on every endpoint with Args, Returns, Raises sections - - Use `summary`, `description`, `response_description` in decorators - - Provide examples in Pydantic models with `model_config = ConfigDict(json_schema_extra={...})` -- **Security**: - - JWT authentication with `python-jose` - - Password hashing with `passlib[bcrypt]` - - OAuth2PasswordBearer for protected routes - - CORS configuration explicit and restrictive - - Rate limiting (use `slowapi` or custom middleware) - - Security headers middleware -- **Performance**: - - Use `BackgroundTasks` for non-blocking operations (emails, logs) - - Connection pooling for databases - - Caching with Redis/in-memory for frequent queries - - Pagination for list endpoints: `limit`/`offset` or cursor-based -- **Startup/Shutdown events**: - - Database connection initialization in `lifespan` context manager - - Graceful shutdown handling -- **Middleware**: - - Request ID tracking - - Logging middleware for all requests - - CORS middleware properly configured - - Compression middleware for large responses -- **Testing**: - - Use `TestClient` for API testing - - Override dependencies with `app.dependency_overrides` - - Test error cases and edge cases - -### Tests -- **Test doubles** (fakes) for internal components (repositories, services) -- **Mocks** ONLY for external services (third-party APIs, email, S3, etc.) -- pytest + pytest-asyncio -- Minimum coverage: 80% -- Fixtures for test doubles injection - -### Data Transformations -```python -# ✅ GOOD: Direct transformation -user_entity = User(**user_model.__dict__) -db_user = UserModel(**user_entity.__dict__) -request_dto = CreateUserRequest(**pydantic_model.model_dump()) - -# ❌ BAD: Unnecessary intermediate methods -user_entity = User.from_model(user_model) -db_user = UserModel.from_entity(user_entity) -``` - -### Infrastructure -- One folder per implementation: `postgres/`, `mongodb/`, `rag/`, `email/` -- Each folder contains: `adapter.py` (port implementation) + `models.py` (if needed) -- Adapters implement ports defined in `domain/ports.py` - -### API -- Routes in `application/api.py` -- Return domain entities directly (FastAPI serializes them automatically) -- No need for separate Response DTOs - -## ✅ Checklist - -### Architecture -- [ ] 3 distinct layers: domain / application / infrastructure -- [ ] Domain without any external dependencies -- [ ] Use cases depend on ports (interfaces), not adapters -- [ ] Infrastructure: one folder per adapter with `adapter.py` + `models.py` - -### SOLID & KISS -- [ ] SRP: One class = one responsibility -- [ ] DIP: Depend on abstractions (ports) -- [ ] Direct transformations with `**.__dict__` or `**model.model_dump()` -- [ ] No methods like `create()`, `from_entity()`, `to_entity()` - -### Code Quality -- [ ] Type hints everywhere -- [ ] Async/await for I/O -- [ ] Pydantic V2 for validation -- [ ] Tests with test doubles (no internal mocks) -- [ ] Coverage ≥ 80% - -### DevOps -- [ ] GitHub Actions CI/CD (tests, linting, security) -- [ ] Docker + Docker Compose -- [ ] Documented `.env.example` -- [ ] README.md with quick start - -## 🚫 Absolutely Avoid - -- ❌ Importing FastAPI, SQLAlchemy, etc. in domain -- ❌ Creating Response DTOs (return domain entities directly) -- ❌ Complex transformation methods (`from_entity`, `to_entity`) -- ❌ Mocking internal components in tests (use test doubles) -- ❌ Over-engineering (unnecessary builders, factories) -- ❌ Too-wide interfaces with too many methods - -## 📌 Critical Reminders - -1. **Domain** = pure Python, zero external imports -2. **Use cases** depend on **ports** (abstractions), never on **adapters** -3. Transformations: `Class(**other.__dict__)` or `Class(**model.model_dump())` -4. Tests: test doubles (fakes) for internal, mocks for external only -5. SOLID + KISS above all: simplicity and design principles \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..55bb87a --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,78 @@ +name: CI Tests + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v4 + with: + python-version: '3.13' + + - name: Install uv + run: | + pip install uv + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: .venv + key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }} + + - name: Install dependencies + run: uv sync --dev + + - name: Setup environment file + run: cp .env.example .env + + - name: Run all tests + run: uv run pytest --cov-report=html + + - name: Trivy FS Scan (report) + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 + with: + scan-type: 'fs' + scan-ref: '.' + format: 'table' + severity: 'CRITICAL,HIGH,MEDIUM' + exit-code: '0' + trivy-config: trivy.yaml + trivy-version: 'v0.69.3' + + - name: Trivy FS Scan (CRITICAL gate) + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 + with: + scan-type: 'fs' + scan-ref: '.' + format: 'table' + severity: 'CRITICAL' + exit-code: '1' + trivy-config: trivy.yaml + trivy-version: 'v0.69.3' + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: htmlcov/ + retention-days: 7 + + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + with: + args: > + -Dsonar.qualitygate.wait=false diff --git a/.gitignore b/.gitignore index b3ba2af..4948b94 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,11 @@ output .env.lightrag.server.docker __pycache__ __init__.py -.mypy_cache \ No newline at end of file +.mypy_cache +.coverage +.scannerwork +trivy-report.json +trivy-report-fixed.json +coverage.xml +.ruff_cache +.pytest_cache \ No newline at end of file diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile index c243cc9..ded5beb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,10 +14,14 @@ RUN uv sync --frozen --no-dev # Stage 2: Runtime image FROM python:3.13-slim-bookworm -# Install system dependencies required by docling and other packages +# Install system dependencies required by docling +# tesseract-ocr: required by docling PDF pipeline init +# libgl1: required by cv2 (used by docling table structure model) RUN apt-get update && apt-get install -y \ libgomp1 \ + libgl1 \ git \ + tesseract-ocr \ && rm -rf /var/lib/apt/lists/* # Set working directory diff --git a/Dockerfile.db b/Dockerfile.db index 60808f9..9ac4947 100644 --- a/Dockerfile.db +++ b/Dockerfile.db @@ -10,15 +10,13 @@ RUN apt-get update && apt-get install -y \ bison \ && rm -rf /var/lib/apt/lists/* -# Install Apache AGE (v1.6.0 for PG17) +# Install Apache AGE (v1.6.0 for PG17) and cleanup RUN cd /tmp && \ git clone --branch PG17/v1.6.0-rc0 https://github.com/apache/age.git && \ cd age && \ make PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config install || \ - (echo "Failed to build AGE" && exit 1) - -# Cleanup -RUN rm -rf /tmp/age + (echo "Failed to build AGE" && exit 1) && \ + rm -rf /tmp/age # Switch back to non-root user for security USER postgres diff --git a/Dockerfile.lightrag b/Dockerfile.lightrag deleted file mode 100644 index b067905..0000000 --- a/Dockerfile.lightrag +++ /dev/null @@ -1,30 +0,0 @@ -# Multi-stage build for LightRAG Server -# Stage 1: Build dependencies with uv -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder - -WORKDIR /app - -# Install lightrag-hku with API extras -RUN uv pip install --system "lightrag-hku[api]" - -# Stage 2: Runtime image -FROM python:3.13-slim-bookworm - -RUN apt-get update && apt-get install -y \ - libgomp1 \ - curl \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Copy installed packages from builder -COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages -COPY --from=builder /usr/local/bin/lightrag-server /usr/local/bin/lightrag-server - -# Create non-root user -RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app -USER appuser - -EXPOSE 9621 - -CMD ["lightrag-server", "--host", "0.0.0.0", "--port", "9621"] diff --git a/README.md b/README.md index 102e5a8..d5d5bc3 100644 --- a/README.md +++ b/README.md @@ -1,141 +1,234 @@ # MCP-RAGAnything -A FastAPI application that provides a REST API and MCP server for Retrieval Augmented Generation (RAG) using the [RAG-Anything](https://github.com/HKUDS/RAG-Anything) library. Built with **hexagonal architecture** for maintainability and testability. - -## Features - -- 🔍 **Multi-modal document processing** — PDF, DOCX, PPTX, images, tables, equations via [Docling](https://github.com/DS4SD/docling) -- 📁 **Batch folder indexing** — Recursive directory traversal with file extension filtering -- 🔌 **LightRAG proxy** — Full pass-through to [LightRAG Server](https://github.com/HKUDS/LightRAG) for queries and knowledge graph operations -- 🤖 **MCP server** — Claude Desktop integration with `query_knowledge_base` tool -- 🐘 **PostgreSQL backend** — pgvector for embeddings + Apache AGE for knowledge graph -- 🏗️ **Hexagonal architecture** — Clean separation of domain, application, and infrastructure layers +Multi-modal RAG service exposing a REST API and MCP server for document indexing and knowledge-base querying, powered by [RAGAnything](https://github.com/HKUDS/RAG-Anything) and [LightRAG](https://github.com/HKUDS/LightRAG). Files are retrieved from MinIO object storage and indexed into a PostgreSQL-backed knowledge graph. Each project is isolated via its own `working_dir`. ## Architecture ``` -┌─────────────────────────────────────────────────────────────────┐ -│ FastAPI App │ -├─────────────────────────────────────────────────────────────────┤ -│ Application Layer │ -│ ├── api/ → REST routes + MCP tools │ -│ ├── use_cases/ → IndexFile, IndexFolder, LightRAGProxy │ -│ └── requests/ → Input DTOs (Pydantic) │ -├─────────────────────────────────────────────────────────────────┤ -│ Domain Layer (pure Python, no external imports) │ -│ ├── entities/ → IndexingResult, LightRAGProxyEntities │ -│ └── ports/ → RAGEnginePort, LightRAGProxyClientPort │ -├─────────────────────────────────────────────────────────────────┤ -│ Infrastructure Layer │ -│ ├── rag/ → LightRAGAdapter (implements RAGEnginePort)│ -│ └── proxy/ → LightRAGProxyClient (HTTP client) │ -└─────────────────────────────────────────────────────────────────┘ + Clients + (REST / MCP / Claude) + | + +-----------------------+ + | FastAPI App | + +-----------+-----------+ + | + +---------------+---------------+ + | | + Application Layer MCP Tools + +------------------------------+ (FastMCP) + | api/ | | + | indexing_routes.py | | + | query_routes.py | | + | health_routes.py | | + | use_cases/ | | + | IndexFileUseCase | | + | IndexFolderUseCase | | + | requests/ responses/ | | + +------------------------------+ | + | | | + v v v + Domain Layer (ports) + +--------------------------------------+ + | RAGEnginePort StoragePort | + +--------------------------------------+ + | | + v v + Infrastructure Layer (adapters) + +--------------------------------------+ + | LightRAGAdapter MinioAdapter | + | (RAGAnything) (minio-py) | + +--------------------------------------+ + | | + v v + PostgreSQL MinIO + (pgvector + (object + Apache AGE) storage) ``` ## Prerequisites -- **Python 3.13+** -- **Docker & Docker Compose** (recommended) -- An [OpenRouter](https://openrouter.ai/) API Key +- Python 3.13+ +- Docker and Docker Compose +- An [OpenRouter](https://openrouter.ai/) API key (or any OpenAI-compatible provider) +- The `soludev-compose-apps/bricks/` stack for production deployment (provides PostgreSQL, MinIO, and this service) -## Quick Start with Docker Compose +## Quick Start -1. **Clone and configure:** +Production runs from the shared compose stack at `soludev-compose-apps/bricks/`. The `docker-compose.yml` in this repository is for local development only. - ```bash - git clone https://github.com/Kaiohz/mcp-raganything.git - cd mcp-raganything +### Local development - # Configure the API service - cp .env.example .env - # Edit .env and set OPEN_ROUTER_API_KEY +```bash +# 1. Install dependencies +uv sync - # Configure LightRAG server - cp .env.lightrag.server.example .env.lightrag.server - # Edit .env.lightrag.server and set LLM_BINDING_API_KEY - ``` +# 2. Start PostgreSQL and MinIO (docker-compose.yml provides Postgres; +# MinIO must be available separately or added to the compose file) +docker compose up -d postgres -2. **Start all services:** +# 3. Configure environment +cp .env.example .env +# Edit .env: set OPEN_ROUTER_API_KEY and adjust MINIO_HOST / POSTGRES_HOST - ```bash - docker-compose up -d - ``` +# 4. Run the server +uv run python src/main.py +``` - This starts three containers: - | Container | Port | Description | - |-----------|------|-------------| - | `postgres` | 5432 | PostgreSQL 16 with pgvector + Apache AGE | - | `api` | 8000 | RAG-Anything FastAPI service | - | `lightrag-server` | 9621 | LightRAG Server with Web UI | +The API is available at `http://localhost:8000`. Swagger UI at `http://localhost:8000/docs`. -3. **Verify services:** +### Production (soludev-compose-apps) - ```bash - curl http://localhost:8000/api/v1/health - curl http://localhost:9621/health - ``` +```bash +cd soludev-compose-apps/bricks/ +docker compose up -d +``` -4. **Access documentation:** +This starts all brick services including `raganything-api`, `postgres`, and `minio`. - - **API Docs (Swagger):** http://localhost:8000/docs - - **LightRAG Web UI:** http://localhost:9621 - - **LightRAG API Docs:** http://localhost:9621/docs +## API Reference -## Configuration +Base path: `/api/v1` -Configuration is managed via environment files. See the example files for all available options: +### Health -- **[`.env.example`](.env.example)** — Main API configuration (OpenRouter, PostgreSQL, RAG settings) -- **[`.env.lightrag.server.example`](.env.lightrag.server.example)** — LightRAG server configuration +```bash +# Health check +curl http://localhost:8000/api/v1/health +``` -### Key Environment Variables +Response: -| Variable | Default | Description | -|----------|---------|-------------| -| `OPEN_ROUTER_API_KEY` | — | Required. Your OpenRouter API key | -| `RAG_STORAGE_TYPE` | `postgres` | Storage backend: `postgres` or `local` | -| `COSINE_THRESHOLD` | `0.2` | Similarity threshold (0.0-1.0) | -| `MCP_TRANSPORT` | `sse` | MCP transport: `stdio`, `sse`, or `streamable` | -| `LIGHTRAG_API_URL` | `http://localhost:9621` | LightRAG server URL for proxy | +```json +{"message": "RAG Anything API is running"} +``` -## Usage +### Indexing -Full API documentation is available at **http://localhost:8000/docs** (Swagger UI). +Both indexing endpoints accept JSON bodies and run processing in the background. Files are downloaded from MinIO, not uploaded directly. -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v1/health` | GET | Health check | -| `/api/v1/file/index` | POST | Index a single file (background) | -| `/api/v1/folder/index` | POST | Index a folder (background) | -| `/api/v1/lightrag/*` | ALL | Proxy to LightRAG API (query, documents, etc.) | +#### Index a single file -### Query Modes +Downloads the file identified by `file_name` from the configured MinIO bucket, then indexes it into the RAG knowledge graph scoped to `working_dir`. -When querying via `/api/v1/lightrag/query`: +```bash +curl -X POST http://localhost:8000/api/v1/file/index \ + -H "Content-Type: application/json" \ + -d '{ + "file_name": "project-alpha/report.pdf", + "working_dir": "project-alpha" + }' +``` -| Mode | Description | -|------|-------------| -| `naive` | Vector search only (fast, recommended) | -| `local` | Entity-focused search | -| `global` | Relationship-focused search | -| `hybrid` | Combines local + global | -| `mix` | Knowledge graph + vector chunks | -| `bypass` | Direct LLM query without retrieval | +Response (`202 Accepted`): -## MCP Server (Claude Desktop Integration) +```json +{"status": "accepted", "message": "File indexing started in background"} +``` -The MCP server exposes a `query_knowledge_base` tool for searching the RAG knowledge base. +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file_name` | string | yes | Object path in the MinIO bucket | +| `working_dir` | string | yes | RAG workspace directory (project isolation) | + +#### Index a folder + +Lists all objects under the `working_dir` prefix in MinIO, downloads them, then indexes the entire folder. + +```bash +curl -X POST http://localhost:8000/api/v1/folder/index \ + -H "Content-Type: application/json" \ + -d '{ + "working_dir": "project-alpha", + "recursive": true, + "file_extensions": [".pdf", ".docx"] + }' +``` + +Response (`202 Accepted`): + +```json +{"status": "accepted", "message": "Folder indexing started in background"} +``` + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `working_dir` | string | yes | -- | RAG workspace directory, also used as the MinIO prefix | +| `recursive` | boolean | no | `true` | Process subdirectories recursively | +| `file_extensions` | list[string] | no | `null` (all files) | Filter by extensions, e.g. `[".pdf", ".docx"]` | + +### Query + +Query the indexed knowledge base. The RAG engine is initialized for the given `working_dir` before executing the query. + +```bash +curl -X POST http://localhost:8000/api/v1/query \ + -H "Content-Type: application/json" \ + -d '{ + "working_dir": "project-alpha", + "query": "What are the main findings of the report?", + "mode": "naive", + "top_k": 10 + }' +``` + +Response (`200 OK`): + +```json +{ + "status": "success", + "message": "", + "data": { + "entities": [], + "relationships": [], + "chunks": [ + { + "reference_id": "...", + "content": "...", + "file_path": "...", + "chunk_id": "..." + } + ], + "references": [] + }, + "metadata": { + "query_mode": "naive", + "keywords": null, + "processing_info": null + } +} +``` + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `working_dir` | string | yes | -- | RAG workspace directory for this project | +| `query` | string | yes | -- | The search query | +| `mode` | string | no | `"naive"` | Search mode (see Query Modes below) | +| `top_k` | integer | no | `10` | Number of chunks to retrieve | + +## MCP Server + +The MCP server is mounted at `/mcp` and exposes a single tool: `query_knowledge_base`. ### Tool: `query_knowledge_base` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| +| `working_dir` | string | required | RAG workspace directory for this project | | `query` | string | required | The search query | -| `mode` | string | `"naive"` | Search mode: `naive`, `local`, `global`, `hybrid` | +| `mode` | string | `"naive"` | Search mode: `naive`, `local`, `global`, `hybrid`, `mix`, `bypass` | | `top_k` | integer | `10` | Number of chunks to retrieve | -| `only_need_context` | boolean | `true` | Return only context (no LLM answer) | -### Claude Desktop Configuration +### Transport modes + +The `MCP_TRANSPORT` environment variable controls how the MCP server is exposed: + +| Value | Behavior | +|-------|----------| +| `stdio` | MCP runs over stdin/stdout; FastAPI runs in a background thread | +| `sse` | MCP mounted at `/mcp` as SSE endpoint | +| `streamable` | MCP mounted at `/mcp` as streamable HTTP endpoint | + +### Claude Desktop configuration Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -160,31 +253,130 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: } ``` -Restart Claude Desktop to activate. +## Configuration + +All configuration is via environment variables, loaded through Pydantic Settings. See `.env.example` for a complete reference. + +### Application (`AppConfig`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `HOST` | `0.0.0.0` | Server bind address | +| `PORT` | `8000` | Server port | +| `MCP_TRANSPORT` | `stdio` | MCP transport: `stdio`, `sse`, `streamable` | +| `ALLOWED_ORIGINS` | `["*"]` | CORS allowed origins | +| `OUTPUT_DIR` | system temp | Temporary directory for downloaded files | +| `UVICORN_LOG_LEVEL` | `critical` | Uvicorn log level | + +### Database (`DatabaseConfig`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `POSTGRES_USER` | `raganything` | PostgreSQL user | +| `POSTGRES_PASSWORD` | `raganything` | PostgreSQL password | +| `POSTGRES_DATABASE` | `raganything` | PostgreSQL database name | +| `POSTGRES_HOST` | `localhost` | PostgreSQL host | +| `POSTGRES_PORT` | `5432` | PostgreSQL port | + +### LLM (`LLMConfig`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPEN_ROUTER_API_KEY` | -- | **Required.** OpenRouter API key | +| `OPEN_ROUTER_API_URL` | `https://openrouter.ai/api/v1` | OpenRouter base URL | +| `BASE_URL` | -- | Override base URL (takes precedence over `OPEN_ROUTER_API_URL`) | +| `CHAT_MODEL` | `openai/gpt-4o-mini` | Chat completion model | +| `EMBEDDING_MODEL` | `text-embedding-3-small` | Embedding model | +| `EMBEDDING_DIM` | `1536` | Embedding vector dimension | +| `MAX_TOKEN_SIZE` | `8192` | Max token size for embeddings | +| `VISION_MODEL` | `openai/gpt-4o` | Vision model for image processing | + +### RAG (`RAGConfig`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `RAG_STORAGE_TYPE` | `postgres` | Storage backend: `postgres` or `local` | +| `COSINE_THRESHOLD` | `0.2` | Similarity threshold for vector search (0.0-1.0) | +| `MAX_CONCURRENT_FILES` | `1` | Concurrent file processing limit | +| `MAX_WORKERS` | `3` | Workers for folder processing | +| `ENABLE_IMAGE_PROCESSING` | `true` | Process images during indexing | +| `ENABLE_TABLE_PROCESSING` | `true` | Process tables during indexing | +| `ENABLE_EQUATION_PROCESSING` | `true` | Process equations during indexing | + +### MinIO (`MinioConfig`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `MINIO_HOST` | `localhost:9000` | MinIO endpoint (host:port) | +| `MINIO_ACCESS` | `minioadmin` | MinIO access key | +| `MINIO_SECRET` | `minioadmin` | MinIO secret key | +| `MINIO_BUCKET` | `raganything` | Default bucket name | +| `MINIO_SECURE` | `false` | Use HTTPS for MinIO | + +## Query Modes + +| Mode | Description | +|------|-------------| +| `naive` | Vector search only -- fast, recommended default | +| `local` | Entity-focused search using the knowledge graph | +| `global` | Relationship-focused search across the knowledge graph | +| `hybrid` | Combines local + global strategies | +| `mix` | Knowledge graph + vector chunks combined | +| `bypass` | Direct LLM query without retrieval | ## Development ```bash -uv sync # Install dependencies -uv run python src/main.py # Run locally -uv run pytest --cov=src # Run tests with coverage -uv run black src/ # Format code -uv run mypy src/ # Type checking +uv sync # Install all dependencies (including dev) +uv run python src/main.py # Run the server locally +uv run pytest # Run tests with coverage +uv run ruff check src/ # Lint +uv run ruff format src/ # Format +uv run mypy src/ # Type checking ``` +### Docker (local) + ```bash -docker-compose build # Build images -docker-compose up # Start in foreground -docker-compose logs -f api # View logs -docker-compose down -v # Stop and remove volumes +docker compose up -d # Start Postgres + API +docker compose logs -f raganything-api # Follow API logs +docker compose down -v # Stop and remove volumes ``` -## Troubleshooting +## Project Structure -- **Empty results:** Lower `COSINE_THRESHOLD` (e.g., `0.1`) or increase `top_k` -- **Port conflicts:** `lsof -ti:8000 | xargs kill -9` -- **Config changes:** Restart server after changing `COSINE_THRESHOLD`, database config, or API keys +``` +src/ + main.py -- FastAPI app, MCP mount, entry point + config.py -- Pydantic Settings config classes + dependencies.py -- Dependency injection wiring + domain/ + entities/ + indexing_result.py -- FileIndexingResult, FolderIndexingResult + ports/ + rag_engine.py -- RAGEnginePort (abstract) + storage_port.py -- StoragePort (abstract) + application/ + api/ + health_routes.py -- GET /health + indexing_routes.py -- POST /file/index, /folder/index + query_routes.py -- POST /query + mcp_tools.py -- MCP tool: query_knowledge_base + requests/ + indexing_request.py -- IndexFileRequest, IndexFolderRequest + query_request.py -- QueryRequest + responses/ + query_response.py -- QueryResponse, QueryDataResponse + use_cases/ + index_file_use_case.py -- Downloads from MinIO, indexes single file + index_folder_use_case.py -- Downloads from MinIO, indexes folder + infrastructure/ + rag/ + lightrag_adapter.py -- LightRAGAdapter (RAGAnything/LightRAG) + storage/ + minio_adapter.py -- MinioAdapter (minio-py client) +``` ## License -MIT License - see LICENSE file for details +MIT diff --git a/claude_desktop_config.json b/claude_desktop_config.json deleted file mode 100644 index e0e2d2b..0000000 --- a/claude_desktop_config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "mcpServers": { - "raganything": { - "command": "uv", - "args": [ - "run", - "--directory", - "${path}", - "python", - "main.py" - ] - } - } -} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 924d791..2c49c12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,43 @@ services: interval: 10s timeout: 5s retries: 5 - networks: - - raganything-network + + # MinIO object storage + minio: + image: minio/minio:latest + container_name: raganything-minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 10 + + # MinIO init — creates the bucket then exits + minio-init: + image: minio/mc:latest + container_name: raganything-minio-init + entrypoint: > + sh -c " + until mc alias set local http://minio:9000 minioadmin minioadmin 2>/dev/null; do sleep 1; done; + mc mb --ignore-existing local/raganything; + echo 'MinIO init done: bucket raganything created.'; + " + depends_on: + minio: + condition: service_healthy + restart: "no" # RAG-Anything API application - api: + raganything-api: build: context: . dockerfile: Dockerfile @@ -34,35 +66,12 @@ services: depends_on: postgres: condition: service_healthy - networks: - - raganything-network - restart: unless-stopped - - # LightRAG Server with Web UI - lightrag-server: - build: - context: . - dockerfile: Dockerfile.lightrag - container_name: raganything-lightrag - ports: - - "9621:9621" - env_file: - - .env.lightrag.server.docker - depends_on: - postgres: + minio: condition: service_healthy - networks: - - raganything-network restart: unless-stopped volumes: postgres_data: driver: local - rag_storage: - driver: local - output_data: - driver: local - -networks: - raganything-network: - driver: bridge \ No newline at end of file + minio_data: + driver: local \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c3fb3e5..ecddf8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,19 +5,22 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "aiofiles>=24.1.0", "asyncpg>=0.31.0", "docling>=2.64.0", "fastapi>=0.124.0", - "fastmcp>=2.13.2", + "authlib>=1.6.9", + "fastmcp>=2.14.3", "httpx>=0.27.0", "lightrag-hku>=1.4.9.8", "lightrag-hku[api]>=1.4.9.8", - "mcp>=1.23.1", + "mcp>=1.24.0", + "minio>=7.2.18", "openai>=2.9.0", "pgvector>=0.4.2", "pydantic-settings>=2.12.0", "python-dotenv>=1.2.1", - "python-multipart>=0.0.20", + "python-multipart>=0.0.22", "raganything>=1.2.8", "sqlalchemy[asyncio]>=2.0.0", "uvicorn>=0.38.0", @@ -25,7 +28,6 @@ dependencies = [ [dependency-groups] dev = [ - "black>=24.0.0", "ruff>=0.8.0", "mypy>=1.0.0", "pytest>=8.0.0", @@ -38,7 +40,7 @@ asyncio_mode = "auto" testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] -addopts = "-v --cov=src --cov-report=term-missing" +addopts = "-v --cov=src --cov-report=term-missing --cov-report=xml:coverage.xml" pythonpath = ["src"] @@ -54,7 +56,7 @@ select = [ "ARG", # flake8-unused-arguments "SIM", # flake8-simplify ] -ignore = ["E501"] # line too long (handled by formatter) +ignore = ["E501", "B008"] # E501: line too long (handled by formatter), B008: FastAPI Depends/File in defaults [tool.ruff.lint.isort] known-first-party = ["src"] diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..bfa4d3b --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,9 @@ +sonar.projectKey=mcp-raganything +sonar.projectName=MCP RAGAnything +sonar.sources=src +sonar.tests=tests +sonar.language=py +sonar.python.version=3.13 +sonar.sourceEncoding=UTF-8 +sonar.exclusions=**/__pycache__/**,**/.venv/** +sonar.python.coverage.reportPaths=coverage.xml diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..80101b5 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/application/api/health_routes.py b/src/application/api/health_routes.py index 32b8d66..7d09e10 100644 --- a/src/application/api/health_routes.py +++ b/src/application/api/health_routes.py @@ -1,6 +1,5 @@ from fastapi import APIRouter - health_router = APIRouter(tags=["Health"]) diff --git a/src/application/api/indexing_routes.py b/src/application/api/indexing_routes.py index 5038b88..391bbaa 100644 --- a/src/application/api/indexing_routes.py +++ b/src/application/api/indexing_routes.py @@ -1,11 +1,9 @@ -from fastapi import APIRouter, UploadFile, File, Depends, BackgroundTasks, status +from fastapi import APIRouter, BackgroundTasks, Depends, status + +from application.requests.indexing_request import IndexFileRequest, IndexFolderRequest from application.use_cases.index_file_use_case import IndexFileUseCase from application.use_cases.index_folder_use_case import IndexFolderUseCase -from application.requests.indexing_request import IndexFolderRequest -from dependencies import get_index_file_use_case, get_index_folder_use_case, OUTPUT_DIR -import shutil -import os - +from dependencies import get_index_file_use_case, get_index_folder_use_case indexing_router = APIRouter(tags=["Multimodal Indexing"]) @@ -14,33 +12,15 @@ "/file/index", response_model=dict, status_code=status.HTTP_202_ACCEPTED ) async def index_file( + request: IndexFileRequest, background_tasks: BackgroundTasks, - file: UploadFile = File(...), use_case: IndexFileUseCase = Depends(get_index_file_use_case), ): - """ - Index a single file upload in the background. - - Args: - background_tasks: FastAPI background tasks handler. - file: The uploaded file to index. - use_case: The indexing use case dependency. - - Returns: - dict: Status message indicating indexing started. - """ - file_name = file.filename or "upload" - file_path = os.path.join(OUTPUT_DIR, file_name) - - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - background_tasks.add_task( use_case.execute, - file_path=file_path, - file_name=file_name, + file_name=request.file_name, + working_dir=request.working_dir, ) - return {"status": "accepted", "message": "File indexing started in background"} @@ -52,20 +32,5 @@ async def index_folder( background_tasks: BackgroundTasks, use_case: IndexFolderUseCase = Depends(get_index_folder_use_case), ): - """ - Index all documents in a folder in the background. - - Args: - request: The indexing request containing folder path and parameters. - background_tasks: FastAPI background tasks handler. - use_case: The indexing use case dependency. - - Returns: - dict: Status message indicating indexing started. - """ - background_tasks.add_task( - use_case.execute, - request=request, - ) - + background_tasks.add_task(use_case.execute, request=request) return {"status": "accepted", "message": "Folder indexing started in background"} diff --git a/src/application/api/lightrag_proxy_routes.py b/src/application/api/lightrag_proxy_routes.py deleted file mode 100644 index 5a55bc5..0000000 --- a/src/application/api/lightrag_proxy_routes.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -Routes for forwarding requests to the LightRAG API. -""" - -import logging -from typing import Any, Optional -from fastapi import APIRouter, Request, Response, Depends -from fastapi.responses import StreamingResponse -from domain.entities.lightrag_proxy_entities import LightRAGProxyRequest -from application.use_cases.lightrag_proxy_use_case import LightRAGProxyUseCase -from dependencies import get_lightrag_proxy_use_case - -logger = logging.getLogger(__name__) - -lightrag_proxy_router = APIRouter(tags=["LightRAG Proxy"]) - - -def extract_headers_for_forwarding(request: Request) -> dict[str, str]: - """Extract headers that should be forwarded to LightRAG.""" - headers = {} - - # Forward Authorization header - auth_header = request.headers.get("authorization") or request.headers.get( - "Authorization" - ) - if auth_header: - headers["Authorization"] = auth_header - - # Forward API key header if present - api_key_header = request.headers.get("api_key_header_value") - if api_key_header: - headers["api_key_header_value"] = api_key_header - - return headers - - -async def build_lightrag_proxy_request( - path: str, - request: Request, -) -> LightRAGProxyRequest: - """ - Build a LightRAGProxyRequest from a FastAPI Request. - - Args: - path: The path to proxy to. - request: The FastAPI request object. - - Returns: - LightRAGProxyRequest: The domain entity for the proxy request. - """ - method = request.method - query_params = dict(request.query_params) if request.query_params else None - headers = extract_headers_for_forwarding(request) - - # Get request body - body: Optional[Any] = None - raw_content: Optional[bytes] = None - content_type = request.headers.get("content-type", "") - - if method in ["POST", "PUT", "PATCH"]: - if "application/json" in content_type: - try: - body = await request.json() - except Exception: - raw_content = await request.body() - elif "multipart/form-data" in content_type: - # For file uploads, get raw content - raw_content = await request.body() - # Preserve content-type with boundary - headers["Content-Type"] = content_type - else: - raw_content = await request.body() - if content_type: - headers["Content-Type"] = content_type - - return LightRAGProxyRequest( - method=method, - path=path, - body=body, - params=query_params, - headers=headers, - content_type=( - content_type if content_type and "multipart" in content_type else None - ), - raw_content=raw_content, - ) - - -@lightrag_proxy_router.api_route( - "/{path:path}", - methods=["GET", "POST", "PUT", "DELETE", "PATCH"], - include_in_schema=False, # Hide from OpenAPI - real endpoints come from LightRAG spec merge -) -async def proxy_to_lightrag( - path: str, - request: Request, - use_case: LightRAGProxyUseCase = Depends(get_lightrag_proxy_use_case), -): - """ - Proxy all requests to LightRAG API. - - This endpoint forwards requests to the LightRAG API, preserving: - - HTTP method - - Request body - - Query parameters - - Authorization headers - - Streaming endpoints (/query/stream, /api/generate, /api/chat) return - StreamingResponse for real-time data delivery. - """ - proxy_request = await build_lightrag_proxy_request(path, request) - - # Check if this is a streaming endpoint - if use_case.is_streaming_request(proxy_request): - - async def stream_generator(): - async for chunk in use_case.execute_stream(proxy_request): - yield chunk - - return StreamingResponse( - stream_generator(), - media_type="application/x-ndjson", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - # Non-streaming request - try: - response = await use_case.execute(proxy_request) - - return Response( - content=response.content, - status_code=response.status_code, - headers=response.headers, - media_type=response.media_type, - ) - - except Exception as e: - logger.error( - f"LightRAG proxy request failed for {proxy_request.method} /{path}: {e}", - exc_info=True, - ) - return Response( - content=f'{{"detail": "LightRAG proxy error: {str(e)}"}}', - status_code=502, - media_type="application/json", - ) diff --git a/src/application/api/mcp_tools.py b/src/application/api/mcp_tools.py index 042d2eb..62cc9c7 100644 --- a/src/application/api/mcp_tools.py +++ b/src/application/api/mcp_tools.py @@ -1,59 +1,31 @@ -""" -MCP tools for RAGAnything. +"""MCP tools for RAGAnything. + These tools are registered with FastMCP for Claude Desktop integration. """ -from dependencies import get_lightrag_proxy_use_case -from domain.entities.lightrag_proxy_entities import LightRAGProxyRequest from fastmcp import FastMCP +from dependencies import get_query_use_case -# MCP instance will be configured in dependencies.py mcp = FastMCP("RAGAnything") @mcp.tool() async def query_knowledge_base( - query: str, mode: str = "naive", top_k: int = 10, only_need_context: bool = True -) -> str: - """ - Search the RAGAnything knowledge base for relevant document chunks. - - Default Strategy (use this first): - - mode="naive" with top_k=10 for fast, focused results - - This works well for most queries - - Fallback Strategy (if no relevant results): - - Ask user if they want a broader search - - Use mode="hybrid" with top_k=20 for comprehensive search - - This casts a wider net and combines multiple search strategies + working_dir: str, query: str, mode: str = "naive", top_k: int = 10 +) -> dict: + """Search the RAGAnything knowledge base for relevant document chunks. Args: - query: The user's question or search query (e.g., "What are the main findings?") - mode: Search mode - "naive" (default, recommended), "local" (context-aware), - "global" (document-level), or "hybrid" (comprehensive) - top_k: Number of chunks to retrieve (default 10, use 20 for broader search) - only_need_context: If True, returns only context chunks without LLM answer (default True) + working_dir: RAG workspace directory for this project + query: The user's question or search query + mode: Search mode - "naive" (default), "local", "global", "hybrid", "mix" + top_k: Number of chunks to retrieve (default 10) Returns: - JSON string containing the query response from LightRAG + Query response from LightRAG """ - use_case = await get_lightrag_proxy_use_case() - - # Build request body for LightRAG /query endpoint - request_body = { - "query": query, - "mode": mode, - "top_k": top_k, - "only_need_context": only_need_context, - } - - proxy_request = LightRAGProxyRequest( - method="POST", - path="query", - body=request_body, + use_case = get_query_use_case() + return await use_case.execute( + working_dir=working_dir, query=query, mode=mode, top_k=top_k ) - - response = await use_case.execute(proxy_request) - - return response.content.decode("utf-8") diff --git a/src/application/api/query_routes.py b/src/application/api/query_routes.py new file mode 100644 index 0000000..93d1f90 --- /dev/null +++ b/src/application/api/query_routes.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends, status + +from application.requests.query_request import QueryRequest +from application.responses.query_response import QueryResponse +from application.use_cases.query_use_case import QueryUseCase +from dependencies import get_query_use_case + +query_router = APIRouter(tags=["RAG Query"]) + + +@query_router.post( + "/query", response_model=QueryResponse, status_code=status.HTTP_200_OK +) +async def query_knowledge_base( + request: QueryRequest, + use_case: QueryUseCase = Depends(get_query_use_case), +) -> QueryResponse: + result = await use_case.execute( + working_dir=request.working_dir, + query=request.query, + mode=request.mode, + top_k=request.top_k, + ) + return QueryResponse(**result) diff --git a/src/application/requests/indexing_request.py b/src/application/requests/indexing_request.py index 206b807..080f163 100644 --- a/src/application/requests/indexing_request.py +++ b/src/application/requests/indexing_request.py @@ -1,20 +1,21 @@ from pydantic import BaseModel, Field -from typing import Optional -class IndexFolderRequest(BaseModel): - """ - Request model for indexing a folder of documents. - """ +class IndexFileRequest(BaseModel): + file_name: str = Field(..., description="File name/path in MinIO bucket") + working_dir: str = Field( + ..., description="RAG workspace directory for this project" + ) + - folder_path: str = Field(..., description="Absolute path to the folder") +class IndexFolderRequest(BaseModel): + working_dir: str = Field( + ..., + description="RAG workspace directory — also used as MinIO prefix", + ) recursive: bool = Field( default=True, description="Process subdirectories recursively" ) - file_extensions: Optional[list[str]] = Field( - default=None, - description="List of file extensions to filter (e.g., ['.pdf', '.docx'])", - ) - display_stats: bool = Field( - default=True, description="Display processing statistics" + file_extensions: list[str] | None = Field( + default=None, description="File extensions to filter" ) diff --git a/src/application/requests/query_request.py b/src/application/requests/query_request.py new file mode 100644 index 0000000..6e41f47 --- /dev/null +++ b/src/application/requests/query_request.py @@ -0,0 +1,27 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class QueryRequest(BaseModel): + working_dir: str = Field( + ..., description="RAG workspace directory for this project" + ) + query: str = Field( + ..., + description="The user's question or search query (e.g., 'What are the main findings?')", + ) + mode: Literal["local", "global", "hybrid", "naive", "mix", "bypass"] = Field( + default="naive", + description=( + "Search mode - 'naive' (default, recommended), 'local' (context-aware), " + "'global' (document-level), or 'hybrid' (comprehensive) or 'mix' (automatic strategy). " + ), + ) + top_k: int = Field( + default=10, + description=( + "Number of chunks to retrieve (default 10, use 20 for broader search). " + "Use 10 for fast, focused results; use 20 for comprehensive search." + ), + ) diff --git a/src/application/responses/query_response.py b/src/application/responses/query_response.py new file mode 100644 index 0000000..91d40e9 --- /dev/null +++ b/src/application/responses/query_response.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel, Field + + +class EntityResponse(BaseModel): + entity_name: str + entity_type: str + description: str + source_id: str + file_path: str + created_at: int + + +class RelationshipResponse(BaseModel): + src_id: str + tgt_id: str + description: str + keywords: str + weight: float + source_id: str + file_path: str + created_at: int + + +class ChunkResponse(BaseModel): + reference_id: str + content: str + file_path: str + chunk_id: str + + +class ReferenceResponse(BaseModel): + reference_id: str + file_path: str + + +class QueryDataResponse(BaseModel): + entities: list[EntityResponse] = Field(default_factory=list) + relationships: list[RelationshipResponse] = Field(default_factory=list) + chunks: list[ChunkResponse] = Field(default_factory=list) + references: list[ReferenceResponse] = Field(default_factory=list) + + +class KeywordsResponse(BaseModel): + high_level: list[str] = Field(default_factory=list) + low_level: list[str] = Field(default_factory=list) + + +class ProcessingInfoResponse(BaseModel): + total_entities_found: int = 0 + total_relations_found: int = 0 + entities_after_truncation: int = 0 + relations_after_truncation: int = 0 + merged_chunks_count: int = 0 + final_chunks_count: int = 0 + + +class QueryMetadataResponse(BaseModel): + query_mode: str = "" + keywords: KeywordsResponse | None = None + processing_info: ProcessingInfoResponse | None = None + + +class QueryResponse(BaseModel): + status: str + message: str = "" + data: QueryDataResponse = Field(default_factory=QueryDataResponse) + metadata: QueryMetadataResponse | None = None diff --git a/src/application/use_cases/index_file_use_case.py b/src/application/use_cases/index_file_use_case.py index dac8341..a884b4b 100644 --- a/src/application/use_cases/index_file_use_case.py +++ b/src/application/use_cases/index_file_use_case.py @@ -1,44 +1,48 @@ import logging import os -from domain.ports.rag_engine import RAGEnginePort + +import aiofiles + from domain.entities.indexing_result import FileIndexingResult +from domain.ports.rag_engine import RAGEnginePort +from domain.ports.storage_port import StoragePort logger = logging.getLogger(__name__) class IndexFileUseCase: - """ - Use case for indexing a single file. - Orchestrates the file indexing process. - """ - - def __init__(self, rag_engine: RAGEnginePort, output_dir: str) -> None: - """ - Initialize the use case. - - Args: - rag_engine: Port for RAG engine operations. - output_dir: Output directory for processing. - """ + """Use case for indexing a single file downloaded from MinIO.""" + + def __init__( + self, + rag_engine: RAGEnginePort, + storage: StoragePort, + bucket: str, + output_dir: str, + ) -> None: self.rag_engine = rag_engine + self.storage = storage + self.bucket = bucket self.output_dir = output_dir - async def execute(self, file_path: str, file_name: str) -> FileIndexingResult: - """ - Execute the file indexing process. + async def execute(self, file_name: str, working_dir: str) -> FileIndexingResult: + os.makedirs(self.output_dir, exist_ok=True) - Args: - file_path: Path to the file to index. - file_name: Name of the file. + data = await self.storage.get_object(self.bucket, file_name) + file_path = os.path.join(self.output_dir, file_name) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + async with aiofiles.open(file_path, "wb") as f: + await f.write(data) - Returns: - FileIndexingResult: Structured result of the indexing operation. - """ - os.makedirs(self.output_dir, exist_ok=True) + self.rag_engine.init_project(working_dir) result = await self.rag_engine.index_document( - file_path=file_path, file_name=file_name, output_dir=self.output_dir + file_path=file_path, + file_name=file_name, + output_dir=self.output_dir, + working_dir=working_dir, ) - logger.info(f"Indexation finished with result: {result.model_dump()}") + logger.info(f"Indexation finished: {result.model_dump()}") + return result diff --git a/src/application/use_cases/index_folder_use_case.py b/src/application/use_cases/index_folder_use_case.py index bb4fe5c..5ab81ef 100644 --- a/src/application/use_cases/index_folder_use_case.py +++ b/src/application/use_cases/index_folder_use_case.py @@ -1,48 +1,65 @@ -import os +import asyncio import logging -from domain.ports.rag_engine import RAGEnginePort -from domain.entities.indexing_result import FolderIndexingResult +import os + +import aiofiles + from application.requests.indexing_request import IndexFolderRequest +from domain.entities.indexing_result import FolderIndexingResult +from domain.ports.rag_engine import RAGEnginePort +from domain.ports.storage_port import StoragePort logger = logging.getLogger(__name__) class IndexFolderUseCase: - """ - Use case for indexing a folder of documents. - Orchestrates the folder indexing process. - """ - - def __init__(self, rag_engine: RAGEnginePort, output_dir: str) -> None: - """ - Initialize the use case. - - Args: - rag_engine: Port for RAG engine operations. - output_dir: Output directory for processing. - """ + """Use case for indexing a folder of documents downloaded from MinIO.""" + + def __init__( + self, + rag_engine: RAGEnginePort, + storage: StoragePort, + bucket: str, + output_dir: str, + ) -> None: self.rag_engine = rag_engine + self.storage = storage + self.bucket = bucket self.output_dir = output_dir async def execute(self, request: IndexFolderRequest) -> FolderIndexingResult: - """ - Execute the folder indexing process. + local_folder = os.path.join(self.output_dir, request.working_dir) + + os.makedirs(local_folder, exist_ok=True) + + files = await self.storage.list_objects( + self.bucket, prefix=request.working_dir, recursive=request.recursive + ) - Args: - request: The indexing request containing folder parameters. + if request.file_extensions: + exts = set(request.file_extensions) + files = [f for f in files if any(f.endswith(ext) for ext in exts)] - Returns: - FolderIndexingResult: Structured result with statistics and file details. - """ - os.makedirs(self.output_dir, exist_ok=True) + semaphore = asyncio.Semaphore(10) + async def _download(file_name: str) -> None: + async with semaphore: + data = await self.storage.get_object(self.bucket, file_name) + local_name = os.path.basename(file_name) + async with aiofiles.open(os.path.join(local_folder, local_name), "wb") as f: + await f.write(data) + + await asyncio.gather(*[_download(f) for f in files]) + + self.rag_engine.init_project(request.working_dir) + result = await self.rag_engine.index_folder( - folder_path=request.folder_path, + folder_path=local_folder, output_dir=self.output_dir, recursive=request.recursive, file_extensions=request.file_extensions, + working_dir=request.working_dir, ) - logger.info(f"Indexation finished with result: {result.model_dump()}") - + logger.info(f"Folder indexation finished: {result.model_dump()}") return result diff --git a/src/application/use_cases/lightrag_proxy_use_case.py b/src/application/use_cases/lightrag_proxy_use_case.py deleted file mode 100644 index 1da7818..0000000 --- a/src/application/use_cases/lightrag_proxy_use_case.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Use case for proxying requests to the LightRAG API. -Implements business logic for the LightRAG proxy functionality. -""" - -import logging -from typing import AsyncIterator -from domain.ports.lightrag_proxy_client import LightRAGProxyClientPort -from domain.entities.lightrag_proxy_entities import ( - LightRAGProxyRequest, - LightRAGProxyResponse, -) - -logger = logging.getLogger(__name__) - - -class LightRAGProxyUseCase: - """ - Use case for proxying requests to the LightRAG API. - Handles the business logic of request forwarding to LightRAG. - """ - - # Paths that support streaming responses - STREAMING_PATHS = [ - "query/stream", - "api/generate", - "api/chat", - ] - - def __init__(self, proxy_client: LightRAGProxyClientPort): - """ - Initialize the LightRAG proxy use case. - - Args: - proxy_client: The LightRAG proxy client port implementation. - """ - self.proxy_client = proxy_client - - def is_streaming_request(self, request: LightRAGProxyRequest) -> bool: - """ - Determine if a request should use streaming response. - - Args: - request: The proxy request to check. - - Returns: - bool: True if the request should stream, False otherwise. - """ - # Check if path matches streaming endpoints - path_supports_streaming = any(sp in request.path for sp in self.STREAMING_PATHS) - - if not path_supports_streaming: - return False - - # Only POST requests can stream - if request.method != "POST": - return False - - # Check if stream is explicitly enabled in body - if isinstance(request.body, dict): - return request.body.get("stream", True) - - return True - - async def execute(self, request: LightRAGProxyRequest) -> LightRAGProxyResponse: - """ - Execute a non-streaming proxy request to LightRAG. - - Args: - request: The proxy request to forward. - - Returns: - LightRAGProxyResponse: The response from LightRAG API. - - Raises: - Exception: If the request fails. - """ - logger.debug(f"Proxying to LightRAG: {request.method} /{request.path}") - - try: - response = await self.proxy_client.forward_request(request) - logger.debug( - f"LightRAG response: {response.status_code} for {request.method} /{request.path}" - ) - return response - except Exception as e: - logger.error( - f"LightRAG proxy request failed for {request.method} /{request.path}: {e}", - exc_info=True, - ) - raise - - async def execute_stream( - self, request: LightRAGProxyRequest - ) -> AsyncIterator[bytes]: - """ - Execute a streaming proxy request to LightRAG. - - Args: - request: The proxy request to forward. - - Yields: - bytes: Chunks of the streaming response. - - Raises: - Exception: If the request fails. - """ - logger.debug( - f"Proxying streaming to LightRAG: {request.method} /{request.path}" - ) - - try: - async for chunk in self.proxy_client.forward_stream(request): - yield chunk - except Exception as e: - logger.error( - f"LightRAG streaming proxy request failed for {request.method} /{request.path}: {e}", - exc_info=True, - ) - raise diff --git a/src/application/use_cases/query_use_case.py b/src/application/use_cases/query_use_case.py new file mode 100644 index 0000000..8a910eb --- /dev/null +++ b/src/application/use_cases/query_use_case.py @@ -0,0 +1,16 @@ +from domain.ports.rag_engine import RAGEnginePort + + +class QueryUseCase: + """Use case for querying the RAG knowledge base.""" + + def __init__(self, rag_engine: RAGEnginePort) -> None: + self.rag_engine = rag_engine + + async def execute( + self, working_dir: str, query: str, mode: str = "naive", top_k: int = 10 + ) -> dict: + self.rag_engine.init_project(working_dir) + return await self.rag_engine.query( + query=query, mode=mode, top_k=top_k, working_dir=working_dir + ) diff --git a/src/config.py b/src/config.py index 57f2c8a..e96dc4c 100644 --- a/src/config.py +++ b/src/config.py @@ -1,17 +1,15 @@ +import os +import tempfile + +from dotenv import load_dotenv from pydantic import Field from pydantic_settings import BaseSettings -from dotenv import load_dotenv -from typing import List, Optional load_dotenv() class AppConfig(BaseSettings): - """ - Application configuration settings. - """ - - ALLOWED_ORIGINS: List[str] = Field( + ALLOWED_ORIGINS: list[str] = Field( default=["*"], description="CORS allowed origins" ) MCP_TRANSPORT: str = Field( @@ -19,8 +17,10 @@ class AppConfig(BaseSettings): ) HOST: str = Field(default="0.0.0.0", description="Server host") PORT: int = Field(default=8000, description="Server port") - UVICORN_LOG_LEVEL: str = Field( - default="critical", description="Uvicorn log level when running with MCP stdio" + UVICORN_LOG_LEVEL: str = Field(default="critical", description="Uvicorn log level") + OUTPUT_DIR: str = Field( + default=os.path.join(tempfile.gettempdir(), "output"), + description="Directory for temporary output file storage", ) @@ -46,10 +46,10 @@ class LLMConfig(BaseSettings): Large Language Model configuration. """ - OPEN_ROUTER_API_KEY: Optional[str] = Field(default=None) - OPENROUTER_API_KEY: Optional[str] = Field(default=None) + OPEN_ROUTER_API_KEY: str | None = Field(default=None) + OPENROUTER_API_KEY: str | None = Field(default=None) OPEN_ROUTER_API_URL: str = Field(default="https://openrouter.ai/api/v1") - BASE_URL: Optional[str] = Field(default=None) + BASE_URL: str | None = Field(default=None) CHAT_MODEL: str = Field( default="openai/gpt-4o-mini", description="Model name for chat completions" @@ -109,17 +109,11 @@ class RAGConfig(BaseSettings): ) -class ProxyConfig(BaseSettings): - """ - Configuration for the LightRAG API proxy. - """ +class MinioConfig(BaseSettings): + """MinIO object storage configuration.""" - LIGHTRAG_API_URL: str = Field( - default="http://localhost:9621", description="LightRAG API base URL" - ) - LIGHTRAG_TIMEOUT: int = Field( - default=60, description="Default timeout for proxy requests in seconds" - ) - LIGHTRAG_STREAM_TIMEOUT: int = Field( - default=300, description="Timeout for streaming proxy requests in seconds" - ) + MINIO_HOST: str = Field(default="localhost:9000") + MINIO_ACCESS: str = Field(default="minioadmin") + MINIO_SECRET: str = Field(default="minioadmin") + MINIO_BUCKET: str = Field(default="raganything") + MINIO_SECURE: bool = Field(default=False) diff --git a/src/dependencies.py b/src/dependencies.py index 79513d7..611a6c7 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -1,193 +1,47 @@ -""" -Dependency injection setup for the application. -Follows the pickpro_indexing_api pattern for wiring components. -""" +"""Dependency injection — module-level singletons following the pickpro pattern.""" import os -import tempfile -from raganything import RAGAnything, RAGAnythingConfig -from lightrag.llm.openai import openai_complete_if_cache, openai_embed -from lightrag.utils import EmbeddingFunc -from config import DatabaseConfig, LLMConfig, RAGConfig, AppConfig, ProxyConfig -from infrastructure.rag.lightrag_adapter import LightRAGAdapter -from infrastructure.proxy.lightrag_proxy_client import ( - LightRAGProxyClient, - get_lightrag_client_instance, - init_lightrag_client, - close_lightrag_client, -) + from application.use_cases.index_file_use_case import IndexFileUseCase from application.use_cases.index_folder_use_case import IndexFolderUseCase -from application.use_cases.lightrag_proxy_use_case import LightRAGProxyUseCase - +from application.use_cases.query_use_case import QueryUseCase +from config import AppConfig, LLMConfig, MinioConfig, RAGConfig +from infrastructure.rag.lightrag_adapter import LightRAGAdapter +from infrastructure.storage.minio_adapter import MinioAdapter -# ============= CONFIG INITIALIZATION ============= +# ============= CONFIG ============= app_config = AppConfig() # type: ignore -db_config = DatabaseConfig() # type: ignore llm_config = LLMConfig() # type: ignore rag_config = RAGConfig() # type: ignore -proxy_config = ProxyConfig() # type: ignore +minio_config = MinioConfig() # type: ignore -# ============= ENVIRONMENT SETUP ============= +os.makedirs(app_config.OUTPUT_DIR, exist_ok=True) -os.environ["POSTGRES_USER"] = db_config.POSTGRES_USER -os.environ["POSTGRES_PASSWORD"] = db_config.POSTGRES_PASSWORD -os.environ["POSTGRES_DATABASE"] = db_config.POSTGRES_DATABASE -os.environ["POSTGRES_HOST"] = db_config.POSTGRES_HOST -os.environ["POSTGRES_PORT"] = db_config.POSTGRES_PORT - -# ============= DIRECTORIES ============= - -WORKING_DIR = os.path.join(tempfile.gettempdir(), "rag_storage") -os.makedirs(WORKING_DIR, exist_ok=True) -OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "output") -os.makedirs(OUTPUT_DIR, exist_ok=True) - -# ============= DATABASE ENGINE ============= +# ============= ADAPTERS ============= +rag_adapter = LightRAGAdapter(llm_config, rag_config) +minio_adapter = MinioAdapter( + host=minio_config.MINIO_HOST, + access=minio_config.MINIO_ACCESS, + secret=minio_config.MINIO_SECRET, + secure=minio_config.MINIO_SECURE, +) -# ============= RAG SETUP ============= +# ============= USE CASE PROVIDERS ============= -async def llm_model_func(prompt, system_prompt=None, history_messages=[], **kwargs): - """LLM function for RAGAnything.""" - return await openai_complete_if_cache( - llm_config.CHAT_MODEL, - prompt, - system_prompt=system_prompt, - history_messages=history_messages, - api_key=llm_config.api_key, - base_url=llm_config.api_base_url, - **kwargs, +def get_index_file_use_case() -> IndexFileUseCase: + return IndexFileUseCase( + rag_adapter, minio_adapter, minio_config.MINIO_BUCKET, app_config.OUTPUT_DIR ) -async def vision_model_func( - prompt, system_prompt=None, history_messages=[], image_data=None, **kwargs -): - """Vision function for RAGAnything.""" - messages = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - if history_messages: - messages.extend(history_messages) - - content = [{"type": "text", "text": prompt}] - - if image_data: - images = image_data if isinstance(image_data, list) else [image_data] - for img in images: - # Simple heuristic: if it looks like a URL, use it; otherwise assume base64 - url = ( - img - if isinstance(img, str) and img.startswith("http") - else f"data:image/jpeg;base64,{img}" - ) - content.append({"type": "image_url", "image_url": {"url": url}}) - - messages.append({"role": "user", "content": content}) - - return await openai_complete_if_cache( - llm_config.VISION_MODEL, - "Image Description Task", # Dummy prompt to avoid lightrag appending None - system_prompt=None, - history_messages=messages, - api_key=llm_config.api_key, - base_url=llm_config.api_base_url, - messages=messages, # Explicitly pass constructed messages - **kwargs, +def get_index_folder_use_case() -> IndexFolderUseCase: + return IndexFolderUseCase( + rag_adapter, minio_adapter, minio_config.MINIO_BUCKET, app_config.OUTPUT_DIR ) -embedding_func = EmbeddingFunc( - embedding_dim=llm_config.EMBEDDING_DIM, - max_token_size=llm_config.MAX_TOKEN_SIZE, - func=lambda texts: openai_embed( - texts, - model=llm_config.EMBEDDING_MODEL, - api_key=llm_config.api_key, - base_url=llm_config.api_base_url, - ), -) - -raganything_config = RAGAnythingConfig( - working_dir=WORKING_DIR, - parser="docling", - parse_method="auto", - enable_image_processing=rag_config.ENABLE_IMAGE_PROCESSING, - enable_table_processing=rag_config.ENABLE_TABLE_PROCESSING, - enable_equation_processing=rag_config.ENABLE_EQUATION_PROCESSING, - max_concurrent_files=rag_config.MAX_CONCURRENT_FILES, -) - -rag_instance = RAGAnything( - config=raganything_config, - llm_model_func=llm_model_func, - vision_model_func=vision_model_func, - embedding_func=embedding_func, - lightrag_kwargs=( - { - "kv_storage": "PGKVStorage", - "vector_storage": "PGVectorStorage", - "graph_storage": "PGGraphStorage", - "doc_status_storage": "PGDocStatusStorage", - "cosine_threshold": rag_config.COSINE_THRESHOLD, - } - if rag_config.RAG_STORAGE_TYPE == "postgres" - else { - "kv_storage": "JsonKVStorage", - "vector_storage": "NanoVectorDBStorage", - "graph_storage": "NetworkXStorage", - "doc_status_storage": "JsonDocStatusStorage", - "cosine_threshold": rag_config.COSINE_THRESHOLD, - } - ), -) - -# ============= ADAPTERS ============= - -rag_adapter = LightRAGAdapter(rag_instance, rag_config.MAX_WORKERS) - -# ============= DEPENDENCY INJECTION FUNCTIONS ============= - - -async def get_index_file_use_case() -> IndexFileUseCase: - """ - Dependency injection function for IndexFileUseCase. - - Returns: - IndexFileUseCase: The configured use case. - """ - return IndexFileUseCase(rag_adapter, OUTPUT_DIR) - - -async def get_index_folder_use_case() -> IndexFolderUseCase: - """ - Dependency injection function for IndexFolderUseCase. - - Returns: - IndexFolderUseCase: The configured use case. - """ - return IndexFolderUseCase(rag_adapter, OUTPUT_DIR) - - -async def get_lightrag_client() -> LightRAGProxyClient: - """ - Dependency injection function for LightRAG proxy client. - - Returns: - LightRAGProxyClient: The configured proxy client. - """ - return get_lightrag_client_instance() - - -async def get_lightrag_proxy_use_case() -> LightRAGProxyUseCase: - """ - Dependency injection function for LightRAGProxyUseCase. - - Returns: - LightRAGProxyUseCase: The configured use case for proxying requests. - """ - proxy_client = get_lightrag_client_instance() - return LightRAGProxyUseCase(proxy_client) +def get_query_use_case() -> QueryUseCase: + return QueryUseCase(rag_adapter) diff --git a/src/domain/entities/indexing_result.py b/src/domain/entities/indexing_result.py index 3a7fa98..ba7d0c4 100644 --- a/src/domain/entities/indexing_result.py +++ b/src/domain/entities/indexing_result.py @@ -1,7 +1,9 @@ -from pydantic import BaseModel, Field -from typing import Optional, List from enum import Enum +from pydantic import BaseModel, Field + +ERROR_MESSAGE_IF_FAILED = "Error message if failed" + class IndexingStatus(str, Enum): """Status of an indexing operation.""" @@ -18,10 +20,10 @@ class FileIndexingResult(BaseModel): message: str = Field(description="Status message") file_path: str = Field(description="Path to the indexed file") file_name: str = Field(description="Name of the indexed file") - processing_time_ms: Optional[float] = Field( + processing_time_ms: float | None = Field( default=None, description="Processing time in milliseconds" ) - error: Optional[str] = Field(default=None, description="Error message if failed") + error: str | None = Field(default=None, description=ERROR_MESSAGE_IF_FAILED) class FileProcessingDetail(BaseModel): @@ -30,7 +32,7 @@ class FileProcessingDetail(BaseModel): file_path: str = Field(description="Path to the file") file_name: str = Field(description="Name of the file") status: IndexingStatus = Field(description="Processing status") - error: Optional[str] = Field(default=None, description="Error message if failed") + error: str | None = Field(default=None, description=ERROR_MESSAGE_IF_FAILED) class FolderIndexingStats(BaseModel): @@ -54,10 +56,10 @@ class FolderIndexingResult(BaseModel): stats: FolderIndexingStats = Field( default_factory=FolderIndexingStats, description="Processing statistics" ) - processing_time_ms: Optional[float] = Field( + processing_time_ms: float | None = Field( default=None, description="Total processing time in milliseconds" ) - file_results: Optional[List[FileProcessingDetail]] = Field( + file_results: list[FileProcessingDetail] | None = Field( default=None, description="Individual file results" ) - error: Optional[str] = Field(default=None, description="Error message if failed") + error: str | None = Field(default=None, description="Error message if failed") diff --git a/src/domain/entities/lightrag_proxy_entities.py b/src/domain/entities/lightrag_proxy_entities.py deleted file mode 100644 index 1f9a4c1..0000000 --- a/src/domain/entities/lightrag_proxy_entities.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Domain entities for LightRAG proxy operations. -""" - -from dataclasses import dataclass, field -from typing import Any, Optional - - -@dataclass -class LightRAGProxyRequest: - """ - Represents a request to be proxied to the LightRAG API. - """ - - method: str - """HTTP method (GET, POST, PUT, DELETE, PATCH).""" - - path: str - """Request path (without base URL).""" - - body: Optional[Any] = None - """Request body (JSON serializable).""" - - params: Optional[dict[str, Any]] = None - """Query parameters.""" - - headers: dict[str, str] = field(default_factory=dict) - """Headers to forward (including Authorization).""" - - content_type: Optional[str] = None - """Content-Type header value.""" - - raw_content: Optional[bytes] = None - """Raw bytes content for non-JSON requests (e.g., file uploads).""" - - is_streaming: bool = False - """Whether this request expects a streaming response.""" - - def __post_init__(self): - """Normalize the HTTP method to uppercase.""" - self.method = self.method.upper() - - -@dataclass -class LightRAGProxyResponse: - """ - Represents a response from the LightRAG API. - """ - - status_code: int - """HTTP status code from the LightRAG API.""" - - content: bytes - """Response body content.""" - - headers: dict[str, str] = field(default_factory=dict) - """Response headers.""" - - media_type: Optional[str] = None - """Content-Type of the response.""" - - @property - def is_success(self) -> bool: - """Check if the response indicates success (2xx status code).""" - return 200 <= self.status_code < 300 - - @property - def is_error(self) -> bool: - """Check if the response indicates an error (4xx or 5xx status code).""" - return self.status_code >= 400 diff --git a/src/domain/ports/lightrag_proxy_client.py b/src/domain/ports/lightrag_proxy_client.py deleted file mode 100644 index 2c75dd7..0000000 --- a/src/domain/ports/lightrag_proxy_client.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Port interface for LightRAG proxy client operations. -Defines the contract for proxying requests to the LightRAG API. -""" - -from abc import ABC, abstractmethod -from typing import Any, AsyncIterator -from domain.entities.lightrag_proxy_entities import ( - LightRAGProxyRequest, - LightRAGProxyResponse, -) - - -class LightRAGProxyClientPort(ABC): - """ - Port interface for LightRAG proxy client operations. - Defines the contract for forwarding requests to the LightRAG API. - """ - - @abstractmethod - async def initialize(self) -> None: - """Initialize the proxy client connection.""" - pass - - @abstractmethod - async def close(self) -> None: - """Close the proxy client connection.""" - pass - - @abstractmethod - async def forward_request( - self, - request: LightRAGProxyRequest, - ) -> LightRAGProxyResponse: - """ - Forward a request to the LightRAG API. - - Args: - request: The proxy request containing method, path, body, params, headers. - - Returns: - LightRAGProxyResponse: The response from the LightRAG API. - """ - pass - - @abstractmethod - def forward_stream( - self, - request: LightRAGProxyRequest, - ) -> AsyncIterator[bytes]: - """ - Forward a streaming request to the LightRAG API. - - Args: - request: The proxy request containing method, path, body, params, headers. - - Yields: - bytes: Chunks of the streaming response. - """ - pass - - @abstractmethod - async def get_openapi_spec(self) -> dict[str, Any]: - """ - Fetch the OpenAPI specification from the LightRAG API. - - Returns: - dict: The OpenAPI specification JSON. - """ - pass diff --git a/src/domain/ports/rag_engine.py b/src/domain/ports/rag_engine.py index 37fe126..9a53afc 100644 --- a/src/domain/ports/rag_engine.py +++ b/src/domain/ports/rag_engine.py @@ -1,29 +1,20 @@ from abc import ABC, abstractmethod -from typing import Dict, Any, Optional, List + from domain.entities.indexing_result import FileIndexingResult, FolderIndexingResult class RAGEnginePort(ABC): - """ - Port interface for RAG engine operations. - Defines the contract for RAG indexing and querying functionality. - """ + """Port interface for RAG engine operations.""" + + @abstractmethod + def init_project(self, working_dir: str) -> None: + """Initialize the RAG engine for a specific project/workspace.""" + pass @abstractmethod async def index_document( - self, file_path: str, file_name: str, output_dir: str + self, file_path: str, file_name: str, output_dir: str, working_dir: str = "" ) -> FileIndexingResult: - """ - Index a single document into the RAG system. - - Args: - file_path: Absolute path to the document to index. - file_name: Name of the file being indexed. - output_dir: Directory for processing outputs. - - Returns: - FileIndexingResult: Structured result of the indexing operation. - """ pass @abstractmethod @@ -32,28 +23,11 @@ async def index_folder( folder_path: str, output_dir: str, recursive: bool = True, - file_extensions: Optional[List[str]] = None, + file_extensions: list[str] | None = None, + working_dir: str = "", ) -> FolderIndexingResult: - """ - Index all documents in a folder. - - Args: - folder_path: Absolute path to the folder containing documents. - output_dir: Directory for processing outputs. - recursive: Whether to process subdirectories recursively. - file_extensions: Optional list of file extensions to filter. - - Returns: - FolderIndexingResult: Structured result with statistics and file details. - """ pass @abstractmethod - async def initialize(self) -> bool: - """ - Initialize the RAG engine. - - Returns: - bool: True if initialization was successful, False otherwise. - """ + async def query(self, query: str, mode: str = "naive", top_k: int = 10, working_dir: str = "") -> dict: pass diff --git a/src/domain/ports/storage_port.py b/src/domain/ports/storage_port.py new file mode 100644 index 0000000..c9a0631 --- /dev/null +++ b/src/domain/ports/storage_port.py @@ -0,0 +1,39 @@ +from abc import ABC, abstractmethod + + +class StoragePort(ABC): + """Abstract port defining the interface for object storage operations.""" + + @abstractmethod + async def get_object(self, bucket: str, object_path: str) -> bytes: + """ + Retrieve an object from storage. + + Args: + bucket: The bucket name where the object is stored. + object_path: The path/key of the object within the bucket. + + Returns: + The object content as bytes. + + Raises: + FileNotFoundError: If the object does not exist. + """ + pass + + @abstractmethod + async def list_objects( + self, bucket: str, prefix: str, recursive: bool = True + ) -> list[str]: + """ + List object keys under a given prefix. + + Args: + bucket: The bucket name to list objects from. + prefix: The prefix to filter objects by. + recursive: Whether to list objects recursively. + + Returns: + A list of object keys matching the prefix. + """ + pass diff --git a/src/infrastructure/proxy/lightrag_proxy_client.py b/src/infrastructure/proxy/lightrag_proxy_client.py deleted file mode 100644 index bbc2482..0000000 --- a/src/infrastructure/proxy/lightrag_proxy_client.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -HTTP client for proxying requests to the LightRAG API. -Implements the LightRAGProxyClientPort interface. -""" - -import httpx -import logging -from typing import Any, AsyncIterator, Optional -from config import ProxyConfig -from domain.ports.lightrag_proxy_client import LightRAGProxyClientPort -from domain.entities.lightrag_proxy_entities import ( - LightRAGProxyRequest, - LightRAGProxyResponse, -) - -logger = logging.getLogger(__name__) - - -class LightRAGProxyClient(LightRAGProxyClientPort): - """ - Async HTTP client for forwarding requests to LightRAG API. - Implements ProxyClientPort for hexagonal architecture compliance. - Handles token forwarding, streaming responses, and OpenAPI spec retrieval. - """ - - def __init__(self, config: ProxyConfig): - self.config = config - self.base_url = config.LIGHTRAG_API_URL.rstrip("/") - self._client: Optional[httpx.AsyncClient] = None - - async def initialize(self) -> None: - """Initialize the HTTP client.""" - if self._client is None: - self._client = httpx.AsyncClient( - base_url=self.base_url, - timeout=httpx.Timeout(self.config.LIGHTRAG_TIMEOUT), - follow_redirects=True, - ) - logger.info(f"LightRAG proxy client initialized for {self.base_url}") - - async def close(self) -> None: - """Close the HTTP client.""" - if self._client: - await self._client.aclose() - self._client = None - logger.info("LightRAG proxy client closed") - - @property - def client(self) -> httpx.AsyncClient: - """Get the HTTP client, raising if not initialized.""" - if self._client is None: - raise RuntimeError( - "LightRAG proxy client not initialized. Call initialize() first." - ) - return self._client - - async def get_openapi_spec(self) -> dict[str, Any]: - """ - Fetch the OpenAPI specification from LightRAG API. - - Returns: - dict: The OpenAPI specification JSON. - """ - try: - response = await self.client.get("/openapi.json") - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - logger.error(f"Failed to fetch OpenAPI spec from LightRAG: {e}") - return {} - - def _build_headers(self, headers: dict[str, str]) -> dict[str, str]: - """ - Build headers for forwarding, extracting Authorization if present. - - Args: - headers: Original headers from the request. - - Returns: - dict: Headers to forward to LightRAG. - """ - request_headers = {} - if headers: - # Forward authorization header (case-insensitive) - if "authorization" in headers: - request_headers["Authorization"] = headers["authorization"] - elif "Authorization" in headers: - request_headers["Authorization"] = headers["Authorization"] - - # Forward API key header if present - if "api_key_header_value" in headers: - request_headers["api_key_header_value"] = headers[ - "api_key_header_value" - ] - - # Forward Content-Type if present - if "Content-Type" in headers: - request_headers["Content-Type"] = headers["Content-Type"] - - return request_headers - - async def forward_request( - self, - request: LightRAGProxyRequest, - ) -> LightRAGProxyResponse: - """ - Forward a request to LightRAG API. - - Args: - request: The proxy request containing method, path, body, params, headers. - - Returns: - LightRAGProxyResponse: The response from LightRAG API. - """ - request_headers = self._build_headers(request.headers) - - # Set content type if provided - if request.content_type: - request_headers["Content-Type"] = request.content_type - - # Prepare request kwargs - kwargs: dict[str, Any] = { - "method": request.method, - "url": f"/{request.path.lstrip('/')}", - "headers": request_headers, - } - - if request.params: - kwargs["params"] = request.params - - if request.raw_content is not None: - kwargs["content"] = request.raw_content - elif request.body is not None: - kwargs["json"] = request.body - - logger.debug(f"Forwarding {request.method} /{request.path} to LightRAG") - - response = await self.client.request(**kwargs) - - # Build response headers (exclude hop-by-hop headers) - excluded_headers = { - "content-encoding", - "content-length", - "transfer-encoding", - "connection", - } - response_headers = { - k: v - for k, v in response.headers.items() - if k.lower() not in excluded_headers - } - - return LightRAGProxyResponse( - status_code=response.status_code, - content=response.content, - headers=response_headers, - media_type=response.headers.get("content-type"), - ) - - async def forward_stream( - self, - request: LightRAGProxyRequest, - ) -> AsyncIterator[bytes]: # type: ignore[override] - """ - Forward a streaming request to LightRAG API. - - Args: - request: The proxy request containing method, path, body, params, headers. - - Yields: - bytes: Chunks of the streaming response. - """ - request_headers = self._build_headers(request.headers) - - # Use longer timeout for streaming - timeout = httpx.Timeout(self.config.LIGHTRAG_STREAM_TIMEOUT) - - async with httpx.AsyncClient( - base_url=self.base_url, - timeout=timeout, - follow_redirects=True, - ) as stream_client: - kwargs: dict[str, Any] = { - "method": request.method, - "url": f"/{request.path.lstrip('/')}", - "headers": request_headers, - } - - if request.params: - kwargs["params"] = request.params - - if request.body is not None: - kwargs["json"] = request.body - - logger.debug( - f"Forwarding streaming {request.method} /{request.path} to LightRAG" - ) - - async with stream_client.stream(**kwargs) as response: - async for chunk in response.aiter_bytes(): - yield chunk - - -# Singleton instance (initialized in dependencies.py) -_lightrag_client: Optional[LightRAGProxyClient] = None - - -def get_lightrag_client_instance() -> LightRAGProxyClient: - """Get the singleton LightRAG client instance.""" - global _lightrag_client - if _lightrag_client is None: - from config import ProxyConfig - - proxy_config = ProxyConfig() # type: ignore - _lightrag_client = LightRAGProxyClient(proxy_config) - return _lightrag_client - - -async def init_lightrag_client() -> LightRAGProxyClient: - """Initialize and return the LightRAG client.""" - client = get_lightrag_client_instance() - await client.initialize() - return client - - -async def close_lightrag_client() -> None: - """Close the LightRAG client.""" - global _lightrag_client - if _lightrag_client: - await _lightrag_client.close() - _lightrag_client = None diff --git a/src/infrastructure/rag/lightrag_adapter.py b/src/infrastructure/rag/lightrag_adapter.py index 95c4b0e..3fc97e8 100644 --- a/src/infrastructure/rag/lightrag_adapter.py +++ b/src/infrastructure/rag/lightrag_adapter.py @@ -1,69 +1,159 @@ -from typing import Optional, List -import time +import hashlib import os -from domain.ports.rag_engine import RAGEnginePort +import time +from typing import Literal, cast + +from fastapi.logger import logger +from lightrag import QueryParam +from lightrag.llm.openai import openai_complete_if_cache, openai_embed +from lightrag.utils import EmbeddingFunc +from raganything import RAGAnything, RAGAnythingConfig + +from config import LLMConfig, RAGConfig from domain.entities.indexing_result import ( FileIndexingResult, + FileProcessingDetail, FolderIndexingResult, FolderIndexingStats, - FileProcessingDetail, IndexingStatus, ) -from raganything import RAGAnything -from fastapi.logger import logger +from domain.ports.rag_engine import RAGEnginePort + +QueryMode = Literal["local", "global", "hybrid", "naive", "mix", "bypass"] + +_POSTGRES_STORAGE = { + "kv_storage": "PGKVStorage", + "vector_storage": "PGVectorStorage", + "graph_storage": "PGGraphStorage", + "doc_status_storage": "PGDocStatusStorage", +} + +_LOCAL_STORAGE = { + "kv_storage": "JsonKVStorage", + "vector_storage": "NanoVectorDBStorage", + "graph_storage": "NetworkXStorage", + "doc_status_storage": "JsonDocStatusStorage", +} class LightRAGAdapter(RAGEnginePort): - """ - Adapter for RAGAnything/LightRAG implementing RAGEnginePort. - Wraps the RAGAnything instance and provides a clean interface. - """ + """Adapter for RAGAnything/LightRAG implementing RAGEnginePort.""" - def __init__(self, rag_instance: RAGAnything, max_workers: int) -> None: - """ - Initialize the LightRAG adapter. + def __init__(self, llm_config: LLMConfig, rag_config: RAGConfig) -> None: + self._llm_config = llm_config + self._rag_config = rag_config + self.rag: dict[str,RAGAnything] = {} - Args: - rag_instance: The configured RAGAnything instance. - """ - self.rag = rag_instance - self._initialized = False - self.max_workers = max_workers + @staticmethod + def _make_workspace(working_dir: str) -> str: + """Create a short, AGE-safe workspace name from the working_dir. - async def initialize(self) -> bool: + Apache AGE graph names must be valid PostgreSQL identifiers + (alphanumeric + underscore, max 63 chars). We use a truncated + SHA-256 hash prefixed with 'ws_' to guarantee uniqueness and + compliance. """ - Initialize the RAG engine. + digest = hashlib.sha256(working_dir.encode()).hexdigest()[:16] + return f"ws_{digest}" - Returns: - bool: True if initialization was successful. - """ - try: - if not self._initialized: - await self.rag._ensure_lightrag_initialized() - self._initialized = True - return True - except Exception as e: - logger.error(f"Failed to initialize RAG engine: {e}", exc_info=True) - return False + def init_project(self, working_dir: str) -> RAGAnything: + if working_dir in self.rag: + return self.rag[working_dir] + workspace = self._make_workspace(working_dir) + self.rag[working_dir] = RAGAnything( + config=RAGAnythingConfig( + working_dir=working_dir, + parser="docling", + parse_method="txt", + enable_image_processing=self._rag_config.ENABLE_IMAGE_PROCESSING, + enable_table_processing=self._rag_config.ENABLE_TABLE_PROCESSING, + enable_equation_processing=self._rag_config.ENABLE_EQUATION_PROCESSING, + max_concurrent_files=self._rag_config.MAX_CONCURRENT_FILES, + ), + llm_model_func=self._llm_call, + vision_model_func=self._vision_call, + embedding_func=EmbeddingFunc( + embedding_dim=self._llm_config.EMBEDDING_DIM, + max_token_size=self._llm_config.MAX_TOKEN_SIZE, + func=lambda texts: openai_embed( + texts, + model=self._llm_config.EMBEDDING_MODEL, + api_key=self._llm_config.api_key, + base_url=self._llm_config.api_base_url, + ), + ), + lightrag_kwargs={ + **( + _POSTGRES_STORAGE + if self._rag_config.RAG_STORAGE_TYPE == "postgres" + else _LOCAL_STORAGE + ), + "cosine_threshold": self._rag_config.COSINE_THRESHOLD, + "workspace": workspace, + }, + ) + return self.rag[working_dir] + # ------------------------------------------------------------------ + # LLM callables (passed directly to RAGAnything) + # ------------------------------------------------------------------ - async def index_document( - self, file_path: str, file_name: str, output_dir: str - ) -> FileIndexingResult: - """ - Index a single document into the RAG system. + async def _llm_call( + self, prompt, system_prompt=None, history_messages=None, **kwargs + ): + if history_messages is None: + history_messages = [] + return await openai_complete_if_cache( + self._llm_config.CHAT_MODEL, + prompt, + system_prompt=system_prompt, + history_messages=history_messages, + api_key=self._llm_config.api_key, + base_url=self._llm_config.api_base_url, + **kwargs, + ) - Args: - file_path: Absolute path to the document to index. - file_name: Name of the file being indexed. - output_dir: Directory for processing outputs. + async def _vision_call( + self, + prompt, + system_prompt=None, + history_messages=None, + image_data=None, + **kwargs, + ): + if history_messages is None: + history_messages = [] + messages = _build_vision_messages( + system_prompt, history_messages, prompt, image_data + ) + return await openai_complete_if_cache( + self._llm_config.VISION_MODEL, + "Image Description Task", + system_prompt=None, + history_messages=messages, + api_key=self._llm_config.api_key, + base_url=self._llm_config.api_base_url, + messages=messages, + **kwargs, + ) - Returns: - FileIndexingResult: Structured result of the indexing operation. - """ + # ------------------------------------------------------------------ + # Port implementation — indexing + # ------------------------------------------------------------------ + + def _ensure_initialized(self, working_dir: str) -> RAGAnything: + if self.rag[working_dir] is None: + raise RuntimeError("RAG engine not initialized. Call init_project() first.") + return self.rag[working_dir] + + async def index_document( + self, file_path: str, file_name: str, output_dir: str, working_dir: str = "" + ) -> FileIndexingResult: start_time = time.time() + rag = self._ensure_initialized(working_dir) + await rag._ensure_lightrag_initialized() try: - await self.rag.process_document_complete( - file_path=file_path, output_dir=output_dir, parse_method="auto" + await rag.process_document_complete( + file_path=file_path, output_dir=output_dir, parse_method="txt" ) processing_time_ms = (time.time() - start_time) * 1000 return FileIndexingResult( @@ -75,7 +165,6 @@ async def index_document( ) except Exception as e: processing_time_ms = (time.time() - start_time) * 1000 - error_msg = str(e) logger.error(f"Failed to index document {file_path}: {e}", exc_info=True) return FileIndexingResult( status=IndexingStatus.FAILED, @@ -83,7 +172,7 @@ async def index_document( file_path=file_path, file_name=file_name, processing_time_ms=round(processing_time_ms, 2), - error=error_msg, + error=str(e), ) async def index_folder( @@ -91,88 +180,28 @@ async def index_folder( folder_path: str, output_dir: str, recursive: bool = True, - file_extensions: Optional[List[str]] = None, + file_extensions: list[str] | None = None, + working_dir: str = "", ) -> FolderIndexingResult: - """ - Index all documents in a folder. - - Args: - folder_path: Absolute path to the folder containing documents. - output_dir: Directory for processing outputs. - recursive: Whether to process subdirectories recursively. - file_extensions: Optional list of file extensions to filter. - - Returns: - FolderIndexingResult: Structured result with statistics and file details. - """ start_time = time.time() + rag = self._ensure_initialized(working_dir) + await rag._ensure_lightrag_initialized() try: - result = await self.rag.process_folder_complete( + result = await rag.process_folder_complete( folder_path=folder_path, output_dir=output_dir, - parse_method="auto", + parse_method="txt", file_extensions=file_extensions, recursive=recursive, display_stats=True, - max_workers=self.max_workers, + max_workers=self._rag_config.MAX_WORKERS, ) processing_time_ms = (time.time() - start_time) * 1000 - - # Parse the result from RAGAnything - result_dict = result if isinstance(result, dict) else {} - stats = FolderIndexingStats( - total_files=result_dict.get("total_files", 0), - files_processed=result_dict.get("successful_files", 0), - files_failed=result_dict.get("failed_files", 0), - files_skipped=result_dict.get("skipped_files", 0), - ) - - # Build file results if available - file_results: Optional[List[FileProcessingDetail]] = None - if result_dict and "file_details" in result_dict: - file_results = [] - file_details = result_dict["file_details"] - if isinstance(file_details, list): - for detail in file_details: - file_results.append( - FileProcessingDetail( - file_path=detail.get("file_path", ""), - file_name=os.path.basename(detail.get("file_path", "")), - status=( - IndexingStatus.SUCCESS - if detail.get("success", False) - else IndexingStatus.FAILED - ), - error=detail.get("error"), - ) - ) - - # Determine overall status - if stats.files_failed == 0 and stats.files_processed > 0: - status = IndexingStatus.SUCCESS - message = f"Successfully indexed {stats.files_processed} file(s) from '{folder_path}'" - elif stats.files_processed > 0 and stats.files_failed > 0: - status = IndexingStatus.PARTIAL - message = f"Partially indexed folder '{folder_path}': {stats.files_processed} succeeded, {stats.files_failed} failed" - elif stats.files_processed == 0 and stats.total_files > 0: - status = IndexingStatus.FAILED - message = f"Failed to index any files from '{folder_path}'" - else: - status = IndexingStatus.SUCCESS - message = f"No files found to index in '{folder_path}'" - - return FolderIndexingResult( - status=status, - message=message, - folder_path=folder_path, - recursive=recursive, - stats=stats, - processing_time_ms=round(processing_time_ms, 2), - file_results=file_results, + return self._build_folder_result( + result, folder_path, recursive, processing_time_ms ) except Exception as e: processing_time_ms = (time.time() - start_time) * 1000 - error_msg = str(e) logger.error(f"Failed to index folder {folder_path}: {e}", exc_info=True) return FolderIndexingResult( status=IndexingStatus.FAILED, @@ -181,5 +210,113 @@ async def index_folder( recursive=recursive, stats=FolderIndexingStats(), processing_time_ms=round(processing_time_ms, 2), - error=error_msg, + error=str(e), + ) + + # ------------------------------------------------------------------ + # Port implementation — query + # ------------------------------------------------------------------ + + async def query(self, query: str, mode: str = "naive", top_k: int = 10, working_dir: str = "") -> dict: + rag = self._ensure_initialized(working_dir) + await rag._ensure_lightrag_initialized() + if rag.lightrag is None: + return { + "status": "failure", + "message": "RAG engine not initialized", + "data": {}, + } + param = QueryParam(mode=cast(QueryMode, mode), top_k=top_k, chunk_top_k=top_k) + return await rag.lightrag.aquery_data(query=query, param=param) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + @staticmethod + def _build_folder_result( + result, folder_path: str, recursive: bool, processing_time_ms: float + ) -> FolderIndexingResult: + result_dict = result if isinstance(result, dict) else {} + stats = FolderIndexingStats( + total_files=result_dict.get("total_files", 0), + files_processed=result_dict.get("successful_files", 0), + files_failed=result_dict.get("failed_files", 0), + files_skipped=result_dict.get("skipped_files", 0), + ) + + file_results = _parse_file_details(result_dict) + + if stats.files_failed == 0 and stats.files_processed > 0: + status = IndexingStatus.SUCCESS + message = f"Successfully indexed {stats.files_processed} file(s) from '{folder_path}'" + elif stats.files_processed > 0 and stats.files_failed > 0: + status = IndexingStatus.PARTIAL + message = f"Partially indexed folder '{folder_path}': {stats.files_processed} succeeded, {stats.files_failed} failed" + elif stats.files_processed == 0 and stats.total_files > 0: + status = IndexingStatus.FAILED + message = f"Failed to index any files from '{folder_path}'" + else: + status = IndexingStatus.SUCCESS + message = f"No files found to index in '{folder_path}'" + + return FolderIndexingResult( + status=status, + message=message, + folder_path=folder_path, + recursive=recursive, + stats=stats, + processing_time_ms=round(processing_time_ms, 2), + file_results=file_results, + ) + + +# ------------------------------------------------------------------ +# Module-level helpers +# ------------------------------------------------------------------ + + +def _build_vision_messages( + system_prompt: str | None, + history_messages: list, + prompt: str, + image_data, +) -> list[dict]: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + if history_messages: + messages.extend(history_messages) + + content: list[dict] = [{"type": "text", "text": prompt}] + if image_data: + images = image_data if isinstance(image_data, list) else [image_data] + for img in images: + url = ( + img + if isinstance(img, str) and img.startswith("http") + else f"data:image/jpeg;base64,{img}" ) + content.append({"type": "image_url", "image_url": {"url": url}}) + + messages.append({"role": "user", "content": content}) + return messages + + +def _parse_file_details(result_dict: dict) -> list[FileProcessingDetail] | None: + if "file_details" not in result_dict: + return None + file_details = result_dict["file_details"] + if not isinstance(file_details, list): + return None + return [ + FileProcessingDetail( + file_path=d.get("file_path", ""), + file_name=os.path.basename(d.get("file_path", "")), + status=IndexingStatus.SUCCESS + if d.get("success", False) + else IndexingStatus.FAILED, + error=d.get("error"), + ) + for d in file_details + ] diff --git a/src/infrastructure/storage/minio_adapter.py b/src/infrastructure/storage/minio_adapter.py new file mode 100644 index 0000000..ae5bdd6 --- /dev/null +++ b/src/infrastructure/storage/minio_adapter.py @@ -0,0 +1,88 @@ +import asyncio +import logging + +from minio import Minio +from minio.error import S3Error + +from domain.ports.storage_port import StoragePort + +logger = logging.getLogger(__name__) + + +class MinioAdapter(StoragePort): + """MinIO implementation of the StoragePort.""" + + def __init__( + self, host: str, access: str, secret: str, secure: bool = False + ) -> None: + """ + Initialize the MinIO adapter with connection parameters. + + Args: + host: The MinIO server endpoint (host:port). + access: The access key for authentication. + secret: The secret key for authentication. + secure: Whether to use HTTPS. Defaults to False. + """ + self.client = Minio( + endpoint=host, + access_key=access, + secret_key=secret, + secure=secure, + ) + + async def get_object(self, bucket: str, object_path: str) -> bytes: + """ + Retrieve an object from MinIO storage. + + Args: + bucket: The bucket name where the object is stored. + object_path: The path/key of the object within the bucket. + + Returns: + The object content as bytes. + + Raises: + FileNotFoundError: If the object or bucket does not exist. + """ + try: + loop = asyncio.get_running_loop() + response = await loop.run_in_executor( + None, lambda: self.client.get_object(bucket, object_path) + ) + try: + return response.read() + finally: + response.close() + response.release_conn() + except S3Error as e: + if e.code in ("NoSuchKey", "NoSuchBucket"): + logger.warning(f"Object not found: bucket={bucket}, path={object_path}") + raise FileNotFoundError( + f"Object not found: bucket={bucket}, path={object_path}" + ) from None + logger.error(f"MinIO error retrieving object: {e}", exc_info=True) + raise + + async def list_objects( + self, bucket: str, prefix: str, recursive: bool = True + ) -> list[str]: + """ + List object keys under a given prefix in MinIO. + + Args: + bucket: The bucket name to list objects from. + prefix: The prefix to filter objects by. + recursive: Whether to list objects recursively. + + Returns: + A list of object keys (excluding directories). + """ + loop = asyncio.get_running_loop() + objects = await loop.run_in_executor( + None, + lambda: list( + self.client.list_objects(bucket, prefix=prefix, recursive=recursive) + ), + ) + return [obj.object_name for obj in objects if not obj.is_dir] diff --git a/src/main.py b/src/main.py index d9f8735..98502cf 100644 --- a/src/main.py +++ b/src/main.py @@ -2,45 +2,34 @@ Simplified following hexagonal architecture pattern from pickpro_indexing_api. """ -import contextlib -import threading import logging +import threading +from contextlib import asynccontextmanager + +import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.openapi.utils import get_openapi -from config import AppConfig -from dependencies import rag_adapter -from infrastructure.proxy.lightrag_proxy_client import ( - init_lightrag_client, - close_lightrag_client, -) -from application.api.indexing_routes import indexing_router + from application.api.health_routes import health_router -from application.api.lightrag_proxy_routes import lightrag_proxy_router +from application.api.indexing_routes import indexing_router from application.api.mcp_tools import mcp -import uvicorn +from application.api.query_routes import query_router +from dependencies import app_config logger = logging.getLogger(__name__) -@contextlib.asynccontextmanager -async def lifespan(app: FastAPI): - """Manage the lifespan of the application.""" - async with contextlib.AsyncExitStack() as stack: - # Initialize RAG engine - await rag_adapter.initialize() - # Initialize LightRAG proxy client - await init_lightrag_client() - logger.info("LightRAG proxy client initialized") - yield - # Cleanup - await close_lightrag_client() - logger.info("LightRAG proxy client closed") - - -app = FastAPI(title="RAG Anything API", lifespan=lifespan) +MCP_PATH = "/mcp" -app_config = AppConfig() # type: ignore +if app_config.MCP_TRANSPORT == "streamable": + mcp_app = mcp.http_app(path="/") + app = FastAPI( + title="RAG Anything API", + lifespan=mcp_app.lifespan, + ) + app.mount(MCP_PATH, mcp_app) +else: + app = FastAPI(title="RAG Anything API") app.add_middleware( CORSMiddleware, @@ -56,144 +45,26 @@ async def lifespan(app: FastAPI): app.include_router(indexing_router, prefix=REST_PATH) app.include_router(health_router, prefix=REST_PATH) -app.include_router( - lightrag_proxy_router, prefix=f"{REST_PATH}/lightrag", tags=["LightRAG Proxy"] -) - - -# ============= CUSTOM OPENAPI WITH LIGHTRAG PROXY ============= - - -def custom_openapi(): - """ - Generate custom OpenAPI schema that includes LightRAG API endpoints. - Fetches LightRAG's OpenAPI spec and merges it with the local spec. - """ - if app.openapi_schema: - return app.openapi_schema - - # Generate base OpenAPI schema - openapi_schema = get_openapi( - title="RAG Anything API", - version="1.0.0", - description=""" -## RAG Anything API - -This API provides RAG (Retrieval-Augmented Generation) capabilities with LightRAG integration. - -### Features: -- **Indexing**: Index files and folders for RAG -- **Query**: Query the RAG system -- **LightRAG Proxy**: Access all LightRAG API endpoints under `/api/v1/lightrag/*` - -### LightRAG Proxy -All LightRAG endpoints are available under the `/api/v1/lightrag/` prefix. -Authorization tokens are automatically forwarded to LightRAG. - """, - routes=app.routes, - ) - - # Try to fetch and merge LightRAG OpenAPI spec - try: - import httpx - from config import ProxyConfig - - proxy_config = ProxyConfig() # type: ignore - - # Synchronous fetch for OpenAPI generation - with httpx.Client(timeout=10) as client: - response = client.get(f"{proxy_config.LIGHTRAG_API_URL}/openapi.json") - if response.status_code == 200: - lightrag_spec = response.json() - - # Add LightRAG paths with /api/v1/lightrag prefix - lightrag_paths = lightrag_spec.get("paths", {}) - for path, path_item in lightrag_paths.items(): - proxy_path = f"/api/v1/lightrag{path}" - - # Add tag to identify LightRAG endpoints - for method_data in path_item.values(): - if isinstance(method_data, dict): - existing_tags = method_data.get("tags", []) - method_data["tags"] = ["LightRAG Proxy"] + [ - f"LightRAG - {tag}" for tag in existing_tags - ] - - openapi_schema["paths"][proxy_path] = path_item - - # Merge components/schemas - if "components" in lightrag_spec: - if "components" not in openapi_schema: - openapi_schema["components"] = {} - - lightrag_schemas = lightrag_spec["components"].get("schemas", {}) - if "schemas" not in openapi_schema["components"]: - openapi_schema["components"]["schemas"] = {} - - # Add LightRAG schemas with prefix to avoid conflicts - for schema_name, schema_def in lightrag_schemas.items(): - prefixed_name = f"LightRAG_{schema_name}" - openapi_schema["components"]["schemas"][ - prefixed_name - ] = schema_def - - # Update $ref references in LightRAG paths - def update_refs(obj): - if isinstance(obj, dict): - for key, value in obj.items(): - if key == "$ref" and isinstance(value, str): - if value.startswith("#/components/schemas/"): - schema_name = value.split("/")[-1] - obj[key] = ( - f"#/components/schemas/LightRAG_{schema_name}" - ) - else: - update_refs(value) - elif isinstance(obj, list): - for item in obj: - update_refs(item) - - # Update refs in lightrag paths - for path in openapi_schema["paths"]: - if path.startswith("/api/v1/lightrag"): - update_refs(openapi_schema["paths"][path]) - - logger.info( - f"Merged {len(lightrag_paths)} LightRAG endpoints into OpenAPI spec" - ) - - except Exception as e: - logger.warning(f"Failed to fetch LightRAG OpenAPI spec: {e}") - - app.openapi_schema = openapi_schema - return app.openapi_schema - - -app.openapi = custom_openapi - -# ============= MCP MOUNTING ============= - -MCP_PATH = "/mcp" - -if app_config.MCP_TRANSPORT == "streamable": - app.mount(MCP_PATH, mcp.streamable_http_app()) -elif app_config.MCP_TRANSPORT == "sse": - app.mount(MCP_PATH, mcp.sse_app()) +app.include_router(query_router, prefix=REST_PATH) # ============= MAIN ============= + def run_fastapi(): uvicorn.run( app, host=app_config.HOST, port=app_config.PORT, - log_level="critical", + log_level=app_config.UVICORN_LOG_LEVEL, access_log=False, + ws="none", ) -if __name__ == "__main__": - api_thread = threading.Thread(target=run_fastapi, daemon=True) - api_thread.start() +if __name__ == "__main__": if app_config.MCP_TRANSPORT == "stdio": + api_thread = threading.Thread(target=run_fastapi, daemon=True) + api_thread.start() mcp.run(transport="stdio") + else: + run_fastapi() diff --git a/tests/conftest.py b/tests/conftest.py index c233248..7e422ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,24 +1,24 @@ -import sys +import importlib.util from pathlib import Path -# Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - import pytest -from tests.doubles.double_rag_engine import DoubleRAGEngine from domain.entities.indexing_result import ( FileIndexingResult, FolderIndexingResult, - IndexingStatus, FolderIndexingStats, + IndexingStatus, ) +# Load external fixtures from tests/fixtures/external.py without __init__.py +_fixtures_path = Path(__file__).parent / "fixtures" / "external.py" +_spec = importlib.util.spec_from_file_location("external_fixtures", _fixtures_path) +_external = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_external) -@pytest.fixture -def double_rag_engine() -> DoubleRAGEngine: - """Provide a fresh DoubleRAGEngine instance.""" - return DoubleRAGEngine() +# Re-export fixtures so pytest discovers them +mock_rag_engine = _external.mock_rag_engine +mock_storage = _external.mock_storage @pytest.fixture diff --git a/tests/doubles/double_rag_engine.py b/tests/doubles/double_rag_engine.py deleted file mode 100644 index 371280a..0000000 --- a/tests/doubles/double_rag_engine.py +++ /dev/null @@ -1,138 +0,0 @@ -from typing import Optional, List -from dataclasses import dataclass, field - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) - -from domain.ports.rag_engine import RAGEnginePort -from domain.entities.indexing_result import ( - FileIndexingResult, - FolderIndexingResult, - IndexingStatus, - FolderIndexingStats, -) - - -@dataclass -class IndexDocumentCall: - """Record of a call to index_document.""" - - file_path: str - file_name: str - output_dir: str - - -@dataclass -class IndexFolderCall: - """Record of a call to index_folder.""" - - folder_path: str - output_dir: str - recursive: bool - file_extensions: Optional[List[str]] - - -class DoubleRAGEngine(RAGEnginePort): - """ - Test double for RAGEnginePort. - Returns configurable responses and tracks calls for assertions. - """ - - def __init__(self) -> None: - self.index_document_calls: list[IndexDocumentCall] = [] - self.index_folder_calls: list[IndexFolderCall] = [] - - self._index_document_result: Optional[FileIndexingResult] = None - self._index_folder_result: Optional[FolderIndexingResult] = None - - def set_index_document_result(self, result: FileIndexingResult) -> None: - """Configure the result to return from index_document.""" - self._index_document_result = result - - def set_index_folder_result(self, result: FolderIndexingResult) -> None: - """Configure the result to return from index_folder.""" - self._index_folder_result = result - - async def index_document( - self, file_path: str, file_name: str, output_dir: str - ) -> FileIndexingResult: - """ - Record the call and return configured result. - - Args: - file_path: Absolute path to the document to index. - file_name: Name of the file being indexed. - output_dir: Directory for processing outputs. - - Returns: - FileIndexingResult: Configured result or default success. - """ - self.index_document_calls.append( - IndexDocumentCall( - file_path=file_path, - file_name=file_name, - output_dir=output_dir, - ) - ) - - if self._index_document_result is not None: - return self._index_document_result - - return FileIndexingResult( - status=IndexingStatus.SUCCESS, - message="Document indexed successfully", - file_path=file_path, - file_name=file_name, - processing_time_ms=100.0, - ) - - async def index_folder( - self, - folder_path: str, - output_dir: str, - recursive: bool = True, - file_extensions: Optional[List[str]] = None, - ) -> FolderIndexingResult: - """ - Record the call and return configured result. - - Args: - folder_path: Absolute path to the folder containing documents. - output_dir: Directory for processing outputs. - recursive: Whether to process subdirectories recursively. - file_extensions: Optional list of file extensions to filter. - - Returns: - FolderIndexingResult: Configured result or default success. - """ - self.index_folder_calls.append( - IndexFolderCall( - folder_path=folder_path, - output_dir=output_dir, - recursive=recursive, - file_extensions=file_extensions, - ) - ) - - if self._index_folder_result is not None: - return self._index_folder_result - - return FolderIndexingResult( - status=IndexingStatus.SUCCESS, - message="Folder indexed successfully", - folder_path=folder_path, - recursive=recursive, - stats=FolderIndexingStats( - total_files=5, - files_processed=5, - files_failed=0, - files_skipped=0, - ), - processing_time_ms=500.0, - ) - - async def initialize(self) -> bool: - """No-op initialization for tests.""" - return True diff --git a/tests/fixtures/external.py b/tests/fixtures/external.py new file mode 100644 index 0000000..7d191a9 --- /dev/null +++ b/tests/fixtures/external.py @@ -0,0 +1,51 @@ +from unittest.mock import AsyncMock + +import pytest + +from domain.entities.indexing_result import ( + FileIndexingResult, + FolderIndexingResult, + FolderIndexingStats, + IndexingStatus, +) +from domain.ports.rag_engine import RAGEnginePort +from domain.ports.storage_port import StoragePort + + +@pytest.fixture +def mock_rag_engine() -> AsyncMock: + """Provide an AsyncMock of RAGEnginePort for external adapter mocking.""" + mock = AsyncMock(spec=RAGEnginePort) + + mock.index_document.return_value = FileIndexingResult( + status=IndexingStatus.SUCCESS, + message="Document indexed successfully", + file_path="/tmp/documents/report.pdf", + file_name="report.pdf", + processing_time_ms=100.0, + ) + + mock.index_folder.return_value = FolderIndexingResult( + status=IndexingStatus.SUCCESS, + message="Folder indexed successfully", + folder_path="/tmp/documents", + recursive=True, + stats=FolderIndexingStats( + total_files=5, + files_processed=5, + files_failed=0, + files_skipped=0, + ), + processing_time_ms=500.0, + ) + + return mock + + +@pytest.fixture +def mock_storage() -> AsyncMock: + """Provide an AsyncMock of StoragePort for external adapter mocking.""" + mock = AsyncMock(spec=StoragePort) + mock.get_object.return_value = b"fake file content" + mock.list_objects.return_value = ["project/doc1.pdf", "project/doc2.pdf"] + return mock diff --git a/tests/unit/test_index_file_use_case.py b/tests/unit/test_index_file_use_case.py index 3eea1ab..7671641 100644 --- a/tests/unit/test_index_file_use_case.py +++ b/tests/unit/test_index_file_use_case.py @@ -1,107 +1,158 @@ import os -import pytest from pathlib import Path +from unittest.mock import AsyncMock + +import pytest from application.use_cases.index_file_use_case import IndexFileUseCase -from domain.entities.indexing_result import IndexingStatus, FileIndexingResult -from tests.doubles.double_rag_engine import DoubleRAGEngine +from domain.entities.indexing_result import FileIndexingResult, IndexingStatus class TestIndexFileUseCase: - """Unit tests for IndexFileUseCase.""" + """Tests for IndexFileUseCase — storage and rag_engine are external, both mocked.""" - async def test_execute_returns_result_from_rag_engine( + async def test_execute_downloads_file_from_storage( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute returns the result from rag_engine.index_document.""" - output_dir = str(tmp_path / "output") + """Should call storage.get_object with the bucket and file_name.""" use_case = IndexFileUseCase( - rag_engine=double_rag_engine, - output_dir=output_dir, + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), ) - result = await use_case.execute( - file_path="/tmp/documents/report.pdf", - file_name="report.pdf", + await use_case.execute( + file_name="reports/report.pdf", working_dir="/tmp/rag/p1" ) - assert result.status == IndexingStatus.SUCCESS - assert result.file_path == "/tmp/documents/report.pdf" - assert result.file_name == "report.pdf" + mock_storage.get_object.assert_called_once_with( + "my-bucket", "reports/report.pdf" + ) - async def test_execute_passes_correct_arguments_to_rag_engine( + async def test_execute_writes_file_to_output_dir( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute passes correct arguments to rag_engine.index_document.""" - output_dir = str(tmp_path / "output") + """Should write the downloaded bytes to output_dir/.""" + mock_storage.get_object.return_value = b"pdf binary data" use_case = IndexFileUseCase( - rag_engine=double_rag_engine, - output_dir=output_dir, + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), + ) + + await use_case.execute(file_name="docs/report.pdf", working_dir="/tmp/rag/p1") + + written_file = tmp_path / "docs" / "report.pdf" + assert written_file.exists() + assert written_file.read_bytes() == b"pdf binary data" + + async def test_execute_calls_init_project( + self, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, + tmp_path: Path, + ) -> None: + """Should call rag_engine.init_project with the working_dir.""" + use_case = IndexFileUseCase( + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), ) await use_case.execute( - file_path="/tmp/documents/report.pdf", - file_name="report.pdf", + file_name="report.pdf", working_dir="/tmp/rag/project_42" ) - assert len(double_rag_engine.index_document_calls) == 1 - call = double_rag_engine.index_document_calls[0] - assert call.file_path == "/tmp/documents/report.pdf" - assert call.file_name == "report.pdf" - assert call.output_dir == output_dir + mock_rag_engine.init_project.assert_called_once_with("/tmp/rag/project_42") - async def test_execute_creates_output_directory( + async def test_execute_calls_index_document( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute creates the output directory if it doesn't exist.""" - output_dir = str(tmp_path / "new_output_dir") + """Should call rag_engine.index_document with the local file path, name, and output_dir.""" + output_dir = str(tmp_path) use_case = IndexFileUseCase( - rag_engine=double_rag_engine, + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", output_dir=output_dir, ) - assert not os.path.exists(output_dir) - await use_case.execute( - file_path="/tmp/documents/report.pdf", - file_name="report.pdf", + file_name="nested/dir/report.pdf", working_dir="/tmp/rag/p1" ) - assert os.path.exists(output_dir) - assert os.path.isdir(output_dir) + expected_file_path = os.path.join(output_dir, "nested/dir/report.pdf") + mock_rag_engine.index_document.assert_called_once_with( + file_path=expected_file_path, + file_name="nested/dir/report.pdf", + output_dir=output_dir, + working_dir="/tmp/rag/p1", + ) - async def test_execute_with_configured_result( + async def test_execute_returns_result( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute returns configured result from double.""" - output_dir = str(tmp_path / "output") + """Should return the FileIndexingResult from rag_engine.""" expected_result = FileIndexingResult( - status=IndexingStatus.FAILED, - message="Custom error message", - file_path="/custom/path.pdf", - file_name="path.pdf", - error="Parsing failed", + status=IndexingStatus.SUCCESS, + message="Indexed", + file_path="/tmp/report.pdf", + file_name="report.pdf", + processing_time_ms=42.0, ) - double_rag_engine.set_index_document_result(expected_result) - + mock_rag_engine.index_document.return_value = expected_result use_case = IndexFileUseCase( - rag_engine=double_rag_engine, - output_dir=output_dir, + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), ) result = await use_case.execute( - file_path="/tmp/documents/report.pdf", - file_name="report.pdf", + file_name="report.pdf", working_dir="/tmp/rag/p1" + ) + + assert result.status == IndexingStatus.SUCCESS + assert result.file_name == "report.pdf" + assert result.processing_time_ms == pytest.approx(42.0) + + async def test_execute_with_failure( + self, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, + tmp_path: Path, + ) -> None: + """Should return a FAILED result when rag_engine reports failure.""" + mock_rag_engine.index_document.return_value = FileIndexingResult( + status=IndexingStatus.FAILED, + message="Parsing error", + file_path="/tmp/bad.pdf", + file_name="bad.pdf", + error="Corrupt PDF", ) + use_case = IndexFileUseCase( + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), + ) + + result = await use_case.execute(file_name="bad.pdf", working_dir="/tmp/rag/p1") assert result.status == IndexingStatus.FAILED - assert result.message == "Custom error message" - assert result.error == "Parsing failed" + assert result.error == "Corrupt PDF" diff --git a/tests/unit/test_index_folder_use_case.py b/tests/unit/test_index_folder_use_case.py index 6af7a98..8db7a11 100644 --- a/tests/unit/test_index_folder_use_case.py +++ b/tests/unit/test_index_folder_use_case.py @@ -1,166 +1,207 @@ import os -import pytest from pathlib import Path +from unittest.mock import AsyncMock, call -from application.use_cases.index_folder_use_case import IndexFolderUseCase from application.requests.indexing_request import IndexFolderRequest +from application.use_cases.index_folder_use_case import IndexFolderUseCase from domain.entities.indexing_result import ( - IndexingStatus, FolderIndexingResult, FolderIndexingStats, + IndexingStatus, ) -from tests.doubles.double_rag_engine import DoubleRAGEngine class TestIndexFolderUseCase: - """Unit tests for IndexFolderUseCase.""" + """Tests for IndexFolderUseCase — storage and rag_engine are external, both mocked.""" - async def test_execute_returns_result_from_rag_engine( + async def test_execute_lists_objects_from_storage( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute returns the result from rag_engine.index_folder.""" - output_dir = str(tmp_path / "output") + """Should call storage.list_objects with bucket, working_dir prefix, and recursive flag.""" use_case = IndexFolderUseCase( - rag_engine=double_rag_engine, - output_dir=output_dir, - ) - request = IndexFolderRequest( - folder_path="/tmp/documents", - recursive=True, + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), ) + request = IndexFolderRequest(working_dir="project/docs", recursive=True) - result = await use_case.execute(request) + await use_case.execute(request) - assert result.status == IndexingStatus.SUCCESS - assert result.folder_path == "/tmp/documents" - assert result.recursive is True + mock_storage.list_objects.assert_called_once_with( + "my-bucket", prefix="project/docs", recursive=True + ) - async def test_execute_passes_correct_arguments_to_rag_engine( + async def test_execute_downloads_all_listed_files( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute passes correct arguments to rag_engine.index_folder.""" - output_dir = str(tmp_path / "output") + """Should call storage.get_object for each file returned by list_objects.""" + mock_storage.list_objects.return_value = [ + "project/docs/a.pdf", + "project/docs/b.pdf", + "project/docs/c.docx", + ] + mock_storage.get_object.return_value = b"content" use_case = IndexFolderUseCase( - rag_engine=double_rag_engine, - output_dir=output_dir, - ) - request = IndexFolderRequest( - folder_path="/tmp/documents", - recursive=True, - file_extensions=[".pdf", ".docx"], + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), ) + request = IndexFolderRequest(working_dir="project/docs") await use_case.execute(request) - assert len(double_rag_engine.index_folder_calls) == 1 - call = double_rag_engine.index_folder_calls[0] - assert call.folder_path == "/tmp/documents" - assert call.output_dir == output_dir - assert call.recursive is True - assert call.file_extensions == [".pdf", ".docx"] + assert mock_storage.get_object.call_count == 3 + mock_storage.get_object.assert_has_calls( + [ + call("my-bucket", "project/docs/a.pdf"), + call("my-bucket", "project/docs/b.pdf"), + call("my-bucket", "project/docs/c.docx"), + ], + any_order=False, + ) - async def test_execute_with_recursive_false( + async def test_execute_filters_by_file_extensions( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute correctly passes recursive=False.""" - output_dir = str(tmp_path / "output") + """Should only download files matching the requested extensions.""" + mock_storage.list_objects.return_value = [ + "project/docs/a.pdf", + "project/docs/b.txt", + "project/docs/c.docx", + ] + mock_storage.get_object.return_value = b"content" use_case = IndexFolderUseCase( - rag_engine=double_rag_engine, - output_dir=output_dir, + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), ) request = IndexFolderRequest( - folder_path="/tmp/documents", - recursive=False, + working_dir="project/docs", + file_extensions=[".pdf", ".docx"], ) await use_case.execute(request) - assert len(double_rag_engine.index_folder_calls) == 1 - call = double_rag_engine.index_folder_calls[0] - assert call.recursive is False + assert mock_storage.get_object.call_count == 2 + mock_storage.get_object.assert_has_calls( + [ + call("my-bucket", "project/docs/a.pdf"), + call("my-bucket", "project/docs/c.docx"), + ], + any_order=False, + ) - async def test_execute_with_no_file_extensions( + async def test_execute_calls_init_project( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute correctly passes None for file_extensions.""" - output_dir = str(tmp_path / "output") + """Should call rag_engine.init_project with the working_dir.""" use_case = IndexFolderUseCase( - rag_engine=double_rag_engine, - output_dir=output_dir, - ) - request = IndexFolderRequest( - folder_path="/tmp/documents", + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), ) + request = IndexFolderRequest(working_dir="project/docs") await use_case.execute(request) - assert len(double_rag_engine.index_folder_calls) == 1 - call = double_rag_engine.index_folder_calls[0] - assert call.file_extensions is None + mock_rag_engine.init_project.assert_called_once_with("project/docs") - async def test_execute_creates_output_directory( + async def test_execute_calls_index_folder( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute creates the output directory if it doesn't exist.""" - output_dir = str(tmp_path / "new_output_dir") + """Should call rag_engine.index_folder with the local folder path and parameters.""" + output_dir = str(tmp_path) use_case = IndexFolderUseCase( - rag_engine=double_rag_engine, + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", output_dir=output_dir, ) request = IndexFolderRequest( - folder_path="/tmp/documents", + working_dir="project/docs", + recursive=False, + file_extensions=[".pdf"], ) - assert not os.path.exists(output_dir) - await use_case.execute(request) - assert os.path.exists(output_dir) - assert os.path.isdir(output_dir) + expected_local_folder = os.path.join(output_dir, "project/docs") + mock_rag_engine.index_folder.assert_called_once_with( + folder_path=expected_local_folder, + output_dir=output_dir, + recursive=False, + file_extensions=[".pdf"], + working_dir="project/docs", + ) - async def test_execute_with_configured_result( + async def test_execute_returns_result( self, - double_rag_engine: DoubleRAGEngine, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, tmp_path: Path, ) -> None: - """Test that execute returns configured result from double.""" - output_dir = str(tmp_path / "output") - expected_result = FolderIndexingResult( - status=IndexingStatus.PARTIAL, - message="Some files failed", - folder_path="/custom/folder", + """Should return the FolderIndexingResult from rag_engine.""" + expected = FolderIndexingResult( + status=IndexingStatus.SUCCESS, + message="All done", + folder_path="/tmp/docs", recursive=True, stats=FolderIndexingStats( - total_files=10, - files_processed=7, - files_failed=3, - files_skipped=0, + total_files=2, files_processed=2, files_failed=0, files_skipped=0 ), - error="3 files failed to process", + processing_time_ms=200.0, ) - double_rag_engine.set_index_folder_result(expected_result) - + mock_rag_engine.index_folder.return_value = expected use_case = IndexFolderUseCase( - rag_engine=double_rag_engine, - output_dir=output_dir, - ) - request = IndexFolderRequest( - folder_path="/tmp/documents", + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), ) + request = IndexFolderRequest(working_dir="project/docs") result = await use_case.execute(request) - assert result.status == IndexingStatus.PARTIAL - assert result.message == "Some files failed" - assert result.stats.files_failed == 3 - assert result.error == "3 files failed to process" + assert result.status == IndexingStatus.SUCCESS + assert result.stats.total_files == 2 + assert result.stats.files_processed == 2 + + async def test_execute_with_empty_folder( + self, + mock_rag_engine: AsyncMock, + mock_storage: AsyncMock, + tmp_path: Path, + ) -> None: + """Should still call index_folder even when list_objects returns an empty list.""" + mock_storage.list_objects.return_value = [] + use_case = IndexFolderUseCase( + rag_engine=mock_rag_engine, + storage=mock_storage, + bucket="my-bucket", + output_dir=str(tmp_path), + ) + request = IndexFolderRequest(working_dir="project/empty") + + await use_case.execute(request) + + mock_storage.get_object.assert_not_called() + mock_rag_engine.index_folder.assert_called_once() diff --git a/tests/unit/test_lightrag_adapter.py b/tests/unit/test_lightrag_adapter.py new file mode 100644 index 0000000..7009064 --- /dev/null +++ b/tests/unit/test_lightrag_adapter.py @@ -0,0 +1,365 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from config import LLMConfig, RAGConfig +from domain.entities.indexing_result import IndexingStatus +from infrastructure.rag.lightrag_adapter import LightRAGAdapter + + +@pytest.fixture +def llm_config() -> LLMConfig: + return LLMConfig( + OPEN_ROUTER_API_KEY="test-key", + CHAT_MODEL="test-model", + EMBEDDING_MODEL="test-embed", + EMBEDDING_DIM=128, + MAX_TOKEN_SIZE=512, + VISION_MODEL="test-vision", + ) + + +@pytest.fixture +def rag_config_postgres() -> RAGConfig: + return RAGConfig(RAG_STORAGE_TYPE="postgres") + + +@pytest.fixture +def rag_config_local() -> RAGConfig: + return RAGConfig(RAG_STORAGE_TYPE="local") + + +class TestLightRAGAdapter: + """Tests for LightRAGAdapter — the external boundary (RAGAnything) is mocked.""" + + def test_init_stores_configs( + self, llm_config: LLMConfig, rag_config_postgres: RAGConfig + ) -> None: + """Should store configs and leave rag as empty dict before init_project.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + + assert adapter._llm_config is llm_config + assert adapter._rag_config is rag_config_postgres + assert adapter.rag == {} + + @patch("infrastructure.rag.lightrag_adapter.EmbeddingFunc") + @patch("infrastructure.rag.lightrag_adapter.RAGAnything") + def test_init_project_creates_rag_instance( + self, + mock_rag_cls: MagicMock, + _mock_embedding_func: MagicMock, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should instantiate RAGAnything with correct working_dir on init_project.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + adapter.init_project("/tmp/test_project") + + mock_rag_cls.assert_called_once() + call_kwargs = mock_rag_cls.call_args[1] + assert call_kwargs["config"].working_dir == "/tmp/test_project" + assert adapter.rag["/tmp/test_project"] is mock_rag_cls.return_value + + @patch("infrastructure.rag.lightrag_adapter.EmbeddingFunc") + @patch("infrastructure.rag.lightrag_adapter.RAGAnything") + def test_init_project_is_idempotent( + self, + mock_rag_cls: MagicMock, + _mock_embedding_func: MagicMock, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should return existing instance on second call, not create a new one.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + first = adapter.init_project("/tmp/test_project") + second = adapter.init_project("/tmp/test_project") + + assert first is second + mock_rag_cls.assert_called_once() + + @patch("infrastructure.rag.lightrag_adapter.EmbeddingFunc") + @patch("infrastructure.rag.lightrag_adapter.RAGAnything") + def test_init_project_passes_postgres_storage_when_configured( + self, + mock_rag_cls: MagicMock, + _mock_embedding_func: MagicMock, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should include PG storage keys in lightrag_kwargs when RAG_STORAGE_TYPE is postgres.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + adapter.init_project("/tmp/pg_project") + + call_kwargs = mock_rag_cls.call_args[1] + lightrag_kwargs = call_kwargs["lightrag_kwargs"] + assert lightrag_kwargs["kv_storage"] == "PGKVStorage" + assert lightrag_kwargs["vector_storage"] == "PGVectorStorage" + assert lightrag_kwargs["graph_storage"] == "PGGraphStorage" + assert lightrag_kwargs["doc_status_storage"] == "PGDocStatusStorage" + + @patch("infrastructure.rag.lightrag_adapter.EmbeddingFunc") + @patch("infrastructure.rag.lightrag_adapter.RAGAnything") + def test_init_project_passes_local_storage_when_configured( + self, + mock_rag_cls: MagicMock, + _mock_embedding_func: MagicMock, + llm_config: LLMConfig, + rag_config_local: RAGConfig, + ) -> None: + """Should include local storage keys in lightrag_kwargs when RAG_STORAGE_TYPE is not postgres.""" + adapter = LightRAGAdapter(llm_config, rag_config_local) + adapter.init_project("/tmp/local_project") + + call_kwargs = mock_rag_cls.call_args[1] + lightrag_kwargs = call_kwargs["lightrag_kwargs"] + assert lightrag_kwargs["kv_storage"] == "JsonKVStorage" + assert lightrag_kwargs["vector_storage"] == "NanoVectorDBStorage" + assert lightrag_kwargs["graph_storage"] == "NetworkXStorage" + assert lightrag_kwargs["doc_status_storage"] == "JsonDocStatusStorage" + + @patch("infrastructure.rag.lightrag_adapter.EmbeddingFunc") + @patch("infrastructure.rag.lightrag_adapter.RAGAnything") + def test_init_project_passes_cosine_threshold( + self, + mock_rag_cls: MagicMock, + _mock_embedding_func: MagicMock, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should forward cosine_threshold from RAGConfig into lightrag_kwargs.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + adapter.init_project("/tmp/cosine_project") + + call_kwargs = mock_rag_cls.call_args[1] + lightrag_kwargs = call_kwargs["lightrag_kwargs"] + assert ( + lightrag_kwargs["cosine_threshold"] == rag_config_postgres.COSINE_THRESHOLD + ) + + async def test_index_document_success( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should return SUCCESS result when process_document_complete succeeds.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + mock_rag = MagicMock() + mock_rag.process_document_complete = AsyncMock() + mock_rag._ensure_lightrag_initialized = AsyncMock() + adapter.rag["test_dir"] = mock_rag + + result = await adapter.index_document( + file_path="/tmp/doc.pdf", + file_name="doc.pdf", + output_dir="/tmp/output", + working_dir="test_dir", + ) + + assert result.status == IndexingStatus.SUCCESS + assert result.file_name == "doc.pdf" + assert result.file_path == "/tmp/doc.pdf" + assert result.processing_time_ms is not None + assert result.error is None + mock_rag.process_document_complete.assert_awaited_once_with( + file_path="/tmp/doc.pdf", + output_dir="/tmp/output", + parse_method="txt", + ) + + async def test_index_document_failure( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should return FAILED result with error when process_document_complete raises.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + mock_rag = MagicMock() + mock_rag._ensure_lightrag_initialized = AsyncMock() + mock_rag.process_document_complete = AsyncMock( + side_effect=RuntimeError("Parsing exploded") + ) + adapter.rag["test_dir"] = mock_rag + + result = await adapter.index_document( + file_path="/tmp/bad.pdf", + file_name="bad.pdf", + output_dir="/tmp/output", + working_dir="test_dir", + ) + + assert result.status == IndexingStatus.FAILED + assert result.file_name == "bad.pdf" + assert result.error == "Parsing exploded" + assert result.processing_time_ms is not None + + async def test_index_folder_success( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should return SUCCESS result when process_folder_complete succeeds.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + mock_rag = MagicMock() + mock_rag._ensure_lightrag_initialized = AsyncMock() + mock_rag.process_folder_complete = AsyncMock( + return_value={ + "total_files": 3, + "successful_files": 3, + "failed_files": 0, + "skipped_files": 0, + } + ) + adapter.rag["test_dir"] = mock_rag + + result = await adapter.index_folder( + folder_path="/tmp/docs", + output_dir="/tmp/output", + recursive=True, + file_extensions=[".pdf"], + working_dir="test_dir", + ) + + assert result.status == IndexingStatus.SUCCESS + assert result.stats.total_files == 3 + assert result.stats.files_processed == 3 + assert result.stats.files_failed == 0 + assert result.folder_path == "/tmp/docs" + assert result.recursive is True + assert result.processing_time_ms is not None + + async def test_index_folder_raises_when_not_initialized( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should raise KeyError when calling index_folder without init_project.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + + with pytest.raises(KeyError): + await adapter.index_folder( + folder_path="/tmp/docs", + output_dir="/tmp/output", + working_dir="missing_dir", + ) + + async def test_index_document_raises_when_not_initialized( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should raise KeyError when calling index_document without init_project.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + + with pytest.raises(KeyError): + await adapter.index_document( + file_path="/tmp/doc.pdf", + file_name="doc.pdf", + output_dir="/tmp/output", + working_dir="missing_dir", + ) + + async def test_query_success( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should return query result from lightrag.aquery_data.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + mock_rag = MagicMock() + mock_rag._ensure_lightrag_initialized = AsyncMock() + mock_lightrag = MagicMock() + mock_lightrag.aquery_data = AsyncMock( + return_value={"status": "success", "data": {"answer": "42"}} + ) + mock_rag.lightrag = mock_lightrag + adapter.rag["test_dir"] = mock_rag + + result = await adapter.query( + query="What is the answer?", mode="naive", top_k=5, working_dir="test_dir" + ) + + assert result["status"] == "success" + assert result["data"]["answer"] == "42" + mock_lightrag.aquery_data.assert_awaited_once() + + async def test_query_returns_failure_when_lightrag_none( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should return failure dict when rag.lightrag is None.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + mock_rag = MagicMock() + mock_rag._ensure_lightrag_initialized = AsyncMock() + mock_rag.lightrag = None + adapter.rag["test_dir"] = mock_rag + + result = await adapter.query(query="anything", working_dir="test_dir") + + assert result["status"] == "failure" + assert result["message"] == "RAG engine not initialized" + + async def test_query_raises_when_not_initialized( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should raise KeyError when calling query without init_project.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + + with pytest.raises(KeyError): + await adapter.query(query="anything", working_dir="missing_dir") + + async def test_index_folder_failure( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should return FAILED result when process_folder_complete raises.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + mock_rag = MagicMock() + mock_rag._ensure_lightrag_initialized = AsyncMock() + mock_rag.process_folder_complete = AsyncMock( + side_effect=OSError("Folder not found") + ) + adapter.rag["test_dir"] = mock_rag + + result = await adapter.index_folder( + folder_path="/tmp/missing", + output_dir="/tmp/output", + working_dir="test_dir", + ) + + assert result.status == IndexingStatus.FAILED + assert result.error == "Folder not found" + assert result.folder_path == "/tmp/missing" + assert result.processing_time_ms is not None + + async def test_index_folder_partial_result( + self, + llm_config: LLMConfig, + rag_config_postgres: RAGConfig, + ) -> None: + """Should return PARTIAL status when some files succeed and some fail.""" + adapter = LightRAGAdapter(llm_config, rag_config_postgres) + mock_rag = MagicMock() + mock_rag._ensure_lightrag_initialized = AsyncMock() + mock_rag.process_folder_complete = AsyncMock( + return_value={ + "total_files": 5, + "successful_files": 3, + "failed_files": 2, + "skipped_files": 0, + } + ) + adapter.rag["test_dir"] = mock_rag + + result = await adapter.index_folder( + folder_path="/tmp/mixed", + output_dir="/tmp/output", + working_dir="test_dir", + ) + + assert result.status == IndexingStatus.PARTIAL + assert result.stats.files_processed == 3 + assert result.stats.files_failed == 2 diff --git a/tests/unit/test_query_use_case.py b/tests/unit/test_query_use_case.py new file mode 100644 index 0000000..8302e1c --- /dev/null +++ b/tests/unit/test_query_use_case.py @@ -0,0 +1,101 @@ +from unittest.mock import AsyncMock + +from application.use_cases.query_use_case import QueryUseCase + + +class TestQueryUseCase: + """Tests for QueryUseCase — rag_engine is external, mocked.""" + + async def test_execute_calls_init_project( + self, + mock_rag_engine: AsyncMock, + ) -> None: + """Should call rag_engine.init_project with the working_dir.""" + use_case = QueryUseCase(rag_engine=mock_rag_engine) + + await use_case.execute( + working_dir="/tmp/rag/project_42", + query="What are the findings?", + ) + + mock_rag_engine.init_project.assert_called_once_with("/tmp/rag/project_42") + + async def test_execute_calls_query_with_correct_params( + self, + mock_rag_engine: AsyncMock, + ) -> None: + """Should call rag_engine.query with query, mode, top_k, and working_dir.""" + mock_rag_engine.query.return_value = {"status": "success", "data": {}} + use_case = QueryUseCase(rag_engine=mock_rag_engine) + + await use_case.execute( + working_dir="/tmp/rag/test", + query="Tell me about X", + mode="hybrid", + top_k=20, + ) + + mock_rag_engine.query.assert_called_once_with( + query="Tell me about X", + mode="hybrid", + top_k=20, + working_dir="/tmp/rag/test", + ) + + async def test_execute_returns_result_from_rag_engine( + self, + mock_rag_engine: AsyncMock, + ) -> None: + """Should return the dict result from rag_engine.query.""" + expected = {"status": "success", "data": {"answer": "42"}} + mock_rag_engine.query.return_value = expected + use_case = QueryUseCase(rag_engine=mock_rag_engine) + + result = await use_case.execute( + working_dir="/tmp/rag/test", + query="What is the answer?", + ) + + assert result == expected + + async def test_execute_uses_default_mode_and_top_k( + self, + mock_rag_engine: AsyncMock, + ) -> None: + """Should use mode='naive' and top_k=10 by default.""" + mock_rag_engine.query.return_value = {"status": "success", "data": {}} + use_case = QueryUseCase(rag_engine=mock_rag_engine) + + await use_case.execute( + working_dir="/tmp/rag/test", + query="test query", + ) + + mock_rag_engine.query.assert_called_once_with( + query="test query", + mode="naive", + top_k=10, + working_dir="/tmp/rag/test", + ) + + async def test_execute_with_mix_mode( + self, + mock_rag_engine: AsyncMock, + ) -> None: + """Should pass different mode values through correctly.""" + mock_rag_engine.query.return_value = {"status": "success", "data": {}} + use_case = QueryUseCase(rag_engine=mock_rag_engine) + + await use_case.execute( + working_dir="/tmp/rag/test", + query="test query", + mode="mix", + top_k=5, + ) + + mock_rag_engine.query.assert_called_once_with( + query="test query", + mode="mix", + top_k=5, + working_dir="/tmp/rag/test", + ) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py new file mode 100644 index 0000000..edf1dff --- /dev/null +++ b/tests/unit/test_routes.py @@ -0,0 +1,315 @@ +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from httpx import ASGITransport + +from application.use_cases.index_file_use_case import IndexFileUseCase +from application.use_cases.index_folder_use_case import IndexFolderUseCase +from application.use_cases.query_use_case import QueryUseCase +from dependencies import get_index_file_use_case, get_index_folder_use_case, get_query_use_case +from main import app + + +@pytest.fixture(autouse=True) +def _clear_dependency_overrides(): + """Reset FastAPI dependency overrides after each test.""" + yield + app.dependency_overrides.clear() + + +class TestHealthRoute: + async def test_health_returns_200(self) -> None: + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/api/v1/health") + + assert response.status_code == 200 + + async def test_health_returns_status_message(self) -> None: + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/api/v1/health") + + body = response.json() + assert body["message"] == "RAG Anything API is running" + + +class TestIndexFileRoute: + @pytest.fixture + def mock_index_file_use_case(self) -> AsyncMock: + mock = AsyncMock(spec=IndexFileUseCase) + mock.execute.return_value = None + return mock + + async def test_index_file_returns_202( + self, + mock_index_file_use_case: AsyncMock, + ) -> None: + """POST JSON with file_name and working_dir should return 202.""" + app.dependency_overrides[get_index_file_use_case] = ( + lambda: mock_index_file_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/file/index", + json={ + "file_name": "doc.pdf", + "working_dir": "/tmp/rag/test", + }, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "accepted" + assert "background" in body["message"].lower() + + async def test_index_file_rejects_missing_file_name(self) -> None: + """Missing file_name in JSON body should return 422.""" + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/file/index", + json={"working_dir": "/tmp/rag/test"}, + ) + + assert response.status_code == 422 + + async def test_index_file_rejects_missing_working_dir(self) -> None: + """Missing working_dir in JSON body should return 422.""" + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/file/index", + json={"file_name": "doc.pdf"}, + ) + + assert response.status_code == 422 + + +class TestIndexFolderRoute: + @pytest.fixture + def mock_index_folder_use_case(self) -> AsyncMock: + mock = AsyncMock(spec=IndexFolderUseCase) + mock.execute.return_value = None + return mock + + async def test_index_folder_returns_202( + self, + mock_index_folder_use_case: AsyncMock, + ) -> None: + """POST JSON with working_dir should return 202.""" + app.dependency_overrides[get_index_folder_use_case] = ( + lambda: mock_index_folder_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/folder/index", + json={"working_dir": "/tmp/rag/test"}, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "accepted" + assert "background" in body["message"].lower() + + async def test_index_folder_accepts_optional_fields( + self, + mock_index_folder_use_case: AsyncMock, + ) -> None: + """Optional recursive and file_extensions should be accepted.""" + app.dependency_overrides[get_index_folder_use_case] = ( + lambda: mock_index_folder_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/folder/index", + json={ + "working_dir": "/tmp/rag/project_1", + "recursive": False, + "file_extensions": [".pdf", ".docx"], + }, + ) + + assert response.status_code == 202 + + async def test_index_folder_rejects_missing_working_dir(self) -> None: + """Missing working_dir in JSON body should return 422.""" + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/folder/index", + json={}, + ) + + assert response.status_code == 422 + + +class TestQueryRoute: + @pytest.fixture + def mock_query_use_case(self) -> AsyncMock: + mock = AsyncMock(spec=QueryUseCase) + mock.execute.return_value = { + "status": "success", + "message": "", + "data": { + "entities": [], + "relationships": [], + "chunks": [], + "references": [], + }, + } + return mock + + async def test_query_returns_200( + self, + mock_query_use_case: AsyncMock, + ) -> None: + app.dependency_overrides[get_query_use_case] = ( + lambda: mock_query_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/query", + json={ + "working_dir": "/tmp/rag/test", + "query": "What is the summary?", + }, + ) + + assert response.status_code == 200 + + async def test_query_calls_use_case_with_correct_params( + self, + mock_query_use_case: AsyncMock, + ) -> None: + app.dependency_overrides[get_query_use_case] = ( + lambda: mock_query_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + await client.post( + "/api/v1/query", + json={ + "working_dir": "/tmp/rag/project_42", + "query": "What are the findings?", + "mode": "hybrid", + "top_k": 20, + }, + ) + + mock_query_use_case.execute.assert_called_once_with( + working_dir="/tmp/rag/project_42", + query="What are the findings?", + mode="hybrid", + top_k=20, + ) + + async def test_query_returns_response_body( + self, + mock_query_use_case: AsyncMock, + ) -> None: + app.dependency_overrides[get_query_use_case] = ( + lambda: mock_query_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/query", + json={ + "working_dir": "/tmp/rag/test", + "query": "summarize", + }, + ) + + body = response.json() + assert body["status"] == "success" + assert "data" in body + assert body["data"]["entities"] == [] + assert body["data"]["relationships"] == [] + assert body["data"]["chunks"] == [] + assert body["data"]["references"] == [] + + async def test_query_uses_default_mode_and_top_k( + self, + mock_query_use_case: AsyncMock, + ) -> None: + app.dependency_overrides[get_query_use_case] = ( + lambda: mock_query_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + await client.post( + "/api/v1/query", + json={ + "working_dir": "/tmp/rag/test", + "query": "test query", + }, + ) + + mock_query_use_case.execute.assert_called_once_with( + working_dir="/tmp/rag/test", + query="test query", + mode="naive", + top_k=10, + ) + + async def test_query_rejects_missing_query_field(self) -> None: + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/query", + json={"working_dir": "/tmp/rag/test"}, + ) + + assert response.status_code == 422 + + async def test_query_rejects_missing_working_dir(self) -> None: + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/query", + json={"query": "some question"}, + ) + + assert response.status_code == 422 + + async def test_query_rejects_invalid_mode(self) -> None: + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/query", + json={ + "working_dir": "/tmp/rag/test", + "query": "test", + "mode": "invalid_mode", + }, + ) + + assert response.status_code == 422 diff --git a/trivy.yaml b/trivy.yaml new file mode 100644 index 0000000..f753968 --- /dev/null +++ b/trivy.yaml @@ -0,0 +1,11 @@ +severity: + - CRITICAL + - HIGH + - MEDIUM + +pkg: + types: + - os + - library + +ignorefile: .trivyignore diff --git a/uv.lock b/uv.lock index 54a4c8c..5d1157a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,14 @@ version = 1 revision = 2 requires-python = ">=3.13" resolution-markers = [ - "sys_platform == 'darwin'", - "platform_machine == 'aarch64' and sys_platform == 'linux'", - "sys_platform == 'win32'", - "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "(python_full_version < '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] [[package]] @@ -26,6 +30,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d2/c581486aa6c4fbd7394c23c47b83fa1a919d34194e16944241daf9e762dd/accelerate-1.12.0-py3-none-any.whl", hash = "sha256:3e2091cd341423207e2f084a6654b1efcd250dc326f2a37d6dde446e07cabb11", size = 380935, upload-time = "2025-11-21T11:27:44.522Z" }, ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -192,6 +208,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "ascii-colors" version = "0.11.4" @@ -303,14 +362,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.5" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] @@ -437,33 +496,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] -[[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 = "boto3" version = "1.42.7" @@ -529,6 +561,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, ] +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -908,15 +957,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, ] -[[package]] -name = "diskcache" -version = "5.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, -] - [[package]] name = "distro" version = "1.9.0" @@ -1186,28 +1226,34 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.13.2" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "httpx" }, + { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["disk", "memory"] }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, + { name = "uncalled-for" }, { name = "uvicorn" }, + { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/7a/4c6375a56f7458a4a6af62f4c4838a2c957a665cf5edad26fe95395666f1/fastmcp-2.13.2.tar.gz", hash = "sha256:2a206401a6579fea621974162674beba85b467ad72c70c1a3752a31951dff7f0", size = 8185950, upload-time = "2025-12-01T18:48:16.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/83/c95d3bf717698a693eccb43e137a32939d2549876e884e246028bff6ecce/fastmcp-3.1.1.tar.gz", hash = "sha256:db184b5391a31199323766a3abf3a8bfbb8010479f77eca84c0e554f18655c48", size = 17347644, upload-time = "2026-03-14T19:12:20.235Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/4b/73c68b0ae9e587f20c5aa13ba5bed9be2bb9248a598555dafcf17df87f70/fastmcp-2.13.2-py3-none-any.whl", hash = "sha256:300c59eb970c235bb9d0575883322922e4f2e2468a3d45e90cbfd6b23b7be245", size = 385643, upload-time = "2025-12-01T18:48:18.515Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/570122de7e24f72138d006f799768e14cc1ccf7fcb22b7750b2bd276c711/fastmcp-3.1.1-py3-none-any.whl", hash = "sha256:8132ba069d89f14566b3266919d6d72e2ec23dd45d8944622dca407e9beda7eb", size = 633754, upload-time = "2026-03-14T19:12:22.736Z" }, ] [[package]] @@ -1705,6 +1751,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -1714,6 +1772,48 @@ 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 = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1858,6 +1958,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -2260,7 +2377,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.23.3" +version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2278,9 +2395,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/a4/d06a303f45997e266f2c228081abe299bbcba216cb806128e2e49095d25f/mcp-1.23.3.tar.gz", hash = "sha256:b3b0da2cc949950ce1259c7bfc1b081905a51916fcd7c8182125b85e70825201", size = 600697, upload-time = "2025-12-09T16:04:37.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/c6/13c1a26b47b3f3a3b480783001ada4268917c9f42d78a079c336da2e75e5/mcp-1.23.3-py3-none-any.whl", hash = "sha256:32768af4b46a1b4f7df34e2bfdf5c6011e7b63d7f1b0e321d0fdef4cd6082031", size = 231570, upload-time = "2025-12-09T16:04:35.56Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] [[package]] @@ -2288,13 +2405,16 @@ name = "mcp-raganything" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "aiofiles" }, { name = "asyncpg" }, + { name = "authlib" }, { name = "docling" }, { name = "fastapi" }, { name = "fastmcp" }, { name = "httpx" }, { name = "lightrag-hku", extra = ["api"] }, { name = "mcp" }, + { name = "minio" }, { name = "openai" }, { name = "pgvector" }, { name = "pydantic-settings" }, @@ -2307,7 +2427,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "black" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2317,19 +2436,22 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiofiles", specifier = ">=24.1.0" }, { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "authlib", specifier = ">=1.6.9" }, { name = "docling", specifier = ">=2.64.0" }, { name = "fastapi", specifier = ">=0.124.0" }, - { name = "fastmcp", specifier = ">=2.13.2" }, + { name = "fastmcp", specifier = ">=2.14.3" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "lightrag-hku", specifier = ">=1.4.9.8" }, { name = "lightrag-hku", extras = ["api"], specifier = ">=1.4.9.8" }, - { name = "mcp", specifier = ">=1.23.1" }, + { name = "mcp", specifier = ">=1.24.0" }, + { name = "minio", specifier = ">=7.2.18" }, { name = "openai", specifier = ">=2.9.0" }, { name = "pgvector", specifier = ">=0.4.2" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, - { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "python-multipart", specifier = ">=0.0.22" }, { name = "raganything", specifier = ">=1.2.8" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" }, { name = "uvicorn", specifier = ">=0.38.0" }, @@ -2337,7 +2459,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "black", specifier = ">=24.0.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, @@ -2428,6 +2549,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/94/fa758f5c9fc3b414d9e39503c4deee2c73e017e6e50881df922f311fc975/mineru_vl_utils-0.1.17-py3-none-any.whl", hash = "sha256:bd2025e72852710d23113540b641a95b4bd05feb0bd36b03346b62193b225f9a", size = 58116, upload-time = "2025-11-26T02:26:38.824Z" }, ] +[[package]] +name = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, +] + [[package]] name = "mlx" version = "0.30.0" @@ -2511,6 +2648,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/05/63f01821681b2be5d1739b4aad7b186c28d4ead2c5c99a9fc4aa53c13c19/modelscope-1.33.0-py3-none-any.whl", hash = "sha256:d9bdd566303f813d762e133410007eaf1b78f065c871228ab38640919b707489", size = 6050040, upload-time = "2025-12-10T03:49:58.428Z" }, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "mpire" version = "2.10.2" @@ -2888,7 +3034,8 @@ name = "onnxruntime" version = "1.20.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", ] dependencies = [ { name = "coloredlogs", marker = "sys_platform == 'win32'" }, @@ -2907,9 +3054,12 @@ name = "onnxruntime" version = "1.23.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "sys_platform == 'darwin'", - "platform_machine == 'aarch64' and sys_platform == 'linux'", - "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "(python_full_version < '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ { name = "coloredlogs", marker = "sys_platform != 'win32'" }, @@ -3005,6 +3155,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, +] + [[package]] name = "orjson" version = "3.11.5" @@ -3124,15 +3287,6 @@ 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 = "pathvalidate" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, -] - [[package]] name = "pdfminer-six" version = "20250506" @@ -3419,39 +3573,29 @@ wheels = [ [[package]] name = "py-key-value-aio" -version = "0.3.0" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, - { name = "py-key-value-shared" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, ] [package.optional-dependencies] -disk = [ - { name = "diskcache" }, - { name = "pathvalidate" }, +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, ] memory = [ { name = "cachetools" }, ] -[[package]] -name = "py-key-value-shared" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, -] - [[package]] name = "pyarrow" version = "22.0.0" @@ -3522,6 +3666,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pydantic" version = "2.12.3" @@ -3882,11 +4056,11 @@ cryptography = [ [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] @@ -3904,15 +4078,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, ] -[[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 = "pytz" version = "2025.2" @@ -3935,6 +4100,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -4429,6 +4603,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "semantic-version" version = "2.10.0" @@ -5117,6 +5304,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/c7/fb42228bb05473d248c110218ffb8b1ad2f76728ed8699856e5af21112ad/ultralytics_thop-2.0.18-py3-none-any.whl", hash = "sha256:2bb44851ad224b116c3995b02dd5e474a5ccf00acf237fe0edb9e1506ede04ec", size = 28941, upload-time = "2025-10-29T16:58:12.093Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" }, +] + [[package]] name = "urllib3" version = "2.6.1" @@ -5139,6 +5335,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14" @@ -5279,3 +5532,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]