From 8df4f930a63be90092e6519995876b650798aef4 Mon Sep 17 00:00:00 2001 From: Devang Borkar Date: Tue, 21 Jul 2026 13:59:11 -0700 Subject: [PATCH 1/6] refactor: modularize backend code --- README.md | 2 +- backend/controller/auth_controller.py | 8 +++--- backend/controller/chat_controller.py | 2 +- backend/controller/file_controller.py | 23 ++++++++--------- backend/controller/helpers.py | 8 +++--- backend/controller/share_controller.py | 6 ++--- backend/{ => models}/file.py | 2 +- backend/{ => models}/node.py | 4 +-- backend/{ => services}/RSDB_kv_service.py | 0 backend/{ => services}/delete_service.py | 10 ++++---- backend/{ipfs.py => services/ipfs_service.py} | 9 ++++--- backend/{ => services}/rag_persistence.py | 4 +-- backend/{ => services}/rag_utils.py | 0 backend/{ => services}/share_manager.py | 4 +-- .../user_authentication_service.py | 10 ++++---- backend/tests/test.py | 5 ++-- backend/tests/test_delete_service.py | 25 +++++++++---------- backend/tests/test_document_metadata.py | 4 +-- backend/tests/test_file_controller.py | 14 +++++------ backend/tests/test_rag_persistence.py | 8 +++--- backend/tests/test_rag_utils.py | 2 +- backend/tests/test_share_manager.py | 6 ++--- backend/{ => utils}/error.py | 0 backend/{ => utils}/util.py | 0 docs/QUICK-START.md | 4 +-- development.md => docs/development.md | 2 +- kv_smoke.py | 2 +- 27 files changed, 81 insertions(+), 83 deletions(-) rename backend/{ => models}/file.py (98%) rename backend/{ => models}/node.py (96%) rename backend/{ => services}/RSDB_kv_service.py (100%) rename backend/{ => services}/delete_service.py (90%) rename backend/{ipfs.py => services/ipfs_service.py} (87%) rename backend/{ => services}/rag_persistence.py (94%) rename backend/{ => services}/rag_utils.py (100%) rename backend/{ => services}/share_manager.py (98%) rename backend/{ => services}/user_authentication_service.py (75%) rename backend/{ => utils}/error.py (100%) rename backend/{ => utils}/util.py (100%) rename development.md => docs/development.md (97%) diff --git a/README.md b/README.md index f70c75c..0cff134 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ docker compose up --build ``` To run IPFS, IPFS Cluster, Qdrant, Flask, and React as separate local processes, -follow the [manual local development guide](development.md). +follow the [manual local development guide](docs/development.md). 2. **Start the frontend development server:** ```bash diff --git a/backend/controller/auth_controller.py b/backend/controller/auth_controller.py index d2b495f..4b096bd 100644 --- a/backend/controller/auth_controller.py +++ b/backend/controller/auth_controller.py @@ -3,16 +3,16 @@ from flask import jsonify, request, session -from backend.RSDB_kv_service import get_kv, set_kv -from backend.error import ErrorCode -from backend.user_authentication_service import login, sign_up +from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.utils.error import ErrorCode +from backend.services.user_authentication_service import login, sign_up from backend.controller.helpers import ( collect_indexed_document_ids, get_resolved_share_list, get_root_node, login_required, ) -from backend.rag_utils import get_rag_manager +from backend.services.rag_utils import get_rag_manager def register_auth_routes(app, logger): diff --git a/backend/controller/chat_controller.py b/backend/controller/chat_controller.py index c817ada..799269b 100644 --- a/backend/controller/chat_controller.py +++ b/backend/controller/chat_controller.py @@ -6,7 +6,7 @@ login_required, route_logger, ) -from backend.rag_utils import get_llm_integration, get_rag_manager +from backend.services.rag_utils import get_llm_integration, get_rag_manager def register_chat_routes(app, logger): diff --git a/backend/controller/file_controller.py b/backend/controller/file_controller.py index 413402c..8bbffa5 100644 --- a/backend/controller/file_controller.py +++ b/backend/controller/file_controller.py @@ -4,13 +4,13 @@ from flask import jsonify, request, send_file, session -from backend.RSDB_kv_service import get_kv, set_kv -from backend.delete_service import delete_node -from backend.error import ErrorCode -from backend.file import File -from backend.ipfs import add_file_to_cluster, download_file_from_ipfs -from backend.node import Node -from backend.rag_persistence import ( +from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.services.delete_service import delete_node +from backend.utils.error import ErrorCode +from backend.models.file import File +from backend.services.ipfs_service import add_file_to_cluster, download_file_from_ipfs +from backend.models.node import Node +from backend.services.rag_persistence import ( RAGPersistenceResult, persist_root_with_rag_rollback, ) @@ -21,8 +21,8 @@ login_required, route_logger, ) -from backend.util import validate_file_size -from backend.rag_utils import get_rag_manager +from backend.utils.util import validate_file_size +from backend.services.rag_utils import get_rag_manager FILE_SIZE_LIMIT = 1024 * 1024 # 1 MB limit @@ -74,10 +74,7 @@ def upload_route(): Upload file to IPFS and process for RAG if it's a supported text format. if user want to upload example.txt to path root/doc/example.txt. The path part in request should be root/doc """ - if 'file' not in request.files: - return jsonify({'message': ErrorCode.INVALID_REQUEST.name}), 400 - - if 'path' not in request.form: + if 'file' not in request.files or 'path' not in request.form: return jsonify({'message': ErrorCode.INVALID_REQUEST.name}), 400 path = request.form['path'] diff --git a/backend/controller/helpers.py b/backend/controller/helpers.py index 261130b..c121316 100644 --- a/backend/controller/helpers.py +++ b/backend/controller/helpers.py @@ -4,10 +4,10 @@ from flask import jsonify, session -from backend.RSDB_kv_service import get_kv -from backend.error import ErrorCode -from backend.node import Node -from backend.share_manager import ShareManager +from backend.services.RSDB_kv_service import get_kv +from backend.utils.error import ErrorCode +from backend.models.node import Node +from backend.services.share_manager import ShareManager route_logger = logging.getLogger(__name__) diff --git a/backend/controller/share_controller.py b/backend/controller/share_controller.py index 405f2e2..e11fa1f 100644 --- a/backend/controller/share_controller.py +++ b/backend/controller/share_controller.py @@ -1,8 +1,8 @@ from flask import jsonify, request, session -from backend.RSDB_kv_service import get_kv, set_kv -from backend.error import ErrorCode -from backend.node import Node +from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.utils.error import ErrorCode +from backend.models.node import Node from backend.controller.helpers import ( get_resolved_share_list, get_root_node, diff --git a/backend/file.py b/backend/models/file.py similarity index 98% rename from backend/file.py rename to backend/models/file.py index 210b6bb..0ba05aa 100644 --- a/backend/file.py +++ b/backend/models/file.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone from typing import Optional -from uuid import NAMESPACE_URL, uuid4, uuid5 +from uuid import uuid4 class File: diff --git a/backend/node.py b/backend/models/node.py similarity index 96% rename from backend/node.py rename to backend/models/node.py index ded9459..f7f7d1d 100644 --- a/backend/node.py +++ b/backend/models/node.py @@ -1,7 +1,7 @@ import json -from backend.error import ErrorCode -from backend.file import File +from backend.utils.error import ErrorCode +from backend.models.file import File class Node: diff --git a/backend/RSDB_kv_service.py b/backend/services/RSDB_kv_service.py similarity index 100% rename from backend/RSDB_kv_service.py rename to backend/services/RSDB_kv_service.py diff --git a/backend/delete_service.py b/backend/services/delete_service.py similarity index 90% rename from backend/delete_service.py rename to backend/services/delete_service.py index 103badd..59e41c5 100644 --- a/backend/delete_service.py +++ b/backend/services/delete_service.py @@ -1,10 +1,10 @@ from flask import jsonify, session -from backend.RSDB_kv_service import get_kv, set_kv -from backend.error import ErrorCode -from backend.node import Node -from backend.share_manager import ShareManager +from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.utils.error import ErrorCode +from backend.models.node import Node +from backend.services.share_manager import ShareManager from backend.controller.helpers import collect_indexed_document_ids -from backend.rag_utils import get_rag_manager +from backend.services.rag_utils import get_rag_manager def _load_root(username: str): diff --git a/backend/ipfs.py b/backend/services/ipfs_service.py similarity index 87% rename from backend/ipfs.py rename to backend/services/ipfs_service.py index 57dafc3..5858985 100644 --- a/backend/ipfs.py +++ b/backend/services/ipfs_service.py @@ -1,6 +1,7 @@ import os import requests from io import BytesIO +from pathlib import Path ipfs_cluster_api_url = None ipfs_gateway_url = None @@ -8,9 +9,11 @@ def read_config_file(): global ipfs_cluster_api_url, ipfs_gateway_url - base_dir = os.path.dirname(__file__) - config_path = os.path.join(base_dir, 'config/ipfs.config') - with open(config_path) as f: + base_dir = Path(__file__).resolve().parent.parent + cfg_path = base_dir / 'config' / 'ipfs.config' + if not cfg_path.exists(): + raise FileNotFoundError(f"IPFS config file not found at {cfg_path}") + with open(cfg_path) as f: ipfs_cluster_api_url = f.readline().strip() ipfs_gateway_url = f.readline().strip() diff --git a/backend/rag_persistence.py b/backend/services/rag_persistence.py similarity index 94% rename from backend/rag_persistence.py rename to backend/services/rag_persistence.py index 1a51085..1b193cd 100644 --- a/backend/rag_persistence.py +++ b/backend/services/rag_persistence.py @@ -1,8 +1,8 @@ import logging from enum import Enum from typing import Optional -from backend.rag_utils import RAGManager -from backend.RSDB_kv_service import set_kv +from backend.services.rag_utils import RAGManager +from backend.services.RSDB_kv_service import set_kv logger = logging.getLogger(__name__) diff --git a/backend/rag_utils.py b/backend/services/rag_utils.py similarity index 100% rename from backend/rag_utils.py rename to backend/services/rag_utils.py diff --git a/backend/share_manager.py b/backend/services/share_manager.py similarity index 98% rename from backend/share_manager.py rename to backend/services/share_manager.py index ab60b64..2436acf 100644 --- a/backend/share_manager.py +++ b/backend/services/share_manager.py @@ -1,8 +1,8 @@ import json from typing import Any, Callable, Dict, List, Optional, Tuple -from backend.error import ErrorCode -from backend.node import Node +from backend.utils.error import ErrorCode +from backend.models.node import Node class ShareManager: diff --git a/backend/user_authentication_service.py b/backend/services/user_authentication_service.py similarity index 75% rename from backend/user_authentication_service.py rename to backend/services/user_authentication_service.py index df4b721..d27bc3e 100644 --- a/backend/user_authentication_service.py +++ b/backend/services/user_authentication_service.py @@ -1,11 +1,11 @@ import hashlib -from backend.share_manager import ShareManager -from backend.util import is_valid_password, is_valid_username +from backend.services.share_manager import ShareManager +from backend.utils.util import is_valid_password, is_valid_username -from backend.error import ErrorCode -from backend.RSDB_kv_service import get_kv, set_kv -from backend.node import Node +from backend.utils.error import ErrorCode +from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.models.node import Node def sign_up(username, password): if get_kv(username).strip(): diff --git a/backend/tests/test.py b/backend/tests/test.py index 8a8925e..f207acf 100644 --- a/backend/tests/test.py +++ b/backend/tests/test.py @@ -1,8 +1,7 @@ import unittest -from backend.file import File -from backend.node import Node - +from backend.models.file import File +from backend.models.node import Node class NodePathTest(unittest.TestCase): def test_find_node_by_absolute_path(self): diff --git a/backend/tests/test_delete_service.py b/backend/tests/test_delete_service.py index 2781830..b473632 100644 --- a/backend/tests/test_delete_service.py +++ b/backend/tests/test_delete_service.py @@ -3,10 +3,9 @@ from flask import Flask, session -from backend.delete_service import delete_node -from backend.file import File -from backend.node import Node - +from backend.services.delete_service import delete_node +from backend.models.file import File +from backend.models.node import Node class DeleteServiceRAGTest(unittest.TestCase): def setUp(self): @@ -33,10 +32,10 @@ def test_delete_removes_qdrant_chunks_before_file_record(self): with self.app.test_request_context("/"): session["username"] = "alice" - with patch("backend.delete_service.get_kv", return_value=root.to_json()), patch( - "backend.delete_service.set_kv", return_value=True + with patch("backend.services.delete_service.get_kv", return_value=root.to_json()), patch( + "backend.services.delete_service.set_kv", return_value=True ) as set_kv, patch( - "backend.delete_service.get_rag_manager", return_value=manager + "backend.services.delete_service.get_rag_manager", return_value=manager ): response, status = delete_node( {"node_path": "indexed.txt", "delete_in_root": True} @@ -54,10 +53,10 @@ def test_delete_keeps_file_when_qdrant_cleanup_fails(self): with self.app.test_request_context("/"): session["username"] = "alice" - with patch("backend.delete_service.get_kv", return_value=root.to_json()), patch( - "backend.delete_service.set_kv", return_value=True + with patch("backend.services.delete_service.get_kv", return_value=root.to_json()), patch( + "backend.services.delete_service.set_kv", return_value=True ) as set_kv, patch( - "backend.delete_service.get_rag_manager", return_value=manager + "backend.services.delete_service.get_rag_manager", return_value=manager ): response, status = delete_node( {"node_path": "indexed.txt", "delete_in_root": True} @@ -92,10 +91,10 @@ def test_folder_delete_removes_all_indexed_descendants(self): with self.app.test_request_context("/"): session["username"] = "alice" - with patch("backend.delete_service.get_kv", return_value=root.to_json()), patch( - "backend.delete_service.set_kv", return_value=True + with patch("backend.services.delete_service.get_kv", return_value=root.to_json()), patch( + "backend.services.delete_service.set_kv", return_value=True ) as set_kv, patch( - "backend.delete_service.get_rag_manager", return_value=manager + "backend.services.delete_service.get_rag_manager", return_value=manager ): _, status = delete_node( {"node_path": "docs", "delete_in_root": True} diff --git a/backend/tests/test_document_metadata.py b/backend/tests/test_document_metadata.py index 3d93ff4..f0bdc84 100644 --- a/backend/tests/test_document_metadata.py +++ b/backend/tests/test_document_metadata.py @@ -2,8 +2,8 @@ from datetime import datetime from backend.controller.helpers import get_indexed_file_stats -from backend.file import File -from backend.node import Node +from backend.models.file import File +from backend.models.node import Node class DocumentMetadataTest(unittest.TestCase): diff --git a/backend/tests/test_file_controller.py b/backend/tests/test_file_controller.py index 029c6ff..2ef5a46 100644 --- a/backend/tests/test_file_controller.py +++ b/backend/tests/test_file_controller.py @@ -5,8 +5,8 @@ from flask import Flask from backend.controller.file_controller import register_file_routes -from backend.node import Node -from backend.rag_utils import RAGProcessResult +from backend.models.node import Node +from backend.services.rag_utils import RAGProcessResult class FileControllerRAGFailureTest(unittest.TestCase): @@ -32,7 +32,7 @@ def capture_root(key, value): with patch( "backend.controller.file_controller.get_kv", return_value=root.to_json() ), patch( - "backend.rag_persistence.set_kv", side_effect=capture_root + "backend.services.rag_persistence.set_kv", side_effect=capture_root ), patch( "backend.controller.file_controller.add_file_to_cluster", return_value="test-cid", @@ -70,7 +70,7 @@ def test_upload_records_failed_rag_status_when_processing_raises(self): with patch( "backend.controller.file_controller.get_kv", return_value=root.to_json() ), patch( - "backend.rag_persistence.set_kv", + "backend.services.rag_persistence.set_kv", side_effect=lambda key, value: saved_roots.append(value) or True, ), patch( "backend.controller.file_controller.add_file_to_cluster", @@ -112,7 +112,7 @@ def test_upload_rolls_back_rag_when_final_metadata_write_fails(self): with patch( "backend.controller.file_controller.get_kv", return_value=root.to_json() ), patch( - "backend.rag_persistence.set_kv", return_value=False + "backend.services.rag_persistence.set_kv", return_value=False ), patch( "backend.controller.file_controller.add_file_to_cluster", return_value="test-cid", @@ -152,7 +152,7 @@ def test_upload_reports_failed_rollback_after_metadata_write_fails(self): with patch( "backend.controller.file_controller.get_kv", return_value=root.to_json() ), patch( - "backend.rag_persistence.set_kv", return_value=False + "backend.services.rag_persistence.set_kv", return_value=False ), patch( "backend.controller.file_controller.add_file_to_cluster", return_value="test-cid", @@ -182,7 +182,7 @@ def test_upload_persists_skipped_file_once(self): with patch( "backend.controller.file_controller.get_kv", return_value=root.to_json() ), patch( - "backend.rag_persistence.set_kv", + "backend.services.rag_persistence.set_kv", side_effect=lambda key, value: saved_roots.append(value) or True, ), patch( "backend.controller.file_controller.add_file_to_cluster", diff --git a/backend/tests/test_rag_persistence.py b/backend/tests/test_rag_persistence.py index 2b72d99..3072667 100644 --- a/backend/tests/test_rag_persistence.py +++ b/backend/tests/test_rag_persistence.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import Mock, patch -from backend.rag_persistence import ( +from backend.services.rag_persistence import ( RAGPersistenceResult, persist_root_with_rag_rollback, ) @@ -11,7 +11,7 @@ class RAGPersistenceTest(unittest.TestCase): def test_successful_metadata_write_does_not_delete_vectors(self): manager = Mock() - with patch("backend.rag_persistence.set_kv", return_value=True) as set_kv: + with patch("backend.services.rag_persistence.set_kv", return_value=True) as set_kv: result = persist_root_with_rag_rollback( "alice", "root-json", @@ -27,7 +27,7 @@ def test_failed_metadata_write_rolls_back_indexed_document(self): manager = Mock() manager.delete_documents.return_value = True - with patch("backend.rag_persistence.set_kv", return_value=False): + with patch("backend.services.rag_persistence.set_kv", return_value=False): result = persist_root_with_rag_rollback( "alice", "root-json", @@ -42,7 +42,7 @@ def test_failed_metadata_write_reports_failed_rollback(self): manager = Mock() manager.delete_documents.return_value = False - with patch("backend.rag_persistence.set_kv", return_value=False): + with patch("backend.services.rag_persistence.set_kv", return_value=False): result = persist_root_with_rag_rollback( "alice", "root-json", diff --git a/backend/tests/test_rag_utils.py b/backend/tests/test_rag_utils.py index c0320fa..3447507 100644 --- a/backend/tests/test_rag_utils.py +++ b/backend/tests/test_rag_utils.py @@ -4,7 +4,7 @@ import numpy as np from qdrant_client import QdrantClient -from backend.rag_utils import RAGManager +from backend.services.rag_utils import RAGManager class QdrantRAGManagerTest(unittest.TestCase): diff --git a/backend/tests/test_share_manager.py b/backend/tests/test_share_manager.py index 2a1d77a..7f297f6 100644 --- a/backend/tests/test_share_manager.py +++ b/backend/tests/test_share_manager.py @@ -1,8 +1,8 @@ import unittest -from backend.error import ErrorCode -from backend.node import Node -from backend.share_manager import ShareManager +from backend.utils.error import ErrorCode +from backend.models.node import Node +from backend.services.share_manager import ShareManager class ShareManagerResolveForClientTest(unittest.TestCase): diff --git a/backend/error.py b/backend/utils/error.py similarity index 100% rename from backend/error.py rename to backend/utils/error.py diff --git a/backend/util.py b/backend/utils/util.py similarity index 100% rename from backend/util.py rename to backend/utils/util.py diff --git a/docs/QUICK-START.md b/docs/QUICK-START.md index b98ff96..62c150c 100644 --- a/docs/QUICK-START.md +++ b/docs/QUICK-START.md @@ -18,9 +18,9 @@ docker compose logs -f curl http://localhost:5000/ ``` -## Manual Local Development +## Local Development -See the dedicated [local development guide](../development.md) for the complete +See the dedicated [local development guide](../docs/development.md) for the complete manual startup flow for IPFS, IPFS Cluster, Qdrant, Flask, and React. ## AWS EC2 Deployment (30 Minutes) diff --git a/development.md b/docs/development.md similarity index 97% rename from development.md rename to docs/development.md index 729c52a..e28750c 100644 --- a/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ ResilientDB metadata storage, you need a reachable ResilientDB KV service. ## One-time setup -Install the project dependencies described in the [README](README.md), then +Install the project dependencies described in the [README](../README.md), then initialize the local IPFS repositories: ```bash diff --git a/kv_smoke.py b/kv_smoke.py index 2873cb9..3be92e5 100644 --- a/kv_smoke.py +++ b/kv_smoke.py @@ -1,6 +1,6 @@ """Manually verify a configured ResilientDB key-value service.""" -from backend.RSDB_kv_service import get_kv, set_kv +from backend.services.RSDB_kv_service import get_kv, set_kv def main(): From 9c05fc461cdebb947c1e4b0c9095df3b51ba48e1 Mon Sep 17 00:00:00 2001 From: Devang Borkar Date: Tue, 21 Jul 2026 23:03:27 -0700 Subject: [PATCH 2/6] refactor: move rag capabilies to seperate module --- env.example => .env.example | 5 +- backend/controller/auth_controller.py | 6 +- backend/controller/chat_controller.py | 12 +- backend/controller/file_controller.py | 147 +--- backend/controller/helpers.py | 2 +- backend/controller/share_controller.py | 2 +- backend/rag/chunking.py | 46 ++ backend/rag/embeddings.py | 54 ++ backend/rag/extraction.py | 64 ++ backend/rag/indexing.py | 70 ++ backend/rag/retrieval.py | 19 + backend/rag/types.py | 120 +++ backend/rag/vector_store.py | 256 +++++++ backend/services/answer_service.py | 45 ++ backend/services/delete_service.py | 31 +- backend/services/rag_persistence.py | 60 -- backend/services/rag_utils.py | 685 ------------------ backend/services/upload_service.py | 177 +++++ .../services/user_authentication_service.py | 2 +- .../ipfs_service.py => storage/ipfs.py} | 3 +- .../RSDB_kv_service.py => storage/kv.py} | 14 +- backend/tests/test_delete_service.py | 136 ++-- backend/tests/test_file_controller.py | 71 +- backend/tests/test_rag_persistence.py | 29 +- backend/tests/test_rag_utils.py | 63 +- backend/utils/error.py | 1 + backend/utils/util.py | 18 - kv_smoke.py | 2 +- 28 files changed, 1056 insertions(+), 1084 deletions(-) rename env.example => .env.example (82%) create mode 100644 backend/rag/chunking.py create mode 100644 backend/rag/embeddings.py create mode 100644 backend/rag/extraction.py create mode 100644 backend/rag/indexing.py create mode 100644 backend/rag/retrieval.py create mode 100644 backend/rag/types.py create mode 100644 backend/rag/vector_store.py create mode 100644 backend/services/answer_service.py delete mode 100644 backend/services/rag_persistence.py delete mode 100644 backend/services/rag_utils.py create mode 100644 backend/services/upload_service.py rename backend/{services/ipfs_service.py => storage/ipfs.py} (98%) rename backend/{services/RSDB_kv_service.py => storage/kv.py} (88%) diff --git a/env.example b/.env.example similarity index 82% rename from env.example rename to .env.example index 9b77521..59e462c 100644 --- a/env.example +++ b/.env.example @@ -13,4 +13,7 @@ FLASK_SECRET_KEY="secret_key_here" # Optional: Override default Flask port # FLASK_RUN_PORT=5000 STORAGE_TYPE=memory -KV_SERVICE_URL="https://crow.resilientdb.com" \ No newline at end of file +KV_SERVICE_URL="https://crow.resilientdb.com" + +# Qdrant Vector Database Configuration +QDRANT_VECTOR_DB_URL="http://localhost:6333" \ No newline at end of file diff --git a/backend/controller/auth_controller.py b/backend/controller/auth_controller.py index 4b096bd..619e4f4 100644 --- a/backend/controller/auth_controller.py +++ b/backend/controller/auth_controller.py @@ -3,8 +3,9 @@ from flask import jsonify, request, session -from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.storage.kv import get_kv, set_kv from backend.utils.error import ErrorCode +from backend.services.delete_service import delete_user_data from backend.services.user_authentication_service import login, sign_up from backend.controller.helpers import ( collect_indexed_document_ids, @@ -12,7 +13,6 @@ get_root_node, login_required, ) -from backend.services.rag_utils import get_rag_manager def register_auth_routes(app, logger): @@ -71,7 +71,7 @@ def delete_user_route(): root = get_root_node(username) has_indexed_documents = bool(root and collect_indexed_document_ids(root)) - if has_indexed_documents and not get_rag_manager().delete_user_data(username): + if has_indexed_documents and not delete_user_data(username): return jsonify({'message': 'RAG_DELETE_FAILED'}), 503 set_kv(username, "\n") diff --git a/backend/controller/chat_controller.py b/backend/controller/chat_controller.py index 799269b..b710330 100644 --- a/backend/controller/chat_controller.py +++ b/backend/controller/chat_controller.py @@ -6,14 +6,13 @@ login_required, route_logger, ) -from backend.services.rag_utils import get_llm_integration, get_rag_manager - +from backend.services.answer_service import generate_answer +from backend.rag.retrieval import search def register_chat_routes(app, logger): @app.route('/chat', methods=['POST']) @login_required def chat_route(): - try: data = request.get_json() @@ -26,8 +25,8 @@ def chat_route(): username = session['username'] - rag_manager = get_rag_manager() - relevant_chunks = rag_manager.search_user_vector_db(username, query, top_k=5) + relevant_chunks = search(username, query, top_k=5) + #TODO: Implement reranking and more sophisticated answer generation if not relevant_chunks: return jsonify({ @@ -36,8 +35,7 @@ def chat_route(): 'chunks_found': 0 }), 200 - llm_integration = get_llm_integration() - answer = llm_integration.generate_answer(query, relevant_chunks) + answer = generate_answer(query, relevant_chunks) sources = [] seen_files = set() diff --git a/backend/controller/file_controller.py b/backend/controller/file_controller.py index 8bbffa5..f193b40 100644 --- a/backend/controller/file_controller.py +++ b/backend/controller/file_controller.py @@ -1,19 +1,14 @@ from io import BytesIO -import mimetypes import zipfile from flask import jsonify, request, send_file, session -from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.storage.kv import get_kv, set_kv from backend.services.delete_service import delete_node from backend.utils.error import ErrorCode -from backend.models.file import File -from backend.services.ipfs_service import add_file_to_cluster, download_file_from_ipfs +from backend.storage.ipfs import download_file_from_ipfs +from backend.services.upload_service import upload_file from backend.models.node import Node -from backend.services.rag_persistence import ( - RAGPersistenceResult, - persist_root_with_rag_rollback, -) from backend.controller.helpers import ( collect_files_recursively, get_root_node, @@ -21,10 +16,6 @@ login_required, route_logger, ) -from backend.utils.util import validate_file_size -from backend.services.rag_utils import get_rag_manager - -FILE_SIZE_LIMIT = 1024 * 1024 # 1 MB limit def register_file_routes(app, logger): @@ -72,7 +63,7 @@ def delete_route(): def upload_route(): """ Upload file to IPFS and process for RAG if it's a supported text format. - if user want to upload example.txt to path root/doc/example.txt. The path part in request should be root/doc + if user wants to upload example.txt to path root/doc/example.txt. The path part in request should be root/doc """ if 'file' not in request.files or 'path' not in request.form: return jsonify({'message': ErrorCode.INVALID_REQUEST.name}), 400 @@ -80,132 +71,12 @@ def upload_route(): path = request.form['path'] username = session['username'] file = request.files['file'] - raw_skip_flag = request.form.get('skip_ai_processing', 'false') - skip_ai_processing = raw_skip_flag.lower() == 'true' - route_logger.info(f"Upload request: user={username}, file={file.filename if file else 'N/A'}, skip_ai_processing_raw='{raw_skip_flag}', parsed={skip_ai_processing}") - - if file.filename == '': - return jsonify({'message': ErrorCode.INVALID_REQUEST.name}), 400 - - filename = file.filename - file_stream = BytesIO(file.read()) - - file_size = file_stream.getbuffer().nbytes - - is_valid, error_message = validate_file_size(file_size, FILE_SIZE_LIMIT) - if not is_valid: - return jsonify({'message': error_message}), 413 - - cid = add_file_to_cluster(file_stream, filename) - - if cid is None: - return jsonify({'message': ErrorCode.IPFS_ERROR.name}), 500 + + skip_flag = request.form.get('skip_ai_processing', 'false') + skip_ai_processing = skip_flag.lower() == 'true' - root = Node.from_json(get_kv(username + " ROOT")) - target_node = root.find_node_by_path(path) - - if target_node is None: - return jsonify({'message': ErrorCode.NODE_NOT_FOUND.name}), 404 - - supported_extensions = {'pdf', 'docx', 'txt'} - file_extension = filename.lower().split('.')[-1] if '.' in filename else '' - should_index = not skip_ai_processing and file_extension in supported_extensions - file_obj = File( - cid, - file_size, - filename, - mime_type=file.mimetype or mimetypes.guess_type(filename)[0], - rag_status="pending" if should_index else "skipped", - ) - result = target_node.add_child(Node(filename, False, file_obj=file_obj)) - - if result != ErrorCode.SUCCESS: - return jsonify({'message': result.name}), 400 - - rag_success = False - rag_skipped = False - rag_manager = None - - if should_index: - try: - file_stream.seek(0) - file_content = file_stream.read() - normalized_parent = path.strip("/") - if normalized_parent == "root": - normalized_parent = "" - elif normalized_parent.startswith("root/"): - normalized_parent = normalized_parent[len("root/"):] - document_path = "/".join( - part for part in (normalized_parent, filename) if part - ) - - rag_manager = get_rag_manager() - rag_result = rag_manager.process_file_for_rag( - file_content, - filename, - username, - cid, - file_obj.document_id, - document_path, - ) - rag_success = rag_result.success - - if rag_success: - file_obj.mark_rag_ready( - rag_result.chunk_count, - rag_manager.embedding_model_name, - ) - route_logger.info( - "Successfully processed %s for RAG for user %s", - filename, - username, - ) - else: - file_obj.mark_rag_failed( - rag_result.error, - rag_manager.embedding_model_name, - ) - route_logger.warning( - "Failed to process %s for RAG for user %s", - filename, - username, - ) - - except Exception as e: - route_logger.error(f"RAG processing error for {filename}: {e}") - rag_success = False - file_obj.mark_rag_failed(str(e), "gemini-embedding-001") - - elif skip_ai_processing: - rag_skipped = True - route_logger.info( - "RAG processing skipped for %s for user %s (AI mode disabled)", - filename, - username, - ) - else: - rag_skipped = True - route_logger.info(f"RAG processing skipped for unsupported file {filename}") - - persistence_result = persist_root_with_rag_rollback( - username, - root.to_json(), - rag_manager=rag_manager if rag_success else None, - indexed_document_id=file_obj.document_id if rag_success else None, - ) - if persistence_result != RAGPersistenceResult.SUCCESS: - return jsonify({'message': persistence_result.value}), 503 - - response_data = { - 'message': ErrorCode.SUCCESS.name, - 'root': root.to_json(), - 'rag_processed': rag_success, - 'rag_skipped': rag_skipped, - 'rag_status': file_obj.rag_status, - 'skip_ai_processing': skip_ai_processing - } - - return jsonify(response_data), 200 + response_data, status_code = upload_file(file, path, username, skip_ai_processing) + return jsonify(response_data), status_code @app.route('/download', methods=['POST']) @login_required diff --git a/backend/controller/helpers.py b/backend/controller/helpers.py index c121316..fec5c77 100644 --- a/backend/controller/helpers.py +++ b/backend/controller/helpers.py @@ -4,7 +4,7 @@ from flask import jsonify, session -from backend.services.RSDB_kv_service import get_kv +from backend.storage.kv import get_kv from backend.utils.error import ErrorCode from backend.models.node import Node from backend.services.share_manager import ShareManager diff --git a/backend/controller/share_controller.py b/backend/controller/share_controller.py index e11fa1f..71cba2e 100644 --- a/backend/controller/share_controller.py +++ b/backend/controller/share_controller.py @@ -1,6 +1,6 @@ from flask import jsonify, request, session -from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.storage.kv import get_kv, set_kv from backend.utils.error import ErrorCode from backend.models.node import Node from backend.controller.helpers import ( diff --git a/backend/rag/chunking.py b/backend/rag/chunking.py new file mode 100644 index 0000000..83dc500 --- /dev/null +++ b/backend/rag/chunking.py @@ -0,0 +1,46 @@ +from uuid import NAMESPACE_URL, uuid5 +from langchain.docstore.document import Document as LangchainDocument +from langchain.text_splitter import RecursiveCharacterTextSplitter +from typing import Dict, List + +def chunk_text(text: str, metadata: Dict) -> List[Dict]: + """ + Split text into chunks with metadata + + Args: + text: Text to chunk + metadata: Metadata to attach to each chunk + + Returns: + List of chunk dictionaries with text and metadata + """ + if not text.strip(): + return [] + + doc = LangchainDocument(page_content=text, metadata=metadata) + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=800, + chunk_overlap=100, + length_function=len, + separators=["\n\n", "\n", ". ", " ", ""], + ) + chunks = text_splitter.split_documents([doc]) + + chunk_dicts = [] + for i, chunk in enumerate(chunks): + chunk_dict = { + 'text': chunk.page_content, + 'metadata': { + **chunk.metadata, + 'chunk_index': i, + 'chunk_id': str(uuid5( # UUIDv5 gives stable, idempotent IDs:same document + same chunk index → same UUID + NAMESPACE_URL, + ( + f"reshare:{metadata['document_id']}:{i}" + ), + )) + } + } + chunk_dicts.append(chunk_dict) + + return chunk_dicts \ No newline at end of file diff --git a/backend/rag/embeddings.py b/backend/rag/embeddings.py new file mode 100644 index 0000000..046d0fb --- /dev/null +++ b/backend/rag/embeddings.py @@ -0,0 +1,54 @@ +import numpy as np +from backend.rag.types import GeminiClientConfig +from typing import List +import requests + +import logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def generate_embeddings( + texts: List[str], + embedding_model: str, + embedding_dimensions: int = 768, + task_type: str = "RETRIEVAL_DOCUMENT" + ) -> np.ndarray: + """ + Generate embeddings for a list of texts using Gemini API batch processing + + Args: + texts: List of text chunks + + Returns: + Numpy array of embeddings + """ + if not texts: + return np.array([]) + + logger.debug(f"Generating embeddings for {len(texts)} texts") + + try: + logger.info(f"Using Gemini API with model: {embedding_model}, dimension: {embedding_dimensions}") + embedding_url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{embedding_model}:batchEmbedContents" + ) + client = GeminiClientConfig(model_name=embedding_model, url=embedding_url) + embeddings = client.get_embeddings(texts, task_type=task_type, dim=embedding_dimensions) + return embeddings + + except requests.exceptions.Timeout: + logger.error("Gemini API timeout during batch embedding generation") + return np.array([]) + except requests.exceptions.RequestException as e: + logger.error(f"Gemini API request failed: {e}") + if hasattr(e, 'response') and e.response is not None: + logger.error(f"Response status: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + return np.array([]) + except Exception as e: + logger.error(f"Failed to generate embeddings with Gemini API: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + return np.array([]) \ No newline at end of file diff --git a/backend/rag/extraction.py b/backend/rag/extraction.py new file mode 100644 index 0000000..c08c080 --- /dev/null +++ b/backend/rag/extraction.py @@ -0,0 +1,64 @@ +import PyPDF2 +from docx import Document +from io import BytesIO +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def extract_text_from_file(file_content: bytes, filename: str) -> str: + """ + Extract text from various file formats + + Args: + file_content: Raw bytes of the file + filename: Name of the file (used to determine format) + + Returns: + Extracted text content + """ + file_extension = filename.lower().split('.')[-1] + + try: + if file_extension == 'pdf': + return _extract_from_pdf(file_content) + elif file_extension == 'docx': + return _extract_from_docx(file_content) + elif file_extension == 'txt': + return _extract_from_txt(file_content) + else: + logger.warning(f"Unsupported file type: {file_extension}") + return "" + except Exception as e: + logger.error(f"Failed to extract text from {filename}: {e}") + return "" + +def _extract_from_pdf(file_content: bytes) -> str: + """Extract text from PDF file""" + text = "" + try: + pdf_reader = PyPDF2.PdfReader(BytesIO(file_content)) + for page in pdf_reader.pages: + text += page.extract_text() + "\n" + except Exception as e: + logger.error(f"PDF extraction error: {e}") + return text.strip() + +def _extract_from_docx(file_content: bytes) -> str: + """Extract text from DOCX file""" + text = "" + try: + doc = Document(BytesIO(file_content)) + for paragraph in doc.paragraphs: + text += paragraph.text + "\n" + except Exception as e: + logger.error(f"DOCX extraction error: {e}") + return text.strip() + +def _extract_from_txt(file_content: bytes) -> str: + """Extract text from TXT file""" + try: + return file_content.decode('utf-8', errors='ignore') + except Exception as e: + logger.error(f"TXT extraction error: {e}") + return "" \ No newline at end of file diff --git a/backend/rag/indexing.py b/backend/rag/indexing.py new file mode 100644 index 0000000..4b0b61e --- /dev/null +++ b/backend/rag/indexing.py @@ -0,0 +1,70 @@ +from backend.rag.types import RAGProcessResult +from backend.rag.extraction import extract_text_from_file +from backend.rag.chunking import chunk_text +from backend.rag.vector_store import get_vector_store + +import logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def process_file_for_rag( + file_content: bytes, + filename: str, + username: str, + cid: str, + document_id: str, + path: str, + ) -> RAGProcessResult: + """ + Complete pipeline to process a file for RAG + + Args: + file_content: Raw file bytes + filename: Name of the file + username: User who uploaded the file + cid: IPFS CID of the file + document_id: Stable uploaded-document identifier + path: Current user-visible file path + + Returns: + Processing result including chunk count and error details + """ + try: + + #surely there is a better way to do this than creating a new instance every time + vector_store = get_vector_store() + + text = extract_text_from_file(file_content, filename) + if not text.strip(): + logger.info(f"No text extracted from {filename}") + return RAGProcessResult(False, error="No extractable text found", embedding_model_name=vector_store.embedding_model_name) + + metadata = { + "user_id": username, + "document_id": document_id, + "filename": filename, + "cid": cid, + "path": path, + "file_type": filename.lower().split('.')[-1], + } + + chunks = chunk_text(text, metadata) + if not chunks: + logger.info(f"No chunks created from {filename}") + return RAGProcessResult(False, error="No text chunks were created", embedding_model_name=vector_store.embedding_model_name) + + + success = vector_store.add_chunks(username, chunks) + if success: + logger.info(f"Successfully processed {filename} for RAG: {len(chunks)} chunks") + return RAGProcessResult(True, chunk_count=len(chunks), embedding_model_name=vector_store.embedding_model_name) + + return RAGProcessResult( + False, + error="Embedding generation or Qdrant indexing failed", + embedding_model_name=vector_store.embedding_model_name, + ) + + except Exception as exc: + logger.error("Failed to process file for RAG: %s", exc) + return RAGProcessResult(False, error=str(exc), embedding_model_name=vector_store.embedding_model_name) \ No newline at end of file diff --git a/backend/rag/retrieval.py b/backend/rag/retrieval.py new file mode 100644 index 0000000..e5fe393 --- /dev/null +++ b/backend/rag/retrieval.py @@ -0,0 +1,19 @@ +from backend.rag.vector_store import get_vector_store + + +def search(username: str, query: str, top_k: int = 5) -> list: + """ + Search for relevant documents using the RAG manager. + + Args: + username (str): The username of the user. + query (str): The search query. + top_k (int): The number of top results to return. + + Returns: + list: A list of relevant documents. + """ + #surely there is a better way to do this than creating a new instance every time + vector_store = get_vector_store() + + return vector_store.search_user_vector_db(username, query, top_k) \ No newline at end of file diff --git a/backend/rag/types.py b/backend/rag/types.py new file mode 100644 index 0000000..1c744f0 --- /dev/null +++ b/backend/rag/types.py @@ -0,0 +1,120 @@ +from typing import Optional +import requests +import logging +import os +import numpy as np + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +class RAGProcessResult: + def __init__( + self, + success: bool, + chunk_count: int = 0, + embedding_model_name: str = "gemini-embedding-001", + error: Optional[str] = None, + + ): + self.success = success + self.chunk_count = chunk_count + self.error = error + self.embedding_model_name = embedding_model_name + +class GeminiClientConfig: + def __init__(self, url: str, model_name: str = "gemini-embedding-001"): + self.api_key = self._load_api_key() + self.model_name = model_name + self.url = url + + def get_text_response(self, prompt: str, max_tokens: int) -> str: + """Generate response using Gemini API""" + try: + headers = { + "x-goog-api-key": self.api_key, + "Content-Type": "application/json" + } + + data = { + "contents": [ + { + "parts": [ + { + "text": prompt + } + ] + } + ], + "generationConfig": { + "maxOutputTokens": max_tokens + }, + "systemInstruction": { + "parts": [ + { + "text": "You are a helpful assistant that answers questions based on the provided context from user's documents." + } + ] + } + } + + response = requests.post(self.url, headers=headers, json=data) + response.raise_for_status() + + result = response.json() + logger.debug(f"Gemini API response: {result}") + return result['candidates'][0]['content']['parts'][0]['text'] + except Exception as e: + logger.error(f"Gemini API error: {e}") + raise + + def get_embeddings(self, texts: list, task_type: str = "RETRIEVAL_DOCUMENT", dim: int = 768) -> np.ndarray: + """Check if Gemini API is reachable and the API key is valid""" + + headers = { + "x-goog-api-key": self.api_key, + "Content-Type": "application/json" + } + + requests_data = [] + for text in texts: + requests_data.append({ + "model": f"models/{self.model_name}", + "content": { + "parts": [{"text": text}] + }, + "task_type": task_type, #Figure out what's the point of adding task_type? + "output_dimensionality": dim + }) + + data = {"requests": requests_data} + + logger.info(f"Embedding request payload count: {len(requests_data)}") + response = requests.post(self.url, headers=headers, json=data, timeout=30) + response.raise_for_status() + + result = response.json() + logger.info(f"Embedding API responded with keys: {list(result.keys())}") + + embeddings = [] + api_embeddings = result.get("embeddings", []) + logger.info(f"Embedding objects returned: {len(api_embeddings)}") + for embedding_response in api_embeddings: + embedding_values = np.array(embedding_response["values"]) + # if embedding_client.embedding_dimension < 3072: # Normalize truncated dimensions + # norm = np.linalg.norm(embedding_values) + # if norm > 0: + # embedding_values = embedding_values / norm + embeddings.append(embedding_values) + + if len(embeddings) != len(texts): + logger.info(f"Mismatch: expected {len(texts)} embeddings, got {len(embeddings)}") + return np.array([]) + + embeddings_np = np.array(embeddings) + logger.debug(f"Built embeddings array with shape {embeddings_np.shape}") + return embeddings_np + + def _load_api_key(self): + api_key = os.getenv("GOOGLE_API_KEY") + if not api_key: + logger.error("GOOGLE_API_KEY not found in environment variables") + return api_key \ No newline at end of file diff --git a/backend/rag/vector_store.py b/backend/rag/vector_store.py new file mode 100644 index 0000000..f210f09 --- /dev/null +++ b/backend/rag/vector_store.py @@ -0,0 +1,256 @@ + +import os +from typing import List, Dict +import logging +from backend.rag.embeddings import generate_embeddings +from qdrant_client import QdrantClient, models + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class QDrantVectorStore: + def __init__( + self, + qdrant_url: str = "http://localhost:6333", + embedding_model_name: str = "gemini-embedding-001", + embedding_dimension: int = 768 + ): + self.qdrant_client = QdrantClient(url=qdrant_url) + self._collection_ready = False + self.embedding_model_name = embedding_model_name + self.embedding_dimension = embedding_dimension + self.collection_name = "resshare_vectors" + + def _ensure_collection(self) -> bool: + if self._collection_ready: + return True + + try: + if not self.qdrant_client.collection_exists(self.collection_name): + self.qdrant_client.create_collection( + collection_name=self.collection_name, + vectors_config=models.VectorParams( + size=self.embedding_dimension, + distance=models.Distance.COSINE, + ), + ) + + for field_name in ("user_id", "document_id"): + self.qdrant_client.create_payload_index( + collection_name=self.collection_name, + field_name=field_name, + field_schema=models.PayloadSchemaType.KEYWORD, + wait=True, + ) + + self._collection_ready = True + return True + except Exception as exc: + logger.error("Failed to initialize Qdrant collection: %s", exc) + return False + + def add_chunks(self, username: str, chunks: List[Dict]) -> bool: + """ + Add text chunks to user's vector database + + Args: + username: User identifier + chunks: List of chunk dictionaries + Returns: + Success boolean + """ + if not chunks: + return True + + try: + if not self._ensure_collection(): + return False + + texts = [chunk["text"] for chunk in chunks] + logger.info("Preparing to add %s chunks for user '%s'", len(texts), username) + + embeddings = generate_embeddings(texts, embedding_model=self.embedding_model_name, embedding_dimensions=self.embedding_dimension, task_type="RETRIEVAL_DOCUMENT") + if embeddings.size == 0: + return False + + #Deleting old chunks for the same document before adding new ones + document_id = chunks[0]["metadata"]["document_id"] + if not self.delete_documents(username, [document_id]): + return False + + points = [] + for chunk, embedding in zip(chunks, embeddings): + metadata = chunk["metadata"] + payload = { + "user_id": username, + "document_id": metadata["document_id"], + "cid": metadata["cid"], + "filename": metadata["filename"], + "path": metadata["path"], + "file_type": metadata["file_type"], + "chunk_index": metadata["chunk_index"], + "chunk_text": chunk["text"], + "embedding_model": self.embedding_model_name, + } + points.append( + models.PointStruct( + id=metadata["chunk_id"], + vector=embedding.tolist(), + payload=payload, + ) + ) + + logger.debug("Upserting %s vectors into Qdrant", len(points)) + self.qdrant_client.upsert( + collection_name=self.collection_name, + points=points, + wait=True, + ) + logger.info(f"Indexed {len(points)} chunks for user '{username}', document '{document_id}'") + return True + + except Exception as exc: + logger.error("Failed to add chunks to Qdrant: %s", exc) + return False + + def search_user_vector_db(self, username: str, query: str, top_k: int = 5) -> List[Dict]: + """ + Search user's vector database for relevant chunks + + Args: + username: User identifier + query: Search query + top_k: Number of results to return + + Returns: + List of relevant chunks with scores + """ + if not self._ensure_collection(): + return [] + + try: + query_embedding = generate_embeddings([query], embedding_model=self.embedding_model_name, embedding_dimensions=self.embedding_dimension, task_type="RETRIEVAL_QUERY") + + if query_embedding.size == 0: + logger.warning("Query embedding generation failed for user '%s'", username) + return [] + + response = self.qdrant_client.query_points( + collection_name=self.collection_name, + query=query_embedding[0].tolist(), + query_filter=models.Filter( + must=[ + models.FieldCondition( + key="user_id", + match=models.MatchValue(value=username), + ) + ] + ), + limit=top_k, + with_payload=True, + with_vectors=False, + ) + + results = [] + for point in response.points: + payload = point.payload or {} + metadata = { + key: payload.get(key) + for key in ( + "user_id", + "document_id", + "cid", + "filename", + "path", + "file_type", + "chunk_index", + "embedding_model", + ) + } + results.append( + { + "chunk": { + "text": payload.get("chunk_text", ""), + "metadata": metadata, + }, + "score": float(point.score), + } + ) + + logger.info(f"Qdrant search returned {len(results)} results") + return results + + except Exception as exc: + logger.error("Failed to search Qdrant: %s", exc) + return [] + + def delete_documents(self, username: str, document_ids: List[str]) -> bool: + """Delete all chunks owned by a user for the supplied document IDs.""" + if not document_ids: + return True + if not self._ensure_collection(): + return False + + try: + self.qdrant_client.delete( + collection_name=self.collection_name, + points_selector=models.FilterSelector( + filter=models.Filter( + must=[ + models.FieldCondition( + key="user_id", + match=models.MatchValue(value=username), + ), + models.FieldCondition( + key="document_id", + match=models.MatchAny(any=document_ids), + ), + ] + ) + ), + wait=True, + ) + return True + except Exception as exc: + logger.error("Failed to delete documents from Qdrant: %s", exc) + return False + + + def delete_user_data(self, username: str) -> bool: + """Delete every indexed chunk owned by a user.""" + if not self._ensure_collection(): + return False + + try: + self.qdrant_client.delete( + collection_name=self.collection_name, + points_selector=models.FilterSelector( + filter=models.Filter( + must=[ + models.FieldCondition( + key="user_id", + match=models.MatchValue(value=username), + ) + ] + ) + ), + wait=True, + ) + return True + except Exception as exc: + logger.error("Failed to delete user data from Qdrant: %s", exc) + return False + +def get_vector_store(qdrant_url: str = "") -> QDrantVectorStore: + """ + Factory function to get a QDrantVectorStore instance + + Args: + qdrant_url: URL of the Qdrant service + + Returns: + QDrantVectorStore instance + """ + if not qdrant_url: + qdrant_url = os.getenv("QDRANT_VECTOR_DB_URL", "http://localhost:6333") + return QDrantVectorStore(qdrant_url=qdrant_url) \ No newline at end of file diff --git a/backend/services/answer_service.py b/backend/services/answer_service.py new file mode 100644 index 0000000..25df58b --- /dev/null +++ b/backend/services/answer_service.py @@ -0,0 +1,45 @@ +from typing import List, Dict +from backend.rag.types import GeminiClientConfig + +import logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def generate_answer(query: str, context_chunks: List[Dict], max_tokens: int = 5000) -> str: + """ + Generate an answer using retrieved context + + Args: + query: User's question + context_chunks: Retrieved relevant chunks + max_tokens: Maximum tokens for response + + Returns: + Generated answer + """ + if not context_chunks: + return "I couldn't find any relevant information in your uploaded files to answer this question." + + context_text = "\n\n".join([ + f"From {chunk['chunk']['metadata']['filename']}:\n{chunk['chunk']['text']}" + for chunk in context_chunks[:3] + ]) + + prompt = f"""Based on the following context from the user's uploaded files, please answer their question. If the context doesn't contain enough information to answer the question, please say so. + +Context: +{context_text} + +Question: {query} + +Answer:""" + + try: + model_name = "gemini-2.5-flash" #Need to move away from hardcoding + text_responses_url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent" + client = GeminiClientConfig(model_name=model_name, url=text_responses_url) + return client.get_text_response(prompt, max_tokens) + except Exception as e: + logger.error(f"Failed to generate LLM response: {e}") + return "I encountered an error while generating the response. Please try again." diff --git a/backend/services/delete_service.py b/backend/services/delete_service.py index 59e41c5..3f4d892 100644 --- a/backend/services/delete_service.py +++ b/backend/services/delete_service.py @@ -1,10 +1,10 @@ from flask import jsonify, session -from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.storage.kv import get_kv, set_kv from backend.utils.error import ErrorCode from backend.models.node import Node from backend.services.share_manager import ShareManager from backend.controller.helpers import collect_indexed_document_ids -from backend.services.rag_utils import get_rag_manager +from backend.rag.vector_store import get_vector_store def _load_root(username: str): @@ -44,10 +44,11 @@ def delete_node(data): if node_name not in parent_node.children: return jsonify({'message': ErrorCode.NODE_NOT_FOUND.name}), 404 - target_node = parent_node.children[node_name] - document_ids = collect_indexed_document_ids(target_node) - if document_ids and not get_rag_manager().delete_documents(username, document_ids): - return jsonify({'message': 'RAG_DELETE_FAILED'}), 503 + #TODO: Figure ts out + # target_node = parent_node.children[node_name] + # document_ids = collect_indexed_document_ids(target_node) + # if document_ids and not get_rag_manager().delete_documents(username, document_ids): + # return jsonify({'message': 'RAG_DELETE_FAILED'}), 503 del parent_node.children[node_name] @@ -69,3 +70,21 @@ def delete_node(data): return jsonify({'message': result.name, 'share_list': share_manager.resolve_for_client(_load_root)}), 200 + +def delete_user_data(username: str) -> bool: + """ + Deletes all user data, including files and RAG data. + Returns True if successful, False otherwise. + """ + root = _load_root(username) + has_indexed_documents = bool(root and collect_indexed_document_ids(root)) + qdrant_vector_store = get_vector_store() + if has_indexed_documents: + if not qdrant_vector_store.delete_user_data(username): + return False + + set_kv(username, "\n") + set_kv(username + " ROOT", "\n") + set_kv(username + " SHARE_MANAGER", "\n") + + return True \ No newline at end of file diff --git a/backend/services/rag_persistence.py b/backend/services/rag_persistence.py deleted file mode 100644 index 1b193cd..0000000 --- a/backend/services/rag_persistence.py +++ /dev/null @@ -1,60 +0,0 @@ -import logging -from enum import Enum -from typing import Optional -from backend.services.rag_utils import RAGManager -from backend.services.RSDB_kv_service import set_kv - - -logger = logging.getLogger(__name__) - - -class RAGPersistenceResult(Enum): - SUCCESS = "SUCCESS" - METADATA_WRITE_FAILED = "METADATA_WRITE_FAILED" - RAG_ROLLBACK_FAILED = "RAG_ROLLBACK_FAILED" - - -def persist_root_with_rag_rollback( - username, - root_json, - rag_manager: Optional[RAGManager]=None, - indexed_document_id: Optional[str]=None, -) -> RAGPersistenceResult: - """Persist the final root and remove new vectors if that write fails.""" - if set_kv(username + " ROOT", root_json): - return RAGPersistenceResult.SUCCESS - - logger.error("Failed to persist final file metadata for user %s", username) - - if indexed_document_id is None: - return RAGPersistenceResult.METADATA_WRITE_FAILED - - if rag_manager is None: - logger.error( - "Cannot roll back indexed document %s without a RAG manager", - indexed_document_id, - ) - return RAGPersistenceResult.RAG_ROLLBACK_FAILED - - try: - rollback_succeeded = rag_manager.delete_documents( - username, - [indexed_document_id], - ) - except Exception as exc: - logger.error( - "Failed to roll back indexed document %s for user %s: %s", - indexed_document_id, - username, - exc, - ) - rollback_succeeded = False - - if not rollback_succeeded: - logger.error( - "RAG data for document %s may be orphaned after metadata write failure", - indexed_document_id, - ) - return RAGPersistenceResult.RAG_ROLLBACK_FAILED - - return RAGPersistenceResult.METADATA_WRITE_FAILED diff --git a/backend/services/rag_utils.py b/backend/services/rag_utils.py deleted file mode 100644 index 77089d8..0000000 --- a/backend/services/rag_utils.py +++ /dev/null @@ -1,685 +0,0 @@ -import os -from dataclasses import dataclass -from io import BytesIO -import logging -from typing import Dict, List, Optional -from uuid import NAMESPACE_URL, uuid5 - -import numpy as np -import PyPDF2 -from docx import Document -from langchain.docstore.document import Document as LangchainDocument -from langchain.text_splitter import RecursiveCharacterTextSplitter -from qdrant_client import QdrantClient, models -import requests - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class RAGProcessResult: - success: bool - chunk_count: int = 0 - error: Optional[str] = None - - -class RAGManager: - """ - Manages RAG (Retrieval-Augmented Generation) functionality for the file sharing app. - Handles text extraction, chunking, embeddings, and vector search. - """ - - COLLECTION_NAME = "document_chunks_v1" - - def __init__( - self, - embedding_model: str = "gemini-embedding-001", - embedding_dimension: int = 768, - qdrant_client: Optional[QdrantClient] = None, - ): - """ - Initialize RAG Manager - - Args: - embedding_model: Gemini embedding model name - embedding_dimension: Output embedding dimension (768, 1536, or 3072) - qdrant_client: Optional client override used by tests - """ - self.embedding_model_name = embedding_model - self.embedding_dimension = embedding_dimension - self.api_key = os.getenv("GOOGLE_API_KEY") - self._collection_ready = False - self.qdrant_client = qdrant_client or QdrantClient( - host=os.getenv("QDRANT_HOST", "localhost"), - port=int(os.getenv("QDRANT_PORT", "6333")), - timeout=10, - ) - - self.text_splitter = RecursiveCharacterTextSplitter( - chunk_size=800, - chunk_overlap=100, - length_function=len, - separators=["\n\n", "\n", ". ", " ", ""], - ) - - self._setup_gemini_api() - - def _ensure_collection(self) -> bool: - if self._collection_ready: - return True - - try: - if not self.qdrant_client.collection_exists(self.COLLECTION_NAME): - self.qdrant_client.create_collection( - collection_name=self.COLLECTION_NAME, - vectors_config=models.VectorParams( - size=self.embedding_dimension, - distance=models.Distance.COSINE, - ), - ) - - for field_name in ("user_id", "document_id"): - self.qdrant_client.create_payload_index( - collection_name=self.COLLECTION_NAME, - field_name=field_name, - field_schema=models.PayloadSchemaType.KEYWORD, - wait=True, - ) - - self._collection_ready = True - return True - except Exception as exc: - logger.error("Failed to initialize Qdrant collection: %s", exc) - return False - - def _setup_gemini_api(self): - """Setup Gemini API configuration (no client needed for REST API)""" - api_key = self.api_key - if not api_key: - logger.warning("GOOGLE_API_KEY not found in environment variables") - return api_key is not None - - def extract_text_from_file(self, file_content: bytes, filename: str) -> str: - """ - Extract text from various file formats - - Args: - file_content: Raw bytes of the file - filename: Name of the file (used to determine format) - - Returns: - Extracted text content - """ - file_extension = filename.lower().split('.')[-1] - - try: - if file_extension == 'pdf': - return self._extract_from_pdf(file_content) - elif file_extension == 'docx': - return self._extract_from_docx(file_content) - elif file_extension == 'txt': - return self._extract_from_txt(file_content) - else: - logger.warning(f"Unsupported file type: {file_extension}") - return "" - except Exception as e: - logger.error(f"Failed to extract text from {filename}: {e}") - return "" - - def _extract_from_pdf(self, file_content: bytes) -> str: - """Extract text from PDF file""" - text = "" - try: - pdf_reader = PyPDF2.PdfReader(BytesIO(file_content)) - for page in pdf_reader.pages: - text += page.extract_text() + "\n" - except Exception as e: - logger.error(f"PDF extraction error: {e}") - return text.strip() - - def _extract_from_docx(self, file_content: bytes) -> str: - """Extract text from DOCX file""" - text = "" - try: - doc = Document(BytesIO(file_content)) - for paragraph in doc.paragraphs: - text += paragraph.text + "\n" - except Exception as e: - logger.error(f"DOCX extraction error: {e}") - return text.strip() - - def _extract_from_txt(self, file_content: bytes) -> str: - """Extract text from TXT file""" - try: - return file_content.decode('utf-8', errors='ignore') - except Exception as e: - logger.error(f"TXT extraction error: {e}") - return "" - - def chunk_text(self, text: str, metadata: Dict) -> List[Dict]: - """ - Split text into chunks with metadata - - Args: - text: Text to chunk - metadata: Metadata to attach to each chunk - - Returns: - List of chunk dictionaries with text and metadata - """ - if not text.strip(): - return [] - - doc = LangchainDocument(page_content=text, metadata=metadata) - - chunks = self.text_splitter.split_documents([doc]) - - chunk_dicts = [] - for i, chunk in enumerate(chunks): - chunk_dict = { - 'text': chunk.page_content, - 'metadata': { - **chunk.metadata, - 'chunk_index': i, - 'chunk_id': str(uuid5( # UUIDv5 gives stable, idempotent IDs:same document + same chunk index → same UUID - NAMESPACE_URL, - ( - f"reshare:{metadata['document_id']}:{i}" - ), - )) - } - } - chunk_dicts.append(chunk_dict) - - return chunk_dicts - - def generate_embeddings( - self, - texts: List[str], - task_type: str = "RETRIEVAL_DOCUMENT", - ) -> np.ndarray: - """ - Generate embeddings for a list of texts using Gemini API batch processing - - Args: - texts: List of text chunks - - Returns: - Numpy array of embeddings - """ - if not texts: - return np.array([]) - - logger.info(f"Generating embeddings for {len(texts)} texts") - - api_key = self.api_key - if not api_key: - logger.error("GOOGLE_API_KEY not found in environment variables") - return np.array([]) - - logger.info(f"Using Gemini API with model: {self.embedding_model_name}, dimension: {self.embedding_dimension}") - - try: - url = ( - "https://generativelanguage.googleapis.com/v1beta/models/" - f"{self.embedding_model_name}:batchEmbedContents" - ) - - headers = { - "x-goog-api-key": api_key, - "Content-Type": "application/json" - } - - requests_data = [] - for text in texts: - requests_data.append({ - "model": f"models/{self.embedding_model_name}", - "content": { - "parts": [{"text": text}] - }, - "task_type": task_type, - "output_dimensionality": self.embedding_dimension - }) - - data = {"requests": requests_data} - - logger.info(f"Embedding request payload count: {len(requests_data)}") - response = requests.post(url, headers=headers, json=data, timeout=30) - response.raise_for_status() - - result = response.json() - logger.info(f"Embedding API responded with keys: {list(result.keys())}") - - embeddings = [] - api_embeddings = result.get("embeddings", []) - logger.info(f"Embedding objects returned: {len(api_embeddings)}") - for embedding_response in api_embeddings: - embedding_values = np.array(embedding_response["values"]) - if self.embedding_dimension < 3072: # Normalize truncated dimensions - norm = np.linalg.norm(embedding_values) - if norm > 0: - embedding_values = embedding_values / norm - embeddings.append(embedding_values) - - if len(embeddings) != len(texts): - logger.info(f"Mismatch: expected {len(texts)} embeddings, got {len(embeddings)}") - return np.array([]) - - embeddings_np = np.array(embeddings) - logger.info(f"Built embeddings array with shape {embeddings_np.shape}") - return embeddings_np - - except requests.exceptions.Timeout: - logger.error("Gemini API timeout during batch embedding generation") - return np.array([]) - except requests.exceptions.RequestException as e: - logger.error(f"Gemini API request failed: {e}") - if hasattr(e, 'response') and e.response is not None: - logger.error(f"Response status: {e.response.status_code}") - logger.error(f"Response body: {e.response.text}") - return np.array([]) - except Exception as e: - logger.error(f"Failed to generate embeddings with Gemini API: {e}") - import traceback - logger.error(f"Traceback: {traceback.format_exc()}") - return np.array([]) - - def add_chunks_to_vector_db(self, username: str, chunks: List[Dict]) -> bool: - """ - Add text chunks to user's vector database - - Args: - username: User identifier - chunks: List of chunk dictionaries - - Returns: - Success boolean - """ - if not chunks: - return True - - try: - if not self._ensure_collection(): - return False - - texts = [chunk["text"] for chunk in chunks] - logger.info("Preparing to add %s chunks for user '%s'", len(texts), username) - - embeddings = self.generate_embeddings(texts) - if embeddings.size == 0: - return False - - document_id = chunks[0]["metadata"]["document_id"] - if not self.delete_documents(username, [document_id]): - return False - - points = [] - for chunk, embedding in zip(chunks, embeddings): - metadata = chunk["metadata"] - payload = { - "user_id": username, - "document_id": metadata["document_id"], - "cid": metadata["cid"], - "filename": metadata["filename"], - "path": metadata["path"], - "file_type": metadata["file_type"], - "chunk_index": metadata["chunk_index"], - "chunk_text": chunk["text"], - "embedding_model": self.embedding_model_name, - } - points.append( - models.PointStruct( - id=metadata["chunk_id"], - vector=embedding.tolist(), - payload=payload, - ) - ) - - logger.debug("Upserting %s vectors into Qdrant", len(points)) - self.qdrant_client.upsert( - collection_name=self.COLLECTION_NAME, - points=points, - wait=True, - ) - logger.info( - "Indexed %s chunks for user '%s', document '%s'", - len(points), - username, - document_id, - ) - return True - - except Exception as exc: - logger.error("Failed to add chunks to Qdrant: %s", exc) - return False - - def delete_documents(self, username: str, document_ids: List[str]) -> bool: - """Delete all chunks owned by a user for the supplied document IDs.""" - if not document_ids: - return True - if not self._ensure_collection(): - return False - - try: - self.qdrant_client.delete( - collection_name=self.COLLECTION_NAME, - points_selector=models.FilterSelector( - filter=models.Filter( - must=[ - models.FieldCondition( - key="user_id", - match=models.MatchValue(value=username), - ), - models.FieldCondition( - key="document_id", - match=models.MatchAny(any=document_ids), - ), - ] - ) - ), - wait=True, - ) - return True - except Exception as exc: - logger.error("Failed to delete documents from Qdrant: %s", exc) - return False - - def delete_user_data(self, username: str) -> bool: - """Delete every indexed chunk owned by a user.""" - if not self._ensure_collection(): - return False - - try: - self.qdrant_client.delete( - collection_name=self.COLLECTION_NAME, - points_selector=models.FilterSelector( - filter=models.Filter( - must=[ - models.FieldCondition( - key="user_id", - match=models.MatchValue(value=username), - ) - ] - ) - ), - wait=True, - ) - return True - except Exception as exc: - logger.error("Failed to delete user data from Qdrant: %s", exc) - return False - - def search_user_vector_db(self, username: str, query: str, top_k: int = 5) -> List[Dict]: - """ - Search user's vector database for relevant chunks - - Args: - username: User identifier - query: Search query - top_k: Number of results to return - - Returns: - List of relevant chunks with scores - """ - if not self._ensure_collection(): - return [] - - try: - query_embedding = self.generate_embeddings( - [query], task_type="RETRIEVAL_QUERY" - ) - logger.debug( - "Query embedding shape: %s (size=%s)", - query_embedding.shape, - query_embedding.size, - ) - if query_embedding.size == 0: - return [] - - response = self.qdrant_client.query_points( - collection_name=self.COLLECTION_NAME, - query=query_embedding[0].tolist(), - query_filter=models.Filter( - must=[ - models.FieldCondition( - key="user_id", - match=models.MatchValue(value=username), - ) - ] - ), - limit=top_k, - with_payload=True, - with_vectors=False, - ) - - results = [] - for point in response.points: - payload = point.payload or {} - metadata = { - key: payload.get(key) - for key in ( - "user_id", - "document_id", - "cid", - "filename", - "path", - "file_type", - "chunk_index", - "embedding_model", - ) - } - results.append( - { - "chunk": { - "text": payload.get("chunk_text", ""), - "metadata": metadata, - }, - "score": float(point.score), - } - ) - - logger.info("Qdrant search returned %s results", len(results)) - if results: - logger.info( - "Top result score=%.6f from file=%s", - results[0]["score"], - results[0]["chunk"]["metadata"].get("filename", "unknown"), - ) - - return results - - except Exception as exc: - logger.error("Failed to search Qdrant: %s", exc) - return [] - - def process_file_for_rag( - self, - file_content: bytes, - filename: str, - username: str, - cid: str, - document_id: str, - path: str, - ) -> RAGProcessResult: - """ - Complete pipeline to process a file for RAG - - Args: - file_content: Raw file bytes - filename: Name of the file - username: User who uploaded the file - cid: IPFS CID of the file - document_id: Stable uploaded-document identifier - path: Current user-visible file path - - Returns: - Processing result including chunk count and error details - """ - try: - text = self.extract_text_from_file(file_content, filename) - if not text.strip(): - logger.info(f"No text extracted from {filename}") - return RAGProcessResult(False, error="No extractable text found") - - metadata = { - "user_id": username, - "document_id": document_id, - "filename": filename, - "cid": cid, - "path": path, - "file_type": filename.lower().split('.')[-1], - } - - chunks = self.chunk_text(text, metadata) - if not chunks: - logger.info(f"No chunks created from {filename}") - return RAGProcessResult(False, error="No text chunks were created") - - success = self.add_chunks_to_vector_db(username, chunks) - if success: - logger.info(f"Successfully processed {filename} for RAG: {len(chunks)} chunks") - return RAGProcessResult(True, chunk_count=len(chunks)) - - return RAGProcessResult( - False, - error="Embedding generation or Qdrant indexing failed", - ) - - except Exception as exc: - logger.error("Failed to process file for RAG: %s", exc) - return RAGProcessResult(False, error=str(exc)) - - -class LLMIntegration: - """ - Handles integration with Language Models for generating responses - """ - - def __init__(self, model_type: str = "gemini", api_key: str = None): - """ - Initialize LLM integration - - Args: - model_type: Type of model ("gemini" or "local") - api_key: API key for external services - """ - self.model_type = model_type - - if model_type == "gemini" and api_key: - self.api_key = api_key - - def generate_answer(self, query: str, context_chunks: List[Dict], max_tokens: int = 5000) -> str: - """ - Generate an answer using retrieved context - - Args: - query: User's question - context_chunks: Retrieved relevant chunks - max_tokens: Maximum tokens for response - - Returns: - Generated answer - """ - if not context_chunks: - return "I couldn't find any relevant information in your uploaded files to answer this question." - - context_text = "\n\n".join([ - f"From {chunk['chunk']['metadata']['filename']}:\n{chunk['chunk']['text']}" - for chunk in context_chunks[:3] - ]) - - prompt = f"""Based on the following context from the user's uploaded files, please answer their question. If the context doesn't contain enough information to answer the question, please say so. - -Context: -{context_text} - -Question: {query} - -Answer:""" - - try: - if self.model_type == "gemini": - return self._generate_gemini_response(prompt, max_tokens) - else: - return self._generate_simple_response(query, context_chunks) - except Exception as e: - logger.error(f"Failed to generate LLM response: {e}") - return "I encountered an error while generating the response. Please try again." - - def _generate_gemini_response(self, prompt: str, max_tokens: int) -> str: - """Generate response using Gemini API""" - try: - model_name = "gemini-2.5-flash" - url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent" - - headers = { - "x-goog-api-key": self.api_key, - "Content-Type": "application/json" - } - - data = { - "contents": [ - { - "parts": [ - { - "text": prompt - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": max_tokens - }, - "systemInstruction": { - "parts": [ - { - "text": "You are a helpful assistant that answers questions based on the provided context from user's documents." - } - ] - } - } - - response = requests.post(url, headers=headers, json=data) - response.raise_for_status() - - result = response.json() - logger.info(f"Gemini API response: {result}") - return result['candidates'][0]['content']['parts'][0]['text'] - except Exception as e: - logger.error(f"Gemini API error: {e}") - raise - - def _generate_simple_response(self, query: str, context_chunks: List[Dict]) -> str: - """Generate a simple extractive response when no LLM is available""" - if not context_chunks: - return "No relevant information found." - - best_chunk = context_chunks[0] - filename = best_chunk['chunk']['metadata']['filename'] - text = best_chunk['chunk']['text'] - - return f"Based on your file '{filename}', here's the most relevant information I found:\n\n{text}..." - - -rag_manager = None -llm_integration = None - -def get_rag_manager() -> RAGManager: - """Get global RAG manager instance""" - global rag_manager - if rag_manager is None: - rag_manager = RAGManager() - return rag_manager - -def get_llm_integration() -> LLMIntegration: - """Get global LLM integration instance""" - global llm_integration - if llm_integration is None: - api_key = os.getenv("GOOGLE_API_KEY") - if api_key: - llm_integration = LLMIntegration("gemini", api_key) - else: - llm_integration = LLMIntegration("simple") - return llm_integration diff --git a/backend/services/upload_service.py b/backend/services/upload_service.py new file mode 100644 index 0000000..cfbdf8a --- /dev/null +++ b/backend/services/upload_service.py @@ -0,0 +1,177 @@ +from backend.models.node import Node +from backend.models.file import File +from backend.storage.kv import get_kv, set_kv +from backend.storage.ipfs import add_file_to_cluster +from backend.rag.vector_store import get_vector_store +from backend.rag.indexing import process_file_for_rag +import mimetypes +from io import BytesIO +from backend.utils.error import ErrorCode +from typing import Tuple, Dict, Optional +import logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +FILE_SIZE_LIMIT = 1024 * 1024 # 1 MB limit + +def upload_file(file: File, path: str, username: str, skip_ai_processing: bool) -> Tuple[Dict, int]: + """ + Handle file upload, including RAG processing if applicable. + File: The file to upload. + Path: The path where the file should be uploaded. + Username: The username of the user uploading the file. + Skip_ai_processing: A flag indicating whether to skip AI processing. + Returns: + Tuple containing response data and HTTP status code. + """ + response_data = { + 'message': ErrorCode.SUCCESS.name, + 'rag_processed': False, + 'rag_skipped': False, + 'skip_ai_processing': skip_ai_processing + } + if file.filename == '': + response_data['message'] = ErrorCode.INVALID_REQUEST.name + return response_data, 400 + filename = file.filename + file_stream = BytesIO(file.read()) + file_size = file_stream.getbuffer().nbytes + + if file_size > FILE_SIZE_LIMIT: + response_data['message'] = ErrorCode.EXCEED_MAX_FILE_SIZE.name + return response_data, 413 + + cid = add_file_to_cluster(file_stream, filename) + + if cid is None: + response_data['message'] = ErrorCode.IPFS_ERROR.name + return response_data, 500 + + root = Node.from_json(get_kv(username + " ROOT")) + target_node = root.find_node_by_path(path) + + if target_node is None: + response_data['message'] = ErrorCode.NODE_NOT_FOUND.name + return response_data, 404 + + supported_extensions = {'pdf', 'docx', 'txt'} + file_extension = filename.lower().split('.')[-1] if '.' in filename else '' + should_index = not skip_ai_processing and file_extension in supported_extensions + file_obj = File( + cid, + file_size, + filename, + mime_type=file.mimetype or mimetypes.guess_type(filename)[0], + rag_status="pending" if should_index else "skipped", + ) + result = target_node.add_child(Node(filename, False, file_obj=file_obj)) + + if result != ErrorCode.SUCCESS: + response_data['message'] = result.name + return response_data, 400 + + rag_result = None + if should_index: + try: + file_stream.seek(0) + file_content = file_stream.read() + normalized_parent = path.strip("/") + if normalized_parent == "root": + normalized_parent = "" + elif normalized_parent.startswith("root/"): + normalized_parent = normalized_parent[len("root/"):] + document_path = "/".join( + part for part in (normalized_parent, filename) if part + ) + + rag_result = process_file_for_rag( + file_content, + filename, + username, + cid, + file_obj.document_id, + document_path, + ) + response_data['rag_processed'] = rag_result.success + + if rag_result.success: + file_obj.mark_rag_ready( + rag_result.chunk_count, + rag_result.embedding_model_name, + ) + logger.info(f"Successfully processed {filename} for RAG for user {username}") + else: + file_obj.mark_rag_failed( + rag_result.error, + rag_result.embedding_model_name, + ) + logger.warning(f"Failed to process {filename} for RAG for user {username}: {rag_result.error}") + + except Exception as e: + logger.error(f"RAG processing error for {filename}: {e}") + response_data['rag_processed'] = False + file_obj.mark_rag_failed(str(e), "gemini-embedding-001") + + elif skip_ai_processing: + response_data['rag_skipped'] = True + logger.info(f"RAG processing skipped for {filename} for user {username} (AI mode disabled)") + else: + response_data['rag_skipped'] = True + logger.info(f"RAG processing skipped for unsupported file {filename}") + + persistence_result = persist_root_with_rag_rollback( + username, + root.to_json(), + indexed_document_id=( + file_obj.document_id if rag_result is not None and rag_result.success else None + ), + ) + if not persistence_result: + response_data['message'] = ErrorCode.KV_SERVICE_ERROR.name + return response_data, 503 + + response_data.update({ + 'root': root.to_json(), + 'rag_processed': rag_result.success if rag_result is not None else False, + 'rag_status': file_obj.rag_status, + }) + + return response_data, 200 + + +def persist_root_with_rag_rollback( + username, + root_json, + indexed_document_id: Optional[str]=None, +) -> bool: + """Persist the final root and remove new vectors if that write fails.""" + if set_kv(username + " ROOT", root_json): + return True + + logger.error(f"Failed to persist final file metadata for user {username}") + + #surely there is a better way to do this than creating a new instance every time + vector_store = get_vector_store() + + if indexed_document_id is None: + return False + + try: + rollback_succeeded = vector_store.delete_documents( + username, + [indexed_document_id], + ) + except Exception as exc: + logger.error( + "Failed to roll back indexed document %s for user %s: %s", + indexed_document_id, + username, + exc, + ) + rollback_succeeded = False + + if not rollback_succeeded: + logger.error(f"RAG data for document {indexed_document_id} may be orphaned after metadata write failure") + return False + + return False \ No newline at end of file diff --git a/backend/services/user_authentication_service.py b/backend/services/user_authentication_service.py index d27bc3e..ac97e88 100644 --- a/backend/services/user_authentication_service.py +++ b/backend/services/user_authentication_service.py @@ -4,7 +4,7 @@ from backend.utils.util import is_valid_password, is_valid_username from backend.utils.error import ErrorCode -from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.storage.kv import get_kv, set_kv from backend.models.node import Node def sign_up(username, password): diff --git a/backend/services/ipfs_service.py b/backend/storage/ipfs.py similarity index 98% rename from backend/services/ipfs_service.py rename to backend/storage/ipfs.py index 5858985..389e460 100644 --- a/backend/services/ipfs_service.py +++ b/backend/storage/ipfs.py @@ -1,8 +1,8 @@ -import os import requests from io import BytesIO from pathlib import Path + ipfs_cluster_api_url = None ipfs_gateway_url = None @@ -51,7 +51,6 @@ def download_file_from_ipfs(cid): read_config_file() url = f"{ipfs_gateway_url}ipfs/{cid}" - print(f"Download URL: {url}") try: response = requests.get(url, stream=True, timeout=10) diff --git a/backend/services/RSDB_kv_service.py b/backend/storage/kv.py similarity index 88% rename from backend/services/RSDB_kv_service.py rename to backend/storage/kv.py index 4f1b1f9..de28ff2 100644 --- a/backend/services/RSDB_kv_service.py +++ b/backend/storage/kv.py @@ -5,6 +5,10 @@ from dotenv import load_dotenv load_dotenv() +import logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + API_URL = os.environ.get('KV_SERVICE_URL') STORE_TYPE = os.environ.get('STORAGE_TYPE', 'memory') @@ -56,11 +60,11 @@ def set_kv(self, key: str, value: str) -> bool: if response.status_code in [200, 201]: return True else: - print(f"SET ERROR: {response.status_code} - {response.text}") + logger.error(f"SET ERROR: {response.status_code} - {response.text}") return False except Exception as e: - print(f"SET EXCEPTION: {e}") + logger.error(f"SET EXCEPTION: {e}") return False def get_kv(self, key: str) -> str: @@ -87,16 +91,16 @@ def get_kv(self, key: str) -> str: data = response.json() return data.get('value', '') except json.JSONDecodeError as e: - print(f"JSON Decode Error: {e}") + logger.error(f"JSON Decode Error: {e}") return response.text else: return "" else: - print(f"GET ERROR: {response.status_code} - {response.text}") + logger.error(f"GET ERROR: {response.status_code} - {response.text}") return "" except Exception as e: - print(f"GET EXCEPTION: {e}") + logger.error(f"GET EXCEPTION: {e}") return "" _kv_service = KVService(store_type=STORE_TYPE) diff --git a/backend/tests/test_delete_service.py b/backend/tests/test_delete_service.py index b473632..8dbbbce 100644 --- a/backend/tests/test_delete_service.py +++ b/backend/tests/test_delete_service.py @@ -1,17 +1,12 @@ import unittest -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch -from flask import Flask, session - -from backend.services.delete_service import delete_node from backend.models.file import File from backend.models.node import Node +from backend.services.delete_service import delete_user_data -class DeleteServiceRAGTest(unittest.TestCase): - def setUp(self): - self.app = Flask(__name__) - self.app.secret_key = "test-secret" +class DeleteServiceRAGTest(unittest.TestCase): @staticmethod def root_with_indexed_file(): root = Node("root", True) @@ -23,89 +18,70 @@ def root_with_indexed_file(): ) file_obj.mark_rag_ready(1, "gemini-embedding-001") root.add_child(Node("indexed.txt", False, file_obj=file_obj)) - return root, file_obj + return root - def test_delete_removes_qdrant_chunks_before_file_record(self): - root, file_obj = self.root_with_indexed_file() - manager = Mock() - manager.delete_documents.return_value = True + def test_delete_user_data_removes_qdrant_data_before_kv_records(self): + root = self.root_with_indexed_file() + vector_store = Mock() + vector_store.delete_user_data.return_value = True - with self.app.test_request_context("/"): - session["username"] = "alice" - with patch("backend.services.delete_service.get_kv", return_value=root.to_json()), patch( - "backend.services.delete_service.set_kv", return_value=True - ) as set_kv, patch( - "backend.services.delete_service.get_rag_manager", return_value=manager - ): - response, status = delete_node( - {"node_path": "indexed.txt", "delete_in_root": True} - ) + with patch( + "backend.services.delete_service.get_kv", return_value=root.to_json() + ), patch( + "backend.services.delete_service.set_kv", return_value=True + ) as set_kv, patch( + "backend.services.delete_service.get_vector_store", + return_value=vector_store, + ): + result = delete_user_data("alice") - self.assertEqual(status, 200) - manager.delete_documents.assert_called_once_with("alice", [file_obj.document_id]) - saved_root = Node.from_json(set_kv.call_args.args[1]) - self.assertNotIn("indexed.txt", saved_root.children) + self.assertTrue(result) + vector_store.delete_user_data.assert_called_once_with("alice") + self.assertEqual( + set_kv.call_args_list, + [ + call("alice", "\n"), + call("alice ROOT", "\n"), + call("alice SHARE_MANAGER", "\n"), + ], + ) - def test_delete_keeps_file_when_qdrant_cleanup_fails(self): - root, _ = self.root_with_indexed_file() - manager = Mock() - manager.delete_documents.return_value = False + def test_delete_user_data_keeps_kv_records_when_qdrant_cleanup_fails(self): + root = self.root_with_indexed_file() + vector_store = Mock() + vector_store.delete_user_data.return_value = False - with self.app.test_request_context("/"): - session["username"] = "alice" - with patch("backend.services.delete_service.get_kv", return_value=root.to_json()), patch( - "backend.services.delete_service.set_kv", return_value=True - ) as set_kv, patch( - "backend.services.delete_service.get_rag_manager", return_value=manager - ): - response, status = delete_node( - {"node_path": "indexed.txt", "delete_in_root": True} - ) + with patch( + "backend.services.delete_service.get_kv", return_value=root.to_json() + ), patch( + "backend.services.delete_service.set_kv", return_value=True + ) as set_kv, patch( + "backend.services.delete_service.get_vector_store", + return_value=vector_store, + ): + result = delete_user_data("alice") - self.assertEqual(status, 503) - self.assertEqual(response.get_json()["message"], "RAG_DELETE_FAILED") + self.assertFalse(result) + vector_store.delete_user_data.assert_called_once_with("alice") set_kv.assert_not_called() - def test_folder_delete_removes_all_indexed_descendants(self): + def test_delete_user_data_skips_qdrant_cleanup_without_indexed_files(self): root = Node("root", True) - folder = Node("docs", True) - first = File( - "first-cid", - 10, - "first.txt", - document_id="95357bf7-0a52-45f2-b048-1032e951bed1", - ) - second = File( - "second-cid", - 10, - "second.txt", - document_id="b4749b34-8c68-4412-86bb-e0a20a0eaa70", - ) - first.mark_rag_ready(1, "gemini-embedding-001") - second.mark_rag_ready(1, "gemini-embedding-001") - folder.add_child(Node("first.txt", False, file_obj=first)) - folder.add_child(Node("second.txt", False, file_obj=second)) - root.add_child(folder) - manager = Mock() - manager.delete_documents.return_value = True + vector_store = Mock() - with self.app.test_request_context("/"): - session["username"] = "alice" - with patch("backend.services.delete_service.get_kv", return_value=root.to_json()), patch( - "backend.services.delete_service.set_kv", return_value=True - ) as set_kv, patch( - "backend.services.delete_service.get_rag_manager", return_value=manager - ): - _, status = delete_node( - {"node_path": "docs", "delete_in_root": True} - ) + with patch( + "backend.services.delete_service.get_kv", return_value=root.to_json() + ), patch( + "backend.services.delete_service.set_kv", return_value=True + ) as set_kv, patch( + "backend.services.delete_service.get_vector_store", + return_value=vector_store, + ): + result = delete_user_data("alice") - self.assertEqual(status, 200) - manager.delete_documents.assert_called_once_with( - "alice", [first.document_id, second.document_id] - ) - saved_root = Node.from_json(set_kv.call_args.args[1]) - self.assertNotIn("docs", saved_root.children) + self.assertTrue(result) + vector_store.delete_user_data.assert_not_called() + self.assertEqual(set_kv.call_count, 3) if __name__ == "__main__": diff --git a/backend/tests/test_file_controller.py b/backend/tests/test_file_controller.py index 2ef5a46..1d02ef8 100644 --- a/backend/tests/test_file_controller.py +++ b/backend/tests/test_file_controller.py @@ -6,7 +6,7 @@ from backend.controller.file_controller import register_file_routes from backend.models.node import Node -from backend.services.rag_utils import RAGProcessResult +from backend.rag.types import RAGProcessResult class FileControllerRAGFailureTest(unittest.TestCase): @@ -17,9 +17,7 @@ def setUp(self): def test_upload_succeeds_and_records_failed_rag_status(self): root = Node("root", True) - manager = Mock() - manager.embedding_model_name = "gemini-embedding-001" - manager.process_file_for_rag.return_value = RAGProcessResult( + rag_result = RAGProcessResult( False, error="Qdrant unavailable", ) @@ -30,14 +28,15 @@ def capture_root(key, value): return True with patch( - "backend.controller.file_controller.get_kv", return_value=root.to_json() + "backend.services.upload_service.get_kv", return_value=root.to_json() ), patch( - "backend.services.rag_persistence.set_kv", side_effect=capture_root + "backend.services.upload_service.set_kv", side_effect=capture_root ), patch( - "backend.controller.file_controller.add_file_to_cluster", + "backend.services.upload_service.add_file_to_cluster", return_value="test-cid", ), patch( - "backend.controller.file_controller.get_rag_manager", return_value=manager + "backend.services.upload_service.process_file_for_rag", + return_value=rag_result, ): with self.app.test_client() as client: with client.session_transaction() as client_session: @@ -62,21 +61,19 @@ def capture_root(key, value): def test_upload_records_failed_rag_status_when_processing_raises(self): root = Node("root", True) - manager = Mock() - manager.embedding_model_name = "gemini-embedding-001" - manager.process_file_for_rag.side_effect = RuntimeError("Qdrant unavailable") saved_roots = [] with patch( - "backend.controller.file_controller.get_kv", return_value=root.to_json() + "backend.services.upload_service.get_kv", return_value=root.to_json() ), patch( - "backend.services.rag_persistence.set_kv", + "backend.services.upload_service.set_kv", side_effect=lambda key, value: saved_roots.append(value) or True, ), patch( - "backend.controller.file_controller.add_file_to_cluster", + "backend.services.upload_service.add_file_to_cluster", return_value="test-cid", ), patch( - "backend.controller.file_controller.get_rag_manager", return_value=manager + "backend.services.upload_service.process_file_for_rag", + side_effect=RuntimeError("Qdrant unavailable"), ): with self.app.test_client() as client: with client.session_transaction() as client_session: @@ -103,21 +100,24 @@ def test_upload_rolls_back_rag_when_final_metadata_write_fails(self): root = Node("root", True) manager = Mock() manager.embedding_model_name = "gemini-embedding-001" - manager.process_file_for_rag.return_value = RAGProcessResult( + rag_result = RAGProcessResult( True, chunk_count=1, ) manager.delete_documents.return_value = True with patch( - "backend.controller.file_controller.get_kv", return_value=root.to_json() + "backend.services.upload_service.get_kv", return_value=root.to_json() ), patch( - "backend.services.rag_persistence.set_kv", return_value=False + "backend.services.upload_service.set_kv", return_value=False ), patch( - "backend.controller.file_controller.add_file_to_cluster", + "backend.services.upload_service.add_file_to_cluster", return_value="test-cid", ), patch( - "backend.controller.file_controller.get_rag_manager", return_value=manager + "backend.services.upload_service.process_file_for_rag", + return_value=rag_result, + ) as process_file_for_rag, patch( + "backend.services.upload_service.get_vector_store", return_value=manager ): with self.app.test_client() as client: with client.session_transaction() as client_session: @@ -132,32 +132,35 @@ def test_upload_rolls_back_rag_when_final_metadata_write_fails(self): ) self.assertEqual(response.status_code, 503) - self.assertEqual(response.get_json()["message"], "METADATA_WRITE_FAILED") + self.assertEqual(response.get_json()["message"], "KV_SERVICE_ERROR") manager.delete_documents.assert_called_once() self.assertEqual( manager.delete_documents.call_args.args, - ("alice", [manager.process_file_for_rag.call_args.args[4]]), + ("alice", [process_file_for_rag.call_args.args[4]]), ) def test_upload_reports_failed_rollback_after_metadata_write_fails(self): root = Node("root", True) manager = Mock() manager.embedding_model_name = "gemini-embedding-001" - manager.process_file_for_rag.return_value = RAGProcessResult( + rag_result = RAGProcessResult( True, chunk_count=1, ) manager.delete_documents.return_value = False with patch( - "backend.controller.file_controller.get_kv", return_value=root.to_json() + "backend.services.upload_service.get_kv", return_value=root.to_json() ), patch( - "backend.services.rag_persistence.set_kv", return_value=False + "backend.services.upload_service.set_kv", return_value=False ), patch( - "backend.controller.file_controller.add_file_to_cluster", + "backend.services.upload_service.add_file_to_cluster", return_value="test-cid", ), patch( - "backend.controller.file_controller.get_rag_manager", return_value=manager + "backend.services.upload_service.process_file_for_rag", + return_value=rag_result, + ), patch( + "backend.services.upload_service.get_vector_store", return_value=manager ): with self.app.test_client() as client: with client.session_transaction() as client_session: @@ -172,7 +175,7 @@ def test_upload_reports_failed_rollback_after_metadata_write_fails(self): ) self.assertEqual(response.status_code, 503) - self.assertEqual(response.get_json()["message"], "RAG_ROLLBACK_FAILED") + self.assertEqual(response.get_json()["message"], "KV_SERVICE_ERROR") manager.delete_documents.assert_called_once() def test_upload_persists_skipped_file_once(self): @@ -180,16 +183,16 @@ def test_upload_persists_skipped_file_once(self): saved_roots = [] with patch( - "backend.controller.file_controller.get_kv", return_value=root.to_json() + "backend.services.upload_service.get_kv", return_value=root.to_json() ), patch( - "backend.services.rag_persistence.set_kv", + "backend.services.upload_service.set_kv", side_effect=lambda key, value: saved_roots.append(value) or True, ), patch( - "backend.controller.file_controller.add_file_to_cluster", + "backend.services.upload_service.add_file_to_cluster", return_value="test-cid", ), patch( - "backend.controller.file_controller.get_rag_manager" - ) as get_rag_manager: + "backend.services.upload_service.process_file_for_rag" + ) as process_file_for_rag: with self.app.test_client() as client: with client.session_transaction() as client_session: client_session["username"] = "alice" @@ -207,7 +210,7 @@ def test_upload_persists_skipped_file_once(self): self.assertEqual(len(saved_roots), 1) saved_root = Node.from_json(saved_roots[0]) self.assertEqual(saved_root.children["notes.txt"].file_obj.rag_status, "skipped") - get_rag_manager.assert_not_called() + process_file_for_rag.assert_not_called() if __name__ == "__main__": diff --git a/backend/tests/test_rag_persistence.py b/backend/tests/test_rag_persistence.py index 3072667..3fed75c 100644 --- a/backend/tests/test_rag_persistence.py +++ b/backend/tests/test_rag_persistence.py @@ -1,56 +1,57 @@ import unittest from unittest.mock import Mock, patch -from backend.services.rag_persistence import ( - RAGPersistenceResult, - persist_root_with_rag_rollback, -) +from backend.services.upload_service import persist_root_with_rag_rollback class RAGPersistenceTest(unittest.TestCase): def test_successful_metadata_write_does_not_delete_vectors(self): manager = Mock() - with patch("backend.services.rag_persistence.set_kv", return_value=True) as set_kv: + with patch("backend.services.upload_service.set_kv", return_value=True) as set_kv, patch( + "backend.services.upload_service.get_vector_store" + ) as get_vector_store: result = persist_root_with_rag_rollback( "alice", "root-json", - rag_manager=manager, indexed_document_id="document-id", ) - self.assertEqual(result, RAGPersistenceResult.SUCCESS) + self.assertTrue(result) set_kv.assert_called_once_with("alice ROOT", "root-json") - manager.delete_documents.assert_not_called() + get_vector_store.assert_not_called() def test_failed_metadata_write_rolls_back_indexed_document(self): manager = Mock() manager.delete_documents.return_value = True - with patch("backend.services.rag_persistence.set_kv", return_value=False): + with patch("backend.services.upload_service.set_kv", return_value=False), patch( + "backend.services.upload_service.get_vector_store", return_value=manager + ): result = persist_root_with_rag_rollback( "alice", "root-json", - rag_manager=manager, indexed_document_id="document-id", ) - self.assertEqual(result, RAGPersistenceResult.METADATA_WRITE_FAILED) + self.assertFalse(result) manager.delete_documents.assert_called_once_with("alice", ["document-id"]) def test_failed_metadata_write_reports_failed_rollback(self): manager = Mock() manager.delete_documents.return_value = False - with patch("backend.services.rag_persistence.set_kv", return_value=False): + with patch("backend.services.upload_service.set_kv", return_value=False), patch( + "backend.services.upload_service.get_vector_store", return_value=manager + ): result = persist_root_with_rag_rollback( "alice", "root-json", - rag_manager=manager, indexed_document_id="document-id", ) - self.assertEqual(result, RAGPersistenceResult.RAG_ROLLBACK_FAILED) + self.assertFalse(result) + manager.delete_documents.assert_called_once_with("alice", ["document-id"]) if __name__ == "__main__": diff --git a/backend/tests/test_rag_utils.py b/backend/tests/test_rag_utils.py index 3447507..d992554 100644 --- a/backend/tests/test_rag_utils.py +++ b/backend/tests/test_rag_utils.py @@ -1,20 +1,31 @@ import unittest from tempfile import TemporaryDirectory +from unittest.mock import patch import numpy as np from qdrant_client import QdrantClient -from backend.services.rag_utils import RAGManager +from backend.rag.chunking import chunk_text +from backend.rag.vector_store import QDrantVectorStore class QdrantRAGManagerTest(unittest.TestCase): - def setUp(self): - self.manager = RAGManager( - embedding_dimension=3, - qdrant_client=QdrantClient(":memory:"), - ) + @staticmethod + def make_manager(client): + with patch("backend.rag.vector_store.QdrantClient", return_value=client): + manager = QDrantVectorStore(embedding_dimension=3) + manager.collection_name = manager.collection_name + return manager - def fake_embeddings(texts, task_type="RETRIEVAL_DOCUMENT"): + def setUp(self): + self.manager = self.make_manager(QdrantClient(":memory:")) + + def fake_embeddings( + texts, + embedding_model=None, + embedding_dimensions=3, + task_type="RETRIEVAL_DOCUMENT", + ): vectors = [] for text in texts: if "alpha" in text: @@ -25,7 +36,13 @@ def fake_embeddings(texts, task_type="RETRIEVAL_DOCUMENT"): vectors.append([0.0, 0.0, 1.0]) return np.array(vectors, dtype=np.float32) - self.manager.generate_embeddings = fake_embeddings + embedding_patch = patch( + "backend.rag.vector_store.generate_embeddings", + side_effect=fake_embeddings, + ) + embedding_patch.start() + self.addCleanup(embedding_patch.stop) + self.addCleanup(self.manager.qdrant_client.close) def chunks(self, username, document_id, filename, text): metadata = { @@ -36,19 +53,19 @@ def chunks(self, username, document_id, filename, text): "path": filename, "file_type": "txt", } - return self.manager.chunk_text(text, metadata) + return chunk_text(text, metadata) def test_search_is_isolated_by_authenticated_user(self): alice_document = "f922b5fe-afb6-4ab4-9f6f-b2a42b8a2cf2" bob_document = "f134fc1d-810f-4c76-9a56-17ddab142c07" self.assertTrue( - self.manager.add_chunks_to_vector_db( + self.manager.add_chunks( "alice", self.chunks("alice", alice_document, "alice.txt", "alpha private"), ) ) self.assertTrue( - self.manager.add_chunks_to_vector_db( + self.manager.add_chunks( "bob", self.chunks("bob", bob_document, "bob.txt", "alpha private"), ) @@ -65,11 +82,11 @@ def test_search_is_isolated_by_authenticated_user(self): def test_document_delete_does_not_remove_other_users_data(self): shared_document_id = "221584c3-1b87-4825-9bda-7c9fb3abf75c" - self.manager.add_chunks_to_vector_db( + self.manager.add_chunks( "alice", self.chunks("alice", shared_document_id, "alice.txt", "alpha"), ) - self.manager.add_chunks_to_vector_db( + self.manager.add_chunks( "bob", self.chunks("bob", shared_document_id, "bob.txt", "alpha"), ) @@ -81,11 +98,11 @@ def test_document_delete_does_not_remove_other_users_data(self): def test_reindex_replaces_old_chunks_for_same_document(self): document_id = "8a16566d-44b0-47d3-b201-c4053a02e91a" - self.manager.add_chunks_to_vector_db( + self.manager.add_chunks( "alice", self.chunks("alice", document_id, "notes.txt", "alpha"), ) - self.manager.add_chunks_to_vector_db( + self.manager.add_chunks( "alice", self.chunks("alice", document_id, "notes.txt", "beta"), ) @@ -101,15 +118,11 @@ def test_vectors_persist_when_manager_is_recreated(self): document_id = "2a1caabf-ee52-4bb6-91fb-03253606f71c" with TemporaryDirectory() as temp_dir: first_client = QdrantClient(path=temp_dir) - first_manager = RAGManager( - embedding_dimension=3, - qdrant_client=first_client, - ) - first_manager.generate_embeddings = self.manager.generate_embeddings + first_manager = self.make_manager(first_client) self.assertTrue( - first_manager.add_chunks_to_vector_db( + first_manager.add_chunks( "alice", - first_manager.chunk_text( + chunk_text( "alpha", { "user_id": "alice", @@ -125,11 +138,7 @@ def test_vectors_persist_when_manager_is_recreated(self): first_client.close() second_client = QdrantClient(path=temp_dir) - second_manager = RAGManager( - embedding_dimension=3, - qdrant_client=second_client, - ) - second_manager.generate_embeddings = self.manager.generate_embeddings + second_manager = self.make_manager(second_client) results = second_manager.search_user_vector_db("alice", "alpha") second_client.close() diff --git a/backend/utils/error.py b/backend/utils/error.py index 263ca20..12e2f34 100644 --- a/backend/utils/error.py +++ b/backend/utils/error.py @@ -21,4 +21,5 @@ class ErrorCode(Enum): EXCEED_MAX_FILE_SIZE = 17 FILE_NOT_FOUND = 18 NOT_LOGGED_IN = 19 + KV_SERVICE_ERROR = 20 UNKNOWN_ERROR = 99 diff --git a/backend/utils/util.py b/backend/utils/util.py index bc9cadd..8b2694c 100644 --- a/backend/utils/util.py +++ b/backend/utils/util.py @@ -1,5 +1,4 @@ import re -from typing import Tuple def is_valid_password(password: str) -> bool: if len(password) < 8: return False @@ -30,20 +29,3 @@ def is_valid_username(username: str) -> bool: return False return True - -def validate_file_size(file_size: int, max_size: int = 1024 * 1024) -> Tuple[bool, str]: - """ - Validate if file size is within the allowed limit. - - Args: - file_size: Size of the file in bytes - max_size: Maximum allowed file size in bytes (default: 1MB) - - Returns: - tuple: (is_valid, error_message) - """ - if file_size > max_size: - max_size_mb = max_size / (1024 * 1024) - return False, f"File size exceeds the maximum limit of {max_size_mb} MB" - return True, "" - diff --git a/kv_smoke.py b/kv_smoke.py index 3be92e5..1cd08e0 100644 --- a/kv_smoke.py +++ b/kv_smoke.py @@ -1,6 +1,6 @@ """Manually verify a configured ResilientDB key-value service.""" -from backend.services.RSDB_kv_service import get_kv, set_kv +from backend.storage.kv import get_kv, set_kv def main(): From 5babba8638d3f3e90d5976f142da3333eff7c284 Mon Sep 17 00:00:00 2001 From: Devang Borkar Date: Tue, 18 Aug 2026 11:22:00 -0700 Subject: [PATCH 3/6] fix: remove regressions --- backend/rag/indexing.py | 2 +- backend/services/upload_service.py | 6 +++--- docker-compose.yml | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/rag/indexing.py b/backend/rag/indexing.py index 4b0b61e..fba4a14 100644 --- a/backend/rag/indexing.py +++ b/backend/rag/indexing.py @@ -67,4 +67,4 @@ def process_file_for_rag( except Exception as exc: logger.error("Failed to process file for RAG: %s", exc) - return RAGProcessResult(False, error=str(exc), embedding_model_name=vector_store.embedding_model_name) \ No newline at end of file + return RAGProcessResult(False, error=str(exc)) \ No newline at end of file diff --git a/backend/services/upload_service.py b/backend/services/upload_service.py index cfbdf8a..e0d3fbf 100644 --- a/backend/services/upload_service.py +++ b/backend/services/upload_service.py @@ -150,12 +150,12 @@ def persist_root_with_rag_rollback( logger.error(f"Failed to persist final file metadata for user {username}") - #surely there is a better way to do this than creating a new instance every time - vector_store = get_vector_store() - if indexed_document_id is None: return False + #surely there is a better way to do this than creating a new instance every time + vector_store = get_vector_store() + try: rollback_succeeded = vector_store.delete_documents( username, diff --git a/docker-compose.yml b/docker-compose.yml index b75e5b5..c43142a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,7 @@ services: - CORS_ORIGINS=${CORS_ORIGINS:-} - STORAGE_TYPE=${STORAGE_TYPE} - KV_SERVICE_URL=${KV_SERVICE_URL} - - QDRANT_HOST=qdrant - - QDRANT_PORT=6333 + - QDRANT_VECTOR_DB_URL=${QDRANT_VECTOR_DB_URL:-http://qdrant:6333} depends_on: - qdrant volumes: From 185c4122fc9f534d418a984f82a9d0b191d5fd80 Mon Sep 17 00:00:00 2001 From: Devang Borkar Date: Tue, 18 Aug 2026 11:55:03 -0700 Subject: [PATCH 4/6] fix: migrate dependencies to uv --- README.md | 8 +- package-lock.json | 6 + pyproject.toml | 15 + requirements.txt | 2 +- uv.lock | 920 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 947 insertions(+), 4 deletions(-) create mode 100644 package-lock.json create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/README.md b/README.md index 0cff134..a246cd9 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ ResShare is a decentralized file sharing application that allows users to secure - Python 3.10+ - Node.js 16+ and npm +- uv (preferred) - IPFS daemon running locally - Qdrant (started automatically by Docker Compose) - Gemini API key for document embedding and generated responses @@ -43,13 +44,14 @@ ResShare is a decentralized file sharing application that allows users to secure 1. **Clone the repository:** ```bash -git clone https://github.com/NoBugInMyCode/ResShareDeployable.git -cd ResShareDeployable +git clone https://github.com/ExpoLab-App/ResShare.git +cd ResShare ``` 2. **Install backend dependencies:** ```bash -pip install -r requirements.txt +cd backend +uv sync ``` 3. **Install frontend dependencies:** diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..31dee60 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "ResShare", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a64c309 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "resshare" +version = "0.1.0" +requires-python = ">=3.10,<3.11" +dependencies = [ + "flask==3.0.0", + "flask-cors==4.0.0", + "langchain==0.2.17", + "numpy==1.26.4", + "pypdf2==3.0.1", + "python-docx==1.1.0", + "python-dotenv==1.0.1", + "qdrant-client==1.18.0", + "requests==2.31.0", +] diff --git a/requirements.txt b/requirements.txt index b0995aa..9daef85 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Flask==3.0.0 Flask-Cors==4.0.0 python-dotenv==1.0.1 langchain==0.2.17 -numpy==1.24.3 +numpy==1.26.4 PyPDF2==3.0.1 python-docx==1.1.0 qdrant-client==1.18.0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..375996a --- /dev/null +++ b/uv.lock @@ -0,0 +1,920 @@ +version = 1 +revision = 3 +requires-python = "==3.10.*" + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" }, + { url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" }, + { url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup" }, + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "async-timeout" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/d6/21b30a550dafea84b1b8eee21b5e23fa16d010ae006011221f33dcd8d7f8/async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", size = 8345, upload-time = "2023-08-10T16:35:56.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/fa/e01228c2938de91d47b307831c62ab9e4001e747789d0b05baf779a6488c/async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028", size = 5721, upload-time = "2023-08-10T16:35:55.203Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +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/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]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "flask" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/09/c1a7354d3925a3c6c8cfdebf4245bae67d633ffda1ba415add06ffc839c5/flask-3.0.0.tar.gz", hash = "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58", size = 674171, upload-time = "2023-09-30T14:36:12.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl", hash = "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638", size = 99724, upload-time = "2023-09-30T14:36:10.961Z" }, +] + +[[package]] +name = "flask-cors" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/b0/bd7130837a921497520f62023c7ba754e441dcedf959a43e6d1fd86e5451/Flask-Cors-4.0.0.tar.gz", hash = "sha256:f268522fcb2f73e2ecdde1ef45e2fd5c71cc48fe03cffb4b441c6d1b40684eb0", size = 29934, upload-time = "2023-06-26T05:38:46.364Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/1e6cfb87117568a9de088c32d6258219e9d1ff7c131abf74249ef2031279/Flask_Cors-4.0.0-py2.py3-none-any.whl", hash = "sha256:bc3492bfd6368d27cfe79c7821df5a8a319e1a6d5eab277a3794be19bdc51783", size = 14273, upload-time = "2023-06-26T05:38:44.733Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/b31e6bd6adb8f80492efb6a47e8f9cd0e7335db5aac2795b1876d5afcfee/greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c", size = 629560, upload-time = "2026-08-10T14:30:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/18/5e/58c561efde575c4ca070030e2e0513e8d1b07b76d8a2d84a9bcef1becd42/greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638", size = 425489, upload-time = "2026-08-10T14:29:59.768Z" }, + { url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/fd/655c8a773d728bc3c93fb4713ae4bf79ffc75996f86fb78b2974c8e1dfbd/grpcio-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:fba099b716e73512d61b97f71ea3c31a72abb36904036e316bf4dd148ca8dcc8", size = 6334247, upload-time = "2026-07-23T15:18:53.099Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9a/1ce5760d35a04a992006dd2f79afff2db548f93ee7426fa95c9f1fc90c61/grpcio-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6755ed67cc3e454d51ae9f6e1915b80d3942fa4de956ef48dacd45ab7f40b727", size = 12168650, upload-time = "2026-07-23T15:18:56.348Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ab/bbcb5be0a1a6cb21f036e2afdd4f7a70147cfb7a7b42648a310d7c43acfc/grpcio-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5882c1a721b50ce0123ee5e839e1ab059ad72a7ade76cdf2d5bd833b56791acf", size = 6916899, upload-time = "2026-07-23T15:18:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/4c27977ecb3b3f9f363b93f570e001cb24ef264a9a907d7fd0f949ed59f0/grpcio-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e3eedfc92b6b9f2960115e7e620cf0cbf80bb7849a51ce3820dc54dfd88b6b9", size = 7648761, upload-time = "2026-07-23T15:19:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/49/0c823a7627ff2e69a61e4a53c4edf215272892fc2c47c6431f033d46f4cc/grpcio-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fcaa7c45c45b4a89e2867d1f1785d9481a788399d915e341ed2eb49aeef9dd4", size = 7074920, upload-time = "2026-07-23T15:19:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ce/963f01ff7c789a76909c9691b704112e02ca1e11c10405cd99c2bd7c40f1/grpcio-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6b6c666a1d5613ff360c9e90f44665e3a88b25a815209ddbc0917eec281931cb", size = 7598046, upload-time = "2026-07-23T15:19:03.921Z" }, + { url = "https://files.pythonhosted.org/packages/eb/de/1ce6bdefc847a7973040d10cebc8996c653a2a687c0a4da8d05dcab4e397/grpcio-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6be5c807b717be3dd649446f021301fd7907e376318675d2147823071034112a", size = 8634792, upload-time = "2026-07-23T15:19:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8b/7fe6a73895e3bdd788101d1276e48e0d262ebb165afacec1ec4efebcd785/grpcio-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c834e86d8fd2f03d7e4db49a027f7c5b89c5b88eed305543a5295bd6fee61e40", size = 8000286, upload-time = "2026-07-23T15:19:07.739Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/9a779de2bcda8722501a056fad1bec3d1117977af0c080ab1fc0655fdf35/grpcio-1.83.0-cp310-cp310-win32.whl", hash = "sha256:35a5b1c192496b6c25956eebfa963468935612206fd2543ac3ce981e6a5e0f03", size = 4404616, upload-time = "2026-07-23T15:19:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8e/ce9a23590cac33a6c24e6386cc0ffc55821cc13212acc822e98f00a67161/grpcio-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:8f6c395e493d20c39b29392ca200e9aaeb78d0bc2f04db0c0a7da7ddc939aa57", size = 5162304, upload-time = "2026-07-23T15:19:11.467Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +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 = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "langchain" +version = "0.2.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "async-timeout" }, + { name = "langchain-core" }, + { name = "langchain-text-splitters" }, + { name = "langsmith" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/ed/f3954eb9c534c0bbaba02f71962b35dfbf24a224b7a658f04f64f0435dd9/langchain-0.2.17.tar.gz", hash = "sha256:5a99ce94aae05925851777dba45cbf2c475565d1e91cbe7d82c5e329d514627e", size = 414625, upload-time = "2024-11-04T19:14:44.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/d5/f72c36008cc086b18c724a51c3a853b150aca6b6f9f72f1795059ec0faf5/langchain-0.2.17-py3-none-any.whl", hash = "sha256:a97a33e775f8de074370aecab95db148b879c794695d9e443c95457dce5eb525", size = 1001153, upload-time = "2024-11-04T19:14:42.679Z" }, +] + +[[package]] +name = "langchain-core" +version = "0.2.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/25/a14a1287e5d7e00eaf8cb3ee3c31d029127f14b945de6fc8e2f0e28e2b12/langchain_core-0.2.43.tar.gz", hash = "sha256:42c2ef6adedb911f4254068b6adc9eb4c4075f6c8cb3d83590d3539a815695f5", size = 316915, upload-time = "2024-10-31T20:30:36.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/9b/b26405992d807a592ab3e7792f0eb2c2f71fe69111c972caf7786ba99199/langchain_core-0.2.43-py3-none-any.whl", hash = "sha256:619601235113298ebf8252a349754b7c28d3cf7166c7c922da24944b78a9363a", size = 397066, upload-time = "2024-10-31T20:30:35.112Z" }, +] + +[[package]] +name = "langchain-text-splitters" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/b3/b1ccde47c86c5fe2585dc012555cff7949c556bd6993dd9c09e49a356190/langchain_text_splitters-0.2.4.tar.gz", hash = "sha256:f7daa7a3b0aa8309ce248e2e2b6fc8115be01118d336c7f7f7dfacda0e89bf29", size = 20236, upload-time = "2024-09-03T18:19:03.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/f3/d01591229e9d0eec1e8106ed6f9b670f299beb1c94fed4aa335afa78acb0/langchain_text_splitters-0.2.4-py3-none-any.whl", hash = "sha256:2702dee5b7cbdd595ccbe43b8d38d01a34aa8583f4d6a5a68ad2305ae3e7b645", size = 25552, upload-time = "2024-09-03T18:19:02.463Z" }, +] + +[[package]] +name = "langsmith" +version = "0.1.147" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/56/201dd94d492ae47c1bf9b50cacc1985113dc2288d8f15857e1f4a6818376/langsmith-0.1.147.tar.gz", hash = "sha256:2e933220318a4e73034657103b3b1a3a6109cc5db3566a7e8e03be8d6d7def7a", size = 300453, upload-time = "2024-11-27T17:32:41.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/f0/63b06b99b730b9954f8709f6f7d9b8d076fa0a973e472efe278089bde42b/langsmith-0.1.147-py3-none-any.whl", hash = "sha256:7166fc23b965ccf839d64945a78e9f1157757add228b086141eb03a60d699a15", size = 311812, upload-time = "2024-11-27T17:32:39.569Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124, upload-time = "2026-05-18T19:17:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783, upload-time = "2026-05-18T19:17:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687, upload-time = "2026-05-18T19:17:01.738Z" }, + { url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099, upload-time = "2026-05-18T19:17:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225, upload-time = "2026-05-18T19:17:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643, upload-time = "2026-05-18T19:17:11.507Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445, upload-time = "2026-05-18T19:17:22.182Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864, upload-time = "2026-05-18T19:17:26.851Z" }, + { url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594, upload-time = "2026-05-18T19:17:32.527Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713, upload-time = "2026-05-18T19:17:04.402Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973, upload-time = "2026-05-18T19:17:06.545Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925, upload-time = "2026-05-18T19:17:09.743Z" }, + { url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825, upload-time = "2026-05-18T19:17:13.831Z" }, + { url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402, upload-time = "2026-05-18T19:17:08.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295, upload-time = "2026-05-18T19:17:28.638Z" }, + { url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717, upload-time = "2026-05-19T19:22:44.474Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411, upload-time = "2024-02-05T23:48:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016, upload-time = "2024-02-05T23:48:54.098Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889, upload-time = "2024-02-05T23:49:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746, upload-time = "2024-02-05T23:49:51.983Z" }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620, upload-time = "2024-02-05T23:50:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659, upload-time = "2024-02-05T23:50:35.834Z" }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905, upload-time = "2024-02-05T23:51:03.701Z" }, +] + +[[package]] +name = "orjson" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/35/819eeb4fa8ee676d38fdbb8213a76fd496f7dbbfdfafa89d34e02b22dfac/orjson-3.12.0-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:747843254519dd43b93eee3153a19e5a509334320c4d2f823ec879232db5c796", size = 224133, upload-time = "2026-08-14T16:12:00.607Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/d9221d4a2b085b073fcddc91728d490f20b9cf010c62c2f42371ab997695/orjson-3.12.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:7c2ad193c8004254f34b499f3bd2c80f043d10754aff2b38f93da574f4883f98", size = 113669, upload-time = "2026-08-14T16:12:02.126Z" }, + { url = "https://files.pythonhosted.org/packages/15/12/644cbbcabb26df61d9ef0c66e6f2bf8b687cc7b66137597f2858951f1952/orjson-3.12.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:bc7a872f03522d90e0429e6c0c5cd23084f767bedcb4c58048eec19294613344", size = 130410, upload-time = "2026-08-14T16:12:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/e3a8c34d687895aecd8b267a01c46106eb98d8424a83bfa7bacb723854f6/orjson-3.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18a87929f31d94a77f7dc93cf527e91f39ce7fe7813d588a4de2507efd32a387", size = 131101, upload-time = "2026-08-14T16:12:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/930824c07685c22af23f26818ed3853b0270488a412b6ab757904b7f787b/orjson-3.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9683ee9ea0659da64f36574ef675b8a86330c34c19ea75db1fb93c3ff99e0ef", size = 131479, upload-time = "2026-08-14T16:12:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a6/22e863bbbe8917aa292e33e0db597000f9a07eb5e6f52efed623fa16bae1/orjson-3.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:103b5db66aa53c1f9e88c2524be4f383e831ba7dfd5f9f5af6336a177c622f11", size = 135865, upload-time = "2026-08-14T16:12:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/a0/ceb5008914a65e9a19a46a09d94bc67a74d120209fdfa772750023ceb377/orjson-3.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd57d79aefa3f84eec851d6de7a366795b9345cfaf17f82b4820430a7a5fa241", size = 127843, upload-time = "2026-08-14T16:12:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/3d/61c6b3b84c250cb09cb7229701ff77e4d763773ad7f577d0b6abf2892664/orjson-3.12.0-cp310-cp310-win32.whl", hash = "sha256:3dbce9b6b3074b31a5d5dd322a9c4e5b16f206091ece4194c2e36952847a105e", size = 128293, upload-time = "2026-08-14T16:12:09.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0e/ea0f4a563253b6363195a4f704123c6bfbf156641bd3be5a75de81c5e917/orjson-3.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:3bb17a06f9bd15237b3216c044209fe92597379124018cfc196fbb846cde64df", size = 122216, upload-time = "2026-08-14T16:12:11.261Z" }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, +] + +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, +] + +[[package]] +name = "pypdf2" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/bb/18dc3062d37db6c491392007dfd1a7f524bb95886eb956569ac38a23a784/PyPDF2-3.0.1.tar.gz", hash = "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", size = 227419, upload-time = "2022-12-31T10:36:13.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5e/c86a5643653825d3c913719e788e41386bee415c2b87b4f955432f2de6b2/pypdf2-3.0.1-py3-none-any.whl", hash = "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928", size = 232572, upload-time = "2022-12-31T10:36:10.327Z" }, +] + +[[package]] +name = "python-docx" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/82/eb6c794e61b7060d465a23917161da4ab554dccce07a3b44a0d69a61ea7d/python-docx-1.1.0.tar.gz", hash = "sha256:5829b722141cf1ab79aedf0c34d9fe9924b29764584c0f2164eb2b02dcdf17c9", size = 5650988, upload-time = "2023-11-04T00:21:25.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/d8/6948f7ac00edf74bfa52b3c5e3073df20284bec1db466d13e668fe991707/python_docx-1.1.0-py3-none-any.whl", hash = "sha256:bac9773278098a1ddc43a52d84e22f5909c4a3080a624530b3ecb3771b07c6cd", size = 239604, upload-time = "2023-11-04T00:21:17.894Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, +] + +[[package]] +name = "qdrant-client" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/45/5b1bdd15a3c7730eefb9c113600829e20d689b82b5a23f9e07d107094004/qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4", size = 352580, upload-time = "2026-05-11T14:12:38.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/10/c437bd2ac41ef30d3019063e6ce537dc111e9214473b337ee88f7fa6359a/qdrant_client-1.18.0-py3-none-any.whl", hash = "sha256:093aa8cf8a420ee3ad2a68b007e1378d7992b2600e0b53c193fc172674f659cd", size = 398126, upload-time = "2026-05-11T14:12:36.998Z" }, +] + +[[package]] +name = "requests" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/be/10918a2eac4ae9f02f6cfe6414b7a155ccd8f7f9d4380d62fd5b955065c3/requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1", size = 110794, upload-time = "2023-05-22T15:12:44.175Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", size = 62574, upload-time = "2023-05-22T15:12:42.313Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "resshare" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "flask-cors" }, + { name = "langchain" }, + { name = "numpy" }, + { name = "pypdf2" }, + { name = "python-docx" }, + { name = "python-dotenv" }, + { name = "qdrant-client" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask", specifier = "==3.0.0" }, + { name = "flask-cors", specifier = "==4.0.0" }, + { name = "langchain", specifier = "==0.2.17" }, + { name = "numpy", specifier = "==1.26.4" }, + { name = "pypdf2", specifier = "==3.0.1" }, + { name = "python-docx", specifier = "==1.1.0" }, + { name = "python-dotenv", specifier = "==1.0.1" }, + { name = "qdrant-client", specifier = "==1.18.0" }, + { name = "requests", specifier = "==2.31.0" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/d7/e0354e7334d33ea2795db3ecbe2977026c05a1ecf8ba4b5953c329872453/sqlalchemy-2.0.52-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101", size = 2172616, upload-time = "2026-08-11T20:58:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/5b/64/98eef682e6946eb1b4195a9a2393db4662ebfcc89f823ae78b938765c3c0/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7", size = 3279798, upload-time = "2026-08-11T21:00:03.535Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/5b96afc1407c314347ad006b72bb251fb68ef84d05505ebf8a39bc47fcde/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14", size = 3277555, upload-time = "2026-08-11T21:05:47.932Z" }, + { url = "https://files.pythonhosted.org/packages/50/69/ce6776724511d1b5dd40477b08d6a5f0953a45375e092dfa852b1857732c/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822", size = 3231246, upload-time = "2026-08-11T21:00:05.184Z" }, + { url = "https://files.pythonhosted.org/packages/72/19/ab0cb9ccdafa2419c796ae62f8740aedb903f1e93bb326064b1a0147e458/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd", size = 3250763, upload-time = "2026-08-11T21:05:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/f2/3c9b54b61bec4f493c0007dc9e2700c963c5b32667e21a506f1aca7a8115/sqlalchemy-2.0.52-cp310-cp310-win32.whl", hash = "sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729", size = 2132169, upload-time = "2026-08-11T21:09:48.108Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a1/934bb6cf543a398c72784d1fc777eb530559c16ebe1549fa7611e5989ce9/sqlalchemy-2.0.52-cp310-cp310-win_amd64.whl", hash = "sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850", size = 2156289, upload-time = "2026-08-11T21:09:49.429Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + +[[package]] +name = "tenacity" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ac/cacdda1f0a90441297210bc34cf7e4ac1b7318c8030ebd83bdf6fe82f1db/yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750", size = 135466, upload-time = "2026-07-20T02:04:21.695Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a5/1b2ceace0230e40c52ab1b263148059a43a6303219b996affc68f8381836/yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2", size = 97291, upload-time = "2026-07-20T02:04:24.045Z" }, + { url = "https://files.pythonhosted.org/packages/59/1d/340d1a0db7bbce1f291afc044255ebf4ebbce2b25ab1b3f7d3d069080f5d/yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871", size = 97154, upload-time = "2026-07-20T02:04:25.761Z" }, + { url = "https://files.pythonhosted.org/packages/05/41/25596a33c2fb5098dca8dc3773b04221db64ded0b7f8f09885647d864610/yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0", size = 109196, upload-time = "2026-07-20T02:04:27.543Z" }, + { url = "https://files.pythonhosted.org/packages/f2/df/dd9f2fb8a5c6054fbefd1538d2b9b1127e612d2ee64b307a070173b57afd/yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e", size = 102556, upload-time = "2026-07-20T02:04:29.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/4754b9d2c8945880290ecba0864e8b0441e117bba70534fe819e3645e174/yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2", size = 117965, upload-time = "2026-07-20T02:04:30.845Z" }, + { url = "https://files.pythonhosted.org/packages/74/b5/6a9ece27d2043c3386f902dd078ab35d29ef5126b3206ebffb673283a7cb/yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621", size = 116266, upload-time = "2026-07-20T02:04:32.573Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bc/a6653249f6ee59ec85dcfec008d9cbc16586dad613963bb17a91b2b993a5/yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba", size = 110758, upload-time = "2026-07-20T02:04:34.235Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c5a12fb8208df7b981bc82256e7831ce428eeaf893f7bbe6179c57bb9252/yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950", size = 110120, upload-time = "2026-07-20T02:04:35.85Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/1b659b964626694667b3ec01bf4bcff564b73ae7c48ea1fbfe588b78b461/yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00", size = 108834, upload-time = "2026-07-20T02:04:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/bf48f55c2104e40c15b7b13fad0a5756a11552a55f01c90bc90a66ab81c3/yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed", size = 103442, upload-time = "2026-07-20T02:04:39.576Z" }, + { url = "https://files.pythonhosted.org/packages/37/ac/84b273ac133ecdce598fc1f4140a08a1bf2044048bff8106371d207d105f/yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440", size = 117413, upload-time = "2026-07-20T02:04:41.549Z" }, + { url = "https://files.pythonhosted.org/packages/a4/55/9307e03977d3b290dfa42e5d2bae7b6140808fd1786fbe70cd9d3bee53c5/yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1", size = 109498, upload-time = "2026-07-20T02:04:43.468Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/791a6f314cb4c989c19f8e3a10271f1e469c077143915e52474d80f26b4b/yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6", size = 116062, upload-time = "2026-07-20T02:04:45.319Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/ddd3807b86055010e2f99aa89b3c640effdb65696766c20597f696f48a1c/yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d", size = 110941, upload-time = "2026-07-20T02:04:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/6d/03/f34271bba042d2187508bf62aea20a14129efb5a1acfc6a2efe7544630b4/yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224", size = 97534, upload-time = "2026-07-20T02:04:48.774Z" }, + { url = "https://files.pythonhosted.org/packages/e4/02/ecc8dc31b9f355731e700f8402b8075d2ea1737dbc4baf4abf0f0fc64288/yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13", size = 93603, upload-time = "2026-07-20T02:04:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] From 6c5ac6eb0de1dd0b00cfe2175a1376283cdc894c Mon Sep 17 00:00:00 2001 From: Devang Borkar Date: Tue, 18 Aug 2026 14:25:14 -0700 Subject: [PATCH 5/6] fix: add rollback for failed persistence --- backend/rag/vector_store.py | 2 +- backend/services/delete_service.py | 12 +++++++----- backend/services/upload_service.py | 3 +-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/rag/vector_store.py b/backend/rag/vector_store.py index f210f09..4452fc8 100644 --- a/backend/rag/vector_store.py +++ b/backend/rag/vector_store.py @@ -142,7 +142,7 @@ def search_user_vector_db(self, username: str, query: str, top_k: int = 5) -> Li must=[ models.FieldCondition( key="user_id", - match=models.MatchValue(value=username), + match=models.MatchValue(value=username), #maybe username is not the best identifier, but for now it works ) ] ), diff --git a/backend/services/delete_service.py b/backend/services/delete_service.py index 3f4d892..0070ff1 100644 --- a/backend/services/delete_service.py +++ b/backend/services/delete_service.py @@ -44,11 +44,13 @@ def delete_node(data): if node_name not in parent_node.children: return jsonify({'message': ErrorCode.NODE_NOT_FOUND.name}), 404 - #TODO: Figure ts out - # target_node = parent_node.children[node_name] - # document_ids = collect_indexed_document_ids(target_node) - # if document_ids and not get_rag_manager().delete_documents(username, document_ids): - # return jsonify({'message': 'RAG_DELETE_FAILED'}), 503 + + target_node = parent_node.children[node_name] + document_ids = collect_indexed_document_ids(target_node) + qdrant_vector_store = get_vector_store() + + if document_ids and not qdrant_vector_store.delete_documents(username, document_ids): + return jsonify({'message': 'RAG_DELETE_FAILED'}), 503 del parent_node.children[node_name] diff --git a/backend/services/upload_service.py b/backend/services/upload_service.py index e0d3fbf..e3ac60f 100644 --- a/backend/services/upload_service.py +++ b/backend/services/upload_service.py @@ -172,6 +172,5 @@ def persist_root_with_rag_rollback( if not rollback_succeeded: logger.error(f"RAG data for document {indexed_document_id} may be orphaned after metadata write failure") - return False - return False \ No newline at end of file + return rollback_succeeded \ No newline at end of file From 9cb09884e8f5f7770128d760b7b252324c37021f Mon Sep 17 00:00:00 2001 From: Devang Borkar Date: Tue, 18 Aug 2026 16:52:52 -0700 Subject: [PATCH 6/6] refactor: seperate embedding and response gen services --- backend/controller/chat_controller.py | 5 +- backend/rag/embeddings.py | 138 ++++++++++++++++++-------- backend/rag/types.py | 92 +---------------- backend/rag/vector_store.py | 9 +- backend/services/answer_service.py | 115 ++++++++++++++------- 5 files changed, 185 insertions(+), 174 deletions(-) diff --git a/backend/controller/chat_controller.py b/backend/controller/chat_controller.py index b710330..e9f0ae3 100644 --- a/backend/controller/chat_controller.py +++ b/backend/controller/chat_controller.py @@ -6,7 +6,7 @@ login_required, route_logger, ) -from backend.services.answer_service import generate_answer +from backend.services.answer_service import GeminiGenerationClient from backend.rag.retrieval import search def register_chat_routes(app, logger): @@ -35,7 +35,8 @@ def chat_route(): 'chunks_found': 0 }), 200 - answer = generate_answer(query, relevant_chunks) + generation_client = GeminiGenerationClient("gemini-2.5-flash") + answer = generation_client.generate_answer(query, relevant_chunks) sources = [] seen_files = set() diff --git a/backend/rag/embeddings.py b/backend/rag/embeddings.py index 046d0fb..ecf73b3 100644 --- a/backend/rag/embeddings.py +++ b/backend/rag/embeddings.py @@ -1,54 +1,106 @@ import numpy as np from backend.rag.types import GeminiClientConfig -from typing import List +from typing import List, Dict, Any import requests - +import traceback import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +class GeminiEmbeddingClient: + def __init__(self, embedding_model: str, embedding_dimensions: int = 768): + self.config = GeminiClientConfig(model_name=embedding_model) + self.embedding_url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{self.config.model_name}:batchEmbedContents" + ) + self.dims = embedding_dimensions -def generate_embeddings( - texts: List[str], - embedding_model: str, - embedding_dimensions: int = 768, - task_type: str = "RETRIEVAL_DOCUMENT" - ) -> np.ndarray: - """ - Generate embeddings for a list of texts using Gemini API batch processing - - Args: - texts: List of text chunks + def generate_document_embeddings(self, texts: List[str]) -> np.ndarray: + return self.generate_embeddings(texts, task_type="RETRIEVAL_DOCUMENT") + + def generate_query_embeddings(self, query: List[str]) -> np.ndarray: + return self.generate_embeddings(query, task_type="RETRIEVAL_QUERY") + + def generate_embeddings( + self, + texts: List[str], + task_type: str = "RETRIEVAL_DOCUMENT" + ) -> np.ndarray: + """ + Generate embeddings for a list of texts using Gemini API batch processing - Returns: - Numpy array of embeddings - """ - if not texts: - return np.array([]) - - logger.debug(f"Generating embeddings for {len(texts)} texts") + Args: + texts: List of text chunks + task_type: Retrieval Query or Indexing docs (https://ai.google.dev/gemini-api/docs/embeddings#task-types-embeddings-1) + Returns: + Numpy array of embeddings + """ + if not texts: + return np.array([]) + + try: + headers = self._build_headers() + data = self._build_request_body(texts, task_type) + + response = requests.post(self.embedding_url, headers=headers, json=data, timeout=30) + response.raise_for_status() + result = response.json() + + embeddings_np = self._get_numpy_embeddings(result.get("embeddings", [])) + logger.debug(f"Built embeddings array with shape {embeddings_np.shape}") + + if len(embeddings_np) != len(texts): + logger.warning(f"Mismatch: expected {len(texts)} embeddings, got {len(embeddings_np)}") + return np.array([]) + + return embeddings_np + + except requests.exceptions.Timeout: + logger.error("Gemini API timeout during batch embedding generation") + return np.array([]) + except requests.exceptions.RequestException as e: + logger.error(f"Gemini API request failed: {e}") + if hasattr(e, 'response') and e.response is not None: + logger.error(f"Response status: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + return np.array([]) + except Exception as e: + logger.error(f"Failed to generate embeddings with Gemini API: {e}") + logger.error(f"Traceback: {traceback.format_exc()}") + return np.array([]) + + def _build_headers(self) -> Dict: + return { + "x-goog-api-key": self.config.api_key, + "Content-Type": "application/json" + } + + def _build_request_body(self, texts: List[str], task_type: str) -> Dict[str, Any]: + requests_data = [] + for text in texts: + requests_data.append({ + "model": f"models/{self.config.model_name}", + "content": { + "parts": [{"text": text}] + }, + "task_type": task_type, + "output_dimensionality": self.dims #check best default + }) - try: - logger.info(f"Using Gemini API with model: {embedding_model}, dimension: {embedding_dimensions}") - embedding_url = ( - "https://generativelanguage.googleapis.com/v1beta/models/" - f"{embedding_model}:batchEmbedContents" - ) - client = GeminiClientConfig(model_name=embedding_model, url=embedding_url) - embeddings = client.get_embeddings(texts, task_type=task_type, dim=embedding_dimensions) - return embeddings + return {"requests": requests_data} + + @staticmethod + def _get_numpy_embeddings(api_embeddings: Dict[str, Any]) -> np.ndarray: + embeddings = [] + for embedding_response in api_embeddings: + embedding_values = np.array(embedding_response["values"]) + # if embedding_client.embedding_dimension < 3072: # Normalize truncated dimensions + # norm = np.linalg.norm(embedding_values) + # if norm > 0: + # embedding_values = embedding_values / norm + embeddings.append(embedding_values) - except requests.exceptions.Timeout: - logger.error("Gemini API timeout during batch embedding generation") - return np.array([]) - except requests.exceptions.RequestException as e: - logger.error(f"Gemini API request failed: {e}") - if hasattr(e, 'response') and e.response is not None: - logger.error(f"Response status: {e.response.status_code}") - logger.error(f"Response body: {e.response.text}") - return np.array([]) - except Exception as e: - logger.error(f"Failed to generate embeddings with Gemini API: {e}") - import traceback - logger.error(f"Traceback: {traceback.format_exc()}") - return np.array([]) \ No newline at end of file + embeddings_np = np.array(embeddings) + logger.debug(f"Built embeddings array with shape {embeddings_np.shape}") + return embeddings_np \ No newline at end of file diff --git a/backend/rag/types.py b/backend/rag/types.py index 1c744f0..3323e8e 100644 --- a/backend/rag/types.py +++ b/backend/rag/types.py @@ -1,8 +1,6 @@ from typing import Optional -import requests import logging import os -import numpy as np logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -21,97 +19,9 @@ def __init__( self.embedding_model_name = embedding_model_name class GeminiClientConfig: - def __init__(self, url: str, model_name: str = "gemini-embedding-001"): + def __init__(self, model_name: str = "gemini-embedding-001"): self.api_key = self._load_api_key() self.model_name = model_name - self.url = url - - def get_text_response(self, prompt: str, max_tokens: int) -> str: - """Generate response using Gemini API""" - try: - headers = { - "x-goog-api-key": self.api_key, - "Content-Type": "application/json" - } - - data = { - "contents": [ - { - "parts": [ - { - "text": prompt - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": max_tokens - }, - "systemInstruction": { - "parts": [ - { - "text": "You are a helpful assistant that answers questions based on the provided context from user's documents." - } - ] - } - } - - response = requests.post(self.url, headers=headers, json=data) - response.raise_for_status() - - result = response.json() - logger.debug(f"Gemini API response: {result}") - return result['candidates'][0]['content']['parts'][0]['text'] - except Exception as e: - logger.error(f"Gemini API error: {e}") - raise - - def get_embeddings(self, texts: list, task_type: str = "RETRIEVAL_DOCUMENT", dim: int = 768) -> np.ndarray: - """Check if Gemini API is reachable and the API key is valid""" - - headers = { - "x-goog-api-key": self.api_key, - "Content-Type": "application/json" - } - - requests_data = [] - for text in texts: - requests_data.append({ - "model": f"models/{self.model_name}", - "content": { - "parts": [{"text": text}] - }, - "task_type": task_type, #Figure out what's the point of adding task_type? - "output_dimensionality": dim - }) - - data = {"requests": requests_data} - - logger.info(f"Embedding request payload count: {len(requests_data)}") - response = requests.post(self.url, headers=headers, json=data, timeout=30) - response.raise_for_status() - - result = response.json() - logger.info(f"Embedding API responded with keys: {list(result.keys())}") - - embeddings = [] - api_embeddings = result.get("embeddings", []) - logger.info(f"Embedding objects returned: {len(api_embeddings)}") - for embedding_response in api_embeddings: - embedding_values = np.array(embedding_response["values"]) - # if embedding_client.embedding_dimension < 3072: # Normalize truncated dimensions - # norm = np.linalg.norm(embedding_values) - # if norm > 0: - # embedding_values = embedding_values / norm - embeddings.append(embedding_values) - - if len(embeddings) != len(texts): - logger.info(f"Mismatch: expected {len(texts)} embeddings, got {len(embeddings)}") - return np.array([]) - - embeddings_np = np.array(embeddings) - logger.debug(f"Built embeddings array with shape {embeddings_np.shape}") - return embeddings_np def _load_api_key(self): api_key = os.getenv("GOOGLE_API_KEY") diff --git a/backend/rag/vector_store.py b/backend/rag/vector_store.py index 4452fc8..1fb7e21 100644 --- a/backend/rag/vector_store.py +++ b/backend/rag/vector_store.py @@ -2,7 +2,7 @@ import os from typing import List, Dict import logging -from backend.rag.embeddings import generate_embeddings +from backend.rag.embeddings import GeminiEmbeddingClient from qdrant_client import QdrantClient, models logging.basicConfig(level=logging.INFO) @@ -68,8 +68,8 @@ def add_chunks(self, username: str, chunks: List[Dict]) -> bool: texts = [chunk["text"] for chunk in chunks] logger.info("Preparing to add %s chunks for user '%s'", len(texts), username) - - embeddings = generate_embeddings(texts, embedding_model=self.embedding_model_name, embedding_dimensions=self.embedding_dimension, task_type="RETRIEVAL_DOCUMENT") + embedding_client = GeminiEmbeddingClient(self.embedding_model_name, self.embedding_dimension) + embeddings = embedding_client.generate_document_embeddings(texts) if embeddings.size == 0: return False @@ -129,7 +129,8 @@ def search_user_vector_db(self, username: str, query: str, top_k: int = 5) -> Li return [] try: - query_embedding = generate_embeddings([query], embedding_model=self.embedding_model_name, embedding_dimensions=self.embedding_dimension, task_type="RETRIEVAL_QUERY") + embedding_client = GeminiEmbeddingClient(embedding_model=self.embedding_model_name, embedding_dimensions=self.embedding_dimension) + query_embedding = embedding_client.generate_query_embeddings([query]) if query_embedding.size == 0: logger.warning("Query embedding generation failed for user '%s'", username) diff --git a/backend/services/answer_service.py b/backend/services/answer_service.py index 25df58b..c13e2a8 100644 --- a/backend/services/answer_service.py +++ b/backend/services/answer_service.py @@ -1,45 +1,92 @@ from typing import List, Dict from backend.rag.types import GeminiClientConfig - +import requests import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +class GeminiGenerationClient: + def __init__(self, generation_model = "gemini-2.5-flash"): + self.config = GeminiClientConfig(model_name=generation_model) + self.url = f"https://generativelanguage.googleapis.com/v1beta/models/{generation_model}:generateContent" -def generate_answer(query: str, context_chunks: List[Dict], max_tokens: int = 5000) -> str: - """ - Generate an answer using retrieved context - - Args: - query: User's question - context_chunks: Retrieved relevant chunks - max_tokens: Maximum tokens for response + def generate_answer( + self, + query: str, + context_chunks: List[Dict], + max_tokens: int = 5000 + ) -> str: + """ + Generate an answer using retrieved context - Returns: - Generated answer - """ - if not context_chunks: - return "I couldn't find any relevant information in your uploaded files to answer this question." - - context_text = "\n\n".join([ - f"From {chunk['chunk']['metadata']['filename']}:\n{chunk['chunk']['text']}" - for chunk in context_chunks[:3] - ]) - - prompt = f"""Based on the following context from the user's uploaded files, please answer their question. If the context doesn't contain enough information to answer the question, please say so. + Args: + query: User's question + context_chunks: Retrieved relevant chunks + max_tokens: Maximum tokens for response + + Returns: + Generated answer + """ + if not context_chunks: + return "I couldn't find any relevant information in your uploaded files to answer this question." + + try: + headers = self._build_headers() + data = self._build_body(context_chunks, query, max_tokens) -Context: -{context_text} + response = requests.post(self.url, headers=headers, json=data) + response.raise_for_status() -Question: {query} + result = response.json() + logger.debug(f"Gemini API response: {result}") + return result['candidates'][0]['content']['parts'][0]['text'] -Answer:""" - - try: - model_name = "gemini-2.5-flash" #Need to move away from hardcoding - text_responses_url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent" - client = GeminiClientConfig(model_name=model_name, url=text_responses_url) - return client.get_text_response(prompt, max_tokens) - except Exception as e: - logger.error(f"Failed to generate LLM response: {e}") - return "I encountered an error while generating the response. Please try again." + except Exception as e: + logger.error(f"Failed to generate LLM response: {e}") + return "I encountered an error while generating the response. Please try again." + + def _build_headers(self): + return { + "x-goog-api-key": self.config.api_key, + "Content-Type": "application/json" + } + + def _build_body(self, context_chunks: List[Dict], query: str, max_tokens: int): + return { + "contents": [ + { + "parts": [ + { + "text": self._build_prompt(context_chunks, query) + } + ] + } + ], + "generationConfig": { + "maxOutputTokens": max_tokens + }, + "systemInstruction": { + "parts": [ + { + "text": "You are a helpful assistant that answers questions based on the provided context from user's documents." + } + ] + } + } + + + def _build_prompt(context_chunks: List[Dict], query: str): + context_text = "\n\n".join([ + f"From {chunk['chunk']['metadata']['filename']}:\n{chunk['chunk']['text']}" + for chunk in context_chunks[:3] + ]) + return f""" + Based on the following context from the user's uploaded files, please answer their question. + If the context doesn't contain enough information to answer the question, always say so. + + Context: {context_text} + + Question: {query} + + Answer: + """