From b43ad51b6c4f711c3675d5878cdb6c1b5840d52e Mon Sep 17 00:00:00 2001 From: rahulk98 Date: Thu, 6 Aug 2026 16:40:42 +0000 Subject: [PATCH 1/5] Migrate from Gemini to OpenRouter free models + local embeddings - LLM: gemini-2.5-flash -> openrouter/free (OpenRouter's free-model router), overridable via OPENROUTER_MODEL; auth via OPENROUTER_API_KEY - Embeddings: gemini-embedding-001 -> local BAAI/bge-small-en-v1.5 via fastembed (ONNX, in-process, zero API cost); OpenRouter has no embeddings API, so this removes the Google dependency entirely - Index must be rebuilt once (embedding dims changed 3072 -> 384) - Dockerfile pre-downloads the embedding model at build time - Add pytest suite: API endpoints, rate/daily limits, caching, SSE streaming, index persist/reload round-trip, plus opt-in live OpenRouter smoke tests (pytest -m live) --- .gitignore | 2 +- DOCKERFILE | 35 ++- README.md | 19 +- app.py | 31 +- build_index.py | 84 ++--- pyproject.toml | 18 +- query_index.py | 76 ++--- requirements.txt | 16 +- tests/__init__.py | 0 tests/test_app.py | 237 ++++++++++++++ uv.lock | 781 +++++++++++++++++++++++++++------------------- 11 files changed, 850 insertions(+), 449 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_app.py diff --git a/.gitignore b/.gitignore index 1ebba88..42c0535 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ scripts/publish-docker.ps1 commands.txt docs/plans/ *.json -CLAUDE.md \ No newline at end of file +CLAUDE.md.pytest_cache/ diff --git a/DOCKERFILE b/DOCKERFILE index 33e22c8..827f5ad 100644 --- a/DOCKERFILE +++ b/DOCKERFILE @@ -1,16 +1,19 @@ -FROM python:3.12-slim - -WORKDIR /app - -# Copy requirements and install dependencies -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -# Copy the application code -COPY index/ ./index/ -COPY app.py . - -EXPOSE 8000 - -# Run the server -CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +FROM python:3.12-slim + +WORKDIR /app + +# Copy requirements and install dependencies +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Pre-download the local embedding model so the container doesn't fetch it at startup +RUN python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')" + +# Copy the application code +COPY index/ ./index/ +COPY app.py . + +EXPOSE 8000 + +# Run the server (requires OPENROUTER_API_KEY at runtime) +CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index ece1fd9..bd77489 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,16 @@ Hosted and running at [rahul-krishnan.is-a.dev](https://rahul-krishnan.is-a.dev). ### Overview -Retrieval-Augmented Generation (RAG) API that indexes documents in `docs/` and answers questions using LlamaIndex with Google Gemini (embeddings + LLM). FastAPI provides `/health` and `/query` endpoints. Containerized with Docker and deployed as an API on GCP Free tier. +Retrieval-Augmented Generation (RAG) API that indexes documents in `docs/` and answers questions using LlamaIndex. The LLM runs on OpenRouter's free-model router (`openrouter/free`) and embeddings run locally with fastembed (`BAAI/bge-small-en-v1.5`, ONNX) — so there are no per-query API charges. FastAPI provides `/health`, `/query`, and `/query/stream` endpoints. Containerized with Docker and deployed as an API on GCP Free tier. ### Prerequisites - **Python**: 3.12+ -- **Environment**: `GOOGLE_API_KEY` set (in shell or `.env`) - - Optional Supabase logging: set `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` (or `SUPABASE_ANON_KEY`) +- **Environment**: `OPENROUTER_API_KEY` set (in shell or `.env`) — get a free key at [openrouter.ai](https://openrouter.ai) + - Optional: `OPENROUTER_MODEL` to pin a specific model (default `openrouter/free`, which auto-routes to an available free model). Note: do **not** use `openrouter/auto:free` — it does not restrict routing to free models and can incur charges. + - Optional: `EMBED_MODEL_NAME` to change the local embedding model (default `BAAI/bge-small-en-v1.5`). Changing it requires rebuilding the index. + - Optional Sheets logging: set `SHEETS_JSON_KEY` (service-account JSON) + +> **Migrating from the Gemini version?** The embedding model changed (3072-dim Gemini → 384-dim local BGE), so you must rebuild the index once with `python build_index.py` before deploying. ### Quickstart ```bash @@ -41,8 +45,15 @@ python -m uvicorn app:app --host 0.0.0.0 --port 8000 docker build -t rag-resume-api . # Run -docker run -e GOOGLE_API_KEY=YOUR_KEY -p 8000:8000 rag-resume-api +docker run -e OPENROUTER_API_KEY=YOUR_KEY -p 8000:8000 rag-resume-api +``` + +### Tests +```bash +pip install pytest httpx +pytest tests/ -v ``` +The suite mocks the LLM, so no API key or network is needed. To also run the live OpenRouter smoke test, set `OPENROUTER_API_KEY` and run `pytest tests/ -v -m live`. ### Screenshots ![Screenshot 1](screenshots/ss1.jpg) diff --git a/app.py b/app.py index 605da2f..693ca42 100644 --- a/app.py +++ b/app.py @@ -7,8 +7,8 @@ from collections import defaultdict from datetime import datetime, timezone from dotenv import load_dotenv -from llama_index.embeddings.google_genai import GoogleGenAIEmbedding -from llama_index.llms.google_genai import GoogleGenAI +from llama_index.embeddings.fastembed import FastEmbedEmbedding +from llama_index.llms.openrouter import OpenRouter from llama_index.core import StorageContext, load_index_from_storage, PromptTemplate from contextlib import asynccontextmanager import uvicorn @@ -46,14 +46,16 @@ async def lifespan(app: FastAPI): max_age=86400, ) -GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") -if not GOOGLE_API_KEY: - print("WARNING: GOOGLE_API_KEY not found in environment. RAG queries will not work.") +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") +if not OPENROUTER_API_KEY: + print("WARNING: OPENROUTER_API_KEY not found in environment. RAG queries will not work.") # configuration INDEX_PERSIST_DIR = "./index" -EMBED_MODEL_NAME = "gemini-embedding-001" -LLM_MODEL_NAME = "gemini-2.5-flash" +# Local ONNX embedding model (no API cost). Changing this requires rebuilding the index. +EMBED_MODEL_NAME = os.getenv("EMBED_MODEL_NAME", "BAAI/bge-small-en-v1.5") +# Free OpenRouter model; override with OPENROUTER_MODEL if this one is deprecated. +LLM_MODEL_NAME = os.getenv("OPENROUTER_MODEL", "openrouter/free") SYSTEM_PROMPT = ( "You are a helpful assistant that answers questions about Rahul Krishnan based on his resume, " @@ -74,7 +76,7 @@ async def lifespan(app: FastAPI): RATE_WINDOW = 60 # seconds _rate_store: dict[str, list[float]] = defaultdict(list) -# Daily Gemini API call limit +# Daily LLM API call limit DAILY_QUERY_LIMIT = 30 _daily_count = 0 _daily_reset_date = datetime.now(timezone.utc).date() @@ -144,19 +146,16 @@ def init_models_and_index(): Called once at startup. """ global embed_model, llm, index - if not GOOGLE_API_KEY: + if not OPENROUTER_API_KEY: print("Skipping model initialization — no API key.") return - embed_model = GoogleGenAIEmbedding( - model_name=EMBED_MODEL_NAME, - embed_batch_size=100, - api_key=GOOGLE_API_KEY, - ) + embed_model = FastEmbedEmbedding(model_name=EMBED_MODEL_NAME) try: - llm = GoogleGenAI( + llm = OpenRouter( model=LLM_MODEL_NAME, - api_key=GOOGLE_API_KEY, + api_key=OPENROUTER_API_KEY, + max_tokens=1024, ) except Exception as e: print(f"Failed to initialize LLM: {e}") diff --git a/build_index.py b/build_index.py index 2f6dc11..f82af3c 100644 --- a/build_index.py +++ b/build_index.py @@ -1,41 +1,43 @@ -from llama_index.core import SimpleDirectoryReader -from llama_index.core import VectorStoreIndex -from llama_index.embeddings.google_genai import GoogleGenAIEmbedding -from llama_index.llms.google_genai import GoogleGenAI -from dotenv import load_dotenv -import os - -load_dotenv() -GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") -if not GOOGLE_API_KEY: - raise RuntimeError("GOOGLE_API_KEY not found in .env") - - -def build_index(): - # Read all the relevant documents in docs folder - documents = SimpleDirectoryReader("./docs").load_data() - - # Setup the gemini model for embeddings - embed_model = GoogleGenAIEmbedding( - model_name="gemini-embedding-001", embed_batch_size=100, api_key=GOOGLE_API_KEY - ) - # Build the vector store index from the documents in docs folder - index = VectorStoreIndex.from_documents(documents, embed_model=embed_model) - - # Store the index for reuse - index.storage_context.persist(persist_dir="./index") - - # Initliase the LLM - llm = GoogleGenAI( - model="gemini-2.5-flash", - api_key=GOOGLE_API_KEY, # uses GOOGLE_API_KEY env var by default - ) - - # create a query engine and query - query_engine = index.as_query_engine(llm=llm) - response = query_engine.query("What are Rahul's skills?") - print(response) - - -if __name__ == "__main__": - build_index() +from llama_index.core import SimpleDirectoryReader +from llama_index.core import VectorStoreIndex +from llama_index.embeddings.fastembed import FastEmbedEmbedding +from llama_index.llms.openrouter import OpenRouter +from dotenv import load_dotenv +import os + +load_dotenv() +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") +if not OPENROUTER_API_KEY: + raise RuntimeError("OPENROUTER_API_KEY not found in .env") + +EMBED_MODEL_NAME = os.getenv("EMBED_MODEL_NAME", "BAAI/bge-small-en-v1.5") +LLM_MODEL_NAME = os.getenv("OPENROUTER_MODEL", "openrouter/free") + + +def build_index(): + # Read all the relevant documents in docs folder + documents = SimpleDirectoryReader("./docs").load_data() + + # Local ONNX embedding model — runs in-process, no API cost + embed_model = FastEmbedEmbedding(model_name=EMBED_MODEL_NAME) + # Build the vector store index from the documents in docs folder + index = VectorStoreIndex.from_documents(documents, embed_model=embed_model) + + # Store the index for reuse + index.storage_context.persist(persist_dir="./index") + + # Initliase the LLM (free OpenRouter model) + llm = OpenRouter( + model=LLM_MODEL_NAME, + api_key=OPENROUTER_API_KEY, + max_tokens=1024, + ) + + # create a query engine and query + query_engine = index.as_query_engine(llm=llm) + response = query_engine.query("What are Rahul's skills?") + print(response) + + +if __name__ == "__main__": + build_index() diff --git a/pyproject.toml b/pyproject.toml index b0ba401..af8a1a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,23 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "fastapi>=0.120.0", - "google-generativeai>=0.3.0", "llama-index>=0.14.5", - "llama-index-embeddings-google-genai>=0.3.1", - "llama-index-llms-google-genai>=0.6.2", + "llama-index-embeddings-fastembed>=0.4.0", + "fastembed>=0.4.0", + "llama-index-llms-openrouter>=0.4.0", "python-dotenv>=1.1.1", "uvicorn>=0.38.0", "supabase", ] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "httpx>=0.27.0", +] + +[tool.pytest.ini_options] +markers = [ + "live: tests that call the real OpenRouter API (need OPENROUTER_API_KEY)", +] +addopts = "-m 'not live'" diff --git a/query_index.py b/query_index.py index 453e669..2cbe402 100644 --- a/query_index.py +++ b/query_index.py @@ -1,37 +1,39 @@ -from llama_index.embeddings.google_genai import GoogleGenAIEmbedding -from llama_index.llms.google_genai import GoogleGenAI -from llama_index.core import StorageContext, load_index_from_storage -from dotenv import load_dotenv -import os - -load_dotenv() -GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") -if not GOOGLE_API_KEY: - raise RuntimeError("GOOGLE_API_KEY not found in .env") - - -def query_index(query_text: str) -> str: - # Setup the gemini model for embeddings - embed_model = GoogleGenAIEmbedding( - model_name="text-embedding-004", embed_batch_size=100, api_key=GOOGLE_API_KEY - ) - # rebuild storage context - storage_context = StorageContext.from_defaults(persist_dir="./index") - # load index - index = load_index_from_storage(storage_context, embed_model=embed_model) - - # Initliase the LLM - llm = GoogleGenAI( - model="gemini-2.5-flash", - api_key=GOOGLE_API_KEY, # uses GOOGLE_API_KEY env var by default - ) - - # create a query engine and query - query_engine = index.as_query_engine(llm=llm) - response = query_engine.query(query_text) - print(response) - return response - - -if __name__ == "__main__": - query_index("Tell me about Rahul in 3 sentences.") \ No newline at end of file +from llama_index.embeddings.fastembed import FastEmbedEmbedding +from llama_index.llms.openrouter import OpenRouter +from llama_index.core import StorageContext, load_index_from_storage +from dotenv import load_dotenv +import os + +load_dotenv() +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") +if not OPENROUTER_API_KEY: + raise RuntimeError("OPENROUTER_API_KEY not found in .env") + +EMBED_MODEL_NAME = os.getenv("EMBED_MODEL_NAME", "BAAI/bge-small-en-v1.5") +LLM_MODEL_NAME = os.getenv("OPENROUTER_MODEL", "openrouter/free") + + +def query_index(query_text: str) -> str: + # Local ONNX embedding model — must match the model used in build_index.py + embed_model = FastEmbedEmbedding(model_name=EMBED_MODEL_NAME) + # rebuild storage context + storage_context = StorageContext.from_defaults(persist_dir="./index") + # load index + index = load_index_from_storage(storage_context, embed_model=embed_model) + + # Initliase the LLM (free OpenRouter model) + llm = OpenRouter( + model=LLM_MODEL_NAME, + api_key=OPENROUTER_API_KEY, + max_tokens=1024, + ) + + # create a query engine and query + query_engine = index.as_query_engine(llm=llm) + response = query_engine.query(query_text) + print(response) + return response + + +if __name__ == "__main__": + query_index("Tell me about Rahul in 3 sentences.") diff --git a/requirements.txt b/requirements.txt index 4a662e0..4d2250e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -fastapi>=0.120.0 -google-generativeai>=0.3.0 -llama-index>=0.14.5 -llama-index-embeddings-google-genai>=0.3.1 -llama-index-llms-google-genai>=0.6.2 -python-dotenv>=1.1.1 -uvicorn>=0.38.0 -gspread>=6.0.0 +fastapi>=0.120.0 +llama-index>=0.14.5 +llama-index-embeddings-fastembed>=0.4.0 +fastembed>=0.4.0 +llama-index-llms-openrouter>=0.4.0 +python-dotenv>=1.1.1 +uvicorn>=0.38.0 +gspread>=6.0.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..93abc12 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,237 @@ +"""Tests for the RAG Resume API after the Gemini -> OpenRouter migration. + +The LLM is mocked (MockLLM) so no API key or network is needed, while the +embedding path is exercised for real with the local fastembed model. + +Run the live OpenRouter smoke tests with: pytest -m live (needs OPENROUTER_API_KEY) +""" + +import os + +import pytest +from fastapi.testclient import TestClient +from llama_index.core import Document, VectorStoreIndex +from llama_index.core.llms import MockLLM +from llama_index.embeddings.fastembed import FastEmbedEmbedding + +import app as app_module + +SAMPLE_DOCS = [ + Document(text="Rahul Krishnan is a software engineer with experience in Python, " + "FastAPI, and machine learning. He built a RAG-based resume assistant."), + Document(text="Rahul's skills include Python, SQL, Docker, GCP, LlamaIndex, " + "and building REST APIs with FastAPI."), + Document(text="Rahul studied computer science and has worked on data engineering " + "and LLM-powered applications."), +] + + +class CountingMockLLM(MockLLM): + """MockLLM that counts completion calls, to verify caching behavior.""" + + call_count: int = 0 + + def complete(self, *args, **kwargs): + CountingMockLLM.call_count += 1 + return super().complete(*args, **kwargs) + + def stream_complete(self, *args, **kwargs): + CountingMockLLM.call_count += 1 + return super().stream_complete(*args, **kwargs) + + +@pytest.fixture(scope="session") +def embed_model(): + return FastEmbedEmbedding(model_name="BAAI/bge-small-en-v1.5") + + +@pytest.fixture(scope="session") +def vector_index(embed_model): + return VectorStoreIndex.from_documents(SAMPLE_DOCS, embed_model=embed_model) + + +@pytest.fixture() +def client(vector_index, embed_model): + """TestClient with a real (local-embedding) index and a mocked LLM.""" + app_module.index = vector_index + app_module.embed_model = embed_model + app_module.llm = CountingMockLLM(max_tokens=32) + CountingMockLLM.call_count = 0 + # reset per-test state + app_module._rate_store.clear() + app_module._query_cache.clear() + app_module._daily_count = 0 + yield TestClient(app_module.app) + app_module.index = None + app_module.llm = None + + +def test_health_with_index(client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok", "model_loaded": True} + + +def test_health_without_index(client): + app_module.index = None + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["model_loaded"] is False + + +def test_query_happy_path(client): + r = client.post("/query", json={"query": "What are Rahul's skills?"}) + assert r.status_code == 200 + body = r.json() + assert body["query"] == "What are Rahul's skills?" + assert isinstance(body["answer"], str) and len(body["answer"]) > 0 + + +def test_query_empty_string_rejected(client): + r = client.post("/query", json={"query": ""}) + assert r.status_code == 400 + + +def test_query_missing_field_rejected(client): + r = client.post("/query", json={}) + assert r.status_code == 422 + + +def test_query_without_index_returns_500(client): + app_module.index = None + r = client.post("/query", json={"query": "anything"}) + assert r.status_code == 500 + assert "Index not built" in r.json()["detail"] + + +def test_rate_limit_kicks_in_at_11th_request(client): + for i in range(app_module.RATE_LIMIT): + r = client.post("/query", json={"query": f"question number {i}"}) + assert r.status_code == 200, f"request {i} unexpectedly failed: {r.text}" + r = client.post("/query", json={"query": "one too many"}) + assert r.status_code == 429 + assert "Too many requests" in r.json()["detail"] + + +def test_rate_limit_is_per_ip(client): + for i in range(app_module.RATE_LIMIT): + client.post("/query", json={"query": f"q{i}"}) + r = client.post( + "/query", + json={"query": "from another ip"}, + headers={"X-Forwarded-For": "203.0.113.7"}, + ) + assert r.status_code == 200 + + +def test_daily_limit_blocks_requests(client): + app_module._daily_count = app_module.DAILY_QUERY_LIMIT + r = client.post("/query", json={"query": "over the daily limit"}) + assert r.status_code == 429 + assert "Daily query limit" in r.json()["detail"] + + +def test_daily_limit_resets_on_new_day(client): + from datetime import date + app_module._daily_count = app_module.DAILY_QUERY_LIMIT + app_module._daily_reset_date = date(2000, 1, 1) + r = client.post("/query", json={"query": "new day query"}) + assert r.status_code == 200 + assert app_module._daily_count == 1 + + +def test_cache_serves_repeat_query_without_llm_call(client): + q = {"query": "Tell me about Rahul"} + r1 = client.post("/query", json=q) + assert r1.status_code == 200 + assert CountingMockLLM.call_count == 1 + count_after_first = app_module._daily_count + + r2 = client.post("/query", json=q) + assert r2.status_code == 200 + assert r2.json()["answer"] == r1.json()["answer"] + assert CountingMockLLM.call_count == 1 # served from cache + assert app_module._daily_count == count_after_first # no quota consumed + + +def test_cache_is_case_insensitive(client): + client.post("/query", json={"query": "Who is Rahul?"}) + client.post("/query", json={"query": " who is rahul? "}) + assert CountingMockLLM.call_count == 1 + + +def test_stream_endpoint_sse_format(client): + r = client.post("/query/stream", json={"query": "What did Rahul build?"}) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/event-stream") + body = r.text + assert body.count("data: ") >= 2 # at least one token + [DONE] + assert body.rstrip().endswith("data: [DONE]") + + +def test_stream_endpoint_caches_response(client): + client.post("/query/stream", json={"query": "streaming cache test"}) + assert CountingMockLLM.call_count == 1 + r2 = client.post("/query/stream", json={"query": "streaming cache test"}) + assert r2.status_code == 200 + assert CountingMockLLM.call_count == 1 # second call served from cache + assert "data: [DONE]" in r2.text + + +def test_index_persist_and_reload_roundtrip(tmp_path, embed_model, vector_index): + """The persisted index reloads with the same local embedding model (what + app startup does) and still retrieves relevant nodes.""" + from llama_index.core import StorageContext, load_index_from_storage + + persist_dir = str(tmp_path / "index") + vector_index.storage_context.persist(persist_dir=persist_dir) + + storage_context = StorageContext.from_defaults(persist_dir=persist_dir) + reloaded = load_index_from_storage(storage_context, embed_model=embed_model) + nodes = reloaded.as_retriever(similarity_top_k=2).retrieve("What are Rahul's skills?") + assert len(nodes) == 2 + assert any("skills" in n.text.lower() or "python" in n.text.lower() for n in nodes) + + +def test_openrouter_llm_configuration(): + """The OpenRouter LLM wires up with the free-router default and streaming support.""" + from llama_index.llms.openrouter import OpenRouter + + llm = OpenRouter(model="openrouter/free", api_key="dummy-key", max_tokens=1024) + assert llm.model == "openrouter/free" + assert llm.metadata.is_chat_model + + +@pytest.mark.live +@pytest.mark.skipif( + not os.getenv("OPENROUTER_API_KEY"), + reason="OPENROUTER_API_KEY not set; skipping live OpenRouter smoke test", +) +def test_openrouter_live_completion(): + from llama_index.llms.openrouter import OpenRouter + + llm = OpenRouter( + model=os.getenv("OPENROUTER_MODEL", "openrouter/free"), + api_key=os.getenv("OPENROUTER_API_KEY"), + max_tokens=64, + ) + resp = llm.complete("Reply with the single word: pong") + assert len(str(resp).strip()) > 0 + + +@pytest.mark.live +@pytest.mark.skipif( + not os.getenv("OPENROUTER_API_KEY"), + reason="OPENROUTER_API_KEY not set; skipping live OpenRouter streaming test", +) +def test_openrouter_live_streaming(): + from llama_index.llms.openrouter import OpenRouter + + llm = OpenRouter( + model=os.getenv("OPENROUTER_MODEL", "openrouter/free"), + api_key=os.getenv("OPENROUTER_API_KEY"), + max_tokens=64, + ) + chunks = [c.delta for c in llm.stream_complete("Count from 1 to 5.")] + assert len(chunks) >= 1 + assert len("".join(filter(None, chunks)).strip()) > 0 diff --git a/uv.lock b/uv.lock index 110253c..33f67e8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -196,15 +196,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] -[[package]] -name = "cachetools" -version = "6.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, -] - [[package]] name = "certifi" version = "2025.10.5" @@ -330,14 +321,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.0" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -484,6 +475,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/60/7a639ceaba54aec4e1d5676498c568abc654b95762d456095b6cb529b1ca/fastapi-0.120.0-py3-none-any.whl", hash = "sha256:84009182e530c47648da2f07eb380b44b69889a4acfd9e9035ee4605c5cfc469", size = 108243, upload-time = "2025-10-23T20:56:33.281Z" }, ] +[[package]] +name = "fastembed" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "loguru" }, + { name = "mmh3" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "pillow", version = "10.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "py-rust-stemmers" }, + { name = "requests" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/25/58865e36b6e8a9a0d0ff905b5601aa30db97956327c0df42ec4ed6accc21/fastembed-0.8.0.tar.gz", hash = "sha256:75966edfa8b006ee78514c726bd7f6a50721dadc89305279052be9db72fd53e8", size = 75115, upload-time = "2026-03-23T16:34:41.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0", size = 116572, upload-time = "2026-03-23T16:34:40.69Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, +] + [[package]] name = "filetype" version = "1.2.0" @@ -493,6 +515,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, ] +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -591,167 +621,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" }, ] -[[package]] -name = "google-ai-generativelanguage" -version = "0.6.15" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, extra = ["grpc"], marker = "python_full_version >= '3.14'" }, - { name = "google-api-core", version = "2.27.0", source = { registry = "https://pypi.org/simple" }, extra = ["grpc"], marker = "python_full_version < '3.14'" }, - { name = "google-auth" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/11/d1/48fe5d7a43d278e9f6b5ada810b0a3530bbeac7ed7fcbcd366f932f05316/google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3", size = 1375443, upload-time = "2025-01-13T21:50:47.459Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/a3/67b8a6ff5001a1d8864922f2d6488dc2a14367ceb651bc3f09a947f2f306/google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c", size = 1327356, upload-time = "2025-01-13T21:50:44.174Z" }, -] - -[[package]] -name = "google-api-core" -version = "2.25.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", -] -dependencies = [ - { name = "google-auth", marker = "python_full_version >= '3.14'" }, - { name = "googleapis-common-protos", marker = "python_full_version >= '3.14'" }, - { name = "proto-plus", marker = "python_full_version >= '3.14'" }, - { name = "protobuf", marker = "python_full_version >= '3.14'" }, - { name = "requests", marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489, upload-time = "2025-10-03T00:07:32.924Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio", marker = "python_full_version >= '3.14'" }, - { name = "grpcio-status", marker = "python_full_version >= '3.14'" }, -] - -[[package]] -name = "google-api-core" -version = "2.27.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.13.*'", - "python_full_version < '3.13'", -] -dependencies = [ - { name = "google-auth", marker = "python_full_version < '3.14'" }, - { name = "googleapis-common-protos", marker = "python_full_version < '3.14'" }, - { name = "proto-plus", marker = "python_full_version < '3.14'" }, - { name = "protobuf", marker = "python_full_version < '3.14'" }, - { name = "requests", marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/99/6c8b44ecc28026fd9441d7fcc5434ee1b3976c491f2f810b464c4702c975/google_api_core-2.27.0.tar.gz", hash = "sha256:d32e2f5dd0517e91037169e75bf0a9783b255aff1d11730517c0b2b29e9db06a", size = 168851, upload-time = "2025-10-22T23:54:14.195Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/93/ecf9f7caa99c71e969091e9a78789f11b2dea5c684917eab7c54a8d13560/google_api_core-2.27.0-py3-none-any.whl", hash = "sha256:779a380db4e21a4ee3d717cf8efbf324e53900bf37e1ffb273e5348a9916dd42", size = 167110, upload-time = "2025-10-22T23:54:12.805Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio", marker = "python_full_version < '3.14'" }, - { name = "grpcio-status", marker = "python_full_version < '3.14'" }, -] - -[[package]] -name = "google-api-python-client" -version = "2.185.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "google-api-core", version = "2.27.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "google-auth" }, - { name = "google-auth-httplib2" }, - { name = "httplib2" }, - { name = "uritemplate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8e/5a/6f9b49d67ea91376305fdb8bbf2877c746d756e45fd8fb7d2e32d6dad19b/google_api_python_client-2.185.0.tar.gz", hash = "sha256:aa1b338e4bb0f141c2df26743f6b46b11f38705aacd775b61971cbc51da089c3", size = 13885609, upload-time = "2025-10-17T15:00:35.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/28/be3b17bd6a190c8c2ec9e4fb65d43e6ecd7b7a1bb19ccc1d9ab4f687a58c/google_api_python_client-2.185.0-py3-none-any.whl", hash = "sha256:00fe173a4b346d2397fbe0d37ac15368170dfbed91a0395a66ef2558e22b93fc", size = 14453595, upload-time = "2025-10-17T15:00:33.176Z" }, -] - -[[package]] -name = "google-auth" -version = "2.41.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cachetools" }, - { name = "pyasn1-modules" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/af/5129ce5b2f9688d2fa49b463e544972a7c82b0fdb50980dafee92e121d9f/google_auth-2.41.1.tar.gz", hash = "sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2", size = 292284, upload-time = "2025-09-30T22:51:26.363Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl", hash = "sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d", size = 221302, upload-time = "2025-09-30T22:51:24.212Z" }, -] - -[[package]] -name = "google-auth-httplib2" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "httplib2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/be/217a598a818567b28e859ff087f347475c807a5649296fb5a817c58dacef/google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05", size = 10842, upload-time = "2023-12-12T17:40:30.722Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d", size = 9253, upload-time = "2023-12-12T17:40:13.055Z" }, -] - -[[package]] -name = "google-genai" -version = "1.46.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "google-auth" }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/2d/d5907af6a46fb0b660291a09bb62f9cbc1365899f7d64a74e7d8d2e056c2/google_genai-1.46.0.tar.gz", hash = "sha256:6824c31149fe3b1c7285b25f79b924c5f89fd52466f62e30f76954f8104fe3a7", size = 239561, upload-time = "2025-10-21T22:55:04.241Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/79/8993ec6cbf56e5c8f88c165380e55de34ec74f7b928bc302ff5c370f9c4e/google_genai-1.46.0-py3-none-any.whl", hash = "sha256:879c4a260d630db0dcedb5cc84a9d7b47acd29e43e9dc63541b511b757ea7296", size = 239445, upload-time = "2025-10-21T22:55:03.072Z" }, -] - -[[package]] -name = "google-generativeai" -version = "0.8.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-ai-generativelanguage" }, - { name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "google-api-core", version = "2.27.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "google-api-python-client" }, - { name = "google-auth" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/40/c42ff9ded9f09ec9392879a8e6538a00b2dc185e834a3392917626255419/google_generativeai-0.8.5-py3-none-any.whl", hash = "sha256:22b420817fb263f8ed520b33285f45976d5b21e904da32b80d4fd20c055123a2", size = 155427, upload-time = "2025-04-17T00:40:00.67Z" }, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.71.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/43/b25abe02db2911397819003029bef768f68a974f2ece483e6084d1a5f754/googleapis_common_protos-1.71.0.tar.gz", hash = "sha256:1aec01e574e29da63c80ba9f7bbf1ccfaacf1da877f23609fe236ca7c72a2e2e", size = 146454, upload-time = "2025-10-20T14:58:08.732Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/e8/eba9fece11d57a71e3e22ea672742c8f3cf23b35730c9e96db768b295216/googleapis_common_protos-1.71.0-py3-none-any.whl", hash = "sha256:59034a1d849dc4d18971997a72ac56246570afdd17f9369a0ff68218d50ab78c", size = 294576, upload-time = "2025-10-20T14:56:21.295Z" }, -] - [[package]] name = "greenlet" version = "3.2.4" @@ -766,6 +635,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -775,6 +646,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, @@ -782,6 +655,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] @@ -797,61 +672,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, ] -[[package]] -name = "grpcio" -version = "1.76.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, - { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, - { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, -] - -[[package]] -name = "grpcio-status" -version = "1.71.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677, upload-time = "2025-06-28T04:24:05.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/58/317b0134129b556a93a3b0afe00ee675b5657f0155509e22fcb853bafe2d/grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3", size = 14424, upload-time = "2025-06-28T04:23:42.136Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -874,6 +694,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, ] +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + [[package]] name = "hpack" version = "4.1.0" @@ -896,18 +740,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] -[[package]] -name = "httplib2" -version = "0.31.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/77/6653db69c1f7ecfe5e3f9726fdadc981794656fcd7d98c4209fecfea9993/httplib2-0.31.0.tar.gz", hash = "sha256:ac7ab497c50975147d4f7b1ade44becc7df2f8954d42b38b3d69c515f531135c", size = 250759, upload-time = "2025-09-11T12:16:03.403Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl", hash = "sha256:b9cd78abea9b4e43a7714c6e0f8b6b8561a6fc1e95d5dbd367f5bf0ef35f5d24", size = 91148, upload-time = "2025-09-11T12:16:01.803Z" }, -] - [[package]] name = "httpx" version = "0.28.1" @@ -928,6 +760,26 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "huggingface-hub" +version = "1.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/64/bfe23dab749cb2342e1e13b61c9e684ce46b4d189c7d433cd26f76f52baf/huggingface_hub-1.26.1.tar.gz", hash = "sha256:7c28860777594ac679233f571552d0e46df34ed4e4239e844190fad3ca05e4cd", size = 936700, upload-time = "2026-08-06T09:42:24.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/a7/b519cd01e57b685c5f3e9db02cb1bd7aaade35fb6554e0d36bee6d6aae28/huggingface_hub-1.26.1-py3-none-any.whl", hash = "sha256:d8676e4ec96c1e481a22a93232bd86d73bbac643fb767399aefaeaa503890fb0", size = 780754, upload-time = "2026-08-06T09:42:22.497Z" }, +] + [[package]] name = "hyperframe" version = "6.1.0" @@ -946,6 +798,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1119,7 +980,8 @@ dependencies = [ { name = "networkx" }, { name = "nltk" }, { name = "numpy" }, - { name = "pillow" }, + { name = "pillow", version = "10.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "platformdirs" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -1139,16 +1001,15 @@ wheels = [ ] [[package]] -name = "llama-index-embeddings-google-genai" -version = "0.3.1" +name = "llama-index-embeddings-fastembed" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-genai" }, { name = "llama-index-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/85/0f95fd3a9e99cddc4d1dec0f168842959f6e3aa0c2570144b452300c8162/llama_index_embeddings_google_genai-0.3.1.tar.gz", hash = "sha256:2514e276dae2a9d44801861bdd9b9a60f566ad861f5fd803e9cb7fba15c5b6d6", size = 5773, upload-time = "2025-09-08T20:16:28.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4f/c7b8ad88de03b432e7b91c6dbda546f577ee750c25f0f1bb837fecbe68a8/llama_index_embeddings_fastembed-0.6.0.tar.gz", hash = "sha256:819a4349e2c49fcd4deabcc873d51da7bf937cb85d81331185b5226137aab2df", size = 4124, upload-time = "2026-03-12T20:19:22.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/8e/217c16fd0699410af986dddded765a5d3a6051823402aa24db61a737576b/llama_index_embeddings_google_genai-0.3.1-py3-none-any.whl", hash = "sha256:07d16431d33ac694325e2279417573a33f35c34d3d98284573b5d58caf3b3b27", size = 5533, upload-time = "2025-09-08T20:16:27.297Z" }, + { url = "https://files.pythonhosted.org/packages/24/e9/6f21f164bed8772bc038cd30897ddff2c18ce48c4aa207b5184e51aee88c/llama_index_embeddings_fastembed-0.6.0-py3-none-any.whl", hash = "sha256:c3277d748fc6ab730e613b89af4da81cb4f2bbd66d91995890d0c00235922f70", size = 3759, upload-time = "2026-03-12T20:19:23.608Z" }, ] [[package]] @@ -1192,30 +1053,42 @@ wheels = [ ] [[package]] -name = "llama-index-llms-google-genai" -version = "0.6.2" +name = "llama-index-llms-openai" +version = "0.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-genai" }, { name = "llama-index-core" }, - { name = "pillow" }, + { name = "openai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/73/8fbae8c3bd6a88177e06dc3ef12f157051532120636e5fd799fb6f134b16/llama_index_llms_google_genai-0.6.2.tar.gz", hash = "sha256:ecf800eb7d442d34318757232234e12371d8a1e5d262f39ff24b3ac53fe32694", size = 12747, upload-time = "2025-09-26T00:00:37.543Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/f8/ce94b3a0f79223a4dff7cd3924ad72a0cd4eaa80e6ad7bd9c9e00a2fe1e9/llama_index_llms_openai-0.6.5.tar.gz", hash = "sha256:20023ac3a0ad829506ea9c7eaae6bdaddc55a97e304f8245f8dd9592a3cf4b4c", size = 25483, upload-time = "2025-10-15T19:36:48.654Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/16/9c0ed8e576423122bcc5d64f93e80142c99ff2d0a9bb7d2af379e3428009/llama_index_llms_google_genai-0.6.2-py3-none-any.whl", hash = "sha256:48fb62b85ac141c7fb32e1109d76492acf4989d797ddf1044fec92a1b873edfd", size = 13268, upload-time = "2025-09-26T00:00:36.289Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/412e2dc47ccd97100868c247347bb23d47e30ba0c544ce34de8f6831f331/llama_index_llms_openai-0.6.5-py3-none-any.whl", hash = "sha256:a0ef24f36a6140ffaf4c4ba254ce28d585635b842b94de65800e479d49cfbbd6", size = 26481, upload-time = "2025-10-15T19:36:47.698Z" }, ] [[package]] -name = "llama-index-llms-openai" -version = "0.6.5" +name = "llama-index-llms-openai-like" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llama-index-core" }, - { name = "openai" }, + { name = "llama-index-llms-openai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/f8/ce94b3a0f79223a4dff7cd3924ad72a0cd4eaa80e6ad7bd9c9e00a2fe1e9/llama_index_llms_openai-0.6.5.tar.gz", hash = "sha256:20023ac3a0ad829506ea9c7eaae6bdaddc55a97e304f8245f8dd9592a3cf4b4c", size = 25483, upload-time = "2025-10-15T19:36:48.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/db/f94c3a760ebab94ae9240c42ff1b4c55f48c87482dbcb7880ded011ce919/llama_index_llms_openai_like-0.7.0.tar.gz", hash = "sha256:f5bca46ca293f911d188bcc22c517db6f18943b69e1dcdbda4d2376db3488e4d", size = 5180, upload-time = "2026-03-12T20:27:36.445Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/80/412e2dc47ccd97100868c247347bb23d47e30ba0c544ce34de8f6831f331/llama_index_llms_openai-0.6.5-py3-none-any.whl", hash = "sha256:a0ef24f36a6140ffaf4c4ba254ce28d585635b842b94de65800e479d49cfbbd6", size = 26481, upload-time = "2025-10-15T19:36:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/0a/2e/412d6f96eaca890049354b1990068897f575f5784dbf75570513e27f1de3/llama_index_llms_openai_like-0.7.0-py3-none-any.whl", hash = "sha256:4df771e4c2e415dd7f72088d4c14fef96c4a217a116302ad1879a9f90a564aa8", size = 4860, upload-time = "2026-03-12T20:27:35.477Z" }, +] + +[[package]] +name = "llama-index-llms-openrouter" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-core" }, + { name = "llama-index-llms-openai-like" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/3d/0411bdd76067023da05ea0fe04c5f68048bb984e49a0f3be20464437772e/llama_index_llms_openrouter-0.5.1.tar.gz", hash = "sha256:986c3287552b6f3439c4f943b425a4c0577a095be0ade69e51972ccf422c0ad6", size = 5001, upload-time = "2026-06-09T22:44:19.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/c4/995f75f8281ade520747e2d0c62a7c522492b6990df586e1953616127357/llama_index_llms_openrouter-0.5.1-py3-none-any.whl", hash = "sha256:43cb82e96614c940f566ef40baea76eb4c9c663154aa7e5d1a66b06829f45a76", size = 4893, upload-time = "2026-06-09T22:44:18.876Z" }, ] [[package]] @@ -1274,6 +1147,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/50/c5ccd2a50daa0a10c7f3f7d4e6992392454198cd8a7d99fcb96cb60d0686/llama_parse-0.6.54-py3-none-any.whl", hash = "sha256:c66c8d51cf6f29a44eaa8595a595de5d2598afc86e5a33a4cebe5fe228036920", size = 4879, upload-time = "2025-08-01T20:09:22.651Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1349,6 +1235,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" }, ] +[[package]] +name = "mmh3" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, + { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, + { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, + { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, + { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, + { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, + { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, + { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, + { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, + { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, + { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, + { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, + { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, + { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, + { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, + { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, +] + [[package]] name = "multidict" version = "6.7.0" @@ -1553,6 +1521,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" }, ] +[[package]] +name = "onnxruntime" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" }, + { url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" }, + { url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" }, + { url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" }, + { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, +] + [[package]] name = "openai" version = "1.109.1" @@ -1619,6 +1619,9 @@ wheels = [ name = "pillow" version = "10.4.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.13'", +] sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350, upload-time = "2024-07-01T09:46:17.177Z" }, @@ -1645,6 +1648,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375, upload-time = "2024-07-01T09:47:09.065Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.5.0" @@ -1654,6 +1732,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "postgrest" version = "2.22.2" @@ -1753,18 +1840,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] -[[package]] -name = "proto-plus" -version = "1.26.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" }, -] - [[package]] name = "protobuf" version = "5.29.5" @@ -1780,24 +1855,41 @@ wheels = [ ] [[package]] -name = "pyasn1" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +name = "py-rust-stemmers" +version = "0.1.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/c1/9763f9fb1cd73f9c317a83feeed6e0d4af320c6bbddab47b4a94f3a47d0c/py_rust_stemmers-0.1.8.tar.gz", hash = "sha256:6b0f6f48bc54d607aed802de872fcd5a71bae969a6760976dc78ce55e8eaf3da", size = 9732, upload-time = "2026-05-22T11:00:24.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/6a/39080bc8f4a441a35378c0faeeb834fb27974997f40d51342574e70f9662/py_rust_stemmers-0.1.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6a9a4b8733d0b307bd0879ab7e321aa8a0bfd054a75a5cb23c647df5ca7d17c3", size = 290230, upload-time = "2026-05-22T10:59:44.551Z" }, + { url = "https://files.pythonhosted.org/packages/73/15/ae60b9010924adac465f418822d9c514690aba6846edd67b6e2b5c227745/py_rust_stemmers-0.1.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51d0042d2a92ef0f7048bfc06b6c2a02306af31ea47f09d24b34e4b7e63c4e80", size = 275449, upload-time = "2026-05-22T10:59:45.547Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7c/94be8b932179823d66e0d2be03a94706132a7d16a640d5e5710de1cb1b8f/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d3d34094b9b6078a8ea6fe1c7044e5fd32f14e76c94818c5008f49ae075f08", size = 316676, upload-time = "2026-05-22T10:59:46.522Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a4/8bd5c9f31207136830457d819e3f98bb21c54c0cdc40d6f1845ce4efdf7c/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:40c86be90cee4a709ad84fde4db7f11ca44d65630a56b77ec86fe84c23adfc09", size = 319458, upload-time = "2026-05-22T10:59:47.914Z" }, + { url = "https://files.pythonhosted.org/packages/f9/95/95da2b353b164a3a2b8a1c799866a58060693be4f1dc21065663dc67dc17/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:515884bcfb47b10335146648f276930d0c1201ae5e8b7b400fb46d8ea05c0ec2", size = 323541, upload-time = "2026-05-22T10:59:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ce/f34403b68808519dfa3220e1d94a40f26d5025f27e28893e2388ab9cfde5/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa42f5f8feb694aaaa869eedf477fcaf66f67a192cd64d94302d06920c33864a", size = 323873, upload-time = "2026-05-22T10:59:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/57/01/fb8527f6474d576975415405c985a97260e0403829e062103d334230b7d2/py_rust_stemmers-0.1.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e86ad68fe297a6652f0f0390625ea81858b6f27862fd4c5ee1214bf5af29b9d", size = 494761, upload-time = "2026-05-22T10:59:51.021Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ac/73816237dbec20a7299abf901e2f7b6061d238754e033b48e423603f5336/py_rust_stemmers-0.1.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4b90fc81411943b114e8eb4988a876ba3b12bd2d20741559803eddc4131575dc", size = 596141, upload-time = "2026-05-22T10:59:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/dd48debf386a206ee1c6ad75a0827eac89428441291c90d98bc3803fccf1/py_rust_stemmers-0.1.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56cc2c2df742fa6529285b7d204720f34b7da789ed78eb578442f93c6de97d89", size = 541633, upload-time = "2026-05-22T10:59:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/ca/ebb707ab280636b8f46d040ccb051d1a9ddbc1f1ca2d90cdba626872f405/py_rust_stemmers-0.1.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd967eea2f808a1e73aa71ecccef0f4925a4cca4eb02ced94057afe3303153ef", size = 212134, upload-time = "2026-05-22T10:59:54.245Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/f078f3930311e7b6154ccdf9166c4e30a416c7d199e136b5f09265d58a35/py_rust_stemmers-0.1.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5bd15b89203ecd886960e237124d1aa6e55498d76418c36c967d3b12168d43dc", size = 290427, upload-time = "2026-05-22T10:59:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/21d784a3f1db6a23051ffd5826d8ee667d26a64587c1cfbda0443ed87fff/py_rust_stemmers-0.1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c92733b020534470ca5a0d7fe8b85c85622ff383d4f37fec75a1c677aa84921", size = 275628, upload-time = "2026-05-22T10:59:56.687Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/701c73a4f6a7fecfd96a6588f0cafe98d6b0acde93adf8a2e45535f3d1d5/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ab605a86c950ba7e8ab1392cf91296c0bec3084babb897a4aecf90a10c82395", size = 316656, upload-time = "2026-05-22T10:59:57.67Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0d/c58fe98153cfdb6abf4dfb6ac335c923000d4af4e736080c3a3045b7aea7/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21ed8055cec1f78d666afad8ffd7a51775ba419d2c615b8a1df7b32ca7f33e2b", size = 319377, upload-time = "2026-05-22T10:59:58.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d7/e60d04849e90aa3ad457211cc4999c30401f433341f9a5588c12b81f9877/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae773e1d01e9aa328d175f461475d0cd7074a82bfcc71de6dc5765e51f1cc9f7", size = 323719, upload-time = "2026-05-22T10:59:59.845Z" }, + { url = "https://files.pythonhosted.org/packages/6a/48/c0e4fb955db784cc354e0756354602f7043ff4c10fcbd9d901a2f8fe3239/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5cc8fab9d0f1b274a26935a632362b8278f03e81b65e8b8644d5ca3f62a5a1a4", size = 324110, upload-time = "2026-05-22T11:00:01.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/981b26baff37cf7a26ee206763cc4d2fb3e1db8f0f86ec030074431fae05/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35570098da02eb439afcd7270a12bf850bbe874b85cb912e0fb2d87a6e703920", size = 494645, upload-time = "2026-05-22T11:00:02.737Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/f16e805b7aefc2257b192b83a89300c8360b0fdffd3dfefa92dee4ec9b15/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0a68745d4b3c7f5abc778ca967e8711df6154873abcfe4e62a6631fa2363cc32", size = 596124, upload-time = "2026-05-22T11:00:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/e7a2c940ba00e0792ae346aed5e755d51d37cf6d6853f6b141e5380e285d/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7cc0cc0b8eb45d2158c28ea43e2f338c110aad63052ad3bd00bc7446a595e12f", size = 541771, upload-time = "2026-05-22T11:00:06.081Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a0/dd7c5fc6ade6d2a2a49e49937f06f2d488511454e8ab1b313d277ee8c3b1/py_rust_stemmers-0.1.8-cp313-cp313-win_amd64.whl", hash = "sha256:15af4e12e1288de2e5241eec375afc6ad6be4c125a28ca010599d9f92db23f01", size = 212438, upload-time = "2026-05-22T11:00:07.244Z" }, + { url = "https://files.pythonhosted.org/packages/b0/7e/f4346adfd44acbd7eaedcbd7d21b7f40ec9712e6c699e71fddad8dae6f8d/py_rust_stemmers-0.1.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:526b58958c6ffa36c4a805326cfb624ecbd665d16ba435027dbed0bcbcaa09d2", size = 290379, upload-time = "2026-05-22T11:00:08.192Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d8/988fc3f5dc0dbbd4bf5909f50ff953ab55ee8b5f79a835d00e57847d3123/py_rust_stemmers-0.1.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2b607f0b270951fb66479baf4b68716cc63a981585cbd898b0b6b5c359efde7e", size = 275458, upload-time = "2026-05-22T11:00:09.522Z" }, + { url = "https://files.pythonhosted.org/packages/f4/94/e04c8b6a8364bca1b368785cef143755dd2d1ffe74df8f8b47b075bb1043/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0327b151ab8a338fb54fdac114ba34394327fc1e2c4c425ad1caf2013e5de3", size = 314711, upload-time = "2026-05-22T11:00:10.878Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cb/f59f9a80caa099cb6625a46c9a8e6e7e80bb3ed284f17e80245c8240a66e/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dadd0e369703817fc7026987b3093f461f9f58d8dde74e689d546184bc8f3451", size = 319370, upload-time = "2026-05-22T11:00:11.961Z" }, + { url = "https://files.pythonhosted.org/packages/06/59/8211cd0f56e53f7770debd9a78de37985fb5662ae66e3b7b380f4c79888b/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:245e2c61c52e073341893a9682cd1396b61047154548aee30bb1af3d8ed4b4cc", size = 321373, upload-time = "2026-05-22T11:00:13.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/72/fe33e614c114264d1ba54d39da4b5a4abeb6aedd0d26e5a8fd0637d6ddba/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:451ee1c02a3f5cf1e161b46ba9032cdda4ba10a8b03ff9ee61c1d34d42a0bc81", size = 321707, upload-time = "2026-05-22T11:00:14.177Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/3cd18902fe2fa54557d3fe9132552256372d381c7aca71346163055d78b1/py_rust_stemmers-0.1.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d396dd25c473c1bc4248c79cd223f4b36356b55a124652f015c6a001547f81ac", size = 492457, upload-time = "2026-05-22T11:00:15.245Z" }, + { url = "https://files.pythonhosted.org/packages/90/d7/32c6d3995e7036b73683389de2771f4dbbf40de192b7efe73c2528ee1eb5/py_rust_stemmers-0.1.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:479c77c32d8be692f3cfcde7e19273f02ac81d6f45c6aef49887ef95cab7abbb", size = 596085, upload-time = "2026-05-22T11:00:16.404Z" }, + { url = "https://files.pythonhosted.org/packages/00/8c/e68fa5d862ea6a27fced3535c25ea4eaa26ba1ce00dfef5841924c74b167/py_rust_stemmers-0.1.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c786235275c5c2abb7f206b8236aee3ca0bc53c7497daf7fb7b01d3491469547", size = 539747, upload-time = "2026-05-22T11:00:17.414Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/aa584cf3772e01231641c95dc1aa73327a7d986c562639d78d0013733acf/py_rust_stemmers-0.1.8-cp314-cp314-win_amd64.whl", hash = "sha256:931d13570962b093417e5443a9d1bd63d73fa239ebb81e5b1d346663571403e4", size = 209636, upload-time = "2026-05-22T11:00:18.662Z" }, ] [[package]] @@ -1891,6 +1983,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pyjwt" version = "2.10.1" @@ -1906,21 +2007,28 @@ crypto = [ ] [[package]] -name = "pyparsing" -version = "3.2.5" +name = "pypdf" +version = "6.1.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/3d/b6ead84ee437444f96862beb68f9796da8c199793bed08e9397b77579f23/pypdf-6.1.3.tar.gz", hash = "sha256:8d420d1e79dc1743f31a57707cabb6dcd5b17e8b9a302af64b30202c5700ab9d", size = 5076271, upload-time = "2025-10-22T16:13:46.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ed/494fd0cc1190a7c335e6958eeaee6f373a281869830255c2ed4785dac135/pypdf-6.1.3-py3-none-any.whl", hash = "sha256:eb049195e46f014fc155f566fa20e09d70d4646a9891164ac25fa0cbcfcdbcb5", size = 323863, upload-time = "2025-10-22T16:13:44.174Z" }, ] [[package]] -name = "pypdf" -version = "6.1.3" +name = "pytest" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/3d/b6ead84ee437444f96862beb68f9796da8c199793bed08e9397b77579f23/pypdf-6.1.3.tar.gz", hash = "sha256:8d420d1e79dc1743f31a57707cabb6dcd5b17e8b9a302af64b30202c5700ab9d", size = 5076271, upload-time = "2025-10-22T16:13:46.061Z" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/ed/494fd0cc1190a7c335e6958eeaee6f373a281869830255c2ed4785dac135/pypdf-6.1.3-py3-none-any.whl", hash = "sha256:eb049195e46f014fc155f566fa20e09d70d4646a9891164ac25fa0cbcfcdbcb5", size = 323863, upload-time = "2025-10-22T16:13:44.174Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -2005,27 +2113,39 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastapi" }, - { name = "google-generativeai" }, + { name = "fastembed" }, { name = "llama-index" }, - { name = "llama-index-embeddings-google-genai" }, - { name = "llama-index-llms-google-genai" }, + { name = "llama-index-embeddings-fastembed" }, + { name = "llama-index-llms-openrouter" }, { name = "python-dotenv" }, { name = "supabase" }, { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.120.0" }, - { name = "google-generativeai", specifier = ">=0.3.0" }, + { name = "fastembed", specifier = ">=0.4.0" }, { name = "llama-index", specifier = ">=0.14.5" }, - { name = "llama-index-embeddings-google-genai", specifier = ">=0.3.1" }, - { name = "llama-index-llms-google-genai", specifier = ">=0.6.2" }, + { name = "llama-index-embeddings-fastembed", specifier = ">=0.4.0" }, + { name = "llama-index-llms-openrouter", specifier = ">=0.4.0" }, { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "supabase" }, { name = "uvicorn", specifier = ">=0.38.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.0.0" }, +] + [[package]] name = "realtime" version = "2.22.2" @@ -2133,18 +2253,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "setuptools" version = "80.9.0" @@ -2362,6 +2470,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -2417,15 +2552,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] -[[package]] -name = "uritemplate" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, -] - [[package]] name = "urllib3" version = "2.5.0" @@ -2479,6 +2605,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "wrapt" version = "1.17.3" From 8b315ea2791008e7983efa22de1500fdd3e7b803 Mon Sep 17 00:00:00 2001 From: rahulk98 Date: Thu, 6 Aug 2026 16:41:20 +0000 Subject: [PATCH 2/5] Auto-rebuild index at startup when embedding model changed Backwards compatibility for Docker rebuilds that still ship the old Gemini-built index (3072-dim): at startup the app compares the persisted index's embedding dimension with the current local model's (384) and rebuilds the index from docs/ on mismatch (or if loading fails), instead of serving broken queries. Docker image now includes docs/ to enable this. build_index.py no longer requires OPENROUTER_API_KEY for indexing since embeddings are local; the key is only needed for its test query. --- DOCKERFILE | 4 ++- README.md | 2 +- app.py | 49 +++++++++++++++++++++++++++++-- build_index.py | 7 +++-- tests/test_app.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/DOCKERFILE b/DOCKERFILE index 827f5ad..cc98cd8 100644 --- a/DOCKERFILE +++ b/DOCKERFILE @@ -9,8 +9,10 @@ RUN pip install --no-cache-dir -r requirements.txt # Pre-download the local embedding model so the container doesn't fetch it at startup RUN python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')" -# Copy the application code +# Copy the application code. docs/ lets the app rebuild the index at startup +# if the shipped index/ was built with a different embedding model. COPY index/ ./index/ +COPY docs/ ./docs/ COPY app.py . EXPOSE 8000 diff --git a/README.md b/README.md index bd77489..0c2669f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Retrieval-Augmented Generation (RAG) API that indexes documents in `docs/` and a - Optional: `EMBED_MODEL_NAME` to change the local embedding model (default `BAAI/bge-small-en-v1.5`). Changing it requires rebuilding the index. - Optional Sheets logging: set `SHEETS_JSON_KEY` (service-account JSON) -> **Migrating from the Gemini version?** The embedding model changed (3072-dim Gemini → 384-dim local BGE), so you must rebuild the index once with `python build_index.py` before deploying. +> **Migrating from the Gemini version?** The embedding model changed (3072-dim Gemini → 384-dim local BGE), so the old persisted index is incompatible. This is handled automatically: at startup the app detects the dimension mismatch and rebuilds the index from `docs/` (the Docker image now includes `docs/` for this). You can also rebuild manually with `python build_index.py` — no API key needed for indexing, since embeddings run locally. ### Quickstart ```bash diff --git a/app.py b/app.py index 693ca42..7cc18af 100644 --- a/app.py +++ b/app.py @@ -52,6 +52,7 @@ async def lifespan(app: FastAPI): # configuration INDEX_PERSIST_DIR = "./index" +DOCS_DIR = "./docs" # Local ONNX embedding model (no API cost). Changing this requires rebuilding the index. EMBED_MODEL_NAME = os.getenv("EMBED_MODEL_NAME", "BAAI/bge-small-en-v1.5") # Free OpenRouter model; override with OPENROUTER_MODEL if this one is deprecated. @@ -140,9 +141,42 @@ class QueryResponse(BaseModel): answer: str +def _stored_index_embedding_dim(persist_dir: str) -> int | None: + """Return the embedding dimension of the persisted index, or None if unknown.""" + try: + with open(os.path.join(persist_dir, "default__vector_store.json")) as f: + data = json.load(f) + embedding_dict = data.get("embedding_dict", {}) + if embedding_dict: + return len(next(iter(embedding_dict.values()))) + except Exception: + pass + return None + + +def _rebuild_index_from_docs(): + """Rebuild the index from DOCS_DIR with the current embedding model, or None.""" + from llama_index.core import SimpleDirectoryReader, VectorStoreIndex + + if not (os.path.isdir(DOCS_DIR) and os.listdir(DOCS_DIR)): + print(f"Cannot rebuild index: no documents in {DOCS_DIR}.") + return None + try: + documents = SimpleDirectoryReader(DOCS_DIR).load_data() + new_index = VectorStoreIndex.from_documents(documents, embed_model=embed_model) + new_index.storage_context.persist(persist_dir=INDEX_PERSIST_DIR) + print("Index rebuilt from docs and persisted.") + return new_index + except Exception as e: + print(f"Failed to rebuild index from docs: {e}") + return None + + def init_models_and_index(): """ Initialize embedding model, llm, and load index from disk (if exists). + An index built with a different embedding model (e.g. the old Gemini + 3072-dim index) is detected by dimension mismatch and rebuilt from docs. Called once at startup. """ global embed_model, llm, index @@ -162,6 +196,15 @@ def init_models_and_index(): llm = None if os.path.isdir(INDEX_PERSIST_DIR) and os.listdir(INDEX_PERSIST_DIR): + stored_dim = _stored_index_embedding_dim(INDEX_PERSIST_DIR) + model_dim = len(embed_model.get_text_embedding("dimension probe")) + if stored_dim is not None and stored_dim != model_dim: + print( + f"Persisted index embedding dim ({stored_dim}) does not match " + f"current model dim ({model_dim}); rebuilding from docs." + ) + index = _rebuild_index_from_docs() + return try: storage_context = StorageContext.from_defaults( persist_dir=INDEX_PERSIST_DIR @@ -170,10 +213,10 @@ def init_models_and_index(): print("Index loaded from disk.") except Exception as e: print(f"Failed to load index from disk: {e}") - index = None + index = _rebuild_index_from_docs() else: - print("No index on disk yet. Call /reindex to build it.") - index = None + print("No index on disk yet; trying to build from docs.") + index = _rebuild_index_from_docs() def init_sheets_client(): diff --git a/build_index.py b/build_index.py index f82af3c..4170ac0 100644 --- a/build_index.py +++ b/build_index.py @@ -6,9 +6,8 @@ import os load_dotenv() +# Only needed for the test query at the end — embeddings are local and free. OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") -if not OPENROUTER_API_KEY: - raise RuntimeError("OPENROUTER_API_KEY not found in .env") EMBED_MODEL_NAME = os.getenv("EMBED_MODEL_NAME", "BAAI/bge-small-en-v1.5") LLM_MODEL_NAME = os.getenv("OPENROUTER_MODEL", "openrouter/free") @@ -26,6 +25,10 @@ def build_index(): # Store the index for reuse index.storage_context.persist(persist_dir="./index") + if not OPENROUTER_API_KEY: + print("Index built. Set OPENROUTER_API_KEY to also run a test query.") + return + # Initliase the LLM (free OpenRouter model) llm = OpenRouter( model=LLM_MODEL_NAME, diff --git a/tests/test_app.py b/tests/test_app.py index 93abc12..0f3cbe4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -193,6 +193,79 @@ def test_index_persist_and_reload_roundtrip(tmp_path, embed_model, vector_index) assert any("skills" in n.text.lower() or "python" in n.text.lower() for n in nodes) +@pytest.fixture() +def old_gemini_style_index_dir(tmp_path): + """Persist an index built with 3072-dim embeddings, like the old Gemini index.""" + from llama_index.core.embeddings import MockEmbedding + + persist_dir = str(tmp_path / "old_index") + old_index = VectorStoreIndex.from_documents( + SAMPLE_DOCS, embed_model=MockEmbedding(embed_dim=3072) + ) + old_index.storage_context.persist(persist_dir=persist_dir) + return persist_dir + + +def _run_startup(monkeypatch, persist_dir, docs_dir): + monkeypatch.setattr(app_module, "OPENROUTER_API_KEY", "dummy-key") + monkeypatch.setattr(app_module, "INDEX_PERSIST_DIR", persist_dir) + monkeypatch.setattr(app_module, "DOCS_DIR", docs_dir) + app_module.index = None + app_module.init_models_and_index() + + +def test_startup_detects_stored_dim(old_gemini_style_index_dir): + assert app_module._stored_index_embedding_dim(old_gemini_style_index_dir) == 3072 + + +def test_startup_rebuilds_old_gemini_index_from_docs( + monkeypatch, tmp_path, old_gemini_style_index_dir +): + """Backwards compatibility: a Docker rebuild that ships the old Gemini-built + index must not break — the app rebuilds it from docs/ at startup.""" + docs_dir = tmp_path / "docs" + docs_dir.mkdir() + (docs_dir / "resume.txt").write_text( + "Rahul Krishnan is a software engineer skilled in Python and FastAPI." + ) + + _run_startup(monkeypatch, old_gemini_style_index_dir, str(docs_dir)) + + assert app_module.index is not None + # rebuilt index was re-persisted with the new model's dimension + assert app_module._stored_index_embedding_dim(old_gemini_style_index_dir) == 384 + nodes = app_module.index.as_retriever(similarity_top_k=1).retrieve("Rahul's skills?") + assert len(nodes) == 1 + app_module.index = None + app_module.llm = None + + +def test_startup_old_index_without_docs_degrades_gracefully( + monkeypatch, tmp_path, old_gemini_style_index_dir +): + """No docs to rebuild from: the app must still boot with index unset.""" + _run_startup(monkeypatch, old_gemini_style_index_dir, str(tmp_path / "missing_docs")) + + assert app_module.index is None + client = TestClient(app_module.app) + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["model_loaded"] is False + app_module.llm = None + + +def test_startup_loads_matching_index_without_rebuild(monkeypatch, tmp_path, vector_index): + """A current-format (384-dim) index loads directly, no rebuild attempted.""" + persist_dir = str(tmp_path / "current_index") + vector_index.storage_context.persist(persist_dir=persist_dir) + + _run_startup(monkeypatch, persist_dir, str(tmp_path / "missing_docs")) + + assert app_module.index is not None + app_module.index = None + app_module.llm = None + + def test_openrouter_llm_configuration(): """The OpenRouter LLM wires up with the free-router default and streaming support.""" from llama_index.llms.openrouter import OpenRouter From 801e847a67cbd7b4b8c1c5dafb486bf71ddcc177 Mon Sep 17 00:00:00 2001 From: rahulk98 Date: Thu, 6 Aug 2026 16:41:20 +0000 Subject: [PATCH 3/5] Fix /query/stream: stream LLM tokens directly instead of query engine Live testing showed /query/stream delivered the whole answer as a single SSE event: current llama-index's Refine synthesizer buffers the entire LLM stream and yields it once, so as_query_engine(streaming=True) no longer streams token-by-token. The endpoint now retrieves context via the index retriever, builds the same QA prompt, and streams deltas from llm.stream_complete directly (verified live: ~300 SSE events, first token ~2.7s). Mid-stream errors now end the stream with an error event instead of aborting silently, and partial responses are not cached. Tightened the SSE test to require genuine multi-event streaming. --- app.py | 42 ++++++++++++++++++++++++++++++------------ tests/test_app.py | 10 +++++++++- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/app.py b/app.py index 7cc18af..8bb7982 100644 --- a/app.py +++ b/app.py @@ -268,18 +268,32 @@ def health(): return HealthResponse(status="ok", model_loaded=index is not None) -def _build_query_engine(top_k: int = 5, streaming: bool = False): +QA_TEMPLATE = ( + SYSTEM_PROMPT + "\n\nContext:\n{context_str}\n\nQuestion: {query_str}\nAnswer:" +) + + +def _build_query_engine(top_k: int = 5): return index.as_query_engine( llm=llm, similarity_top_k=top_k, response_mode="compact", - streaming=streaming, - text_qa_template=PromptTemplate( - SYSTEM_PROMPT + "\n\nContext:\n{context_str}\n\nQuestion: {query_str}\nAnswer:" - ), + text_qa_template=PromptTemplate(QA_TEMPLATE), ) +def _stream_llm_tokens(query_text: str, top_k: int): + """Retrieve context and stream raw LLM deltas. The query engine's streaming + mode buffers the full answer in current llama-index, so we stream from the + LLM directly using the same prompt the query engine would build.""" + nodes = index.as_retriever(similarity_top_k=top_k).retrieve(query_text) + context_str = "\n\n".join(n.get_content() for n in nodes) + prompt = QA_TEMPLATE.format(context_str=context_str, query_str=query_text) + for chunk in llm.stream_complete(prompt): + if chunk.delta: + yield chunk.delta + + def _validate_query(request: QueryRequest, req: Request) -> tuple[str, str, int]: """Common validation for query endpoints. Returns (query_text, client_ip, top_k).""" client_ip = req.headers.get("X-Forwarded-For", req.client.host or "").split(",")[0].strip() @@ -325,18 +339,22 @@ def cached_gen(): return StreamingResponse(cached_gen(), media_type="text/event-stream") try: - query_engine = _build_query_engine(top_k, streaming=True) - streaming_response = query_engine.query(query_text) + token_stream = _stream_llm_tokens(query_text, top_k) _daily_count += 1 def token_generator(): full_response = "" - for token in streaming_response.response_gen: - full_response += token - yield f"data: {json.dumps({'token': token})}\n\n" + try: + for token in token_stream: + full_response += token + yield f"data: {json.dumps({'token': token})}\n\n" + except Exception as e: + print(f"Stream interrupted: {e}") + yield f"data: {json.dumps({'error': 'stream interrupted'})}\n\n" yield "data: [DONE]\n\n" - set_cache(query_text, full_response) - background_tasks.add_task(log_to_sheets, query_text, full_response, client_ip) + if full_response: + set_cache(query_text, full_response) + background_tasks.add_task(log_to_sheets, query_text, full_response, client_ip) return StreamingResponse(token_generator(), media_type="text/event-stream") except Exception as e: diff --git a/tests/test_app.py b/tests/test_app.py index 0f3cbe4..45df32f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,6 +6,7 @@ Run the live OpenRouter smoke tests with: pytest -m live (needs OPENROUTER_API_KEY) """ +import json import os import pytest @@ -165,8 +166,15 @@ def test_stream_endpoint_sse_format(client): assert r.status_code == 200 assert r.headers["content-type"].startswith("text/event-stream") body = r.text - assert body.count("data: ") >= 2 # at least one token + [DONE] + # real token-by-token streaming: several token events, not one buffered blob + assert body.count("data: ") > 3 assert body.rstrip().endswith("data: [DONE]") + tokens = [ + json.loads(line[6:])["token"] + for line in body.splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ] + assert len("".join(tokens)) > 0 def test_stream_endpoint_caches_response(client): From d80a66bbb07cd560b3844c4c43414ad5edf7fe95 Mon Sep 17 00:00:00 2001 From: rahulk98 Date: Thu, 6 Aug 2026 17:36:43 +0000 Subject: [PATCH 4/5] Add hybrid retrieval, multi-turn chat, eval harness, and CI - Hybrid retrieval (retrieval.py): BM25 + vector search fused with reciprocal rank fusion, reranked by a local ONNX cross-encoder (Xenova/ms-marco-MiniLM-L-6-v2). All local, zero API cost. - Multi-turn chat: optional session_id on /query and /query/stream; follow-up questions are condensed into standalone queries using the session history (in-memory store with TTL, size caps). Cache now keys on the condensed question. - Degenerate-answer guard: free-router responses that come back as junk (observed: a bare moderation verdict) are retried on a re-route. - Eval harness (evals/): golden Q&A set over committed sample docs; offline retrieval-quality gate (100% hit rate, threshold 90%) plus --live mode scoring keyword coverage, abstention, and LLM-judged faithfulness via OpenRouter. - CI (GitHub Actions): pytest suite + offline eval gate + Docker build on every push/PR. - Endpoints now build prompts via the shared pipeline instead of as_query_engine; tests extended to 28 (sessions, hybrid retrieval, reranker ordering, eval gate). --- .github/workflows/ci.yml | 32 ++++++ DOCKERFILE | 7 +- README.md | 28 ++++-- app.py | 156 ++++++++++++++++++++++-------- evals/__init__.py | 0 evals/golden_qa.jsonl | 10 ++ evals/run_eval.py | 177 ++++++++++++++++++++++++++++++++++ evals/sample_docs/projects.md | 14 +++ evals/sample_docs/resume.md | 22 +++++ pyproject.toml | 1 + requirements.txt | 1 + retrieval.py | 63 ++++++++++++ tests/test_app.py | 69 ++++++++++++- tests/test_eval.py | 12 +++ uv.lock | 52 ++++++++++ 15 files changed, 593 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 evals/__init__.py create mode 100644 evals/golden_qa.jsonl create mode 100644 evals/run_eval.py create mode 100644 evals/sample_docs/projects.md create mode 100644 evals/sample_docs/resume.md create mode 100644 retrieval.py create mode 100644 tests/test_eval.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a8e5cd5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install dependencies + run: pip install -r requirements.txt pytest httpx + - name: Unit and API tests (LLM mocked, embeddings real) + run: pytest -q + - name: Retrieval quality gate (offline eval) + run: python evals/run_eval.py + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # index/ and docs/ are gitignored (private); build with empty stand-ins — + # the app rebuilds/loads them at deploy time + - name: Create placeholder dirs + run: mkdir -p index docs + - name: Docker build + run: docker build -f DOCKERFILE -t rag-resume-api . diff --git a/DOCKERFILE b/DOCKERFILE index cc98cd8..3ebab79 100644 --- a/DOCKERFILE +++ b/DOCKERFILE @@ -6,14 +6,15 @@ WORKDIR /app COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -# Pre-download the local embedding model so the container doesn't fetch it at startup -RUN python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')" +# Pre-download the local embedding + reranker models so the container doesn't fetch them at startup +RUN python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')" \ + && python -c "from fastembed.rerank.cross_encoder import TextCrossEncoder; TextCrossEncoder('Xenova/ms-marco-MiniLM-L-6-v2')" # Copy the application code. docs/ lets the app rebuild the index at startup # if the shipped index/ was built with a different embedding model. COPY index/ ./index/ COPY docs/ ./docs/ -COPY app.py . +COPY app.py retrieval.py ./ EXPOSE 8000 diff --git a/README.md b/README.md index 0c2669f..90227c7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,20 @@ ## RAG Resume API +![CI](https://github.com/rahulk98/Resume-RAG-System/actions/workflows/ci.yml/badge.svg) + ### Live Hosted and running at [rahul-krishnan.is-a.dev](https://rahul-krishnan.is-a.dev). ### Overview -Retrieval-Augmented Generation (RAG) API that indexes documents in `docs/` and answers questions using LlamaIndex. The LLM runs on OpenRouter's free-model router (`openrouter/free`) and embeddings run locally with fastembed (`BAAI/bge-small-en-v1.5`, ONNX) — so there are no per-query API charges. FastAPI provides `/health`, `/query`, and `/query/stream` endpoints. Containerized with Docker and deployed as an API on GCP Free tier. +Retrieval-Augmented Generation (RAG) API that indexes documents in `docs/` and answers questions using LlamaIndex. The LLM runs on OpenRouter's free-model router (`openrouter/free`) and everything else runs locally — so there are no per-query API charges. FastAPI provides `/health`, `/query`, and `/query/stream` endpoints. Containerized with Docker and deployed as an API on GCP Free tier. + +**Pipeline highlights** +- **Hybrid retrieval**: BM25 keyword search fused with vector search (reciprocal rank fusion), then reranked by a local ONNX cross-encoder (`Xenova/ms-marco-MiniLM-L-6-v2`) — better recall than embeddings alone, still zero API cost. +- **Local embeddings**: `BAAI/bge-small-en-v1.5` via fastembed (ONNX, in-process). +- **Multi-turn chat**: pass a `session_id` and follow-up questions are condensed into standalone queries using conversation history ("what did he do there?" → "what did Rahul do at SAP?"). +- **Degenerate-answer guard**: the free router occasionally returns a junk response; answers are validated and retried on a re-routed model. +- **Evaluated**: a golden Q&A set gates retrieval quality in CI (offline) and scores answer faithfulness with an LLM judge (live) — see `evals/`. +- **Production hardening**: per-IP rate limiting, daily quota, response caching, SSE streaming, query logging. ### Prerequisites - **Python**: 3.12+ @@ -30,14 +40,15 @@ python -m uvicorn app:app --host 0.0.0.0 --port 8000 ### API - **GET `/health`** → `{ "status": "ok", "model_loaded": boolean }` - **POST `/query`** - - Request body: + - Request body (`top_k` and `session_id` optional; send a stable `session_id` to enable follow-up questions): ```json -{ "query": "Tell me about Rahul in 2 sentences.", "top_k": 5 } +{ "query": "Tell me about Rahul in 2 sentences.", "top_k": 5, "session_id": "abc123" } ``` - Response body: ```json { "query": "...", "answer": "..." } ``` +- **POST `/query/stream`** — same request body; responds with `text/event-stream` of `data: {"token": "..."}` events terminated by `data: [DONE]`. ### Docker ```bash @@ -48,12 +59,17 @@ docker build -t rag-resume-api . docker run -e OPENROUTER_API_KEY=YOUR_KEY -p 8000:8000 rag-resume-api ``` -### Tests +### Tests & evals ```bash pip install pytest httpx -pytest tests/ -v +pytest tests/ -v # unit/API tests — LLM mocked, no key needed +python evals/run_eval.py # offline retrieval-quality eval (also runs in CI) +``` +With `OPENROUTER_API_KEY` set: +```bash +pytest tests/ -v -m live # live OpenRouter smoke tests +python evals/run_eval.py --live # full answer eval: keyword coverage, abstention, LLM-judged faithfulness ``` -The suite mocks the LLM, so no API key or network is needed. To also run the live OpenRouter smoke test, set `OPENROUTER_API_KEY` and run `pytest tests/ -v -m live`. ### Screenshots ![Screenshot 1](screenshots/ss1.jpg) diff --git a/app.py b/app.py index 8bb7982..0dd6c68 100644 --- a/app.py +++ b/app.py @@ -9,7 +9,9 @@ from dotenv import load_dotenv from llama_index.embeddings.fastembed import FastEmbedEmbedding from llama_index.llms.openrouter import OpenRouter -from llama_index.core import StorageContext, load_index_from_storage, PromptTemplate +from llama_index.core import StorageContext, load_index_from_storage + +import retrieval from contextlib import asynccontextmanager import uvicorn from fastapi.middleware.cors import CORSMiddleware @@ -71,6 +73,8 @@ async def lifespan(app: FastAPI): embed_model = None llm = None sheets_worksheet = None +retriever = None +reranker = None # Rate limiting: max 10 requests per IP per minute RATE_LIMIT = 10 @@ -87,6 +91,12 @@ async def lifespan(app: FastAPI): CACHE_TTL = 3600 # 1 hour CACHE_MAX_SIZE = 100 +# Multi-turn chat sessions: {session_id: {"turns": [(role, text)], "ts": last_used}} +_sessions: dict[str, dict] = {} +SESSION_TTL = 1800 # 30 minutes +MAX_SESSIONS = 500 +MAX_HISTORY_TURNS = 10 # question+answer pairs kept per session + def check_rate_limit(ip: str) -> None: @@ -134,6 +144,7 @@ class HealthResponse(BaseModel): class QueryRequest(BaseModel): query: str top_k: Optional[int] = None + session_id: Optional[str] = None # enables multi-turn follow-up questions class QueryResponse(BaseModel): @@ -183,7 +194,9 @@ def init_models_and_index(): if not OPENROUTER_API_KEY: print("Skipping model initialization — no API key.") return + global retriever, reranker embed_model = FastEmbedEmbedding(model_name=EMBED_MODEL_NAME) + reranker = retrieval.load_reranker() try: llm = OpenRouter( @@ -204,20 +217,23 @@ def init_models_and_index(): f"current model dim ({model_dim}); rebuilding from docs." ) index = _rebuild_index_from_docs() - return - try: - storage_context = StorageContext.from_defaults( - persist_dir=INDEX_PERSIST_DIR - ) - index = load_index_from_storage(storage_context, embed_model=embed_model) - print("Index loaded from disk.") - except Exception as e: - print(f"Failed to load index from disk: {e}") - index = _rebuild_index_from_docs() + else: + try: + storage_context = StorageContext.from_defaults( + persist_dir=INDEX_PERSIST_DIR + ) + index = load_index_from_storage(storage_context, embed_model=embed_model) + print("Index loaded from disk.") + except Exception as e: + print(f"Failed to load index from disk: {e}") + index = _rebuild_index_from_docs() else: print("No index on disk yet; trying to build from docs.") index = _rebuild_index_from_docs() + if index is not None: + retriever = retrieval.build_hybrid_retriever(index) + def init_sheets_client(): """Initialize Google Sheets client from SHEETS_JSON_KEY env var.""" @@ -272,23 +288,72 @@ def health(): SYSTEM_PROMPT + "\n\nContext:\n{context_str}\n\nQuestion: {query_str}\nAnswer:" ) +CONDENSE_TEMPLATE = ( + "Given the conversation below and a follow-up question, rephrase the " + "follow-up as a single standalone question about Rahul Krishnan. Return " + "only the rewritten question, nothing else.\n\n" + "Conversation:\n{history}\n\nFollow-up question: {question}\n\n" + "Standalone question:" +) + -def _build_query_engine(top_k: int = 5): - return index.as_query_engine( - llm=llm, - similarity_top_k=top_k, - response_mode="compact", - text_qa_template=PromptTemplate(QA_TEMPLATE), - ) +def _get_session_history(session_id: str | None) -> list[tuple[str, str]]: + if not session_id: + return [] + now = time.time() + for sid in [s for s, data in _sessions.items() if now - data["ts"] > SESSION_TTL]: + del _sessions[sid] + session = _sessions.get(session_id) + return session["turns"] if session else [] -def _stream_llm_tokens(query_text: str, top_k: int): - """Retrieve context and stream raw LLM deltas. The query engine's streaming - mode buffers the full answer in current llama-index, so we stream from the - LLM directly using the same prompt the query engine would build.""" - nodes = index.as_retriever(similarity_top_k=top_k).retrieve(query_text) +def _append_session_turn(session_id: str | None, question: str, answer: str) -> None: + if not session_id: + return + if session_id not in _sessions and len(_sessions) >= MAX_SESSIONS: + oldest = min(_sessions, key=lambda s: _sessions[s]["ts"]) + del _sessions[oldest] + session = _sessions.setdefault(session_id, {"turns": [], "ts": 0}) + session["turns"].append((question, answer)) + session["turns"] = session["turns"][-MAX_HISTORY_TURNS:] + session["ts"] = time.time() + + +def _condense_question(history: list[tuple[str, str]], question: str) -> str: + """Rewrite a follow-up question as standalone using the chat history, so + retrieval and caching work on the full meaning ('what did he do there?' + -> 'what did Rahul do at SAP?').""" + history_str = "\n".join(f"User: {q}\nAssistant: {a}" for q, a in history) + try: + condensed = str( + llm.complete(CONDENSE_TEMPLATE.format(history=history_str, question=question)) + ).strip() + return condensed or question + except Exception as e: + print(f"Condense step failed ({e}); using original question.") + return question + + +def _build_prompt(query_text: str, top_k: int) -> str: + """Hybrid retrieve (BM25 + vector fusion), rerank locally, build QA prompt.""" + nodes = retrieval.retrieve(retriever, reranker, query_text, top_k) context_str = "\n\n".join(n.get_content() for n in nodes) - prompt = QA_TEMPLATE.format(context_str=context_str, query_str=query_text) + return QA_TEMPLATE.format(context_str=context_str, query_str=query_text) + + +def _complete_with_retry(prompt: str, attempts: int = 2, min_chars: int = 20) -> str: + """The free router occasionally lands on a model that returns a degenerate + non-answer (e.g. a bare moderation verdict). Retry once — a re-route + almost always picks a different model.""" + answer = "" + for _ in range(attempts): + answer = str(llm.complete(prompt)).strip() + if len(answer) >= min_chars: + return answer + return answer + + +def _stream_llm_tokens(prompt: str): for chunk in llm.stream_complete(prompt): if chunk.delta: yield chunk.delta @@ -311,17 +376,21 @@ def _validate_query(request: QueryRequest, req: Request) -> tuple[str, str, int] def query(request: QueryRequest, background_tasks: BackgroundTasks, req: Request): global _daily_count query_text, client_ip, top_k = _validate_query(request, req) - cached = get_cached_response(query_text) - if cached is not None: - return QueryResponse(query=query_text, answer=cached) + history = _get_session_history(request.session_id) try: - query_engine = _build_query_engine(top_k) - response = query_engine.query(query_text) + standalone = _condense_question(history, query_text) if history else query_text + cached = get_cached_response(standalone) + if cached is not None: + _append_session_turn(request.session_id, query_text, cached) + return QueryResponse(query=query_text, answer=cached) + answer = _complete_with_retry(_build_prompt(standalone, top_k)) _daily_count += 1 - answer = str(response) - set_cache(query_text, answer) + set_cache(standalone, answer) + _append_session_turn(request.session_id, query_text, answer) background_tasks.add_task(log_to_sheets, query_text, answer, client_ip) return QueryResponse(query=query_text, answer=answer) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -330,16 +399,20 @@ def query(request: QueryRequest, background_tasks: BackgroundTasks, req: Request def query_stream(request: QueryRequest, background_tasks: BackgroundTasks, req: Request): global _daily_count query_text, client_ip, top_k = _validate_query(request, req) - - cached = get_cached_response(query_text) - if cached is not None: - def cached_gen(): - yield f"data: {json.dumps({'token': cached})}\n\n" - yield "data: [DONE]\n\n" - return StreamingResponse(cached_gen(), media_type="text/event-stream") + history = _get_session_history(request.session_id) try: - token_stream = _stream_llm_tokens(query_text, top_k) + standalone = _condense_question(history, query_text) if history else query_text + cached = get_cached_response(standalone) + if cached is not None: + _append_session_turn(request.session_id, query_text, cached) + + def cached_gen(): + yield f"data: {json.dumps({'token': cached})}\n\n" + yield "data: [DONE]\n\n" + return StreamingResponse(cached_gen(), media_type="text/event-stream") + + token_stream = _stream_llm_tokens(_build_prompt(standalone, top_k)) _daily_count += 1 def token_generator(): @@ -353,10 +426,13 @@ def token_generator(): yield f"data: {json.dumps({'error': 'stream interrupted'})}\n\n" yield "data: [DONE]\n\n" if full_response: - set_cache(query_text, full_response) + set_cache(standalone, full_response) + _append_session_turn(request.session_id, query_text, full_response) background_tasks.add_task(log_to_sheets, query_text, full_response, client_ip) return StreamingResponse(token_generator(), media_type="text/event-stream") + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/evals/__init__.py b/evals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/evals/golden_qa.jsonl b/evals/golden_qa.jsonl new file mode 100644 index 0000000..f714d23 --- /dev/null +++ b/evals/golden_qa.jsonl @@ -0,0 +1,10 @@ +{"id": "skills", "question": "What are Rahul's skills?", "retrieval_keywords": ["Python", "FastAPI"], "expected_keywords": [["Python"], ["FastAPI", "LlamaIndex"]], "should_abstain": false} +{"id": "education", "question": "What is Rahul studying and where?", "retrieval_keywords": ["Trier", "Data Science"], "expected_keywords": [["Trier"], ["Data Science"]], "should_abstain": false} +{"id": "past-role", "question": "What did Rahul do before his Master's degree?", "retrieval_keywords": ["SAP"], "expected_keywords": [["SAP"]], "should_abstain": false} +{"id": "main-project", "question": "Tell me about Rahul's resume Q&A project.", "retrieval_keywords": ["LlamaIndex", "RAG"], "expected_keywords": [["RAG", "retrieval"], ["LlamaIndex"]], "should_abstain": false} +{"id": "deployment", "question": "How is the resume assistant deployed?", "retrieval_keywords": ["Docker", "GCP"], "expected_keywords": [["Docker", "container"], ["GCP", "Google Cloud"]], "should_abstain": false} +{"id": "embeddings", "question": "What embedding technology does the project use?", "retrieval_keywords": ["fastembed"], "expected_keywords": [["fastembed", "bge"]], "should_abstain": false} +{"id": "streaming", "question": "Does the API support streaming responses?", "retrieval_keywords": ["stream"], "expected_keywords": [["stream"]], "should_abstain": false} +{"id": "website", "question": "Where is Rahul's portfolio website hosted?", "retrieval_keywords": ["rahul-krishnan.is-a.dev"], "expected_keywords": [["rahul-krishnan.is-a.dev", "GitHub Pages"]], "should_abstain": false} +{"id": "abstain-salary", "question": "What is Rahul's salary?", "retrieval_keywords": [], "expected_keywords": [], "should_abstain": true} +{"id": "abstain-address", "question": "What is Rahul's home address?", "retrieval_keywords": [], "expected_keywords": [], "should_abstain": true} diff --git a/evals/run_eval.py b/evals/run_eval.py new file mode 100644 index 0000000..8407f0d --- /dev/null +++ b/evals/run_eval.py @@ -0,0 +1,177 @@ +"""RAG evaluation harness for the Resume RAG System. + +Offline mode (default, CI-safe — no API key needed): + python evals/run_eval.py + Builds the full hybrid retrieval pipeline (local embeddings + BM25 fusion + + cross-encoder rerank) over the sample docs and scores retrieval quality + against the golden Q&A set. Fails (exit 1) below the hit-rate threshold. + +Live mode (needs OPENROUTER_API_KEY): + python evals/run_eval.py --live + Additionally generates real answers through OpenRouter and scores: + - keyword coverage: expected facts present in the answer + - abstention: questions outside the docs get an honest "don't know" + - faithfulness: an LLM judge checks the answer is grounded in the context +""" + +import argparse +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +RETRIEVAL_THRESHOLD = 0.9 +ANSWER_THRESHOLD = 0.8 +TOP_K = 5 + +EVALS_DIR = os.path.dirname(os.path.abspath(__file__)) +GOLDEN_PATH = os.path.join(EVALS_DIR, "golden_qa.jsonl") +DEFAULT_DOCS = os.path.join(EVALS_DIR, "sample_docs") + +QA_TEMPLATE = ( + "You are a helpful assistant that answers questions about Rahul Krishnan based on his resume, " + "CV, and project documents. Be concise, professional, and friendly. " + "If the provided context doesn't contain enough information to answer, say so honestly. " + "Do not make up information that isn't in the documents." + "\n\nContext:\n{context_str}\n\nQuestion: {query_str}\nAnswer:" +) + +JUDGE_TEMPLATE = ( + "You are a strict evaluator. Given a context and an answer, decide whether " + "every factual claim in the answer is supported by the context. Reply with " + "exactly one word: YES if fully supported, NO otherwise.\n\n" + "Context:\n{context}\n\nAnswer:\n{answer}\n\nFully supported (YES/NO):" +) + +ABSTAIN_MARKERS = [ + "don't", "do not", "doesn't", "does not", "no information", "not mentioned", + "not included", "not provided", "not specified", "sorry", "unable", + "cannot", "can't", "not contain", +] + + +def load_golden(path: str = GOLDEN_PATH) -> list[dict]: + with open(path) as f: + return [json.loads(line) for line in f if line.strip()] + + +def build_pipeline(docs_dir: str): + from llama_index.core import SimpleDirectoryReader, VectorStoreIndex + from llama_index.embeddings.fastembed import FastEmbedEmbedding + + import retrieval + + documents = SimpleDirectoryReader(docs_dir).load_data() + embed_model = FastEmbedEmbedding( + model_name=os.getenv("EMBED_MODEL_NAME", "BAAI/bge-small-en-v1.5") + ) + index = VectorStoreIndex.from_documents(documents, embed_model=embed_model) + retriever = retrieval.build_hybrid_retriever(index) + reranker = retrieval.load_reranker() + return retriever, reranker + + +def get_context(retriever, reranker, question: str, top_k: int = TOP_K) -> str: + import retrieval + + nodes = retrieval.retrieve(retriever, reranker, question, top_k) + return "\n\n".join(n.get_content() for n in nodes) + + +def eval_retrieval(retriever, reranker, golden: list[dict]) -> tuple[float, list[dict]]: + """Fraction of questions whose expected keywords all appear in the + retrieved context. Deterministic and fully offline.""" + rows = [] + scored = [g for g in golden if g["retrieval_keywords"]] + for item in scored: + context = get_context(retriever, reranker, item["question"]) + hit = all(kw.lower() in context.lower() for kw in item["retrieval_keywords"]) + rows.append({"id": item["id"], "retrieval_hit": hit}) + hit_rate = sum(r["retrieval_hit"] for r in rows) / len(rows) if rows else 1.0 + return hit_rate, rows + + +def eval_answers_live(retriever, reranker, golden: list[dict]) -> tuple[float, list[dict]]: + """Generate real answers via OpenRouter and score keyword coverage, + abstention behavior, and LLM-judged faithfulness.""" + from llama_index.llms.openrouter import OpenRouter + + llm = OpenRouter( + model=os.getenv("OPENROUTER_MODEL", "openrouter/free"), + api_key=os.environ["OPENROUTER_API_KEY"], + max_tokens=1024, + ) + def complete_with_retry(prompt: str, attempts: int = 2, min_chars: int = 20) -> str: + # the free router occasionally returns a degenerate non-answer; re-route + answer = "" + for _ in range(attempts): + answer = str(llm.complete(prompt)).strip() + if len(answer) >= min_chars: + return answer + return answer + + rows = [] + for item in golden: + context = get_context(retriever, reranker, item["question"]) + answer = complete_with_retry( + QA_TEMPLATE.format(context_str=context, query_str=item["question"]) + ) + row = {"id": item["id"], "answer": answer} + if item["should_abstain"]: + row["pass"] = any(m in answer.lower() for m in ABSTAIN_MARKERS) + row["check"] = "abstained" if row["pass"] else "DID NOT ABSTAIN" + else: + coverage = all( + any(kw.lower() in answer.lower() for kw in group) + for group in item["expected_keywords"] + ) + verdict = "" + for _ in range(2): # judge is also served by the free router + verdict = str( + llm.complete(JUDGE_TEMPLATE.format(context=context, answer=answer)) + ).strip().upper() + if verdict.startswith(("YES", "NO")): + break + faithful = verdict.startswith("YES") + row["pass"] = coverage and faithful + row["check"] = f"coverage={coverage} faithful={faithful}" + rows.append(row) + score = sum(r["pass"] for r in rows) / len(rows) if rows else 1.0 + return score, rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--docs", default=DEFAULT_DOCS, help="docs dir to index") + parser.add_argument("--live", action="store_true", help="also eval real answers via OpenRouter") + args = parser.parse_args() + + golden = load_golden() + print(f"Golden set: {len(golden)} questions | docs: {args.docs}") + retriever, reranker = build_pipeline(args.docs) + + hit_rate, rows = eval_retrieval(retriever, reranker, golden) + print("\n== Retrieval eval (offline) ==") + for r in rows: + print(f" {'PASS' if r['retrieval_hit'] else 'FAIL'} {r['id']}") + print(f"Hit rate: {hit_rate:.0%} (threshold {RETRIEVAL_THRESHOLD:.0%})") + failed = hit_rate < RETRIEVAL_THRESHOLD + + if args.live: + if not os.getenv("OPENROUTER_API_KEY"): + print("OPENROUTER_API_KEY not set; cannot run --live eval.") + return 2 + score, rows = eval_answers_live(retriever, reranker, golden) + print("\n== Answer eval (live, via OpenRouter) ==") + for r in rows: + print(f" {'PASS' if r['pass'] else 'FAIL'} {r['id']}: {r['check']}") + print(f"Answer score: {score:.0%} (threshold {ANSWER_THRESHOLD:.0%})") + failed = failed or score < ANSWER_THRESHOLD + + print("\nRESULT:", "FAIL" if failed else "PASS") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/evals/sample_docs/projects.md b/evals/sample_docs/projects.md new file mode 100644 index 0000000..9bf7798 --- /dev/null +++ b/evals/sample_docs/projects.md @@ -0,0 +1,14 @@ +# Rahul Krishnan — Projects + +## AI-Powered Resume Q&A System (RAG Resume API) +A context-aware question-answering tool that retrieves information from Rahul's resume, projects, and notes, hosted live at rahul-krishnan.is-a.dev. + +- Retrieval-Augmented Generation (RAG) pipeline built with **LlamaIndex** +- Answers are generated by an LLM served through **OpenRouter** (free-model router); embeddings run locally with **fastembed** (BAAI/bge-small-en-v1.5, ONNX) +- **FastAPI** backend exposing `/health`, `/query`, and `/query/stream` (server-sent events streaming) endpoints +- Production hardening: per-IP rate limiting, daily query quota, response caching, and query logging to Google Sheets +- Containerized with **Docker** and deployed on the **GCP free tier** +- Embedded as a chat widget on his personal portfolio website + +## Personal Portfolio Website +Personal site at rahul-krishnan.is-a.dev showcasing his background and projects, hosted via GitHub Pages, integrated with the RAG resume assistant above. diff --git a/evals/sample_docs/resume.md b/evals/sample_docs/resume.md new file mode 100644 index 0000000..394b797 --- /dev/null +++ b/evals/sample_docs/resume.md @@ -0,0 +1,22 @@ +# Rahul Krishnan — Resume + +## Summary +Former SAP Developer now pursuing a Data Science Master's degree at the University of Trier, Germany. Builds AI solutions that solve real-world problems through machine learning and data science. + +## Education +- **M.Sc. Data Science** — University of Trier, Germany (in progress) + +## Work Experience +- **SAP Developer** — worked as an SAP developer before transitioning into data science. Experience with enterprise software development, ABAP, and business process integration. + +## Skills +- **Programming**: Python, SQL, ABAP +- **AI / ML**: Machine learning, retrieval-augmented generation (RAG), LLM-powered applications, vector search, embeddings +- **Frameworks & Tools**: LlamaIndex, FastAPI, Docker, Git +- **Cloud**: Google Cloud Platform (GCP), deployment on GCP free tier +- **Data**: Data engineering, data science workflows + +## Links +- GitHub: https://github.com/rahulk98 +- LinkedIn: https://linkedin.com/in/rahulk98 +- Website: https://rahul-krishnan.is-a.dev diff --git a/pyproject.toml b/pyproject.toml index af8a1a5..d909c64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "fastapi>=0.120.0", "llama-index>=0.14.5", "llama-index-embeddings-fastembed>=0.4.0", + "llama-index-retrievers-bm25>=0.7.0", "fastembed>=0.4.0", "llama-index-llms-openrouter>=0.4.0", "python-dotenv>=1.1.1", diff --git a/requirements.txt b/requirements.txt index 4d2250e..9de794e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ fastapi>=0.120.0 llama-index>=0.14.5 llama-index-embeddings-fastembed>=0.4.0 +llama-index-retrievers-bm25>=0.7.0 fastembed>=0.4.0 llama-index-llms-openrouter>=0.4.0 python-dotenv>=1.1.1 diff --git a/retrieval.py b/retrieval.py new file mode 100644 index 0000000..c203937 --- /dev/null +++ b/retrieval.py @@ -0,0 +1,63 @@ +"""Hybrid retrieval pipeline: BM25 + vector fusion, reranked by a local +ONNX cross-encoder. Everything runs in-process — no API calls, no cost. + +Used by both the API (app.py) and the eval harness (evals/run_eval.py). +""" + +import os + +from llama_index.core.llms import MockLLM +from llama_index.core.retrievers import QueryFusionRetriever + +RERANK_MODEL_NAME = os.getenv("RERANK_MODEL_NAME", "Xenova/ms-marco-MiniLM-L-6-v2") +# candidates fetched per retriever before fusion + reranking narrows them down +CANDIDATE_POOL = 8 + + +def build_hybrid_retriever(index, candidate_pool: int = CANDIDATE_POOL): + """Fuse vector search (semantic) with BM25 (keyword) via reciprocal rank + fusion. Falls back to vector-only if the BM25 index can't be built.""" + vector_retriever = index.as_retriever(similarity_top_k=candidate_pool) + try: + from llama_index.retrievers.bm25 import BM25Retriever + + bm25_retriever = BM25Retriever.from_defaults( + docstore=index.docstore, similarity_top_k=candidate_pool + ) + except Exception as e: + print(f"BM25 unavailable ({e}); falling back to vector-only retrieval.") + return vector_retriever + return QueryFusionRetriever( + [vector_retriever, bm25_retriever], + mode="reciprocal_rerank", + similarity_top_k=candidate_pool, + num_queries=1, # no LLM query expansion — keep retrieval free and fast + use_async=False, + llm=MockLLM(), # required by the constructor, never called with num_queries=1 + ) + + +def load_reranker(): + """Local cross-encoder reranker (ONNX via fastembed), or None if unavailable.""" + try: + from fastembed.rerank.cross_encoder import TextCrossEncoder + + return TextCrossEncoder(model_name=RERANK_MODEL_NAME) + except Exception as e: + print(f"Reranker unavailable ({e}); skipping rerank stage.") + return None + + +def rerank_nodes(reranker, query_text: str, nodes: list, top_k: int) -> list: + """Order retrieved nodes by cross-encoder relevance and keep the top_k.""" + if reranker is None or not nodes: + return nodes[:top_k] + scores = list(reranker.rerank(query_text, [n.get_content() for n in nodes])) + ranked = sorted(zip(nodes, scores), key=lambda pair: pair[1], reverse=True) + return [node for node, _ in ranked[:top_k]] + + +def retrieve(retriever, reranker, query_text: str, top_k: int) -> list: + """Full pipeline: hybrid retrieve a candidate pool, then rerank to top_k.""" + nodes = retriever.retrieve(query_text) + return rerank_nodes(reranker, query_text, nodes, top_k) diff --git a/tests/test_app.py b/tests/test_app.py index 45df32f..3618d2c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,6 +16,7 @@ from llama_index.embeddings.fastembed import FastEmbedEmbedding import app as app_module +import retrieval SAMPLE_DOCS = [ Document(text="Rahul Krishnan is a software engineer with experience in Python, " @@ -51,19 +52,28 @@ def vector_index(embed_model): return VectorStoreIndex.from_documents(SAMPLE_DOCS, embed_model=embed_model) +@pytest.fixture(scope="session") +def hybrid_retriever(vector_index): + return retrieval.build_hybrid_retriever(vector_index) + + @pytest.fixture() -def client(vector_index, embed_model): - """TestClient with a real (local-embedding) index and a mocked LLM.""" +def client(vector_index, embed_model, hybrid_retriever): + """TestClient with a real (local-embedding) hybrid index and a mocked LLM.""" app_module.index = vector_index app_module.embed_model = embed_model + app_module.retriever = hybrid_retriever + app_module.reranker = None # cross-encoder covered by test_retrieval tests app_module.llm = CountingMockLLM(max_tokens=32) CountingMockLLM.call_count = 0 # reset per-test state app_module._rate_store.clear() app_module._query_cache.clear() + app_module._sessions.clear() app_module._daily_count = 0 yield TestClient(app_module.app) app_module.index = None + app_module.retriever = None app_module.llm = None @@ -201,6 +211,61 @@ def test_index_persist_and_reload_roundtrip(tmp_path, embed_model, vector_index) assert any("skills" in n.text.lower() or "python" in n.text.lower() for n in nodes) +def test_multi_turn_session_condenses_followup(client): + r1 = client.post("/query", json={"query": "Where does Rahul work?", "session_id": "s1"}) + assert r1.status_code == 200 + assert CountingMockLLM.call_count == 1 # no history yet -> no condense call + + r2 = client.post("/query", json={"query": "What did he do there?", "session_id": "s1"}) + assert r2.status_code == 200 + # follow-up: one condense call + one answer call + assert CountingMockLLM.call_count == 3 + assert len(app_module._sessions["s1"]["turns"]) == 2 + + +def test_sessions_are_isolated(client): + client.post("/query", json={"query": "Question one", "session_id": "a"}) + client.post("/query", json={"query": "Question two", "session_id": "b"}) + assert len(app_module._sessions["a"]["turns"]) == 1 + assert len(app_module._sessions["b"]["turns"]) == 1 + + +def test_session_without_id_is_stateless(client): + client.post("/query", json={"query": "No session here"}) + assert app_module._sessions == {} + + +def test_session_history_capped(client): + for i in range(app_module.MAX_HISTORY_TURNS + 5): + client.post("/query", json={"query": f"unique question {i}", "session_id": "cap"}) + assert len(app_module._sessions["cap"]["turns"]) == app_module.MAX_HISTORY_TURNS + + +def test_expired_session_forgotten(client): + client.post("/query", json={"query": "Old question", "session_id": "expiring"}) + app_module._sessions["expiring"]["ts"] -= app_module.SESSION_TTL + 1 + assert app_module._get_session_history("expiring") == [] + + +def test_hybrid_retrieval_finds_keyword_and_semantic_matches(hybrid_retriever): + """BM25 catches exact keywords; vector side catches paraphrases.""" + keyword_nodes = retrieval.retrieve(hybrid_retriever, None, "LlamaIndex", 2) + assert any("LlamaIndex" in n.get_content() for n in keyword_nodes) + semantic_nodes = retrieval.retrieve( + hybrid_retriever, None, "What web backend technologies does he know?", 2 + ) + assert any("FastAPI" in n.get_content() for n in semantic_nodes) + + +def test_reranker_orders_by_relevance(hybrid_retriever): + """The local cross-encoder puts the most relevant chunk first.""" + reranker = retrieval.load_reranker() + assert reranker is not None + nodes = retrieval.retrieve(hybrid_retriever, reranker, "What are Rahul's skills?", 3) + assert len(nodes) == 3 + assert "skills" in nodes[0].get_content().lower() + + @pytest.fixture() def old_gemini_style_index_dir(tmp_path): """Persist an index built with 3072-dim embeddings, like the old Gemini index.""" diff --git a/tests/test_eval.py b/tests/test_eval.py new file mode 100644 index 0000000..58bb881 --- /dev/null +++ b/tests/test_eval.py @@ -0,0 +1,12 @@ +"""Retrieval-quality gate: the hybrid pipeline must surface the expected +facts for the golden questions. Offline — no API key needed.""" + +from evals import run_eval + + +def test_retrieval_hit_rate_meets_threshold(): + golden = run_eval.load_golden() + retriever, reranker = run_eval.build_pipeline(run_eval.DEFAULT_DOCS) + hit_rate, rows = run_eval.eval_retrieval(retriever, reranker, golden) + misses = [r["id"] for r in rows if not r["retrieval_hit"]] + assert hit_rate >= run_eval.RETRIEVAL_THRESHOLD, f"retrieval misses: {misses}" diff --git a/uv.lock b/uv.lock index 33f67e8..a5f7a21 100644 --- a/uv.lock +++ b/uv.lock @@ -196,6 +196,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] +[[package]] +name = "bm25s" +version = "0.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/ab/8fac50e2095d26dd9ccaf3c35bddb96ff45a8cca9fc3ac0e98209971828f/bm25s-0.3.10.tar.gz", hash = "sha256:fafdfc45e6c064fc117b08de8f59b47564a3879d3ca593daa0b2a40b2c05da43", size = 80762, upload-time = "2026-07-22T20:54:28.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/06/7ec2175c185661a976589fffde16eb7c36d69eaa43eed26fe5ad0ce3cd25/bm25s-0.3.10-py3-none-any.whl", hash = "sha256:d271d4e1ad7ffdacb224f41bc54aba55159438ecf06439ffe929f088efa96858", size = 74687, upload-time = "2026-07-22T20:54:27.447Z" }, +] + [[package]] name = "certifi" version = "2025.10.5" @@ -1121,6 +1133,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/81/52410c7245dcbf1a54756a9ce3892cdd167ec0b884d696de1304ca3f452e/llama_index_readers_llama_parse-0.5.1-py3-none-any.whl", hash = "sha256:0d41450ed29b0c49c024e206ef6c8e662b1854e77a1c5faefed3b958be54f880", size = 3203, upload-time = "2025-09-08T20:41:28.438Z" }, ] +[[package]] +name = "llama-index-retrievers-bm25" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bm25s" }, + { name = "llama-index-core" }, + { name = "pystemmer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/72/c51933e684e5480c8c9560536d3fc8b68919ab3c857f556bd5a332293b21/llama_index_retrievers_bm25-0.7.1.tar.gz", hash = "sha256:65bff95c5c1354854394209406d2d342ff5e62983ff2818fd2b85d5bed70bed0", size = 5532, upload-time = "2026-03-13T15:06:25.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/c3/4a8d46d73829a528c552c86c0ea8b92f06df40e5fd338c0c7c6ffe8804e6/llama_index_retrievers_bm25-0.7.1-py3-none-any.whl", hash = "sha256:6feb1882fb43608bfcb8ea6466b5fabda78b097c7b7cbe28a4f2b3cc5a3705ee", size = 5061, upload-time = "2026-03-13T15:06:24.772Z" }, +] + [[package]] name = "llama-index-workflows" version = "2.8.3" @@ -2015,6 +2041,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/ed/494fd0cc1190a7c335e6958eeaee6f373a281869830255c2ed4785dac135/pypdf-6.1.3-py3-none-any.whl", hash = "sha256:eb049195e46f014fc155f566fa20e09d70d4646a9891164ac25fa0cbcfcdbcb5", size = 323863, upload-time = "2025-10-22T16:13:44.174Z" }, ] +[[package]] +name = "pystemmer" +version = "2.2.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/12/0378ba4391a4674067ae9db0e025ec998f6ca74caddd22fbdc59dc19aafb/pystemmer-2.2.0.3.tar.gz", hash = "sha256:9ac74c8d0f3358dbb050f64cddbb8d55021d831d92305d7c20780ea8d6c0020e", size = 303860, upload-time = "2024-10-10T00:36:25.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/7c/e09c0ac1aa4571ded5f46c14aed5d4828b61392611ed1c59a8faf1cae98a/PyStemmer-2.2.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c30c44241065beb9432273874f199fc109473338d9f2c921a3387fd534fd94a7", size = 214916, upload-time = "2024-10-10T00:56:41.803Z" }, + { url = "https://files.pythonhosted.org/packages/e5/dc/b30dd228af740b1ffe18e4cfb48c22aa8481ce1e79405cac31086fb849bd/PyStemmer-2.2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a6adf0b86b6be85f0cf80b2b255b2b0179782b4a3f39c0a6c5b3dd07af5f95eb", size = 220418, upload-time = "2024-10-10T00:56:43.354Z" }, + { url = "https://files.pythonhosted.org/packages/f7/68/eb9f06b2f04f408ed748d6e71fb2f5ae92deba0eea07b6e374268d53cb49/PyStemmer-2.2.0.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d42b41082553fa23a4ce191860fd7caffdeaf8507e84db630a97ed154bd2320", size = 644074, upload-time = "2024-10-10T00:56:45.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9f/e95eeccf29ad1e1dbf4a14b76e2fa5b6698ba06f10634b097b32258839b0/PyStemmer-2.2.0.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec763ee2994402c534bf898ff318edd158c32071c3ffbdcd7ae7b7c884250471", size = 683255, upload-time = "2024-10-10T00:56:47.314Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4b/8b077958eb760ac647d2362f8bd1ae99097fa351edc6333fde0dee51f912/PyStemmer-2.2.0.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:264f09d5f70b09c845a6f0d0d4973de674056fd50452cb9383ffae8fc0967f1d", size = 649228, upload-time = "2024-10-10T00:56:50.101Z" }, + { url = "https://files.pythonhosted.org/packages/7f/13/3d57038439293d93e40af89b48bc06c714f8cd8c5d22a9e6d0870f49ea1c/PyStemmer-2.2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5634f38a781b9a893550c23380af080ca5291d19c2bcb1753a34022d1d0de7cb", size = 682987, upload-time = "2024-10-10T00:56:53.094Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/f04305ab6fc6312b4727b725ab983342237c7bb95d21d98bd768146dd9e8/PyStemmer-2.2.0.3-cp312-cp312-win32.whl", hash = "sha256:186c2e90ea2c3d0fab21f10f17b48fb7d716cba5f49b68f7f0fe539db4ff0499", size = 141412, upload-time = "2024-10-10T00:56:54.64Z" }, + { url = "https://files.pythonhosted.org/packages/0f/73/861a71009f20331cd22aff1c37b9c39ef3af4d57d6816df79d51b3fca533/PyStemmer-2.2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:320c1da333f5f8571e2b313c9fa6c0a7a79d8a00a2ad0bf29932d931d236d7e8", size = 185395, upload-time = "2024-10-10T00:56:56.177Z" }, + { url = "https://files.pythonhosted.org/packages/c0/eb/b39bef7c8ec2ad9e198b956104741c1e7a94f0003875ef0a5a32ae20443f/PyStemmer-2.2.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:806530b6a1542efd6453fc5f5b5aa348d52c337d0eb1dfc54a5ff6a8733d7ccc", size = 214375, upload-time = "2024-10-10T00:56:57.681Z" }, + { url = "https://files.pythonhosted.org/packages/ad/db/93f0fd99c99cfdfb68a6d671324ceb3e9c0cf2040f47d917a42e615faf03/PyStemmer-2.2.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d3fe53911811ec554b13a2c3b0ceb1a23c6fbed3d510ea0d8544a4e0b861e4d6", size = 219992, upload-time = "2024-10-10T00:56:59.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/52/cb3ad58e8ce701c97a38f7828d899cfd12f0b7b06c49a4d67a17298641eb/PyStemmer-2.2.0.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf26cc1071685597b54b78dd2f62080c58f9be1cb9b4f9c92f94d5c0b5e5e65d", size = 638201, upload-time = "2024-10-10T00:57:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e7/d22c50ac8e76c2878cc1056010d0db26b7e5a7936262320c62a6dcdfdbc9/PyStemmer-2.2.0.3-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d229a8451e5e909c3f41e19c2f1c9a531d3281954a8cbc06163a458adcc465", size = 678416, upload-time = "2024-10-10T00:57:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/90/bd/cd603a89316769c4ae3a4d2271c9c9707b26939517bbe2f780c4f171ade3/PyStemmer-2.2.0.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f44e27fbdeffd46b513ed80d5dab0c7e0e09fb1cd85e8dbf8041b6e4a2d55bee", size = 643274, upload-time = "2024-10-10T00:57:06.675Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0e/7fad0a408def5738ce1deffc6242bd8086f76fae1f7a5a4c36e31ab528d0/PyStemmer-2.2.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4acd71d4359399e41543198caf150e7f398a8d52e371a0c89ba63a90ec3e0909", size = 677487, upload-time = "2024-10-10T00:57:08.474Z" }, + { url = "https://files.pythonhosted.org/packages/11/79/3f429bd0c97fe95d62aee32216dfbe3db9a59c3a8a3ff185bb1927e45658/PyStemmer-2.2.0.3-cp313-cp313-win32.whl", hash = "sha256:91ab47d071383b5c558542bf54facf116f3fd1516c177ef10843f41e528d8873", size = 141345, upload-time = "2024-10-10T00:57:10.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/65/87b637539c73bf5b1074eba87389616949e073d91f6c670a4d91e16504de/PyStemmer-2.2.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:4e192613a1e02b0cebcbb9f8a708001bdf7ec842972b42008f3b0b006a8c53b6", size = 185251, upload-time = "2024-10-10T00:57:12.081Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -2117,6 +2167,7 @@ dependencies = [ { name = "llama-index" }, { name = "llama-index-embeddings-fastembed" }, { name = "llama-index-llms-openrouter" }, + { name = "llama-index-retrievers-bm25" }, { name = "python-dotenv" }, { name = "supabase" }, { name = "uvicorn" }, @@ -2135,6 +2186,7 @@ requires-dist = [ { name = "llama-index", specifier = ">=0.14.5" }, { name = "llama-index-embeddings-fastembed", specifier = ">=0.4.0" }, { name = "llama-index-llms-openrouter", specifier = ">=0.4.0" }, + { name = "llama-index-retrievers-bm25", specifier = ">=0.7.0" }, { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "supabase" }, { name = "uvicorn", specifier = ">=0.38.0" }, From 31c646aef355f44ae28bd535b148490ce74ee2ca Mon Sep 17 00:00:00 2001 From: rahulk98 Date: Fri, 7 Aug 2026 07:09:56 +0000 Subject: [PATCH 5/5] Keep raw docs out of the Docker image; validate index at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipping docs/ in the image put raw source files (which may hold more than the indexed text, e.g. PDF metadata) into every image layer and the container registry. The image now ships only the pre-built index, and a build-time guard (check_index.py) fails the docker build if that index was built with a different embedding model — surfacing the Gemini-to-local migration at build time instead of at runtime. A missing index is still allowed (CI builds with an empty placeholder). The startup auto-rebuild remains as a local-dev safety net when docs/ is present. --- .github/workflows/ci.yml | 8 +++---- DOCKERFILE | 10 +++++---- README.md | 2 +- check_index.py | 46 +++++++++++++++++++++++++++++++++++++++ tests/test_check_index.py | 43 ++++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 check_index.py create mode 100644 tests/test_check_index.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e5cd5..6b8402f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # index/ and docs/ are gitignored (private); build with empty stand-ins — - # the app rebuilds/loads them at deploy time - - name: Create placeholder dirs - run: mkdir -p index docs + # index/ is gitignored (private); build with an empty stand-in — the + # build-time index check treats a missing index as "ship without one" + - name: Create placeholder dir + run: mkdir -p index - name: Docker build run: docker build -f DOCKERFILE -t rag-resume-api . diff --git a/DOCKERFILE b/DOCKERFILE index 3ebab79..8b6b36d 100644 --- a/DOCKERFILE +++ b/DOCKERFILE @@ -10,11 +10,13 @@ RUN pip install --no-cache-dir -r requirements.txt RUN python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')" \ && python -c "from fastembed.rerank.cross_encoder import TextCrossEncoder; TextCrossEncoder('Xenova/ms-marco-MiniLM-L-6-v2')" -# Copy the application code. docs/ lets the app rebuild the index at startup -# if the shipped index/ was built with a different embedding model. +# Copy the application code and the pre-built index (docs/ stays out of the +# image — the index already holds the indexed text, raw files aren't needed) COPY index/ ./index/ -COPY docs/ ./docs/ -COPY app.py retrieval.py ./ +COPY app.py retrieval.py check_index.py ./ + +# Fail the build if the shipped index was built with a different embedding model +RUN python check_index.py EXPOSE 8000 diff --git a/README.md b/README.md index 90227c7..56d6353 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Retrieval-Augmented Generation (RAG) API that indexes documents in `docs/` and a - Optional: `EMBED_MODEL_NAME` to change the local embedding model (default `BAAI/bge-small-en-v1.5`). Changing it requires rebuilding the index. - Optional Sheets logging: set `SHEETS_JSON_KEY` (service-account JSON) -> **Migrating from the Gemini version?** The embedding model changed (3072-dim Gemini → 384-dim local BGE), so the old persisted index is incompatible. This is handled automatically: at startup the app detects the dimension mismatch and rebuilds the index from `docs/` (the Docker image now includes `docs/` for this). You can also rebuild manually with `python build_index.py` — no API key needed for indexing, since embeddings run locally. +> **Migrating from the Gemini version?** The embedding model changed (3072-dim Gemini → 384-dim local BGE), so the old persisted index is incompatible. Rebuild it once with `python build_index.py` (no API key needed — embeddings run locally). Safety nets: the Docker build fails fast if the shipped index doesn't match the embedding model (`check_index.py`), and at startup the app rebuilds a mismatched index from `docs/` when that folder is present. Raw documents are never copied into the Docker image. ### Quickstart ```bash diff --git a/check_index.py b/check_index.py new file mode 100644 index 0000000..67f51ba --- /dev/null +++ b/check_index.py @@ -0,0 +1,46 @@ +"""Build-time guard used by the Dockerfile: refuse to ship an index that was +built with a different embedding model than the app will use at runtime +(e.g. an old 3072-dim Gemini index vs the 384-dim local model). + +Exit codes: 0 = index matches or no index shipped; 1 = dimension mismatch. +""" + +import json +import os +import sys + +INDEX_DIR = os.getenv("INDEX_PERSIST_DIR", "./index") +EMBED_MODEL_NAME = os.getenv("EMBED_MODEL_NAME", "BAAI/bge-small-en-v1.5") + + +def main() -> int: + path = os.path.join(INDEX_DIR, "default__vector_store.json") + if not os.path.exists(path): + print( + f"No index found at {INDEX_DIR}; the app will start without one. " + "Run `python build_index.py` and rebuild the image to ship it." + ) + return 0 + with open(path) as f: + embedding_dict = json.load(f).get("embedding_dict", {}) + if not embedding_dict: + print("Index contains no embeddings; treating as empty.") + return 0 + stored_dim = len(next(iter(embedding_dict.values()))) + + from fastembed import TextEmbedding + + model_dim = len(next(iter(TextEmbedding(EMBED_MODEL_NAME).embed(["probe"])))) + if stored_dim != model_dim: + print( + f"ERROR: shipped index is {stored_dim}-dim but {EMBED_MODEL_NAME} " + f"produces {model_dim}-dim embeddings. Rebuild the index with " + "`python build_index.py` before building the image." + ) + return 1 + print(f"Index OK: {stored_dim}-dim matches {EMBED_MODEL_NAME}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_index.py b/tests/test_check_index.py new file mode 100644 index 0000000..8a12442 --- /dev/null +++ b/tests/test_check_index.py @@ -0,0 +1,43 @@ +"""Tests for the Docker build-time index compatibility guard.""" + +import json +import subprocess +import sys +from pathlib import Path + +CHECK_SCRIPT = str(Path(__file__).resolve().parent.parent / "check_index.py") + + +def run_check(index_dir: Path) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, CHECK_SCRIPT], + env={"INDEX_PERSIST_DIR": str(index_dir), "PATH": "/usr/bin:/bin"}, + capture_output=True, + text=True, + ) + + +def write_vector_store(index_dir: Path, dim: int) -> None: + index_dir.mkdir(parents=True, exist_ok=True) + (index_dir / "default__vector_store.json").write_text( + json.dumps({"embedding_dict": {"node-1": [0.0] * dim}}) + ) + + +def test_missing_index_passes(tmp_path): + result = run_check(tmp_path / "empty") + assert result.returncode == 0, result.stdout + result.stderr + + +def test_matching_index_passes(tmp_path): + write_vector_store(tmp_path / "index", 384) # bge-small dimension + result = run_check(tmp_path / "index") + assert result.returncode == 0, result.stdout + result.stderr + assert "Index OK" in result.stdout + + +def test_old_gemini_index_fails_build(tmp_path): + write_vector_store(tmp_path / "index", 3072) # old Gemini dimension + result = run_check(tmp_path / "index") + assert result.returncode == 1 + assert "Rebuild the index" in result.stdout