diff --git a/.env.example b/.env.example index c1135d7..3ad327b 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,14 @@ BM25_ENABLED=true BM25_TEXT_CONFIG=english BM25_RRF_K=60 +# Classical RAG Configuration +CLASSICAL_CHUNK_SIZE=1000 +CLASSICAL_CHUNK_OVERLAP=200 +CLASSICAL_NUM_QUERY_VARIATIONS=3 +CLASSICAL_RELEVANCE_THRESHOLD=5.0 +CLASSICAL_TABLE_PREFIX=classical_rag_ +CLASSICAL_LLM_TEMPERATURE=0.0 + # Server Configuration MCP_TRANSPORT=sse ALLOWED_ORIGINS=["*"] diff --git a/Dockerfile.db b/Dockerfile.db index 716d7bf..d59bdd5 100644 --- a/Dockerfile.db +++ b/Dockerfile.db @@ -1,6 +1,5 @@ FROM pgvector/pgvector:pg17 -# Install build dependencies USER root RUN apt-get update && apt-get install -y \ build-essential \ @@ -8,26 +7,19 @@ RUN apt-get update && apt-get install -y \ postgresql-server-dev-17 \ flex \ bison \ - && rm -rf /var/lib/apt/lists/* - -# Install Apache AGE (v1.6.0 for PG17) -RUN cd /tmp && \ + && rm -rf /var/lib/apt/lists/* \ + && 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) - -# Install pg_textsearch extension for BM25 full-text search -RUN cd /tmp && \ + (echo "Failed to build AGE" && exit 1) \ + && cd /tmp && \ git clone https://github.com/timescale/pg_textsearch.git && \ cd pg_textsearch && \ make PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config || \ (echo "Failed to build pg_textsearch" && exit 1) && \ make PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config install || \ - (echo "Failed to install pg_textsearch" && exit 1) - -# Cleanup build artifacts -RUN rm -rf /tmp/age /tmp/pg_textsearch + (echo "Failed to install pg_textsearch" && exit 1) \ + && rm -rf /tmp/age /tmp/pg_textsearch -# Switch back to non-root user for security USER postgres \ No newline at end of file diff --git a/README.md b/README.md index e380b84..36a6001 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,69 @@ # MCP-RAGAnything -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`. +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). Two retrieval pathways are available: a **graph-based LightRAG pipeline** and a **classical RAG pipeline** using multi-query generation with LLM-as-judge scoring. 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 ``` - Clients - (REST / MCP / Claude) - | - +-----------------------+ - | FastAPI App | - +-----------+-----------+ - | - +---------------+---------------+ - | | - Application Layer MCP Servers (FastMCP) - +------------------------------+ | - | api/ | +---+--------+ +--+-----------+ - | indexing_routes.py | | RAGAnything | | RAGAnything | - | query_routes.py | | Query | | Files | - | file_routes.py | | /rag/mcp | | /files/mcp | - | health_routes.py | +---+--------+ +--+-----------+ - | use_cases/ | | | - | IndexFileUseCase | query_knowledge list_files - | IndexFolderUseCase | _base read_file - | QueryUseCase | query_knowledge - | ListFilesUseCase | _base_multimodal - | ListFoldersUseCase | - | ReadFileUseCase | - | requests/ responses/ | - +------------------------------+ - | | | - v v v - Domain Layer (ports) - +------------------------------------------+ - | RAGEnginePort StoragePort BM25EnginePort DocumentReaderPort | - +------------------------------------------+ - | | | | - v v v v - Infrastructure Layer (adapters) - +------------------------------------------+ - | LightRAGAdapter MinioAdapter | - | (RAGAnything) (minio-py) | - | | - | PostgresBM25Adapter RRFCombiner | - | (pg_textsearch) (hybrid+ fusion) | - | | - | KreuzbergAdapter | - | (kreuzberg - 91 formats) | - +------------------------------------------+ - | | | | - v v v v - PostgreSQL MinIO Kreuzberg - (pgvector + (object (document - Apache AGE storage) extraction) - pg_textsearch) + Clients + (REST / MCP / Claude) + | + +-------------+-------------+ + | FastAPI App | + +-------------+-------------+ + | + +---------------+---------------+ + | | + Application Layer MCP Servers (FastMCP) + +------------------------------+ | + | api/ | +---+--------+ +--+-----------+ +--+-------------+ + | indexing_routes.py | | RAGAnything | | RAGAnything | | RAGAnything | + | query_routes.py | | Query | | Files | | Classical | + | file_routes.py | | /rag/mcp | | /files/mcp | | /classical/mcp| + | health_routes.py | +---+--------+ +--+-----------+ +--+-------------+ + | classical_indexing_routes | | | | + | classical_query_routes | | | classical_index_file + | use_cases/ | | | classical_index_folder + | IndexFileUseCase | | | classical_query + | IndexFolderUseCase | + | QueryUseCase | + | ClassicalIndexFileUseCase | + | ClassicalIndexFolderUseCase | + | ClassicalQueryUseCase | + | ListFilesUseCase | + | ListFoldersUseCase | + | ReadFileUseCase | + | requests/ responses/ | + +------------------------------+ + | | | + v v v + Domain Layer (ports) + +----------------------------------------------------------+ + | RAGEnginePort StoragePort BM25EnginePort | + | DocumentReaderPort VectorStorePort LLMPort | + +----------------------------------------------------------+ + | | | | | + v v v v v + Infrastructure Layer (adapters) + +----------------------------------------------------------+ + | LightRAGAdapter MinioAdapter | + | (RAGAnything) (minio-py) | + | | + | PostgresBM25Adapter RRFCombiner | + | (pg_textsearch) (hybrid+ fusion) | + | | + | KreuzbergAdapter LangchainPgvectorAdapter | + | (kreuzberg - 91 formats) (langchain-postgres PGVector) | + | | + | LangchainOpenAIAdapter | + | (langchain-openai ChatOpenAI) | + +----------------------------------------------------------+ + | | | | | + v v v v v + PostgreSQL MinIO Kreuzberg OpenAI-compatible + (pgvector + (object (document (LLM API) + Apache AGE storage) extraction) + pg_textsearch) ``` ## Prerequisites @@ -414,9 +423,148 @@ Response (`200 OK`): The `combined_score` is the sum of `bm25_score` and `vector_score`, each computed as `1 / (k + rank)`. Results are sorted by `combined_score` descending. A chunk that appears in both result sets will have a higher combined score than one that appears in only one. +--- + +## Classical RAG Pipeline + +A second retrieval pathway alongside the graph-based LightRAG. Classical RAG uses a straightforward chunk → embed → retrieve flow with two quality-enhancing techniques: **multi-query generation** and **LLM-as-judge relevance scoring**. It stores chunks in dedicated PGVector tables (one per `working_dir`) and does not build a knowledge graph. + +### How it works + +1. **Indexing** — A file is downloaded from MinIO, text is extracted via Kreuzberg (with chunking), and each chunk is embedded and stored in a PGVector table. +2. **Querying** — The LLM generates N alternative phrasings of the user query (multi-query), similarity search runs for each variation, results are deduplicated by `chunk_id`, then an LLM judge scores each chunk's relevance on a 0–10 scale. Chunks below the relevance threshold are discarded; the rest are returned sorted by score. + +### Classical Indexing + +Both classical indexing endpoints accept JSON bodies and run processing in the background. + +#### Index a single file (classical) + +Downloads the file from MinIO, extracts text with Kreuzberg chunking, and embeds the chunks into a PGVector table scoped to `working_dir`. + +```bash +curl -X POST http://localhost:8000/api/v1/classical/file/index \ + -H "Content-Type: application/json" \ + -d '{ + "file_name": "project-alpha/report.pdf", + "working_dir": "project-alpha", + "chunk_size": 1000, + "chunk_overlap": 200 + }' +``` + +Response (`202 Accepted`): + +```json +{"status": "accepted", "message": "File indexing started in background"} +``` + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `file_name` | string | yes | -- | Object path in the MinIO bucket | +| `working_dir` | string | yes | -- | RAG workspace directory (project isolation) | +| `chunk_size` | integer | no | `1000` | Max characters per chunk (100–10000) | +| `chunk_overlap` | integer | no | `200` | Overlap characters between chunks (0–2000) | + +#### Index a folder (classical) + +Lists all objects under the `working_dir` prefix in MinIO, downloads them, and indexes each file into the PGVector table. + +```bash +curl -X POST http://localhost:8000/api/v1/classical/folder/index \ + -H "Content-Type: application/json" \ + -d '{ + "working_dir": "project-alpha", + "recursive": true, + "file_extensions": [".pdf", ".docx", ".txt"], + "chunk_size": 1000, + "chunk_overlap": 200 + }' +``` + +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", ".txt"]` | +| `chunk_size` | integer | no | `1000` | Max characters per chunk (100–10000) | +| `chunk_overlap` | integer | no | `200` | Overlap characters between chunks (0–2000) | + +### Classical Query + +Query the classical RAG pipeline. The LLM generates query variations, runs vector similarity search for each, deduplicates results, then scores and filters them with an LLM judge. + +```bash +curl -X POST http://localhost:8000/api/v1/classical/query \ + -H "Content-Type: application/json" \ + -d '{ + "working_dir": "project-alpha", + "query": "What are the main findings of the report?", + "top_k": 10, + "num_variations": 3, + "relevance_threshold": 5.0 + }' +``` + +Response (`200 OK`): + +```json +{ + "status": "success", + "message": "", + "queries": [ + "What are the main findings of the report?", + "What key results does the report present?", + "Summarize the primary conclusions from the report" + ], + "chunks": [ + { + "chunk_id": "a1b2c3d4-...", + "content": "The primary finding indicates that...", + "file_path": "project-alpha/report.pdf", + "relevance_score": 8.5, + "metadata": {"chunk_index": 0} + }, + { + "chunk_id": "e5f6g7h8-...", + "content": "Secondary findings suggest...", + "file_path": "project-alpha/report.pdf", + "relevance_score": 7.2, + "metadata": {"chunk_index": 3} + } + ] +} +``` + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `working_dir` | string | yes | -- | RAG workspace directory for this project | +| `query` | string | yes | -- | The search query | +| `top_k` | integer | no | `10` | Maximum chunks to retrieve per query variation (1–100) | +| `num_variations` | integer | no | `3` | Number of LLM-generated query variations (1–10) | +| `relevance_threshold` | float | no | `5.0` | Minimum LLM judge score (0–10) to include a chunk | + +### LightRAG vs Classical RAG + +| Aspect | LightRAG (graph-based) | Classical RAG | +|--------|----------------------|---------------| +| Storage | Apache AGE knowledge graph + pgvector | PGVector tables only | +| Indexing | Builds entity/relationship graph | Chunk + embed only | +| Query modes | `naive`, `local`, `global`, `hybrid`, `hybrid+`, `mix`, `bm25`, `bypass` | Multi-query + LLM judge | +| Project isolation | Shared graph per `working_dir` | Separate PG table per `working_dir` | +| Best for | Complex reasoning, relationship traversal | Straightforward document Q&A, simpler setup | + +--- + ## MCP Servers -The service exposes **two separate MCP servers**, both using streamable HTTP transport: +The service exposes **three MCP servers**, all using streamable HTTP transport: ### RAGAnythingQuery — `/rag/mcp` @@ -460,13 +608,47 @@ File browsing tools for listing and reading files from MinIO storage. Downloads the file from MinIO, extracts its text content using Kreuzberg, and returns the extracted text along with metadata and any detected tables. +### RAGAnythingClassical — `/classical/mcp` + +Classical RAG tools for indexing and querying without a knowledge graph. + +#### Tool: `classical_index_file` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `file_name` | string | required | Object path in the MinIO bucket | +| `working_dir` | string | required | RAG workspace directory (project isolation) | +| `chunk_size` | integer | `1000` | Max characters per chunk (100–10000) | +| `chunk_overlap` | integer | `200` | Overlap characters between chunks (0–2000) | + +#### Tool: `classical_index_folder` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `working_dir` | string | required | RAG workspace directory, also used as MinIO prefix | +| `recursive` | boolean | `true` | Process subdirectories recursively | +| `file_extensions` | list[string] | `null` (all files) | Filter by extensions, e.g. `[".pdf", ".docx"]` | +| `chunk_size` | integer | `1000` | Max characters per chunk (100–10000) | +| `chunk_overlap` | integer | `200` | Overlap characters between chunks (0–2000) | + +#### Tool: `classical_query` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `working_dir` | string | required | RAG workspace directory for this project | +| `query` | string | required | The search query | +| `top_k` | integer | `10` | Maximum chunks to retrieve per query variation | +| `num_variations` | integer | `3` | Number of LLM-generated query variations (1–10) | +| `relevance_threshold` | float | `5.0` | Minimum LLM judge score (0–10) to include a chunk | + ### Transport -Both MCP servers use **streamable HTTP** transport exclusively. Connect MCP clients to the mount paths: +All MCP servers use **streamable HTTP** transport exclusively. Connect MCP clients to the mount paths: ``` -http://localhost:8000/rag/mcp # RAGAnythingQuery -http://localhost:8000/files/mcp # RAGAnythingFiles +http://localhost:8000/rag/mcp # RAGAnythingQuery +http://localhost:8000/files/mcp # RAGAnythingFiles +http://localhost:8000/classical/mcp # RAGAnythingClassical ``` ## Configuration @@ -528,6 +710,19 @@ All configuration is via environment variables, loaded through Pydantic Settings When `BM25_ENABLED` is `false` or the pg_textsearch extension is not available, `hybrid+` mode falls back to `naive` (vector-only) and `bm25` mode returns an error. +### Classical RAG (`ClassicalRAGConfig`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLASSICAL_CHUNK_SIZE` | `1000` | Max characters per chunk (Kreuzberg `ChunkingConfig`) | +| `CLASSICAL_CHUNK_OVERLAP` | `200` | Overlap characters between chunks | +| `CLASSICAL_NUM_QUERY_VARIATIONS` | `3` | Number of multi-query variations the LLM generates (1–10) | +| `CLASSICAL_RELEVANCE_THRESHOLD` | `5.0` | Minimum LLM judge score (0–10) for a chunk to be included in results | +| `CLASSICAL_TABLE_PREFIX` | `classical_rag_` | Prefix for PGVectorStore table names. Full name: `{prefix}{sha256(working_dir)[:16]}` | +| `CLASSICAL_LLM_TEMPERATURE` | `0.0` | Temperature for LLM calls (multi-query generation + judge scoring) | + +The classical RAG adapters share the same `OPEN_ROUTER_API_KEY`, `OPEN_ROUTER_API_URL`/`BASE_URL`, `CHAT_MODEL`, `EMBEDDING_MODEL`, and `EMBEDDING_DIM` settings from the LLM config. If initialization fails (e.g. missing API key), the classical endpoints return `503 Service Unavailable` with a descriptive error. + ### MinIO (`MinioConfig`) | Variable | Default | Description | @@ -590,7 +785,7 @@ The PostgreSQL server must have the `pg_textsearch` extension installed and load ``` src/ - main.py -- FastAPI app, dual MCP mounts, entry point + main.py -- FastAPI app, triple MCP mounts, entry point config.py -- Pydantic Settings config classes dependencies.py -- Dependency injection wiring domain/ @@ -601,25 +796,37 @@ src/ storage_port.py -- StoragePort (abstract) + FileInfo bm25_engine.py -- BM25EnginePort (abstract) document_reader_port.py -- DocumentReaderPort (abstract) + DocumentContent + vector_store_port.py -- VectorStorePort (abstract) + SearchResult + llm_port.py -- LLMPort (abstract) application/ api/ health_routes.py -- GET /health indexing_routes.py -- POST /file/index, /folder/index query_routes.py -- POST /query file_routes.py -- GET /files/list, GET /files/folders, POST /files/read + classical_indexing_routes.py -- POST /classical/file/index, /classical/folder/index + classical_query_routes.py -- POST /classical/query mcp_query_tools.py -- MCP tools: query_knowledge_base, query_knowledge_base_multimodal - mcp_file_tools.py -- MCP tools: list_files, read_file + mcp_file_tools.py -- MCP tools: list_files, read_file + mcp_classical_tools.py -- MCP tools: classical_index_file, classical_index_folder, classical_query requests/ indexing_request.py -- IndexFileRequest, IndexFolderRequest + classical_indexing_request.py -- ClassicalIndexFileRequest, ClassicalIndexFolderRequest + classical_query_request.py -- ClassicalQueryRequest query_request.py -- QueryRequest, MultimodalQueryRequest file_request.py -- ListFilesRequest, ReadFileRequest responses/ query_response.py -- QueryResponse, QueryDataResponse + classical_query_response.py -- ClassicalQueryResponse, ClassicalChunkResponse file_response.py -- FileInfoResponse, FileContentResponse use_cases/ - index_file_use_case.py -- Downloads from MinIO, indexes single file - index_folder_use_case.py -- Downloads from MinIO, indexes folder + index_file_use_case.py -- Downloads from MinIO, indexes single file (LightRAG) + index_folder_use_case.py -- Downloads from MinIO, indexes folder (LightRAG) query_use_case.py -- Query with bm25/hybrid+ support + classical_index_file_use_case.py -- Classical: download → Kreuzberg chunk → PGVector + classical_index_folder_use_case.py -- Classical: folder batch index + classical_query_use_case.py -- Classical: multi-query + LLM judge scoring + _classical_helpers.py -- validate_path, build_documents_from_extraction list_files_use_case.py -- Lists files with metadata from MinIO list_folders_use_case.py -- Lists folder prefixes from MinIO read_file_use_case.py -- Reads file from MinIO, extracts content via Kreuzberg @@ -635,6 +842,10 @@ src/ pg_textsearch_adapter.py -- PostgresBM25Adapter (pg_textsearch) hybrid/ rrf_combiner.py -- RRFCombiner (Reciprocal Rank Fusion) + vector_store/ + langchain_pgvector_adapter.py -- LangchainPgvectorAdapter (langchain-postgres PGVectorStore) + llm/ + langchain_openai_adapter.py -- LangchainOpenAIAdapter (langchain-openai ChatOpenAI) alembic/ env.py -- Alembic migration environment (async) versions/ diff --git a/pyproject.toml b/pyproject.toml index 781bbef..7ce32d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,13 +20,16 @@ dependencies = [ "mcp>=1.24.0", "minio>=7.2.18", "openai>=2.9.0", - "pgvector>=0.4.2", + "pgvector>=0.3.6,<0.4.0", "pydantic-settings>=2.12.0", "python-dotenv>=1.2.1", "python-multipart>=0.0.22", "raganything[all]>=1.2.10", "sqlalchemy[asyncio]>=2.0.0", "uvicorn>=0.38.0", + "langchain-postgres>=0.0.17", + "langchain-core>=1.2.28", + "langchain-openai>=1.1.12", ] [dependency-groups] @@ -59,7 +62,7 @@ select = [ "ARG", # flake8-unused-arguments "SIM", # flake8-simplify ] -ignore = ["E501", "B008"] # E501: line too long (handled by formatter), B008: FastAPI Depends/File in defaults +ignore = ["E501", "B008", "ARG001", "ARG002"] [tool.ruff.lint.isort] known-first-party = ["src"] diff --git a/src/application/api/classical_indexing_routes.py b/src/application/api/classical_indexing_routes.py new file mode 100644 index 0000000..b8947ff --- /dev/null +++ b/src/application/api/classical_indexing_routes.py @@ -0,0 +1,81 @@ +import asyncio +import logging + +from fastapi import APIRouter, Depends, status + +from application.requests.classical_indexing_request import ( + ClassicalIndexFileRequest, + ClassicalIndexFolderRequest, +) +from application.use_cases.classical_index_file_use_case import ( + ClassicalIndexFileUseCase, +) +from application.use_cases.classical_index_folder_use_case import ( + ClassicalIndexFolderUseCase, +) +from dependencies import ( + get_classical_index_file_use_case, + get_classical_index_folder_use_case, +) + +logger = logging.getLogger(__name__) + +classical_indexing_router = APIRouter(tags=["Classical Indexing"]) + +_background_tasks: set[asyncio.Task] = set() + + +async def _run_in_background(coro, label: str) -> None: + try: + await coro + except Exception: + logger.exception("Background %s failed", label) + + +@classical_indexing_router.post( + "/classical/file/index", response_model=dict, status_code=status.HTTP_202_ACCEPTED +) +async def classical_index_file( + request: ClassicalIndexFileRequest, + use_case: ClassicalIndexFileUseCase = Depends(get_classical_index_file_use_case), +): + task = asyncio.create_task( + _run_in_background( + use_case.execute( + file_name=request.file_name, + working_dir=request.working_dir, + chunk_size=request.chunk_size, + chunk_overlap=request.chunk_overlap, + ), + label=f"classical file indexing {request.file_name}", + ) + ) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + return {"status": "accepted", "message": "File indexing started in background"} + + +@classical_indexing_router.post( + "/classical/folder/index", response_model=dict, status_code=status.HTTP_202_ACCEPTED +) +async def classical_index_folder( + request: ClassicalIndexFolderRequest, + use_case: ClassicalIndexFolderUseCase = Depends( + get_classical_index_folder_use_case + ), +): + task = asyncio.create_task( + _run_in_background( + use_case.execute( + working_dir=request.working_dir, + recursive=request.recursive, + file_extensions=request.file_extensions, + chunk_size=request.chunk_size, + chunk_overlap=request.chunk_overlap, + ), + label=f"classical folder indexing {request.working_dir}", + ) + ) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + return {"status": "accepted", "message": "Folder indexing started in background"} diff --git a/src/application/api/classical_query_routes.py b/src/application/api/classical_query_routes.py new file mode 100644 index 0000000..3b12fcb --- /dev/null +++ b/src/application/api/classical_query_routes.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends, status + +from application.requests.classical_query_request import ClassicalQueryRequest +from application.responses.classical_query_response import ClassicalQueryResponse +from application.use_cases.classical_query_use_case import ClassicalQueryUseCase +from dependencies import get_classical_query_use_case + +classical_query_router = APIRouter(tags=["Classical Query"]) + + +@classical_query_router.post( + "/classical/query", + status_code=status.HTTP_200_OK, +) +async def classical_query( + request: ClassicalQueryRequest, + use_case: ClassicalQueryUseCase = Depends(get_classical_query_use_case), +) -> ClassicalQueryResponse: + return await use_case.execute( + working_dir=request.working_dir, + query=request.query, + top_k=request.top_k, + num_variations=request.num_variations, + relevance_threshold=request.relevance_threshold, + ) diff --git a/src/application/api/file_routes.py b/src/application/api/file_routes.py index b87db88..3a14be4 100644 --- a/src/application/api/file_routes.py +++ b/src/application/api/file_routes.py @@ -21,9 +21,26 @@ MAX_UPLOAD_SIZE = 50 * 1024 * 1024 ALLOWED_EXTENSIONS = { - ".pdf", ".txt", ".docx", ".xlsx", ".pptx", ".md", ".csv", - ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", - ".html", ".xml", ".json", ".rtf", ".odt", ".ods", + ".pdf", + ".txt", + ".docx", + ".xlsx", + ".pptx", + ".md", + ".csv", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".bmp", + ".html", + ".xml", + ".json", + ".rtf", + ".odt", + ".ods", } ALLOWED_MIME_PREFIXES = ( @@ -50,9 +67,7 @@ def _sanitize_filename(filename: str | None) -> str: def _validate_file_type(filename: str, content_type: str) -> None: ext = posixpath.splitext(filename)[1].lower() if ext not in ALLOWED_EXTENSIONS: - raise HTTPException( - status_code=422, detail=f"File type '{ext}' is not allowed" - ) + raise HTTPException(status_code=422, detail=f"File type '{ext}' is not allowed") if not any(content_type.startswith(p) for p in ALLOWED_MIME_PREFIXES): raise HTTPException( status_code=422, detail=f"Content type '{content_type}' is not allowed" @@ -145,9 +160,7 @@ async def upload_file( if normalized == ".": normalized = "" if normalized.startswith("..") or posixpath.isabs(normalized): - raise HTTPException( - status_code=422, detail="prefix must be a relative path" - ) + raise HTTPException(status_code=422, detail="prefix must be a relative path") if prefix.endswith("/") and not normalized.endswith("/"): normalized += "/" diff --git a/src/application/api/mcp_classical_tools.py b/src/application/api/mcp_classical_tools.py new file mode 100644 index 0000000..94c25a6 --- /dev/null +++ b/src/application/api/mcp_classical_tools.py @@ -0,0 +1,58 @@ +from fastmcp import FastMCP + +from dependencies import ( + get_classical_index_file_use_case, + get_classical_index_folder_use_case, + get_classical_query_use_case, +) + +mcp_classical = FastMCP("RAGAnythingClassical") + + +@mcp_classical.tool() +async def classical_index_file( + file_name: str, working_dir: str, chunk_size: int = 1000, chunk_overlap: int = 200 +): + use_case = get_classical_index_file_use_case() + return await use_case.execute( + file_name=file_name, + working_dir=working_dir, + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + ) + + +@mcp_classical.tool() +async def classical_index_folder( + working_dir: str, + recursive: bool = True, + file_extensions: list[str] | None = None, + chunk_size: int = 1000, + chunk_overlap: int = 200, +): + use_case = get_classical_index_folder_use_case() + return await use_case.execute( + working_dir=working_dir, + recursive=recursive, + file_extensions=file_extensions, + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + ) + + +@mcp_classical.tool() +async def classical_query( + working_dir: str, + query: str, + top_k: int = 10, + num_variations: int = 3, + relevance_threshold: float = 5.0, +): + use_case = get_classical_query_use_case() + return await use_case.execute( + working_dir=working_dir, + query=query, + top_k=top_k, + num_variations=num_variations, + relevance_threshold=relevance_threshold, + ) diff --git a/src/application/api/mcp_file_tools.py b/src/application/api/mcp_file_tools.py index 862fcb7..e106fc0 100644 --- a/src/application/api/mcp_file_tools.py +++ b/src/application/api/mcp_file_tools.py @@ -27,7 +27,9 @@ def _validate_prefix(prefix: str) -> str: if normalized == ".": normalized = "" if normalized.startswith("..") or posixpath.isabs(normalized): - raise ToolError(f"prefix must be a relative path within the bucket, got: {prefix!r}") + raise ToolError( + f"prefix must be a relative path within the bucket, got: {prefix!r}" + ) if prefix.endswith("/") and not normalized.endswith("/"): normalized += "/" return normalized diff --git a/src/application/requests/classical_indexing_request.py b/src/application/requests/classical_indexing_request.py new file mode 100644 index 0000000..e9adf0b --- /dev/null +++ b/src/application/requests/classical_indexing_request.py @@ -0,0 +1,44 @@ +"""Request models for classical RAG indexing endpoints.""" + +from typing import Annotated + +from pydantic import BaseModel, BeforeValidator, Field + + +def _coerce_file_extensions(v: str | list[str] | None) -> list[str] | None: + if v is None: + return None + if isinstance(v, str): + return [ext.strip() for ext in v.split(",") if ext.strip()] + return v + + +class ClassicalIndexFileRequest(BaseModel): + file_name: str = Field(..., description="Object path in the MinIO bucket") + working_dir: str = Field( + ..., description="RAG workspace directory (project isolation)" + ) + chunk_size: int = Field( + default=1000, description="Max chars per chunk", ge=100, le=10000 + ) + chunk_overlap: int = Field( + default=200, description="Overlap between chunks", ge=0, le=2000 + ) + + +class ClassicalIndexFolderRequest(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: Annotated[ + list[str] | None, BeforeValidator(_coerce_file_extensions) + ] = Field(default=None, description="Filter by extensions, e.g. ['.pdf', '.docx']") + chunk_size: int = Field( + default=1000, description="Max chars per chunk", ge=100, le=10000 + ) + chunk_overlap: int = Field( + default=200, description="Overlap between chunks", ge=0, le=2000 + ) diff --git a/src/application/requests/classical_query_request.py b/src/application/requests/classical_query_request.py new file mode 100644 index 0000000..1c68f95 --- /dev/null +++ b/src/application/requests/classical_query_request.py @@ -0,0 +1,28 @@ +"""Request model for classical RAG query endpoint.""" + +from pydantic import BaseModel, Field + + +class ClassicalQueryRequest(BaseModel): + working_dir: str = Field( + ..., description="RAG workspace directory for this project" + ) + query: str = Field(..., description="The search query") + top_k: int = Field( + default=10, + description="Maximum number of chunks to retrieve per query variation", + ge=1, + le=100, + ) + num_variations: int = Field( + default=3, + description="Number of query variations to generate (multi-query)", + ge=1, + le=10, + ) + relevance_threshold: float = Field( + default=5.0, + description="Minimum relevance score (0-10) from LLM judge to include a chunk", + ge=0.0, + le=10.0, + ) diff --git a/src/application/responses/classical_query_response.py b/src/application/responses/classical_query_response.py new file mode 100644 index 0000000..c1b6622 --- /dev/null +++ b/src/application/responses/classical_query_response.py @@ -0,0 +1,25 @@ +"""Response models for classical RAG query endpoint.""" + +from pydantic import BaseModel, Field + + +class ClassicalChunkResponse(BaseModel): + chunk_id: str = Field(description="Unique identifier for the chunk") + content: str = Field(description="The text content of the chunk") + file_path: str = Field(description="Source file path in MinIO") + relevance_score: float = Field(description="LLM judge relevance score (0-10)") + metadata: dict[str, str | int | float | None] = Field( + default_factory=dict, description="Additional metadata" + ) + + +class ClassicalQueryResponse(BaseModel): + status: str = Field(default="success", description="Response status") + message: str = Field(default="", description="Status message") + queries: list[str] = Field( + default_factory=list, + description="List of query variations used (original + generated)", + ) + chunks: list[ClassicalChunkResponse] = Field( + default_factory=list, description="Filtered and ranked chunks" + ) diff --git a/src/application/services/classical_helpers.py b/src/application/services/classical_helpers.py new file mode 100644 index 0000000..8cf2f20 --- /dev/null +++ b/src/application/services/classical_helpers.py @@ -0,0 +1,22 @@ +import os + + +def validate_path(output_dir: str, file_name: str) -> str: + file_path = os.path.join(output_dir, file_name) + real_output = os.path.realpath(output_dir) + real_file = os.path.realpath(file_path) + if not real_file.startswith(real_output + os.sep) and real_file != real_output: + raise ValueError(f"file_name escapes output directory: {file_name}") + return file_path + + +def build_documents_from_extraction( + result, file_name: str +) -> list[tuple[str, str, dict[str, int]]]: + documents = [] + if result.chunks: + for i, chunk in enumerate(result.chunks): + documents.append((chunk.content, file_name, {"chunk_index": i})) + elif result.content and result.content.strip(): + documents.append((result.content, file_name, {})) + return documents diff --git a/src/application/use_cases/classical_index_file_use_case.py b/src/application/use_cases/classical_index_file_use_case.py new file mode 100644 index 0000000..ea57767 --- /dev/null +++ b/src/application/use_cases/classical_index_file_use_case.py @@ -0,0 +1,76 @@ +import contextlib +import os + +import aiofiles +from kreuzberg import ChunkingConfig, ExtractionConfig, extract_file + +from application.services.classical_helpers import ( + build_documents_from_extraction, + validate_path, +) +from domain.entities.indexing_result import FileIndexingResult, IndexingStatus +from domain.ports.storage_port import StoragePort +from domain.ports.vector_store_port import VectorStorePort + + +class ClassicalIndexFileUseCase: + def __init__( + self, + vector_store: VectorStorePort, + storage: StoragePort, + bucket: str, + output_dir: str, + ) -> None: + self.vector_store = vector_store + self.storage = storage + self.bucket = bucket + self.output_dir = output_dir + + async def execute( + self, + file_name: str, + working_dir: str, + chunk_size: int = 1000, + chunk_overlap: int = 200, + ) -> FileIndexingResult: + file_path = None + try: + data = await self.storage.get_object(self.bucket, file_name) + + file_path = validate_path(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) + + await self.vector_store.ensure_table(working_dir) + + config = ExtractionConfig( + chunking=ChunkingConfig(max_chars=chunk_size, max_overlap=chunk_overlap) + ) + result = await extract_file(file_path, config=config) + + documents = build_documents_from_extraction(result, file_name) + + if documents: + await self.vector_store.add_documents( + working_dir=working_dir, documents=documents + ) + + return FileIndexingResult( + status=IndexingStatus.SUCCESS, + message="File indexed successfully", + file_path=file_path, + file_name=file_name, + ) + except Exception as e: + return FileIndexingResult( + status=IndexingStatus.FAILED, + message="File indexing failed", + file_path=file_path or file_name, + file_name=file_name, + error=str(e), + ) + finally: + if file_path and os.path.exists(file_path): + with contextlib.suppress(OSError): + os.remove(file_path) diff --git a/src/application/use_cases/classical_index_folder_use_case.py b/src/application/use_cases/classical_index_folder_use_case.py new file mode 100644 index 0000000..f868947 --- /dev/null +++ b/src/application/use_cases/classical_index_folder_use_case.py @@ -0,0 +1,120 @@ +import contextlib +import os +from pathlib import Path + +import aiofiles +from kreuzberg import ChunkingConfig, ExtractionConfig, extract_file + +from application.services.classical_helpers import ( + build_documents_from_extraction, + validate_path, +) +from domain.entities.indexing_result import ( + FileProcessingDetail, + FolderIndexingResult, + FolderIndexingStats, + IndexingStatus, +) +from domain.ports.storage_port import StoragePort +from domain.ports.vector_store_port import VectorStorePort + + +class ClassicalIndexFolderUseCase: + def __init__( + self, + vector_store: VectorStorePort, + storage: StoragePort, + bucket: str, + output_dir: str, + ) -> None: + self.vector_store = vector_store + self.storage = storage + self.bucket = bucket + self.output_dir = output_dir + + async def execute( + self, + working_dir: str, + recursive: bool = True, + file_extensions: list[str] | None = None, + chunk_size: int = 1000, + chunk_overlap: int = 200, + ) -> FolderIndexingResult: + await self.vector_store.ensure_table(working_dir) + + files = await self.storage.list_objects( + self.bucket, prefix=working_dir, recursive=recursive + ) + + if file_extensions: + exts = set(file_extensions) + files = [f for f in files if Path(f).suffix in exts] + + processed = 0 + failed = 0 + file_results = [] + config = ExtractionConfig( + chunking=ChunkingConfig(max_chars=chunk_size, max_overlap=chunk_overlap) + ) + + for file_name in files: + local_path = None + try: + data = await self.storage.get_object(self.bucket, file_name) + + local_path = validate_path(self.output_dir, file_name) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + async with aiofiles.open(local_path, "wb") as f: + await f.write(data) + + result = await extract_file(local_path, config=config) + + documents = build_documents_from_extraction(result, file_name) + + if documents: + await self.vector_store.add_documents( + working_dir=working_dir, documents=documents + ) + + processed += 1 + file_results.append( + FileProcessingDetail( + file_path=file_name, + file_name=os.path.basename(file_name), + status=IndexingStatus.SUCCESS, + ) + ) + except Exception as exc: + failed += 1 + file_results.append( + FileProcessingDetail( + file_path=file_name, + file_name=os.path.basename(file_name), + status=IndexingStatus.FAILED, + error=str(exc), + ) + ) + finally: + if local_path and os.path.exists(local_path): + with contextlib.suppress(OSError): + os.remove(local_path) + + if failed > 0 and processed > 0: + status = IndexingStatus.PARTIAL + elif failed > 0: + status = IndexingStatus.FAILED + else: + status = IndexingStatus.SUCCESS + + return FolderIndexingResult( + status=status, + message="Folder indexing completed", + folder_path=working_dir, + recursive=recursive, + stats=FolderIndexingStats( + total_files=len(files), + files_processed=processed, + files_failed=failed, + ), + file_results=file_results, + ) diff --git a/src/application/use_cases/classical_query_use_case.py b/src/application/use_cases/classical_query_use_case.py new file mode 100644 index 0000000..c49c260 --- /dev/null +++ b/src/application/use_cases/classical_query_use_case.py @@ -0,0 +1,122 @@ +import asyncio +import json +import re + +from application.responses.classical_query_response import ( + ClassicalChunkResponse, + ClassicalQueryResponse, +) +from config import ClassicalRAGConfig +from domain.ports.llm_port import LLMPort +from domain.ports.vector_store_port import SearchResult, VectorStorePort + + +class ClassicalQueryUseCase: + def __init__( + self, + vector_store: VectorStorePort, + llm: LLMPort, + config: ClassicalRAGConfig, + ) -> None: + self.vector_store = vector_store + self.llm = llm + self.config = config + + @staticmethod + def _extract_json_array(text: str) -> list[str]: + match = re.search(r"\[.*\]", text, re.DOTALL) + if match: + try: + parsed = json.loads(match.group(0)) + if isinstance(parsed, list): + return [v for v in parsed if isinstance(v, str)] + except ValueError: + pass + return [] + + async def _generate_variations(self, query: str, num_variations: int) -> list[str]: + try: + system_prompt = "You are a helpful assistant. Generate alternative phrasings of the given query for improved search retrieval." + user_message = f"Generate {num_variations} alternative versions of this query: {query}. Return ONLY a JSON array of strings." + response = await self.llm.generate( + system_prompt=system_prompt, user_message=user_message + ) + return self._extract_json_array(response) + except Exception: + return [] + + async def _score_chunk(self, query: str, chunk: SearchResult) -> float: + try: + judge_system = "You are a relevance judge. Score how relevant the given chunk is to the query on a scale of 0 to 10. Return ONLY a number." + judge_user = f"Query: {query}\nChunk: {chunk.content[:500]}\nScore 0-10:" + score_response = await self.llm.generate( + system_prompt=judge_system, user_message=judge_user + ) + nums = re.findall(r"[\d.]+", score_response.strip()) + return float(nums[0]) if nums else 0.0 + except (ValueError, TypeError, IndexError): + return 0.0 + + async def execute( + self, + working_dir: str, + query: str, + top_k: int = 10, + num_variations: int | None = None, + relevance_threshold: float | None = None, + ) -> ClassicalQueryResponse: + if num_variations is None: + num_variations = self.config.CLASSICAL_NUM_QUERY_VARIATIONS + if relevance_threshold is None: + relevance_threshold = self.config.CLASSICAL_RELEVANCE_THRESHOLD + + variations = await self._generate_variations(query, num_variations) + queries = [query] + variations + + search_tasks = [ + self.vector_store.similarity_search( + working_dir=working_dir, query=q, top_k=top_k + ) + for q in queries + ] + search_results = await asyncio.gather(*search_tasks) + + all_results: dict[str, SearchResult] = {} + for results in search_results: + for r in results: + if r.chunk_id and r.chunk_id not in all_results: + all_results[r.chunk_id] = r + + sem = asyncio.Semaphore(5) + + async def _bounded_score(c: SearchResult) -> tuple[SearchResult, float]: + async with sem: + score = await self._score_chunk(query, c) + return (c, score) + + scored = await asyncio.gather( + *(_bounded_score(c) for c in all_results.values()) + ) + + scored_chunks = sorted( + [ + ClassicalChunkResponse( + chunk_id=chunk.chunk_id, + content=chunk.content, + file_path=chunk.file_path, + relevance_score=score, + metadata=chunk.metadata, + ) + for chunk, score in scored + if score >= relevance_threshold + ], + key=lambda c: c.relevance_score, + reverse=True, + ) + + return ClassicalQueryResponse( + status="success", + message="", + queries=queries, + chunks=scored_chunks, + ) diff --git a/src/config.py b/src/config.py index 8fb94de..6ba2b5e 100644 --- a/src/config.py +++ b/src/config.py @@ -112,6 +112,38 @@ class BM25Config(BaseSettings): ) +class ClassicalRAGConfig(BaseSettings): + """Configuration for the classical RAG pathway.""" + + CLASSICAL_CHUNK_SIZE: int = Field( + default=1000, description="Max characters per chunk (Kreuzberg ChunkingConfig)" + ) + CLASSICAL_CHUNK_OVERLAP: int = Field( + default=200, description="Overlap characters between chunks" + ) + CLASSICAL_NUM_QUERY_VARIATIONS: int = Field( + default=3, + description="Number of multi-query variations to generate", + ge=1, + le=10, + ) + CLASSICAL_RELEVANCE_THRESHOLD: float = Field( + default=5.0, + description="Minimum LLM judge score (0-10) to include a chunk", + ge=0.0, + le=10.0, + ) + CLASSICAL_TABLE_PREFIX: str = Field( + default="classical_rag_", description="Prefix for PGVectorStore table names" + ) + CLASSICAL_LLM_TEMPERATURE: float = Field( + default=0.0, + description="Temperature for LLM calls (multi-query + judge)", + ge=0.0, + le=2.0, + ) + + class MinioConfig(BaseSettings): """MinIO object storage configuration.""" diff --git a/src/dependencies.py b/src/dependencies.py index feef70e..2ca6020 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -2,6 +2,13 @@ import os +from application.use_cases.classical_index_file_use_case import ( + ClassicalIndexFileUseCase, +) +from application.use_cases.classical_index_folder_use_case import ( + ClassicalIndexFolderUseCase, +) +from application.use_cases.classical_query_use_case import ClassicalQueryUseCase from application.use_cases.index_file_use_case import IndexFileUseCase from application.use_cases.index_folder_use_case import IndexFolderUseCase from application.use_cases.list_files_use_case import ListFilesUseCase @@ -14,12 +21,15 @@ from config import ( AppConfig, BM25Config, + ClassicalRAGConfig, DatabaseConfig, LLMConfig, MinioConfig, RAGConfig, ) from domain.ports.bm25_engine import BM25EnginePort +from domain.ports.llm_port import LLMPort +from domain.ports.vector_store_port import VectorStorePort from infrastructure.database.asyncpg_health_adapter import AsyncpgHealthAdapter from infrastructure.document_reader.kreuzberg_adapter import KreuzbergAdapter from infrastructure.rag.lightrag_adapter import LightRAGAdapter @@ -57,6 +67,73 @@ kreuzberg_adapter = KreuzbergAdapter() postgres_health_adapter = AsyncpgHealthAdapter(db_config) +classical_rag_config = ClassicalRAGConfig() + +classical_vector_store: VectorStorePort | None = None +classical_llm: LLMPort | None = None +try: + from langchain_openai import OpenAIEmbeddings + + _embedding = OpenAIEmbeddings( + model=llm_config.EMBEDDING_MODEL, + api_key=llm_config.api_key, + base_url=llm_config.api_base_url, + ) + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + classical_vector_store = LangchainPgvectorAdapter( + connection_string=db_config.DATABASE_URL.replace("+asyncpg", ""), + table_prefix=classical_rag_config.CLASSICAL_TABLE_PREFIX, + embedding_dimension=llm_config.EMBEDDING_DIM, + embedding_service=_embedding, + ) + from infrastructure.llm.langchain_openai_adapter import LangchainOpenAIAdapter + + classical_llm = LangchainOpenAIAdapter( + api_key=llm_config.api_key, + base_url=llm_config.api_base_url, + model=llm_config.CHAT_MODEL, + temperature=classical_rag_config.CLASSICAL_LLM_TEMPERATURE, + ) +except Exception as e: + print(f"WARNING: Classical RAG adapter initialization failed: {e}") + + +def get_classical_index_file_use_case() -> ClassicalIndexFileUseCase: + if classical_vector_store is None: + raise RuntimeError("Classical RAG unavailable: vector store not initialized") + return ClassicalIndexFileUseCase( + vector_store=classical_vector_store, + storage=minio_adapter, + bucket=minio_config.MINIO_BUCKET, + output_dir=app_config.OUTPUT_DIR, + ) + + +def get_classical_index_folder_use_case() -> ClassicalIndexFolderUseCase: + if classical_vector_store is None: + raise RuntimeError("Classical RAG unavailable: vector store not initialized") + return ClassicalIndexFolderUseCase( + vector_store=classical_vector_store, + storage=minio_adapter, + bucket=minio_config.MINIO_BUCKET, + output_dir=app_config.OUTPUT_DIR, + ) + + +def get_classical_query_use_case() -> ClassicalQueryUseCase: + if classical_vector_store is None or classical_llm is None: + raise RuntimeError( + "Classical RAG unavailable: vector store or LLM not initialized" + ) + return ClassicalQueryUseCase( + vector_store=classical_vector_store, + llm=classical_llm, + config=classical_rag_config, + ) + def get_index_file_use_case() -> IndexFileUseCase: return IndexFileUseCase( @@ -101,7 +178,8 @@ def get_read_file_use_case() -> ReadFileUseCase: def get_upload_file_use_case() -> UploadFileUseCase: return UploadFileUseCase(storage=minio_adapter, bucket=minio_config.MINIO_BUCKET) - + + def get_liveness_check_use_case() -> LivenessCheckUseCase: return LivenessCheckUseCase( storage=minio_adapter, diff --git a/src/domain/entities/indexing_result.py b/src/domain/entities/indexing_result.py index 162cc17..c62af6d 100644 --- a/src/domain/entities/indexing_result.py +++ b/src/domain/entities/indexing_result.py @@ -62,4 +62,4 @@ class FolderIndexingResult(BaseModel): file_results: list[FileProcessingDetail] | None = Field( default=None, description="Individual file results" ) - error: str | None = Field(default=None, description="Error message if failed") + error: str | None = Field(default=None, description=ERROR_MESSAGE_IF_FAILED) diff --git a/src/domain/ports/llm_port.py b/src/domain/ports/llm_port.py new file mode 100644 index 0000000..af08067 --- /dev/null +++ b/src/domain/ports/llm_port.py @@ -0,0 +1,32 @@ +"""Abstract port for LLM operations (classical RAG).""" + +from abc import ABC, abstractmethod + + +class LLMPort(ABC): + """Port interface for LLM operations (multi-query generation + judge scoring).""" + + @abstractmethod + async def generate(self, system_prompt: str, user_message: str) -> str: + """Generate a text response from the LLM. + + Args: + system_prompt: The system prompt to use. + user_message: The user message to send. + + Returns: + The generated text response. + """ + pass + + @abstractmethod + async def generate_chat(self, messages: list[dict[str, str]]) -> str: + """Generate a response from a list of chat messages. + + Args: + messages: List of message dicts with 'role' and 'content' keys. + + Returns: + The generated text response. + """ + pass diff --git a/src/domain/ports/vector_store_port.py b/src/domain/ports/vector_store_port.py new file mode 100644 index 0000000..2794f8d --- /dev/null +++ b/src/domain/ports/vector_store_port.py @@ -0,0 +1,75 @@ +"""Abstract port for vector store operations.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + + +@dataclass +class SearchResult: + """A single search result from the vector store.""" + + chunk_id: str + content: str + file_path: str + score: float + metadata: dict[str, str | int | float | None] = field(default_factory=dict) + + +class VectorStorePort(ABC): + """Port interface for vector store operations (classical RAG).""" + + @abstractmethod + async def ensure_table(self, working_dir: str) -> None: + """Create the vector store table for the given workspace if it doesn't exist.""" + pass + + @abstractmethod + async def add_documents( + self, + working_dir: str, + documents: list[tuple[str, str, dict[str, str | int | None]]], + ) -> list[str]: + """Add documents to the vector store. + + Args: + working_dir: Workspace identifier (used to select the table). + documents: List of (content, file_path, metadata) tuples. + + Returns: + List of document IDs. + """ + pass + + @abstractmethod + async def similarity_search( + self, working_dir: str, query: str, top_k: int = 10 + ) -> list[SearchResult]: + """Search for similar documents using vector similarity. + + Args: + working_dir: Workspace identifier. + query: The search query text. + top_k: Maximum number of results to return. + + Returns: + List of SearchResult objects sorted by relevance. + """ + pass + + @abstractmethod + async def delete_documents(self, working_dir: str, file_path: str) -> int: + """Delete all documents for a given file_path from the vector store. + + Args: + working_dir: Workspace identifier. + file_path: The file path to delete documents for. + + Returns: + Number of documents deleted. + """ + pass + + @abstractmethod + async def close(self) -> None: + """Close the vector store connection pool and cleanup resources.""" + pass diff --git a/src/infrastructure/llm/langchain_openai_adapter.py b/src/infrastructure/llm/langchain_openai_adapter.py new file mode 100644 index 0000000..ee2fd11 --- /dev/null +++ b/src/infrastructure/llm/langchain_openai_adapter.py @@ -0,0 +1,37 @@ +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI + +from domain.ports.llm_port import LLMPort + +_MESSAGE_TYPES = { + "system": SystemMessage, + "user": HumanMessage, + "assistant": AIMessage, +} + + +class LangchainOpenAIAdapter(LLMPort): + def __init__(self, api_key: str, base_url: str, model: str, temperature: float): + self._llm = ChatOpenAI( + api_key=api_key, + base_url=base_url, + model=model, + temperature=temperature, + ) + + async def generate(self, system_prompt: str, user_message: str) -> str: + messages = [ + SystemMessage(content=system_prompt), + HumanMessage(content=user_message), + ] + response = await self._llm.ainvoke(messages) + return response.content + + async def generate_chat(self, messages: list[dict[str, str]]) -> str: + lc_messages = [ + _MESSAGE_TYPES[msg["role"]](content=msg["content"]) + for msg in messages + if msg["role"] in _MESSAGE_TYPES + ] + response = await self._llm.ainvoke(lc_messages) + return response.content diff --git a/src/infrastructure/rag/lightrag_adapter.py b/src/infrastructure/rag/lightrag_adapter.py index b0c004a..1877f31 100644 --- a/src/infrastructure/rag/lightrag_adapter.py +++ b/src/infrastructure/rag/lightrag_adapter.py @@ -183,6 +183,18 @@ async def index_document( error=str(e), ) + @staticmethod + def _determine_folder_status( + total: int, succeeded: int, failed: int, folder_path: str + ) -> tuple[IndexingStatus, str]: + if total == 0: + return IndexingStatus.SUCCESS, f"No files found in '{folder_path}'" + if failed == 0: + return IndexingStatus.SUCCESS, f"Successfully indexed {succeeded} file(s) from '{folder_path}'" + if succeeded == 0: + return IndexingStatus.FAILED, f"Failed to index folder '{folder_path}'" + return IndexingStatus.PARTIAL, f"Partially indexed: {succeeded} succeeded, {failed} failed" + async def index_folder( self, folder_path: str, @@ -191,13 +203,6 @@ async def index_folder( file_extensions: list[str] | None = None, working_dir: str = "", ) -> FolderIndexingResult: - """Index a folder by processing documents concurrently. - - Uses ``asyncio.Semaphore`` bounded by ``MAX_CONCURRENT_FILES`` so - that at most *N* files are processed at the same time. When - ``MAX_CONCURRENT_FILES <= 1`` behaviour is identical to the old - sequential loop. - """ start_time = time.time() rag = self._ensure_initialized(working_dir) await rag._ensure_lightrag_initialized() @@ -210,9 +215,39 @@ async def index_folder( exts = set(file_extensions) all_files = [f for f in all_files if f.suffix in exts] + succeeded, failed, file_results = await self._process_files_concurrently( + rag, all_files, output_dir + ) + + processing_time_ms = (time.time() - start_time) * 1000 + total = len(all_files) + status, message = self._determine_folder_status( + total, succeeded, failed, folder_path + ) + + return FolderIndexingResult( + status=status, + message=message, + folder_path=folder_path, + recursive=recursive, + stats=FolderIndexingStats( + total_files=total, + files_processed=succeeded, + files_failed=failed, + files_skipped=0, + ), + file_results=file_results, + processing_time_ms=round(processing_time_ms, 2), + ) + + async def _process_files_concurrently( + self, + rag: RAGAnything, + all_files: list[Path], + output_dir: str, + ) -> tuple[int, int, list[FileProcessingDetail]]: max_workers = max(1, self._rag_config.MAX_CONCURRENT_FILES) semaphore = asyncio.Semaphore(max_workers) - succeeded = 0 failed = 0 file_results: list[FileProcessingDetail] = [] @@ -250,36 +285,7 @@ async def _process_file(file_path_obj: Path) -> None: ) await asyncio.gather(*[_process_file(f) for f in all_files]) - - processing_time_ms = (time.time() - start_time) * 1000 - total = len(all_files) - if total == 0: - status = IndexingStatus.SUCCESS - message = f"No files found in '{folder_path}'" - elif failed == 0: - status = IndexingStatus.SUCCESS - message = f"Successfully indexed {succeeded} file(s) from '{folder_path}'" - elif succeeded == 0: - status = IndexingStatus.FAILED - message = f"Failed to index folder '{folder_path}'" - else: - status = IndexingStatus.PARTIAL - message = f"Partially indexed: {succeeded} succeeded, {failed} failed" - - return FolderIndexingResult( - status=status, - message=message, - folder_path=folder_path, - recursive=recursive, - stats=FolderIndexingStats( - total_files=total, - files_processed=succeeded, - files_failed=failed, - files_skipped=0, - ), - file_results=file_results, - processing_time_ms=round(processing_time_ms, 2), - ) + return succeeded, failed, file_results # ------------------------------------------------------------------ # Port implementation — query diff --git a/src/infrastructure/rag/rrf_combiner.py b/src/infrastructure/rag/rrf_combiner.py index 9f57166..7c0b858 100644 --- a/src/infrastructure/rag/rrf_combiner.py +++ b/src/infrastructure/rag/rrf_combiner.py @@ -51,15 +51,17 @@ def _add_bm25_result( scores[chunk_id]["bm25_rank"] = min(scores[chunk_id]["bm25_rank"], rank) scores[chunk_id]["bm25_score"] = 1.0 / (self.k + scores[chunk_id]["bm25_rank"]) + def _resolve_chunk_id(self, chunk: dict[str, Any]) -> tuple[str, str | None]: + raw_chunk_id = chunk.get("chunk_id") + raw_ref_id = chunk.get("reference_id") + return raw_chunk_id or raw_ref_id, raw_ref_id + def _add_vector_result( self, scores: dict[str, dict[str, Any]], rank: int, chunk: dict[str, Any] ) -> None: - raw_chunk_id = chunk.get("chunk_id") - raw_ref_id = chunk.get("reference_id") - chunk_id = raw_chunk_id or raw_ref_id + chunk_id, reference_id = self._resolve_chunk_id(chunk) if not chunk_id: return - reference_id = raw_ref_id if chunk_id not in scores: scores[chunk_id] = { "content": chunk.get("content", ""), diff --git a/src/infrastructure/vector_store/langchain_pgvector_adapter.py b/src/infrastructure/vector_store/langchain_pgvector_adapter.py new file mode 100644 index 0000000..acb4187 --- /dev/null +++ b/src/infrastructure/vector_store/langchain_pgvector_adapter.py @@ -0,0 +1,115 @@ +import hashlib +import uuid + +from langchain_core.documents import Document +from langchain_postgres import PGEngine, PGVectorStore + +from domain.ports.vector_store_port import SearchResult, VectorStorePort + + +class LangchainPgvectorAdapter(VectorStorePort): + def __init__( + self, + connection_string: str, + table_prefix: str, + embedding_dimension: int, + embedding_service=None, + ): + self._connection_string = connection_string + self._table_prefix = table_prefix + self._embedding_dimension = embedding_dimension + self._embedding_service = embedding_service + self._engine = None + self._stores = {} + self._id_maps = {} + + def _get_table_name(self, working_dir: str) -> str: + hashed = hashlib.sha256(working_dir.encode()).hexdigest()[:16] + return f"{self._table_prefix}{hashed}" + + async def ensure_table(self, working_dir: str) -> None: + if self._engine is None: + self._engine = PGEngine.from_connection_string(self._connection_string) + + table_name = self._get_table_name(working_dir) + + if working_dir not in self._stores: + store = PGVectorStore.create( + engine=self._engine, + table_name=table_name, + embedding_service=self._embedding_service, + embedding_dimension=self._embedding_dimension, + ) + if hasattr(store, "__await__"): + store = await store + self._stores[working_dir] = store + self._id_maps[working_dir] = {} + + async def add_documents( + self, + working_dir: str, + documents: list[tuple[str, str, dict[str, str | int | None]]], + ) -> list[str]: + store = self._stores.get(working_dir) + if store is None: + raise ValueError( + f"Vector store not initialized for working_dir: {working_dir}" + ) + + id_map = self._id_maps.get(working_dir, {}) + lc_docs = [] + ids = [] + for content, file_path, metadata in documents: + doc_id = str(uuid.uuid4()) + meta = {"file_path": file_path, "chunk_id": doc_id, **metadata} + lc_docs.append(Document(id=doc_id, page_content=content, metadata=meta)) + ids.append(doc_id) + id_map[doc_id] = file_path + + await store.aadd_documents(lc_docs, ids=ids) + return ids + + async def similarity_search( + self, working_dir: str, query: str, top_k: int = 10 + ) -> list[SearchResult]: + store = self._stores.get(working_dir) + if store is None: + raise ValueError( + f"Vector store not initialized for working_dir: {working_dir}" + ) + + lc_results = await store.asimilarity_search_with_score(query, k=top_k) + return [ + SearchResult( + chunk_id=str(doc.metadata.get("chunk_id", doc.id)), + content=doc.page_content, + file_path=doc.metadata.get("file_path", ""), + score=float(score), + metadata={ + k: v + for k, v in doc.metadata.items() + if k not in ("chunk_id", "file_path") + }, + ) + for doc, score in lc_results + ] + + async def delete_documents(self, working_dir: str, file_path: str) -> int: + store = self._stores.get(working_dir) + if store is None: + raise ValueError( + f"Vector store not initialized for working_dir: {working_dir}" + ) + + id_map = self._id_maps.get(working_dir, {}) + ids_to_delete = [doc_id for doc_id, fp in id_map.items() if fp == file_path] + if ids_to_delete: + await store.adelete(ids=ids_to_delete) + for doc_id in ids_to_delete: + id_map.pop(doc_id, None) + return len(ids_to_delete) + + async def close(self) -> None: + if self._engine is not None: + await self._engine.close() + self._engine = None diff --git a/src/main.py b/src/main.py index a4efa80..9237afe 100644 --- a/src/main.py +++ b/src/main.py @@ -11,13 +11,16 @@ from fastapi.middleware.cors import CORSMiddleware from alembic import command +from application.api.classical_indexing_routes import classical_indexing_router +from application.api.classical_query_routes import classical_query_router from application.api.file_routes import file_router from application.api.health_routes import health_router from application.api.indexing_routes import indexing_router +from application.api.mcp_classical_tools import mcp_classical from application.api.mcp_file_tools import mcp_files from application.api.mcp_query_tools import mcp_query from application.api.query_routes import query_router -from dependencies import app_config, bm25_adapter +from dependencies import app_config, bm25_adapter, classical_vector_store _LOG_FORMAT = "%(asctime)s %(levelname)-8s [%(name)s] %(message)s" @@ -73,11 +76,17 @@ async def db_lifespan(_app: FastAPI): await bm25_adapter.close() except Exception: logger.exception("Failed to close BM25 adapter") + if classical_vector_store is not None: + try: + await classical_vector_store.close() + except Exception: + logger.exception("Failed to close classical vector store") logger.info("Application shutdown complete") mcp_query_app = mcp_query.http_app(path="/") mcp_files_app = mcp_files.http_app(path="/") +mcp_classical_app = mcp_classical.http_app(path="/") @asynccontextmanager @@ -87,6 +96,7 @@ async def combined_lifespan(app: FastAPI): db_lifespan(app), mcp_query_app.lifespan(app), mcp_files_app.lifespan(app), + mcp_classical_app.lifespan(app), ): yield @@ -109,9 +119,12 @@ async def combined_lifespan(app: FastAPI): app.include_router(health_router, prefix=REST_PATH) app.include_router(query_router, prefix=REST_PATH) app.include_router(file_router, prefix=REST_PATH) +app.include_router(classical_indexing_router, prefix=REST_PATH) +app.include_router(classical_query_router, prefix=REST_PATH) app.mount("/rag/mcp", mcp_query_app) app.mount("/files/mcp", mcp_files_app) +app.mount("/classical/mcp", mcp_classical_app) def run_fastapi(): diff --git a/tests/conftest.py b/tests/conftest.py index fb71b45..277e2eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,8 @@ mock_rag_engine = _external.mock_rag_engine mock_storage = _external.mock_storage mock_document_reader = _external.mock_document_reader +mock_vector_store = _external.mock_vector_store +mock_llm = _external.mock_llm @pytest.fixture diff --git a/tests/fixtures/external.py b/tests/fixtures/external.py index 8a3dbb5..fd35f32 100644 --- a/tests/fixtures/external.py +++ b/tests/fixtures/external.py @@ -13,8 +13,10 @@ DocumentMetadata, DocumentReaderPort, ) +from domain.ports.llm_port import LLMPort from domain.ports.rag_engine import RAGEnginePort from domain.ports.storage_port import FileInfo, StoragePort +from domain.ports.vector_store_port import SearchResult, VectorStorePort @pytest.fixture @@ -80,3 +82,41 @@ def mock_document_reader() -> AsyncMock: tables=[], ) return mock + + +@pytest.fixture +def mock_vector_store() -> AsyncMock: + """Provide an AsyncMock of VectorStorePort for external adapter mocking.""" + mock = AsyncMock(spec=VectorStorePort) + mock.ensure_table.return_value = None + mock.add_documents.return_value = ["chunk-1", "chunk-2", "chunk-3"] + mock.similarity_search.return_value = [ + SearchResult( + chunk_id="chunk-abc123", + content="Relevant text about the query topic.", + file_path="/docs/report.pdf", + score=0.92, + metadata={"page": 1}, + ), + SearchResult( + chunk_id="chunk-def456", + content="Another relevant chunk of text.", + file_path="/docs/notes.txt", + score=0.85, + metadata={"page": 3}, + ), + ] + mock.delete_documents.return_value = 5 + mock.close.return_value = None + return mock + + +@pytest.fixture +def mock_llm() -> AsyncMock: + """Provide an AsyncMock of LLMPort for external adapter mocking.""" + mock = AsyncMock(spec=LLMPort) + mock.generate.return_value = ( + '["alternative query 1", "alternative query 2", "alternative query 3"]' + ) + mock.generate_chat.return_value = "LLM generated response text" + return mock diff --git a/tests/unit/test_classical_index_file_use_case.py b/tests/unit/test_classical_index_file_use_case.py new file mode 100644 index 0000000..30f393a --- /dev/null +++ b/tests/unit/test_classical_index_file_use_case.py @@ -0,0 +1,278 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from application.use_cases.classical_index_file_use_case import ( + ClassicalIndexFileUseCase, +) +from domain.entities.indexing_result import FileIndexingResult, IndexingStatus + + +class TestClassicalIndexFileUseCase: + @pytest.fixture + def use_case( + self, + mock_vector_store: AsyncMock, + mock_storage: AsyncMock, + ) -> ClassicalIndexFileUseCase: + return ClassicalIndexFileUseCase( + vector_store=mock_vector_store, + storage=mock_storage, + bucket="test-bucket", + output_dir="/tmp/output", + ) + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_downloads_file_from_storage( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + mock_storage: AsyncMock, + ) -> None: + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "Some text" + mock_extract.return_value = mock_result + + await use_case.execute( + file_name="reports/quarterly.pdf", + working_dir="/tmp/rag/project_1", + ) + + mock_storage.get_object.assert_called_once_with( + "test-bucket", "reports/quarterly.pdf" + ) + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_ensures_vector_store_table( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + mock_vector_store: AsyncMock, + ) -> None: + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "text" + mock_extract.return_value = mock_result + + await use_case.execute( + file_name="report.pdf", + working_dir="/tmp/rag/project_1", + ) + + mock_vector_store.ensure_table.assert_called_once_with("/tmp/rag/project_1") + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_extracts_content_via_kreuzberg( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + ) -> None: + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "Extracted text" + mock_extract.return_value = mock_result + + await use_case.execute( + file_name="docs/report.pdf", + working_dir="/tmp/rag/project_42", + ) + + mock_extract.assert_called_once() + call_args = mock_extract.call_args + assert call_args[0][0].endswith("docs/report.pdf") or "report.pdf" in str( + call_args + ) + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_adds_documents_to_vector_store( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + mock_vector_store: AsyncMock, + ) -> None: + mock_result = MagicMock() + chunk = MagicMock() + chunk.content = "chunk text" + mock_result.chunks = [chunk] + mock_result.content = "full text" + mock_extract.return_value = mock_result + + await use_case.execute( + file_name="report.pdf", + working_dir="/tmp/rag/project_1", + ) + + mock_vector_store.add_documents.assert_called_once() + call_kwargs = mock_vector_store.add_documents.call_args + assert call_kwargs[1]["working_dir"] == "/tmp/rag/project_1" + documents = call_kwargs[1]["documents"] + assert isinstance(documents, list) + assert len(documents) > 0 + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_passes_chunk_size_and_overlap( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + ) -> None: + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "text" + mock_extract.return_value = mock_result + + await use_case.execute( + file_name="report.pdf", + working_dir="/tmp/rag/project_1", + chunk_size=500, + chunk_overlap=100, + ) + + mock_extract.assert_called_once() + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_uses_default_chunk_params( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + mock_vector_store: AsyncMock, + ) -> None: + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "text" + mock_extract.return_value = mock_result + + await use_case.execute( + file_name="report.pdf", + working_dir="/tmp/rag/project_1", + ) + + mock_vector_store.add_documents.assert_called_once() + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_returns_file_indexing_result_on_success( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + ) -> None: + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "text" + mock_extract.return_value = mock_result + + result = await use_case.execute( + file_name="report.pdf", + working_dir="/tmp/rag/project_1", + ) + + assert isinstance(result, FileIndexingResult) + assert result.status == IndexingStatus.SUCCESS + assert result.file_name == "report.pdf" + + async def test_execute_returns_failed_result_on_vector_store_error( + self, + use_case: ClassicalIndexFileUseCase, + mock_vector_store: AsyncMock, + ) -> None: + mock_vector_store.add_documents.side_effect = RuntimeError("Connection lost") + + result = await use_case.execute( + file_name="report.pdf", + working_dir="/tmp/rag/project_1", + ) + + assert result.status == IndexingStatus.FAILED + assert result.error is not None + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_returns_failed_result_on_extraction_error( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + ) -> None: + mock_extract.side_effect = RuntimeError("Unsupported format") + + result = await use_case.execute( + file_name="corrupt.xyz", + working_dir="/tmp/rag/project_1", + ) + + assert result.status == IndexingStatus.FAILED + + async def test_execute_returns_failed_result_on_storage_error( + self, + use_case: ClassicalIndexFileUseCase, + mock_storage: AsyncMock, + ) -> None: + mock_storage.get_object.side_effect = FileNotFoundError("File not found") + + result = await use_case.execute( + file_name="nonexistent.pdf", + working_dir="/tmp/rag/project_1", + ) + + assert result.status == IndexingStatus.FAILED + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_adds_documents_with_file_path_metadata( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + mock_vector_store: AsyncMock, + ) -> None: + chunk = MagicMock() + chunk.content = "chunk text" + mock_result = MagicMock() + mock_result.chunks = [chunk] + mock_result.content = "full text" + mock_extract.return_value = mock_result + + await use_case.execute( + file_name="docs/report.pdf", + working_dir="/tmp/rag/project_1", + ) + + call_kwargs = mock_vector_store.add_documents.call_args[1] + documents = call_kwargs["documents"] + for _content, file_path, _metadata in documents: + assert file_path == "docs/report.pdf" + + async def test_execute_custom_bucket_and_output_dir( + self, + mock_vector_store: AsyncMock, + mock_storage: AsyncMock, + ) -> None: + use_case = ClassicalIndexFileUseCase( + vector_store=mock_vector_store, + storage=mock_storage, + bucket="custom-bucket", + output_dir="/custom/output", + ) + + mock_storage.get_object.side_effect = FileNotFoundError("skip extract") + + await use_case.execute( + file_name="test.pdf", + working_dir="/tmp/rag/custom", + ) + + mock_storage.get_object.assert_called_once_with("custom-bucket", "test.pdf") + + @patch("application.use_cases.classical_index_file_use_case.extract_file") + async def test_execute_processes_empty_document( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFileUseCase, + mock_vector_store: AsyncMock, + ) -> None: + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "" + mock_extract.return_value = mock_result + + result = await use_case.execute( + file_name="empty.pdf", + working_dir="/tmp/rag/project_1", + ) + + assert result.status == IndexingStatus.SUCCESS diff --git a/tests/unit/test_classical_index_folder_use_case.py b/tests/unit/test_classical_index_folder_use_case.py new file mode 100644 index 0000000..026b4b1 --- /dev/null +++ b/tests/unit/test_classical_index_folder_use_case.py @@ -0,0 +1,245 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from application.use_cases.classical_index_folder_use_case import ( + ClassicalIndexFolderUseCase, +) +from domain.entities.indexing_result import ( + FolderIndexingResult, + IndexingStatus, +) + + +class TestClassicalIndexFolderUseCase: + @pytest.fixture + def use_case( + self, + mock_vector_store: AsyncMock, + mock_storage: AsyncMock, + ) -> ClassicalIndexFolderUseCase: + return ClassicalIndexFolderUseCase( + vector_store=mock_vector_store, + storage=mock_storage, + bucket="test-bucket", + output_dir="/tmp/output", + ) + + async def test_execute_lists_objects_from_storage( + self, + use_case: ClassicalIndexFolderUseCase, + mock_storage: AsyncMock, + ) -> None: + mock_storage.list_objects.return_value = [] + + await use_case.execute( + working_dir="project/docs", + recursive=True, + ) + + mock_storage.list_objects.assert_called_once_with( + "test-bucket", prefix="project/docs", recursive=True + ) + + async def test_execute_ensures_vector_store_table( + self, + use_case: ClassicalIndexFolderUseCase, + mock_vector_store: AsyncMock, + mock_storage: AsyncMock, + ) -> None: + mock_storage.list_objects.return_value = [] + + await use_case.execute( + working_dir="project/docs", + recursive=True, + ) + + mock_vector_store.ensure_table.assert_called_once_with("project/docs") + + @patch("application.use_cases.classical_index_folder_use_case.extract_file") + async def test_execute_downloads_and_indexes_each_file( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFolderUseCase, + mock_storage: AsyncMock, + mock_vector_store: AsyncMock, + ) -> None: + mock_storage.list_objects.return_value = [ + "project/docs/report.pdf", + "project/docs/notes.txt", + ] + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "text" + mock_extract.return_value = mock_result + + await use_case.execute( + working_dir="project/docs", + recursive=True, + ) + + assert mock_storage.get_object.call_count == 2 + mock_storage.get_object.assert_any_call( + "test-bucket", "project/docs/report.pdf" + ) + mock_storage.get_object.assert_any_call("test-bucket", "project/docs/notes.txt") + + @patch("application.use_cases.classical_index_folder_use_case.extract_file") + async def test_execute_filters_by_file_extensions( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFolderUseCase, + mock_storage: AsyncMock, + ) -> None: + mock_storage.list_objects.return_value = [ + "project/docs/report.pdf", + "project/docs/notes.txt", + "project/docs/data.xlsx", + ] + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "text" + mock_extract.return_value = mock_result + + await use_case.execute( + working_dir="project/docs", + file_extensions=[".pdf", ".txt"], + ) + + assert mock_storage.get_object.call_count == 2 + mock_storage.get_object.assert_any_call( + "test-bucket", "project/docs/report.pdf" + ) + mock_storage.get_object.assert_any_call("test-bucket", "project/docs/notes.txt") + + @patch("application.use_cases.classical_index_folder_use_case.extract_file") + async def test_execute_returns_folder_indexing_result( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFolderUseCase, + mock_storage: AsyncMock, + ) -> None: + mock_storage.list_objects.return_value = [ + "project/docs/report.pdf", + ] + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "text" + mock_extract.return_value = mock_result + + result = await use_case.execute( + working_dir="project/docs", + recursive=True, + ) + + assert isinstance(result, FolderIndexingResult) + assert result.status == IndexingStatus.SUCCESS + assert result.folder_path == "project/docs" + + async def test_execute_handles_empty_folder( + self, + use_case: ClassicalIndexFolderUseCase, + mock_storage: AsyncMock, + ) -> None: + mock_storage.list_objects.return_value = [] + + result = await use_case.execute( + working_dir="project/empty", + recursive=True, + ) + + assert isinstance(result, FolderIndexingResult) + assert result.status == IndexingStatus.SUCCESS + assert result.stats.total_files == 0 + + @patch("application.use_cases.classical_index_folder_use_case.extract_file") + async def test_execute_tracks_successful_and_failed_files( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFolderUseCase, + mock_storage: AsyncMock, + ) -> None: + mock_storage.list_objects.return_value = [ + "project/docs/good.pdf", + "project/docs/bad.pdf", + ] + call_count = 0 + + def _extract_with_failure(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 2: + raise RuntimeError("Extraction failed") + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "Good content" + return mock_result + + mock_extract.side_effect = _extract_with_failure + + result = await use_case.execute( + working_dir="project/docs", + recursive=True, + ) + + assert isinstance(result, FolderIndexingResult) + assert result.stats.files_processed + result.stats.files_failed > 0 + + async def test_execute_uses_custom_bucket( + self, + mock_vector_store: AsyncMock, + mock_storage: AsyncMock, + ) -> None: + use_case = ClassicalIndexFolderUseCase( + vector_store=mock_vector_store, + storage=mock_storage, + bucket="custom-folder-bucket", + output_dir="/custom/output", + ) + + mock_storage.list_objects.return_value = [] + + await use_case.execute(working_dir="project/docs") + + mock_storage.list_objects.assert_called_once_with( + "custom-folder-bucket", prefix="project/docs", recursive=True + ) + + async def test_execute_non_recursive_listing( + self, + use_case: ClassicalIndexFolderUseCase, + mock_storage: AsyncMock, + ) -> None: + mock_storage.list_objects.return_value = [] + + await use_case.execute( + working_dir="project/docs", + recursive=False, + ) + + mock_storage.list_objects.assert_called_once_with( + "test-bucket", prefix="project/docs", recursive=False + ) + + @patch("application.use_cases.classical_index_folder_use_case.extract_file") + async def test_execute_passes_chunk_params( + self, + mock_extract: AsyncMock, + use_case: ClassicalIndexFolderUseCase, + mock_storage: AsyncMock, + ) -> None: + mock_storage.list_objects.return_value = [ + "project/docs/report.pdf", + ] + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.content = "text" + mock_extract.return_value = mock_result + + await use_case.execute( + working_dir="project/docs", + chunk_size=500, + chunk_overlap=100, + ) + + mock_extract.assert_called_once() diff --git a/tests/unit/test_classical_indexing_routes.py b/tests/unit/test_classical_indexing_routes.py new file mode 100644 index 0000000..c1a8326 --- /dev/null +++ b/tests/unit/test_classical_indexing_routes.py @@ -0,0 +1,180 @@ +"""Tests for classical indexing routes — TDD Red phase. + +These tests will FAIL until the production code is implemented. +Tests cover POST /classical/file/index and POST /classical/folder/index +with background task execution (202 accepted). +""" + +from unittest.mock import AsyncMock + +import httpx +import pytest +from httpx import ASGITransport + +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 TestClassicalIndexFileRoute: + """Tests for POST /classical/file/index.""" + + @pytest.fixture + def mock_classical_index_file_use_case(self) -> AsyncMock: + mock = AsyncMock() + mock.execute.return_value = None + return mock + + async def test_index_file_returns_202( + self, + mock_classical_index_file_use_case: AsyncMock, + ) -> None: + """POST with file_name and working_dir should return 202 accepted.""" + # Will fail until get_classical_index_file_use_case dependency exists + from dependencies import get_classical_index_file_use_case + + app.dependency_overrides[get_classical_index_file_use_case] = lambda: ( + mock_classical_index_file_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/file/index", + json={ + "file_name": "docs/report.pdf", + "working_dir": "/tmp/rag/project_1", + }, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "accepted" + + async def test_index_file_rejects_missing_file_name(self) -> None: + """Missing file_name should return 422.""" + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/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 should return 422.""" + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/file/index", + json={"file_name": "doc.pdf"}, + ) + + assert response.status_code == 422 + + async def test_index_file_accepts_optional_chunk_params( + self, + mock_classical_index_file_use_case: AsyncMock, + ) -> None: + """Should accept optional chunk_size and chunk_overlap.""" + from dependencies import get_classical_index_file_use_case + + app.dependency_overrides[get_classical_index_file_use_case] = lambda: ( + mock_classical_index_file_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/file/index", + json={ + "file_name": "doc.pdf", + "working_dir": "/tmp/rag/test", + "chunk_size": 500, + "chunk_overlap": 100, + }, + ) + + assert response.status_code == 202 + + +class TestClassicalIndexFolderRoute: + """Tests for POST /classical/folder/index.""" + + @pytest.fixture + def mock_classical_index_folder_use_case(self) -> AsyncMock: + mock = AsyncMock() + mock.execute.return_value = None + return mock + + async def test_index_folder_returns_202( + self, + mock_classical_index_folder_use_case: AsyncMock, + ) -> None: + """POST with working_dir should return 202 accepted.""" + from dependencies import get_classical_index_folder_use_case + + app.dependency_overrides[get_classical_index_folder_use_case] = lambda: ( + mock_classical_index_folder_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/folder/index", + json={"working_dir": "/tmp/rag/project_1"}, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "accepted" + + async def test_index_folder_accepts_optional_params( + self, + mock_classical_index_folder_use_case: AsyncMock, + ) -> None: + """Should accept optional recursive, file_extensions, chunk_size, chunk_overlap.""" + from dependencies import get_classical_index_folder_use_case + + app.dependency_overrides[get_classical_index_folder_use_case] = lambda: ( + mock_classical_index_folder_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/folder/index", + json={ + "working_dir": "/tmp/rag/project_1", + "recursive": False, + "file_extensions": [".pdf", ".txt"], + "chunk_size": 500, + "chunk_overlap": 100, + }, + ) + + assert response.status_code == 202 + + async def test_index_folder_rejects_missing_working_dir(self) -> None: + """Missing working_dir should return 422.""" + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/folder/index", + json={}, + ) + + assert response.status_code == 422 diff --git a/tests/unit/test_classical_query_routes.py b/tests/unit/test_classical_query_routes.py new file mode 100644 index 0000000..2d748a4 --- /dev/null +++ b/tests/unit/test_classical_query_routes.py @@ -0,0 +1,174 @@ +"""Tests for classical query routes — TDD Red phase. + +These tests will FAIL until the production code is implemented. +Tests cover POST /classical/query which runs synchronously. +""" + +from unittest.mock import AsyncMock + +import httpx +import pytest +from httpx import ASGITransport + +from application.responses.classical_query_response import ClassicalQueryResponse +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 TestClassicalQueryRoute: + """Tests for POST /classical/query.""" + + @pytest.fixture + def mock_classical_query_use_case(self) -> AsyncMock: + mock = AsyncMock() + mock.execute.return_value = ClassicalQueryResponse( + status="success", + message="", + queries=["What is ML?"], + chunks=[], + ) + return mock + + async def test_query_returns_200( + self, + mock_classical_query_use_case: AsyncMock, + ) -> None: + """POST with working_dir and query should return 200.""" + from dependencies import get_classical_query_use_case + + app.dependency_overrides[get_classical_query_use_case] = lambda: ( + mock_classical_query_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/query", + json={ + "working_dir": "/tmp/rag/project_1", + "query": "What is machine learning?", + }, + ) + + assert response.status_code == 200 + + async def test_query_calls_use_case_with_correct_params( + self, + mock_classical_query_use_case: AsyncMock, + ) -> None: + """Should forward working_dir, query, and params to the use case.""" + from dependencies import get_classical_query_use_case + + app.dependency_overrides[get_classical_query_use_case] = lambda: ( + mock_classical_query_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + await client.post( + "/api/v1/classical/query", + json={ + "working_dir": "/tmp/rag/project_42", + "query": "What are the findings?", + "top_k": 20, + "num_variations": 5, + "relevance_threshold": 7.0, + }, + ) + + mock_classical_query_use_case.execute.assert_called_once_with( + working_dir="/tmp/rag/project_42", + query="What are the findings?", + top_k=20, + num_variations=5, + relevance_threshold=7.0, + ) + + async def test_query_uses_default_params( + self, + mock_classical_query_use_case: AsyncMock, + ) -> None: + """Should use defaults for top_k, num_variations, relevance_threshold.""" + from dependencies import get_classical_query_use_case + + app.dependency_overrides[get_classical_query_use_case] = lambda: ( + mock_classical_query_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + await client.post( + "/api/v1/classical/query", + json={ + "working_dir": "/tmp/rag/test", + "query": "test query", + }, + ) + + mock_classical_query_use_case.execute.assert_called_once_with( + working_dir="/tmp/rag/test", + query="test query", + top_k=10, + num_variations=3, + relevance_threshold=5.0, + ) + + async def test_query_returns_response_body( + self, + mock_classical_query_use_case: AsyncMock, + ) -> None: + """Should return the ClassicalQueryResponse body.""" + from dependencies import get_classical_query_use_case + + app.dependency_overrides[get_classical_query_use_case] = lambda: ( + mock_classical_query_use_case + ) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/query", + json={ + "working_dir": "/tmp/rag/test", + "query": "What is ML?", + }, + ) + + body = response.json() + assert body["status"] == "success" + assert "queries" in body + assert "chunks" in body + + async def test_query_rejects_missing_query(self) -> None: + """Missing query field should return 422.""" + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/query", + json={"working_dir": "/tmp/rag/test"}, + ) + + assert response.status_code == 422 + + async def test_query_rejects_missing_working_dir(self) -> None: + """Missing working_dir field should return 422.""" + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/classical/query", + json={"query": "some question"}, + ) + + assert response.status_code == 422 diff --git a/tests/unit/test_classical_query_use_case.py b/tests/unit/test_classical_query_use_case.py new file mode 100644 index 0000000..3b59acc --- /dev/null +++ b/tests/unit/test_classical_query_use_case.py @@ -0,0 +1,482 @@ +"""Tests for ClassicalQueryUseCase — TDD Red phase. + +These tests will FAIL until the production code is implemented. +The use case orchestrates: generate multi-query variations → similarity search +for each variation → deduplicate → LLM-as-judge scoring → filter → return response. +""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from application.responses.classical_query_response import ( + ClassicalChunkResponse, + ClassicalQueryResponse, +) +from application.use_cases.classical_query_use_case import ClassicalQueryUseCase +from config import ClassicalRAGConfig +from domain.ports.vector_store_port import SearchResult + + +class TestClassicalQueryUseCase: + """Tests for ClassicalQueryUseCase.""" + + @pytest.fixture + def config(self) -> ClassicalRAGConfig: + """Provide a test configuration.""" + return ClassicalRAGConfig( + CLASSICAL_CHUNK_SIZE=1000, + CLASSICAL_CHUNK_OVERLAP=200, + CLASSICAL_NUM_QUERY_VARIATIONS=3, + CLASSICAL_RELEVANCE_THRESHOLD=5.0, + CLASSICAL_TABLE_PREFIX="classical_rag_", + CLASSICAL_LLM_TEMPERATURE=0.0, + ) + + @pytest.fixture + def use_case( + self, + mock_vector_store: AsyncMock, + mock_llm: AsyncMock, + config: ClassicalRAGConfig, + ) -> ClassicalQueryUseCase: + """Provide a use case instance with mocked ports.""" + return ClassicalQueryUseCase( + vector_store=mock_vector_store, + llm=mock_llm, + config=config, + ) + + # ------------------------------------------------------------------ + # Multi-query generation + # ------------------------------------------------------------------ + + async def test_execute_calls_llm_to_generate_query_variations( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + ) -> None: + """Should call LLM.generate to produce query variations.""" + await use_case.execute( + working_dir="/tmp/rag/project_1", + query="What is machine learning?", + ) + + mock_llm.generate.assert_called() + # First call should be for multi-query generation + first_call = mock_llm.generate.call_args_list[0] + assert ( + "machine learning" in str(first_call).lower() + or "query" in str(first_call).lower() + ) + + async def test_execute_generates_specified_number_of_variations( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + ) -> None: + """Should generate num_variations query variations via LLM.""" + await use_case.execute( + working_dir="/tmp/rag/project_1", + query="What is machine learning?", + num_variations=3, + ) + + # LLM should be called at least once for multi-query generation + assert mock_llm.generate.call_count >= 1 + + async def test_execute_includes_original_query_in_search( + self, + use_case: ClassicalQueryUseCase, + mock_vector_store: AsyncMock, + mock_llm: AsyncMock, + ) -> None: + """Should include the original query in the list of search queries.""" + await use_case.execute( + working_dir="/tmp/rag/project_1", + query="What is machine learning?", + num_variations=3, + ) + + # similarity_search should be called at least once with the original query + # or with variation queries — verify it was called + assert mock_vector_store.similarity_search.call_count >= 1 + + # ------------------------------------------------------------------ + # Similarity search + # ------------------------------------------------------------------ + + async def test_execute_calls_similarity_search_for_each_variation( + self, + use_case: ClassicalQueryUseCase, + mock_vector_store: AsyncMock, + mock_llm: AsyncMock, + ) -> None: + """Should call similarity_search for the original query plus each variation.""" + mock_llm.generate.return_value = json.dumps( + [ + "What is ML?", + "Explain machine learning", + "Define ML", + ] + ) + + await use_case.execute( + working_dir="/tmp/rag/project_1", + query="What is machine learning?", + num_variations=3, + top_k=10, + ) + + # Should search for original + 3 variations = 4 calls + assert mock_vector_store.similarity_search.call_count == 4 + + async def test_execute_passes_working_dir_and_top_k_to_similarity_search( + self, + use_case: ClassicalQueryUseCase, + mock_vector_store: AsyncMock, + ) -> None: + """Should forward working_dir and top_k to each similarity_search call.""" + await use_case.execute( + working_dir="/tmp/rag/my_project", + query="test query", + top_k=5, + ) + + for call_item in mock_vector_store.similarity_search.call_args_list: + assert call_item[1]["working_dir"] == "/tmp/rag/my_project" + assert call_item[1]["top_k"] == 5 + + # ------------------------------------------------------------------ + # Deduplication + # ------------------------------------------------------------------ + + async def test_execute_deduplicates_chunks_by_chunk_id( + self, + use_case: ClassicalQueryUseCase, + mock_vector_store: AsyncMock, + mock_llm: AsyncMock, + ) -> None: + """Should deduplicate search results by chunk_id across variations.""" + # Both variations return the same chunk + mock_llm.generate.side_effect = [ + json.dumps(["variation 1", "variation 2"]), + "8", + "8", + ] + duplicate_result = SearchResult( + chunk_id="chunk-same-id", + content="Same chunk appears in both variations", + file_path="/docs/report.pdf", + score=0.90, + ) + mock_vector_store.similarity_search.return_value = [duplicate_result] + + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + num_variations=2, + ) + + # The duplicate should appear only once + chunk_ids = [c.chunk_id for c in result.chunks] + assert chunk_ids.count("chunk-same-id") <= 1 + + # ------------------------------------------------------------------ + # LLM-as-judge scoring + # ------------------------------------------------------------------ + + async def test_execute_calls_llm_judge_for_each_chunk( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + mock_vector_store: AsyncMock, + ) -> None: + mock_llm.generate.side_effect = [ + json.dumps(["var1"]), + "7", + "7", + ] + + await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + num_variations=1, + ) + + assert mock_llm.generate.call_count >= 2 + + async def test_execute_filters_chunks_below_relevance_threshold( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + mock_vector_store: AsyncMock, + ) -> None: + mock_llm.generate.side_effect = [ + json.dumps(["variation 1"]), + "3", + "3", + ] + mock_vector_store.similarity_search.return_value = [ + SearchResult( + chunk_id="chunk-1", + content="text", + file_path="/docs/a.pdf", + score=0.9, + ) + ] + + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + num_variations=1, + relevance_threshold=5.0, + ) + + for chunk in result.chunks: + assert chunk.relevance_score >= 5.0 + + async def test_execute_includes_chunks_above_relevance_threshold( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + mock_vector_store: AsyncMock, + ) -> None: + mock_llm.generate.side_effect = [ + json.dumps(["variation 1"]), + "8", + "8", + ] + mock_vector_store.similarity_search.return_value = [ + SearchResult( + chunk_id="chunk-1", + content="relevant text", + file_path="/docs/a.pdf", + score=0.9, + ) + ] + + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + num_variations=1, + relevance_threshold=5.0, + ) + + assert len(result.chunks) >= 1 + assert result.chunks[0].relevance_score >= 5.0 + + async def test_execute_custom_relevance_threshold( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + mock_vector_store: AsyncMock, + ) -> None: + mock_llm.generate.side_effect = [ + json.dumps(["var1"]), + "4", + "4", + ] + mock_vector_store.similarity_search.return_value = [ + SearchResult( + chunk_id="chunk-1", + content="text", + file_path="/docs/a.pdf", + score=0.9, + ) + ] + + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + num_variations=1, + relevance_threshold=3.0, + ) + + # Score of 4 should pass a threshold of 3.0 + assert len(result.chunks) >= 1 + + # ------------------------------------------------------------------ + # Response shape + # ------------------------------------------------------------------ + + async def test_execute_returns_classical_query_response( + self, + use_case: ClassicalQueryUseCase, + ) -> None: + """Should return a ClassicalQueryResponse.""" + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="What is ML?", + ) + + assert isinstance(result, ClassicalQueryResponse) + + async def test_execute_response_includes_queries( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + ) -> None: + """Response should include the original and generated query variations.""" + mock_llm.generate.return_value = json.dumps( + [ + "What is ML?", + "Explain machine learning concepts", + ] + ) + + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="What is machine learning?", + num_variations=2, + ) + + # queries list should include the original query + assert "What is machine learning?" in result.queries + + async def test_execute_response_includes_chunks_with_required_fields( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + mock_vector_store: AsyncMock, + ) -> None: + """Each chunk in the response should have chunk_id, content, file_path, relevance_score.""" + mock_llm.generate.side_effect = [ + json.dumps(["var1"]), + "9", + "9", + ] + mock_vector_store.similarity_search.return_value = [ + SearchResult( + chunk_id="chunk-x1", + content="relevant text", + file_path="/docs/a.pdf", + score=0.9, + ) + ] + + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + num_variations=1, + ) + + if result.chunks: + chunk = result.chunks[0] + assert isinstance(chunk, ClassicalChunkResponse) + assert chunk.chunk_id is not None + assert chunk.content is not None + assert chunk.file_path is not None + assert chunk.relevance_score is not None + + async def test_execute_response_status_is_success( + self, + use_case: ClassicalQueryUseCase, + ) -> None: + """Response status should be 'success'.""" + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + ) + + assert result.status == "success" + + # ------------------------------------------------------------------ + # Error handling + # ------------------------------------------------------------------ + + async def test_execute_handles_invalid_llm_json_response( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + ) -> None: + """Should handle LLM returning non-JSON for query variations gracefully.""" + mock_llm.generate.return_value = "This is not valid JSON" + + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + num_variations=3, + ) + + # Should still return a valid response (possibly only with original query) + assert isinstance(result, ClassicalQueryResponse) + + async def test_execute_handles_empty_search_results( + self, + use_case: ClassicalQueryUseCase, + mock_vector_store: AsyncMock, + mock_llm: AsyncMock, + ) -> None: + """Should handle when similarity_search returns no results.""" + mock_vector_store.similarity_search.return_value = [] + mock_llm.generate.return_value = json.dumps(["var1", "var2"]) + + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="obscure topic", + num_variations=2, + ) + + assert isinstance(result, ClassicalQueryResponse) + assert result.chunks == [] + + async def test_execute_handles_llm_judge_failure( + self, + use_case: ClassicalQueryUseCase, + mock_llm: AsyncMock, + mock_vector_store: AsyncMock, + ) -> None: + """Should handle when LLM judge returns an unparseable score.""" + mock_llm.generate.side_effect = [ + json.dumps(["var1"]), + "not a number", + "not a number", + ] + mock_vector_store.similarity_search.return_value = [ + SearchResult( + chunk_id="chunk-j1", + content="text", + file_path="/docs/a.pdf", + score=0.9, + ) + ] + + result = await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + num_variations=1, + ) + + assert isinstance(result, ClassicalQueryResponse) + + # ------------------------------------------------------------------ + # Configuration-driven behavior + # ------------------------------------------------------------------ + + async def test_execute_uses_config_defaults( + self, + mock_vector_store: AsyncMock, + mock_llm: AsyncMock, + ) -> None: + """Should use config defaults for num_variations and relevance_threshold.""" + config = ClassicalRAGConfig( + CLASSICAL_NUM_QUERY_VARIATIONS=5, + CLASSICAL_RELEVANCE_THRESHOLD=7.0, + ) + use_case = ClassicalQueryUseCase( + vector_store=mock_vector_store, + llm=mock_llm, + config=config, + ) + + mock_llm.generate.return_value = json.dumps(["v1", "v2", "v3", "v4", "v5"]) + + await use_case.execute( + working_dir="/tmp/rag/project_1", + query="test query", + ) + + # Should have generated 5 variations + original = 6 searches + assert mock_vector_store.similarity_search.call_count == 6 diff --git a/tests/unit/test_langchain_openai_adapter.py b/tests/unit/test_langchain_openai_adapter.py new file mode 100644 index 0000000..a919971 --- /dev/null +++ b/tests/unit/test_langchain_openai_adapter.py @@ -0,0 +1,171 @@ +"""Tests for LangchainOpenAIAdapter — TDD Red phase. + +These tests will FAIL until the production code is implemented. +This adapter implements LLMPort using langchain-openai ChatOpenAI. +The ChatOpenAI client is an external dependency and is fully mocked. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from domain.ports.llm_port import LLMPort + + +class TestLangchainOpenAIAdapter: + """Tests for LangchainOpenAIAdapter.""" + + @pytest.fixture + def adapter(self): + from infrastructure.llm.langchain_openai_adapter import LangchainOpenAIAdapter + + return LangchainOpenAIAdapter( + api_key="test-api-key", + base_url="https://api.openai.com/v1", + model="gpt-4o-mini", + temperature=0.0, + ) + + @patch("infrastructure.llm.langchain_openai_adapter.ChatOpenAI") + async def test_generate_calls_chat_openAI( + self, + mock_chat_cls: MagicMock, + ) -> None: + """Should call ChatOpenAI with system and user messages.""" + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "Generated response text" + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_chat_cls.return_value = mock_llm + + from infrastructure.llm.langchain_openai_adapter import LangchainOpenAIAdapter + + adapter = LangchainOpenAIAdapter( + api_key="test-key", + base_url="https://api.openai.com/v1", + model="gpt-4o-mini", + temperature=0.0, + ) + + result = await adapter.generate( + system_prompt="You are a helpful assistant.", + user_message="What is machine learning?", + ) + + assert result == "Generated response text" + mock_llm.ainvoke.assert_called_once() + + @patch("infrastructure.llm.langchain_openai_adapter.ChatOpenAI") + async def test_generate_returns_string( + self, + mock_chat_cls: MagicMock, + ) -> None: + """Should return a string from generate.""" + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "Hello world" + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_chat_cls.return_value = mock_llm + + from infrastructure.llm.langchain_openai_adapter import LangchainOpenAIAdapter + + adapter = LangchainOpenAIAdapter( + api_key="test-key", + base_url="https://api.openai.com/v1", + model="gpt-4o-mini", + temperature=0.0, + ) + + result = await adapter.generate( + system_prompt="Be concise.", + user_message="Say hello", + ) + + assert isinstance(result, str) + assert result == "Hello world" + + @patch("infrastructure.llm.langchain_openai_adapter.ChatOpenAI") + async def test_generate_chat_calls_with_messages( + self, + mock_chat_cls: MagicMock, + ) -> None: + """Should call ChatOpenAI with the full message list.""" + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "Chat response" + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_chat_cls.return_value = mock_llm + + from infrastructure.llm.langchain_openai_adapter import LangchainOpenAIAdapter + + adapter = LangchainOpenAIAdapter( + api_key="test-key", + base_url="https://api.openai.com/v1", + model="gpt-4o-mini", + temperature=0.0, + ) + + messages = [ + {"role": "system", "content": "You are a judge."}, + {"role": "user", "content": "Score this chunk: 8/10"}, + ] + + result = await adapter.generate_chat(messages=messages) + + assert result == "Chat response" + mock_llm.ainvoke.assert_called_once() + + @patch("infrastructure.llm.langchain_openai_adapter.ChatOpenAI") + async def test_generate_chat_returns_string( + self, + mock_chat_cls: MagicMock, + ) -> None: + """Should return a string from generate_chat.""" + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "Chat result" + mock_llm.ainvoke = AsyncMock(return_value=mock_response) + mock_chat_cls.return_value = mock_llm + + from infrastructure.llm.langchain_openai_adapter import LangchainOpenAIAdapter + + adapter = LangchainOpenAIAdapter( + api_key="test-key", + base_url="https://api.openai.com/v1", + model="gpt-4o-mini", + temperature=0.0, + ) + + result = await adapter.generate_chat( + messages=[{"role": "user", "content": "Hello"}], + ) + + assert isinstance(result, str) + + @patch("infrastructure.llm.langchain_openai_adapter.ChatOpenAI") + async def test_adapter_configures_chat_openai_with_params( + self, + mock_chat_cls: MagicMock, + ) -> None: + """Should configure ChatOpenAI with api_key, base_url, model, temperature.""" + from infrastructure.llm.langchain_openai_adapter import LangchainOpenAIAdapter + + LangchainOpenAIAdapter( + api_key="my-key", + base_url="https://custom.api.com/v1", + model="gpt-4o", + temperature=0.7, + ) + + mock_chat_cls.assert_called_once() + call_kwargs = mock_chat_cls.call_args[1] + assert call_kwargs["api_key"] == "my-key" + assert call_kwargs["base_url"] == "https://custom.api.com/v1" + assert call_kwargs["model"] == "gpt-4o" + assert call_kwargs["temperature"] == 0.7 + + def test_implements_llm_port(self) -> None: + """LangchainOpenAIAdapter should implement LLMPort.""" + from infrastructure.llm.langchain_openai_adapter import LangchainOpenAIAdapter + + assert issubclass(LangchainOpenAIAdapter, LLMPort) diff --git a/tests/unit/test_langchain_pgvector_adapter.py b/tests/unit/test_langchain_pgvector_adapter.py new file mode 100644 index 0000000..6b256a2 --- /dev/null +++ b/tests/unit/test_langchain_pgvector_adapter.py @@ -0,0 +1,346 @@ +"""Tests for LangchainPgvectorAdapter — TDD Red phase. + +These tests will FAIL until the production code is implemented. +This adapter implements VectorStorePort using langchain-postgres PGVectorStore. +All langchain internals are mocked since they are external dependencies. +""" + +import hashlib +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from domain.ports.vector_store_port import SearchResult, VectorStorePort + + +class TestLangchainPgvectorAdapter: + """Tests for LangchainPgvectorAdapter.""" + + @pytest.fixture + def mock_pg_engine(self) -> MagicMock: + """Mock PGEngine for external dependency.""" + engine = MagicMock() + engine.close = AsyncMock() + return engine + + @pytest.fixture + def mock_pgvector_store(self) -> MagicMock: + """Mock PGVectorStore for external dependency.""" + store = MagicMock() + store.aadd_documents = AsyncMock(return_value=["id-1", "id-2"]) + store.asimilarity_search = AsyncMock(return_value=[]) + store.adelete = AsyncMock(return_value=None) + return store + + @pytest.fixture + def connection_string(self) -> str: + return "postgresql+asyncpg://user:pass@localhost:5432/testdb" + + @pytest.fixture + def adapter(self, connection_string: str): + """Provide an adapter instance. Will fail until production code exists.""" + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + return LangchainPgvectorAdapter( + connection_string=connection_string, + table_prefix="classical_rag_", + embedding_dimension=1536, + ) + + # ------------------------------------------------------------------ + # Table naming convention + # ------------------------------------------------------------------ + + def test_table_name_format(self) -> None: + """Table name should be {prefix}{sha256(working_dir)[:16]}.""" + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + adapter = LangchainPgvectorAdapter( + connection_string="postgresql+asyncpg://u:p@h:5432/db", + table_prefix="classical_rag_", + embedding_dimension=1536, + ) + + working_dir = "/tmp/rag/project_42" + expected_hash = hashlib.sha256(working_dir.encode()).hexdigest()[:16] + expected_table = f"classical_rag_{expected_hash}" + + table_name = adapter._get_table_name(working_dir) + assert table_name == expected_table + + def test_table_name_deterministic(self) -> None: + """Same working_dir should always produce the same table name.""" + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + adapter = LangchainPgvectorAdapter( + connection_string="postgresql+asyncpg://u:p@h:5432/db", + table_prefix="classical_rag_", + embedding_dimension=1536, + ) + + name1 = adapter._get_table_name("/tmp/rag/project_1") + name2 = adapter._get_table_name("/tmp/rag/project_1") + assert name1 == name2 + + def test_different_working_dirs_produce_different_tables(self) -> None: + """Different working_dirs should produce different table names.""" + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + adapter = LangchainPgvectorAdapter( + connection_string="postgresql+asyncpg://u:p@h:5432/db", + table_prefix="classical_rag_", + embedding_dimension=1536, + ) + + name1 = adapter._get_table_name("/tmp/rag/project_1") + name2 = adapter._get_table_name("/tmp/rag/project_2") + assert name1 != name2 + + def test_custom_table_prefix(self) -> None: + """Should use the configured table prefix.""" + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + adapter = LangchainPgvectorAdapter( + connection_string="postgresql+asyncpg://u:p@h:5432/db", + table_prefix="custom_prefix_", + embedding_dimension=1536, + ) + + table_name = adapter._get_table_name("/tmp/rag/project_1") + assert table_name.startswith("custom_prefix_") + + # ------------------------------------------------------------------ + # ensure_table + # ------------------------------------------------------------------ + + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGVectorStore") + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGEngine") + async def test_ensure_table_creates_pgvector_store( + self, + mock_pg_engine_cls: MagicMock, + mock_pgvector_store_cls: MagicMock, + connection_string: str, + ) -> None: + """Should create PGEngine and PGVectorStore on ensure_table.""" + mock_engine = MagicMock() + mock_pg_engine_cls.from_connection_string.return_value = mock_engine + mock_store = MagicMock() + mock_pgvector_store_cls.create.return_value = mock_store + + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + adapter = LangchainPgvectorAdapter( + connection_string=connection_string, + table_prefix="classical_rag_", + embedding_dimension=1536, + ) + + await adapter.ensure_table(working_dir="/tmp/rag/project_1") + + mock_pg_engine_cls.from_connection_string.assert_called_once_with( + connection_string + ) + mock_pgvector_store_cls.create.assert_called_once() + + # ------------------------------------------------------------------ + # add_documents + # ------------------------------------------------------------------ + + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGVectorStore") + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGEngine") + async def test_add_documents_calls_aadd_documents( + self, + mock_pg_engine_cls: MagicMock, + mock_pgvector_store_cls: MagicMock, + connection_string: str, + ) -> None: + """Should call aadd_documents on the PGVectorStore.""" + mock_engine = MagicMock() + mock_pg_engine_cls.from_connection_string.return_value = mock_engine + mock_store = MagicMock() + mock_store.aadd_documents = AsyncMock(return_value=["id-1", "id-2"]) + mock_pgvector_store_cls.create.return_value = mock_store + + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + adapter = LangchainPgvectorAdapter( + connection_string=connection_string, + table_prefix="classical_rag_", + embedding_dimension=1536, + ) + await adapter.ensure_table(working_dir="/tmp/rag/project_1") + + documents = [ + ("chunk text 1", "/docs/report.pdf", {"page": 1}), + ("chunk text 2", "/docs/report.pdf", {"page": 2}), + ] + + result = await adapter.add_documents( + working_dir="/tmp/rag/project_1", + documents=documents, + ) + + assert len(result) == 2 + for doc_id in result: + assert len(doc_id) == 36 + mock_store.aadd_documents.assert_called_once() + + # ------------------------------------------------------------------ + # similarity_search + # ------------------------------------------------------------------ + + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGVectorStore") + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGEngine") + async def test_similarity_search_returns_search_results( + self, + mock_pg_engine_cls: MagicMock, + mock_pgvector_store_cls: MagicMock, + connection_string: str, + ) -> None: + """Should return list of SearchResult from asimilarity_search_with_score.""" + from langchain_core.documents import Document + + mock_engine = MagicMock() + mock_pg_engine_cls.from_connection_string.return_value = mock_engine + mock_store = MagicMock() + mock_doc = Document( + page_content="Relevant chunk text", + metadata={ + "file_path": "/docs/report.pdf", + "chunk_id": "chunk-1", + "page": 1, + }, + ) + mock_store.asimilarity_search_with_score = AsyncMock( + return_value=[(mock_doc, 0.92)] + ) + mock_pgvector_store_cls.create.return_value = mock_store + + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + adapter = LangchainPgvectorAdapter( + connection_string=connection_string, + table_prefix="classical_rag_", + embedding_dimension=1536, + ) + await adapter.ensure_table(working_dir="/tmp/rag/project_1") + + results = await adapter.similarity_search( + working_dir="/tmp/rag/project_1", + query="What is machine learning?", + top_k=10, + ) + + assert isinstance(results, list) + assert len(results) >= 1 + assert isinstance(results[0], SearchResult) + assert results[0].content == "Relevant chunk text" + assert results[0].score == 0.92 + mock_store.asimilarity_search_with_score.assert_called_once() + + # ------------------------------------------------------------------ + # delete_documents + # ------------------------------------------------------------------ + + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGVectorStore") + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGEngine") + async def test_delete_documents_calls_adelete( + self, + mock_pg_engine_cls: MagicMock, + mock_pgvector_store_cls: MagicMock, + connection_string: str, + ) -> None: + """Should call adelete on the PGVectorStore for matching file_path.""" + mock_engine = MagicMock() + mock_pg_engine_cls.from_connection_string.return_value = mock_engine + mock_store = MagicMock() + mock_store.aadd_documents = AsyncMock(return_value=None) + mock_store.adelete = AsyncMock(return_value=None) + mock_pgvector_store_cls.create.return_value = mock_store + + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + adapter = LangchainPgvectorAdapter( + connection_string=connection_string, + table_prefix="classical_rag_", + embedding_dimension=1536, + ) + await adapter.ensure_table(working_dir="/tmp/rag/project_1") + + documents = [ + ("chunk text 1", "/docs/report.pdf", {"page": 1}), + ] + await adapter.add_documents( + working_dir="/tmp/rag/project_1", documents=documents + ) + + result = await adapter.delete_documents( + working_dir="/tmp/rag/project_1", + file_path="/docs/report.pdf", + ) + + assert isinstance(result, int) + assert result == 1 + mock_store.adelete.assert_called_once() + + # ------------------------------------------------------------------ + # close + # ------------------------------------------------------------------ + + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGVectorStore") + @patch("infrastructure.vector_store.langchain_pgvector_adapter.PGEngine") + async def test_close_closes_engine( + self, + mock_pg_engine_cls: MagicMock, + mock_pgvector_store_cls: MagicMock, + connection_string: str, + ) -> None: + """Should close the PGEngine connection pool.""" + mock_engine = MagicMock() + mock_engine.close = AsyncMock() + mock_pg_engine_cls.from_connection_string.return_value = mock_engine + + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + adapter = LangchainPgvectorAdapter( + connection_string=connection_string, + table_prefix="classical_rag_", + embedding_dimension=1536, + ) + await adapter.ensure_table(working_dir="/tmp/rag/project_1") + + await adapter.close() + + mock_engine.close.assert_called_once() + + # ------------------------------------------------------------------ + # Interface compliance + # ------------------------------------------------------------------ + + def test_implements_vector_store_port(self) -> None: + """LangchainPgvectorAdapter should implement VectorStorePort.""" + from infrastructure.vector_store.langchain_pgvector_adapter import ( + LangchainPgvectorAdapter, + ) + + assert issubclass(LangchainPgvectorAdapter, VectorStorePort) diff --git a/tests/unit/test_mcp_classical_tools.py b/tests/unit/test_mcp_classical_tools.py new file mode 100644 index 0000000..1d6878a --- /dev/null +++ b/tests/unit/test_mcp_classical_tools.py @@ -0,0 +1,353 @@ +"""Tests for MCP classical tools — TDD Red phase. + +These tests will FAIL until the production code is implemented. +Tests cover FastMCP tools: classical_index_file, classical_index_folder, classical_query. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from application.responses.classical_query_response import ClassicalQueryResponse +from domain.entities.indexing_result import ( + FileIndexingResult, + FolderIndexingResult, + FolderIndexingStats, + IndexingStatus, +) + + +class TestMCPClassicalToolsInstance: + """Verify the FastMCP instance configuration.""" + + def test_mcp_classical_has_correct_name(self) -> None: + """mcp_classical should be named 'RAGAnythingClassical'.""" + from application.api.mcp_classical_tools import mcp_classical + + assert mcp_classical.name == "RAGAnythingClassical" + + +class TestClassicalIndexFileTool: + """Tests for the classical_index_file MCP tool.""" + + async def test_calls_use_case_with_correct_params(self) -> None: + """Should call use_case.execute with file_name, working_dir, and optional params.""" + mock_use_case = AsyncMock() + mock_use_case.execute.return_value = FileIndexingResult( + status=IndexingStatus.SUCCESS, + message="File indexed", + file_path="/tmp/output/docs/report.pdf", + file_name="docs/report.pdf", + processing_time_ms=100.0, + ) + + with patch( + "application.api.mcp_classical_tools.get_classical_index_file_use_case", + return_value=mock_use_case, + ): + from application.api.mcp_classical_tools import classical_index_file + + await classical_index_file( + file_name="docs/report.pdf", + working_dir="/tmp/rag/project_1", + ) + + mock_use_case.execute.assert_called_once_with( + file_name="docs/report.pdf", + working_dir="/tmp/rag/project_1", + chunk_size=1000, + chunk_overlap=200, + ) + + async def test_passes_custom_chunk_params(self) -> None: + """Should forward chunk_size and chunk_overlap to use case.""" + mock_use_case = AsyncMock() + mock_use_case.execute.return_value = FileIndexingResult( + status=IndexingStatus.SUCCESS, + message="File indexed", + file_path="/tmp/output/report.pdf", + file_name="report.pdf", + processing_time_ms=50.0, + ) + + with patch( + "application.api.mcp_classical_tools.get_classical_index_file_use_case", + return_value=mock_use_case, + ): + from application.api.mcp_classical_tools import classical_index_file + + await classical_index_file( + file_name="report.pdf", + working_dir="/tmp/rag/test", + chunk_size=500, + chunk_overlap=100, + ) + + mock_use_case.execute.assert_called_once_with( + file_name="report.pdf", + working_dir="/tmp/rag/test", + chunk_size=500, + chunk_overlap=100, + ) + + async def test_returns_file_indexing_result(self) -> None: + """Should return the FileIndexingResult from the use case.""" + expected = FileIndexingResult( + status=IndexingStatus.SUCCESS, + message="Document indexed successfully", + file_path="/tmp/output/report.pdf", + file_name="report.pdf", + processing_time_ms=75.0, + ) + mock_use_case = AsyncMock() + mock_use_case.execute.return_value = expected + + with patch( + "application.api.mcp_classical_tools.get_classical_index_file_use_case", + return_value=mock_use_case, + ): + from application.api.mcp_classical_tools import classical_index_file + + result = await classical_index_file( + file_name="report.pdf", + working_dir="/tmp/rag/project_1", + ) + + assert result.status == IndexingStatus.SUCCESS + assert result.file_name == "report.pdf" + + async def test_propagates_use_case_error(self) -> None: + """Should let exceptions from the use case propagate.""" + mock_use_case = AsyncMock() + mock_use_case.execute.side_effect = RuntimeError("Vector store down") + + with ( + patch( + "application.api.mcp_classical_tools.get_classical_index_file_use_case", + return_value=mock_use_case, + ), + pytest.raises(RuntimeError, match="Vector store down"), + ): + from application.api.mcp_classical_tools import classical_index_file + + await classical_index_file( + file_name="report.pdf", + working_dir="/tmp/rag/test", + ) + + +class TestClassicalIndexFolderTool: + """Tests for the classical_index_folder MCP tool.""" + + async def test_calls_use_case_with_correct_params(self) -> None: + """Should call use_case.execute with working_dir and optional params.""" + mock_use_case = AsyncMock() + mock_use_case.execute.return_value = FolderIndexingResult( + status=IndexingStatus.SUCCESS, + message="Folder indexed", + folder_path="project/docs", + recursive=True, + stats=FolderIndexingStats( + total_files=2, + files_processed=2, + files_failed=0, + files_skipped=0, + ), + processing_time_ms=200.0, + ) + + with patch( + "application.api.mcp_classical_tools.get_classical_index_folder_use_case", + return_value=mock_use_case, + ): + from application.api.mcp_classical_tools import classical_index_folder + + await classical_index_folder( + working_dir="project/docs", + recursive=True, + ) + + mock_use_case.execute.assert_called_once_with( + working_dir="project/docs", + recursive=True, + file_extensions=None, + chunk_size=1000, + chunk_overlap=200, + ) + + async def test_passes_custom_params(self) -> None: + """Should forward all parameters to use case.""" + mock_use_case = AsyncMock() + mock_use_case.execute.return_value = FolderIndexingResult( + status=IndexingStatus.SUCCESS, + message="Folder indexed", + folder_path="project/docs", + recursive=False, + stats=FolderIndexingStats( + total_files=1, + files_processed=1, + files_failed=0, + files_skipped=0, + ), + processing_time_ms=100.0, + ) + + with patch( + "application.api.mcp_classical_tools.get_classical_index_folder_use_case", + return_value=mock_use_case, + ): + from application.api.mcp_classical_tools import classical_index_folder + + await classical_index_folder( + working_dir="project/docs", + recursive=False, + file_extensions=[".pdf"], + chunk_size=500, + chunk_overlap=50, + ) + + mock_use_case.execute.assert_called_once_with( + working_dir="project/docs", + recursive=False, + file_extensions=[".pdf"], + chunk_size=500, + chunk_overlap=50, + ) + + async def test_returns_folder_indexing_result(self) -> None: + """Should return the FolderIndexingResult from the use case.""" + expected = FolderIndexingResult( + status=IndexingStatus.SUCCESS, + message="Folder indexed successfully", + folder_path="project/docs", + recursive=True, + stats=FolderIndexingStats( + total_files=5, + files_processed=5, + files_failed=0, + files_skipped=0, + ), + processing_time_ms=300.0, + ) + mock_use_case = AsyncMock() + mock_use_case.execute.return_value = expected + + with patch( + "application.api.mcp_classical_tools.get_classical_index_folder_use_case", + return_value=mock_use_case, + ): + from application.api.mcp_classical_tools import classical_index_folder + + result = await classical_index_folder( + working_dir="project/docs", + ) + + assert result.status == IndexingStatus.SUCCESS + + +class TestClassicalQueryTool: + """Tests for the classical_query MCP tool.""" + + async def test_calls_use_case_with_correct_params(self) -> None: + """Should call use_case.execute with working_dir, query, and optional params.""" + mock_use_case = AsyncMock() + mock_use_case.execute.return_value = ClassicalQueryResponse( + status="success", + message="", + queries=["What is ML?"], + chunks=[], + ) + + with patch( + "application.api.mcp_classical_tools.get_classical_query_use_case", + return_value=mock_use_case, + ): + from application.api.mcp_classical_tools import classical_query + + await classical_query( + working_dir="/tmp/rag/project_1", + query="What is machine learning?", + ) + + mock_use_case.execute.assert_called_once_with( + working_dir="/tmp/rag/project_1", + query="What is machine learning?", + top_k=10, + num_variations=3, + relevance_threshold=5.0, + ) + + async def test_passes_custom_params(self) -> None: + """Should forward custom top_k, num_variations, relevance_threshold.""" + mock_use_case = AsyncMock() + mock_use_case.execute.return_value = ClassicalQueryResponse( + status="success", + queries=["test"], + chunks=[], + ) + + with patch( + "application.api.mcp_classical_tools.get_classical_query_use_case", + return_value=mock_use_case, + ): + from application.api.mcp_classical_tools import classical_query + + await classical_query( + working_dir="/tmp/rag/project_42", + query="Find relevant info", + top_k=20, + num_variations=5, + relevance_threshold=7.0, + ) + + mock_use_case.execute.assert_called_once_with( + working_dir="/tmp/rag/project_42", + query="Find relevant info", + top_k=20, + num_variations=5, + relevance_threshold=7.0, + ) + + async def test_returns_classical_query_response(self) -> None: + """Should return the ClassicalQueryResponse from the use case.""" + expected = ClassicalQueryResponse( + status="success", + message="Found 3 relevant chunks", + queries=["What is ML?", "Define machine learning", "Explain ML concepts"], + chunks=[], + ) + mock_use_case = AsyncMock() + mock_use_case.execute.return_value = expected + + with patch( + "application.api.mcp_classical_tools.get_classical_query_use_case", + return_value=mock_use_case, + ): + from application.api.mcp_classical_tools import classical_query + + result = await classical_query( + working_dir="/tmp/rag/project_1", + query="What is ML?", + ) + + assert result.status == "success" + assert len(result.queries) == 3 + + async def test_propagates_use_case_error(self) -> None: + """Should let exceptions from the use case propagate.""" + mock_use_case = AsyncMock() + mock_use_case.execute.side_effect = RuntimeError("LLM unavailable") + + with ( + patch( + "application.api.mcp_classical_tools.get_classical_query_use_case", + return_value=mock_use_case, + ), + pytest.raises(RuntimeError, match="LLM unavailable"), + ): + from application.api.mcp_classical_tools import classical_query + + await classical_query( + working_dir="/tmp/rag/test", + query="test", + ) diff --git a/uv.lock b/uv.lock index d49d462..a705610 100644 --- a/uv.lock +++ b/uv.lock @@ -828,55 +828,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.6" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, - { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] @@ -2004,6 +2004,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, ] +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + [[package]] name = "jsonref" version = "1.1.0" @@ -2089,6 +2110,77 @@ all = [ { name = "torch", marker = "python_full_version < '3.14'" }, ] +[[package]] +name = "langchain-core" +version = "1.2.28" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" }, +] + +[[package]] +name = "langchain-openai" +version = "1.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/fd/7dee16e882c4c1577d48db174d85aa3a0ee09ba61eb6a5d41650285ca80c/langchain_openai-1.1.12.tar.gz", hash = "sha256:ccf5ef02c896f6807b4d0e51aaf678a72ce81ae41201cae8d65e11eeff9ecb79", size = 1114119, upload-time = "2026-03-23T18:59:19.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/a6/68fb22e3604015e6f546fa1d3677d24378b482855ae74710cbf4aec44132/langchain_openai-1.1.12-py3-none-any.whl", hash = "sha256:da71ca3f2d18c16f7a2443cc306aa195ad2a07054335ac9b0626dcae02b6a0c5", size = 88487, upload-time = "2026-03-23T18:59:17.978Z" }, +] + +[[package]] +name = "langchain-postgres" +version = "0.0.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asyncpg" }, + { name = "langchain-core" }, + { name = "numpy" }, + { name = "pgvector" }, + { name = "psycopg", extra = ["binary"] }, + { name = "psycopg-pool" }, + { name = "sqlalchemy", extra = ["asyncio"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/16/27327ba9b12aa4835cfc1dad3ece7be13ec0f1619c42329640382251e87d/langchain_postgres-0.0.17.tar.gz", hash = "sha256:8d0d4f8223f3d74471abd640e4173316f9874f28f417d674cc8b0b50ee735c09", size = 238731, upload-time = "2026-02-17T08:21:24.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/f2/be46a73f4ab41c7ea80834a63f19ad446f4e770ea81d14cc14550d5c73dc/langchain_postgres-0.0.17-py3-none-any.whl", hash = "sha256:2bf18f0619a13827f957bd1e9e5d97199df54772e71e105610955c4d78bfd527", size = 48511, upload-time = "2026-02-17T08:21:23.336Z" }, +] + +[[package]] +name = "langsmith" +version = "0.7.30" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e7/d27d952ce9824d684a3bb500a06541a2d55734bc4d849cdfcca2dfd4d93a/langsmith-0.7.30.tar.gz", hash = "sha256:d9df7ba5e42f818b63bda78776c8f2fc853388be3ae77b117e5d183a149321a2", size = 1106040, upload-time = "2026-04-09T21:12:01.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/19/96250cf58070c5563446651b03bb76c2eb5afbf08e754840ab639532d8c6/langsmith-0.7.30-py3-none-any.whl", hash = "sha256:43dd9f8d290e4d406606d6cc0bd62f5d1050963f05fe0ab6ffe50acf41f2f55a", size = 372682, upload-time = "2026-04-09T21:12:00.481Z" }, +] + [[package]] name = "latex2mathml" version = "3.79.0" @@ -2159,7 +2251,7 @@ wheels = [ [[package]] name = "lightrag-hku" -version = "1.4.13" +version = "1.4.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2181,9 +2273,9 @@ dependencies = [ { name = "tiktoken" }, { name = "xlsxwriter" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/c9/6dc760199eca1a80358e14a46f36502d7bcefef8a8ec1017bae5a89129c3/lightrag_hku-1.4.13.tar.gz", hash = "sha256:53873bd0a2aa29509692f9bddf15f9338bdede5acd27339a69f9726d2a7d0ea5", size = 3757456, upload-time = "2026-04-02T17:01:53.985Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/34/ae9f0f1a9e98f581369ec412ef49797541ecce5375953855876aa9713ae6/lightrag_hku-1.4.14.tar.gz", hash = "sha256:c80e6355a647bbbf2d4f510bf38780f32f2d9023e99e21fdd486a0a4d8d19cd7", size = 3723051, upload-time = "2026-04-12T20:44:30.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/7a/caaeb5634db84e3f5b72e16a2b906f49fd01825655ebfc685566d6af848c/lightrag_hku-1.4.13-py3-none-any.whl", hash = "sha256:44d356dc9c61a867a0d209be432fcbf31a4aef010ba4de348271f1343d8692c5", size = 3651157, upload-time = "2026-04-02T17:01:50.782Z" }, + { url = "https://files.pythonhosted.org/packages/2b/03/47cd3a0aac779592d634c4c043b8060378a059c5c01874c416f6936d65b5/lightrag_hku-1.4.14-py3-none-any.whl", hash = "sha256:4af94adede9be5036813bbe604ac80bf1dc169d4cd68b5034fa4e3a51548e2b3", size = 3656347, upload-time = "2026-04-12T20:44:27.552Z" }, ] [package.optional-dependencies] @@ -2485,6 +2577,9 @@ dependencies = [ { name = "fastmcp" }, { name = "httpx" }, { name = "kreuzberg", extra = ["all"] }, + { name = "langchain-core" }, + { name = "langchain-openai" }, + { name = "langchain-postgres" }, { name = "lightrag-hku", extra = ["api"] }, { name = "mcp" }, { name = "minio" }, @@ -2519,12 +2614,15 @@ requires-dist = [ { name = "fastmcp", specifier = ">=3.2.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "kreuzberg", extras = ["all"], specifier = ">=4.8.2" }, + { name = "langchain-core", specifier = ">=1.2.28" }, + { name = "langchain-openai", specifier = ">=1.1.12" }, + { name = "langchain-postgres", specifier = ">=0.0.17" }, { name = "lightrag-hku", specifier = ">=1.4.13" }, { name = "lightrag-hku", extras = ["api"], specifier = ">=1.4.13" }, { 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 = "pgvector", specifier = ">=0.3.6,<0.4.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-multipart", specifier = ">=0.0.22" }, @@ -3418,14 +3516,14 @@ wheels = [ [[package]] name = "pgvector" -version = "0.4.2" +version = "0.3.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/d8/fd6009cee3e03214667df488cdcf9609461d729968da94e4f95d6359d304/pgvector-0.3.6.tar.gz", hash = "sha256:31d01690e6ea26cea8a633cde5f0f55f5b246d9c8292d68efdef8c22ec994ade", size = 25421, upload-time = "2024-10-27T00:15:09.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/f457d6d361e04d061bef413749a6e1ab04d98cfeec6d8abcfe40184750f3/pgvector-0.3.6-py3-none-any.whl", hash = "sha256:f6c269b3c110ccb7496bac87202148ed18f34b390a0189c783e351062400a75a", size = 24880, upload-time = "2024-10-27T00:15:08.045Z" }, ] [[package]] @@ -3654,6 +3752,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, +] + [[package]] name = "py-key-value-aio" version = "0.4.4" @@ -3976,11 +4132,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.9.2" +version = "6.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" }, + { url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" }, ] [[package]] @@ -4488,6 +4644,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -5475,6 +5643,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uuid-utils" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" }, + { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" }, + { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" }, + { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" }, + { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" }, +] + [[package]] name = "uvicorn" version = "0.42.0" @@ -5636,6 +5826,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, ] +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, +] + [[package]] name = "yarl" version = "1.23.0" @@ -5745,3 +6003,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/4d/1ef17017d38eabe7ae28f18ef0f16d48966cc23a5657e4555fff61704539/zopfli-0.4.1-cp310-abi3-win32.whl", hash = "sha256:a899eca405662a23ae75054affa3517a060362eae1185d3d791c86a50153c4dd", size = 82314, upload-time = "2026-02-13T14:17:20.795Z" }, { url = "https://files.pythonhosted.org/packages/0f/94/806bc84b389c7d70051d7c9a0179cff52de8b9f8dc2fc25bcf0bca302986/zopfli-0.4.1-cp310-abi3-win_amd64.whl", hash = "sha256:84a31ba9edc921b1d3a4449929394a993888f32d70de3a3617800c428a947b9b", size = 102186, upload-time = "2026-02-13T14:17:21.622Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +]