Spaces:
Running
Running
Soumik Bose commited on
Commit ·
bd469c1
1
Parent(s): 066203b
optimization 404
Browse files- .dockerignore +54 -0
- .gitignore +1 -1
- app/api/deps.py +6 -22
- app/api/server.py +45 -9
- app/api/v1/batch.py +10 -13
- app/api/v1/chat.py +1 -1
- app/api/v1/convert.py +8 -9
- app/api/v1/json_extract.py +2 -1
- app/api/v1/reconcile.py +26 -6
- app/api/v1/semantic_router.py +2 -1
- app/api/v1/sql_validator.py +2 -1
- app/api/v1/system.py +13 -6
- app/api/v1/token_counter.py +2 -1
- app/api/v1/vector_stores.py +3 -15
- app/api/verify.py +2 -1
- app/config.py +27 -1
- app/core/auth/deps.py +2 -4
- app/core/banner.py +0 -26
- app/core/security.py +0 -33
- app/core/thread_pool.py +16 -0
- app/core/vector_store/deps.py +0 -2
- app/services/auth_service.py +35 -12
- app/services/code_executor_service.py +1 -9
- app/services/csv_analysis_service.py +0 -83
- app/services/embeddings_service.py +0 -4
- app/services/gcs_service.py +14 -5
- app/services/google_oauth_service.py +3 -24
- app/services/google_scope_service.py +0 -3
- app/services/ocr_service.py +0 -6
- app/services/reconciliation_service.py +0 -177
- app/services/scheduler_service.py +1 -7
- app/services/supabase/client.py +0 -8
- app/services/supabase/repositories.py +2 -18
- app/services/url_shortener_service.py +3 -3
- app/services/vector_store_service.py +0 -47
- app/services/webhook_socket_service.py +0 -6
- app/utils/http_utils.py +18 -18
- requirements.txt +56 -66
.dockerignore
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
*.md
|
| 5 |
+
|
| 6 |
+
# Secrets (never ship)
|
| 7 |
+
.env
|
| 8 |
+
.env.local
|
| 9 |
+
.env.*
|
| 10 |
+
!.env.example
|
| 11 |
+
google_oauth_test_creds.postman_environment.json
|
| 12 |
+
|
| 13 |
+
# Python / build
|
| 14 |
+
__pycache__/
|
| 15 |
+
*.py[cod]
|
| 16 |
+
*.egg-info/
|
| 17 |
+
.venv/
|
| 18 |
+
venv/
|
| 19 |
+
.pytest_cache/
|
| 20 |
+
.ruff_cache/
|
| 21 |
+
htmlcov/
|
| 22 |
+
.tox/
|
| 23 |
+
.nox/
|
| 24 |
+
build/
|
| 25 |
+
dist/
|
| 26 |
+
|
| 27 |
+
# Local / tooling artifacts
|
| 28 |
+
.mimocode
|
| 29 |
+
.devenv/
|
| 30 |
+
|
| 31 |
+
# Runtime data
|
| 32 |
+
data/
|
| 33 |
+
persistence/
|
| 34 |
+
logs/
|
| 35 |
+
*.log
|
| 36 |
+
*.db
|
| 37 |
+
*.sqlite3
|
| 38 |
+
|
| 39 |
+
# Large repo blobs not needed by the image
|
| 40 |
+
generated-python-sdk/
|
| 41 |
+
ddl/
|
| 42 |
+
searxng/
|
| 43 |
+
scripts/
|
| 44 |
+
*.json
|
| 45 |
+
|
| 46 |
+
# NOTE: `lua/` (Redis Lua scripts) is loaded at runtime and MUST stay in the image.
|
| 47 |
+
|
| 48 |
+
# Tests / deploy scripts (kept out of the image)
|
| 49 |
+
tests/
|
| 50 |
+
test_integration.py
|
| 51 |
+
local_deploy.py
|
| 52 |
+
deploy_sdk.py
|
| 53 |
+
deploy_hf.py
|
| 54 |
+
server.log
|
.gitignore
CHANGED
|
@@ -81,7 +81,7 @@ celerybeat.pid
|
|
| 81 |
|
| 82 |
*.sage.py
|
| 83 |
|
| 84 |
-
.env
|
| 85 |
.venv
|
| 86 |
env/
|
| 87 |
venv/
|
|
|
|
| 81 |
|
| 82 |
*.sage.py
|
| 83 |
|
| 84 |
+
.env*
|
| 85 |
.venv
|
| 86 |
env/
|
| 87 |
venv/
|
app/api/deps.py
CHANGED
|
@@ -4,44 +4,28 @@ from typing import Any, Dict, Optional, Tuple
|
|
| 4 |
|
| 5 |
from fastapi import Request
|
| 6 |
|
| 7 |
-
from app.services.auth_service import AuthService
|
| 8 |
from app.services.converter_service import ConverterService
|
| 9 |
-
from app.services.database_service import DatabaseService
|
| 10 |
from app.services.embeddings_service import EmbeddingService
|
| 11 |
from app.services.extraction_service import ExtractionService
|
| 12 |
-
from app.services.ocr_service import OCRService
|
| 13 |
-
from app.services.sql_validator_service import SqlValidatorService
|
| 14 |
from app.services.text_cleaner_service import TextCleanerService
|
| 15 |
from app.services.scheduler_service import SchedulerService
|
| 16 |
from app.services.vector_store_service import VectorStoreService
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
|
| 22 |
|
| 23 |
def get_text_cleaner_service() -> TextCleanerService:
|
| 24 |
-
return
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def get_auth_service() -> AuthService:
|
| 28 |
-
return AuthService()
|
| 29 |
|
| 30 |
|
| 31 |
def get_converter_service() -> ConverterService:
|
| 32 |
-
return
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def get_ocr_service() -> OCRService:
|
| 36 |
-
return OCRService()
|
| 37 |
|
| 38 |
|
| 39 |
def get_extraction_service() -> ExtractionService:
|
| 40 |
-
return
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def get_database_service() -> DatabaseService:
|
| 44 |
-
return DatabaseService()
|
| 45 |
|
| 46 |
|
| 47 |
def get_embeddings_service() -> EmbeddingService:
|
|
|
|
| 4 |
|
| 5 |
from fastapi import Request
|
| 6 |
|
|
|
|
| 7 |
from app.services.converter_service import ConverterService
|
|
|
|
| 8 |
from app.services.embeddings_service import EmbeddingService
|
| 9 |
from app.services.extraction_service import ExtractionService
|
|
|
|
|
|
|
| 10 |
from app.services.text_cleaner_service import TextCleanerService
|
| 11 |
from app.services.scheduler_service import SchedulerService
|
| 12 |
from app.services.vector_store_service import VectorStoreService
|
| 13 |
|
| 14 |
+
_converter_service = ConverterService()
|
| 15 |
+
_extraction_service = ExtractionService()
|
| 16 |
+
_text_cleaner_service = TextCleanerService()
|
| 17 |
|
| 18 |
|
| 19 |
def get_text_cleaner_service() -> TextCleanerService:
|
| 20 |
+
return _text_cleaner_service
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def get_converter_service() -> ConverterService:
|
| 24 |
+
return _converter_service
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def get_extraction_service() -> ExtractionService:
|
| 28 |
+
return _extraction_service
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def get_embeddings_service() -> EmbeddingService:
|
app/api/server.py
CHANGED
|
@@ -3,9 +3,13 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
from contextlib import asynccontextmanager
|
| 5 |
|
| 6 |
-
from fastapi import FastAPI, Request
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.middleware.gzip import GZipMiddleware
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from app.api.v1.router import api_v1_router
|
| 11 |
from app.api.v1.system import is_maintenance
|
|
@@ -18,6 +22,7 @@ from app.core.scripts import load_scripts
|
|
| 18 |
from app.services.embeddings_service import EmbeddingService
|
| 19 |
from app.services.scheduler_service import SchedulerService
|
| 20 |
from app.services.vector_store_service import VectorStoreService
|
|
|
|
| 21 |
|
| 22 |
_logger = get_logger(__name__)
|
| 23 |
_settings = get_settings()
|
|
@@ -40,19 +45,34 @@ def _is_public_path(path: str) -> bool:
|
|
| 40 |
_embedding_service: EmbeddingService = EmbeddingService()
|
| 41 |
_vector_store_service: VectorStoreService = VectorStoreService(_embedding_service)
|
| 42 |
_scheduler_service: SchedulerService = SchedulerService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
async def _self_ping():
|
| 46 |
-
import httpx
|
| 47 |
health_url = _settings.self_ping_url
|
| 48 |
while True:
|
| 49 |
try:
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
except Exception as exc:
|
| 57 |
_logger.error("Self-ping error: %s", exc)
|
| 58 |
await asyncio.sleep(900)
|
|
@@ -127,6 +147,7 @@ async def lifespan(app: FastAPI):
|
|
| 127 |
await close_storage_service()
|
| 128 |
from app.utils.http_utils import close_shared_aiohttp_sessions
|
| 129 |
await close_shared_aiohttp_sessions()
|
|
|
|
| 130 |
from app.services.supabase import get_supabase_client
|
| 131 |
client = get_supabase_client()
|
| 132 |
if client:
|
|
@@ -162,6 +183,17 @@ def create_application() -> FastAPI:
|
|
| 162 |
allow_headers=["*"],
|
| 163 |
)
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
@app.middleware("http")
|
| 166 |
async def maintenance_middleware(request: Request, call_next):
|
| 167 |
if is_maintenance():
|
|
@@ -202,7 +234,11 @@ def create_application() -> FastAPI:
|
|
| 202 |
)
|
| 203 |
return await call_next(request)
|
| 204 |
|
| 205 |
-
app.include_router(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
@app.get("/", include_in_schema=False)
|
| 208 |
async def root(request: Request):
|
|
|
|
| 3 |
import asyncio
|
| 4 |
from contextlib import asynccontextmanager
|
| 5 |
|
| 6 |
+
from fastapi import Depends, FastAPI, Request
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 9 |
+
from fastapi.responses import JSONResponse
|
| 10 |
+
from slowapi import Limiter
|
| 11 |
+
from slowapi.errors import RateLimitExceeded
|
| 12 |
+
from slowapi.util import get_remote_address
|
| 13 |
|
| 14 |
from app.api.v1.router import api_v1_router
|
| 15 |
from app.api.v1.system import is_maintenance
|
|
|
|
| 22 |
from app.services.embeddings_service import EmbeddingService
|
| 23 |
from app.services.scheduler_service import SchedulerService
|
| 24 |
from app.services.vector_store_service import VectorStoreService
|
| 25 |
+
from app.utils.http_utils import SharedAsyncClient
|
| 26 |
|
| 27 |
_logger = get_logger(__name__)
|
| 28 |
_settings = get_settings()
|
|
|
|
| 45 |
_embedding_service: EmbeddingService = EmbeddingService()
|
| 46 |
_vector_store_service: VectorStoreService = VectorStoreService(_embedding_service)
|
| 47 |
_scheduler_service: SchedulerService = SchedulerService()
|
| 48 |
+
_ping_client = SharedAsyncClient(timeout=30.0)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# Global per-client-IP rate limit applied to every /api/v1 route.
|
| 52 |
+
# Configure via RATE_LIMIT_PER_MINUTE (0 or empty disables).
|
| 53 |
+
limiter = Limiter(key_func=get_remote_address, storage_uri="memory://", headers_enabled=False)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def _api_rate_limit(request: Request) -> None:
|
| 57 |
+
"""Apply the global per-client-IP rate limit to all /api/v1 routes."""
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
_RATE_LIMIT_PER_MINUTE = _settings.rate_limit_per_minute
|
| 62 |
+
if _RATE_LIMIT_PER_MINUTE and _RATE_LIMIT_PER_MINUTE > 0:
|
| 63 |
+
_api_rate_limit = limiter.limit(f"{_RATE_LIMIT_PER_MINUTE}/minute")(_api_rate_limit)
|
| 64 |
|
| 65 |
|
| 66 |
async def _self_ping():
|
|
|
|
| 67 |
health_url = _settings.self_ping_url
|
| 68 |
while True:
|
| 69 |
try:
|
| 70 |
+
client = await _ping_client.get()
|
| 71 |
+
response = await client.get(health_url)
|
| 72 |
+
if response.status_code == 200:
|
| 73 |
+
_logger.info("Self-ping successful: %s", health_url)
|
| 74 |
+
else:
|
| 75 |
+
_logger.warning("Self-ping returned: %s - %s", health_url, response.status_code)
|
| 76 |
except Exception as exc:
|
| 77 |
_logger.error("Self-ping error: %s", exc)
|
| 78 |
await asyncio.sleep(900)
|
|
|
|
| 147 |
await close_storage_service()
|
| 148 |
from app.utils.http_utils import close_shared_aiohttp_sessions
|
| 149 |
await close_shared_aiohttp_sessions()
|
| 150 |
+
await _ping_client.close()
|
| 151 |
from app.services.supabase import get_supabase_client
|
| 152 |
client = get_supabase_client()
|
| 153 |
if client:
|
|
|
|
| 183 |
allow_headers=["*"],
|
| 184 |
)
|
| 185 |
|
| 186 |
+
app.state.limiter = limiter
|
| 187 |
+
|
| 188 |
+
@app.exception_handler(RateLimitExceeded)
|
| 189 |
+
async def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
| 190 |
+
retry_after = getattr(exc, "retry_after", 60) or 60
|
| 191 |
+
return JSONResponse(
|
| 192 |
+
status_code=429,
|
| 193 |
+
content={"success": False, "detail": "Rate limit exceeded. Please retry later."},
|
| 194 |
+
headers={"Retry-After": str(int(retry_after))},
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
@app.middleware("http")
|
| 198 |
async def maintenance_middleware(request: Request, call_next):
|
| 199 |
if is_maintenance():
|
|
|
|
| 234 |
)
|
| 235 |
return await call_next(request)
|
| 236 |
|
| 237 |
+
app.include_router(
|
| 238 |
+
api_v1_router,
|
| 239 |
+
prefix="/api/v1",
|
| 240 |
+
dependencies=[Depends(_api_rate_limit)],
|
| 241 |
+
)
|
| 242 |
|
| 243 |
@app.get("/", include_in_schema=False)
|
| 244 |
async def root(request: Request):
|
app/api/v1/batch.py
CHANGED
|
@@ -6,7 +6,6 @@ import time
|
|
| 6 |
from typing import Annotated, List, Optional
|
| 7 |
from urllib.parse import urlparse
|
| 8 |
|
| 9 |
-
import httpx
|
| 10 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
| 11 |
|
| 12 |
from app.api.deps import (
|
|
@@ -26,6 +25,7 @@ from app.models.schemas import (
|
|
| 26 |
from app.services.converter_service import ConverterService
|
| 27 |
from app.services.extraction_service import ExtractionService
|
| 28 |
from app.services.text_cleaner_service import TextCleanerService
|
|
|
|
| 29 |
|
| 30 |
router = APIRouter()
|
| 31 |
_logger = get_logger(__name__)
|
|
@@ -156,15 +156,9 @@ async def batch_urls(
|
|
| 156 |
|
| 157 |
if body.return_json:
|
| 158 |
try:
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
raw_data = resp.content
|
| 163 |
-
if len(raw_data) > _MAX_UPLOAD_BYTES:
|
| 164 |
-
return BatchFileResult(
|
| 165 |
-
filename=filename, success=False, time_ms=0,
|
| 166 |
-
error=f"File exceeds {_settings.max_upload_mb} MB limit.",
|
| 167 |
-
)
|
| 168 |
loop = asyncio.get_running_loop()
|
| 169 |
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_stream, raw_data, filename)
|
| 170 |
if isinstance(outcome, ConversionError):
|
|
@@ -185,10 +179,13 @@ async def batch_urls(
|
|
| 185 |
result.json_content = json_result if "error" not in json_result else None
|
| 186 |
result.error = json_result.get("error") if "error" in json_result else None
|
| 187 |
return result
|
| 188 |
-
except
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
return BatchFileResult(
|
| 190 |
-
filename=filename, success=False, time_ms=0,
|
| 191 |
-
error=f"Failed to fetch URL: {exc}",
|
| 192 |
)
|
| 193 |
|
| 194 |
loop = asyncio.get_running_loop()
|
|
|
|
| 6 |
from typing import Annotated, List, Optional
|
| 7 |
from urllib.parse import urlparse
|
| 8 |
|
|
|
|
| 9 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
| 10 |
|
| 11 |
from app.api.deps import (
|
|
|
|
| 25 |
from app.services.converter_service import ConverterService
|
| 26 |
from app.services.extraction_service import ExtractionService
|
| 27 |
from app.services.text_cleaner_service import TextCleanerService
|
| 28 |
+
from app.utils.http_utils import DownloadError, download_url
|
| 29 |
|
| 30 |
router = APIRouter()
|
| 31 |
_logger = get_logger(__name__)
|
|
|
|
| 156 |
|
| 157 |
if body.return_json:
|
| 158 |
try:
|
| 159 |
+
raw_data, _ = await download_url(
|
| 160 |
+
url, timeout_seconds=30.0, max_size_bytes=_MAX_UPLOAD_BYTES
|
| 161 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
loop = asyncio.get_running_loop()
|
| 163 |
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_stream, raw_data, filename)
|
| 164 |
if isinstance(outcome, ConversionError):
|
|
|
|
| 179 |
result.json_content = json_result if "error" not in json_result else None
|
| 180 |
result.error = json_result.get("error") if "error" in json_result else None
|
| 181 |
return result
|
| 182 |
+
except DownloadError as exc:
|
| 183 |
+
if exc.is_size_error:
|
| 184 |
+
error = f"File exceeds {_settings.max_upload_mb} MB limit."
|
| 185 |
+
else:
|
| 186 |
+
error = f"Failed to fetch URL: {exc}"
|
| 187 |
return BatchFileResult(
|
| 188 |
+
filename=filename, success=False, time_ms=0, error=error,
|
|
|
|
| 189 |
)
|
| 190 |
|
| 191 |
loop = asyncio.get_running_loop()
|
app/api/v1/chat.py
CHANGED
|
@@ -5,7 +5,7 @@ import logging
|
|
| 5 |
import time
|
| 6 |
from typing import Any, AsyncGenerator, Dict, List, Optional
|
| 7 |
|
| 8 |
-
from fastapi import APIRouter,
|
| 9 |
from fastapi.responses import StreamingResponse
|
| 10 |
|
| 11 |
from app.api.deps import get_redis_scripts
|
|
|
|
| 5 |
import time
|
| 6 |
from typing import Any, AsyncGenerator, Dict, List, Optional
|
| 7 |
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 9 |
from fastapi.responses import StreamingResponse
|
| 10 |
|
| 11 |
from app.api.deps import get_redis_scripts
|
app/api/v1/convert.py
CHANGED
|
@@ -6,7 +6,6 @@ import json as json_mod
|
|
| 6 |
from typing import Annotated, Any, Dict, Optional
|
| 7 |
from urllib.parse import urlparse
|
| 8 |
|
| 9 |
-
import httpx
|
| 10 |
from fastapi import (
|
| 11 |
APIRouter,
|
| 12 |
Depends,
|
|
@@ -32,6 +31,7 @@ from app.models.schemas import ConversionMetadata, ConversionResponse, UrlReques
|
|
| 32 |
from app.services.converter_service import ConverterService
|
| 33 |
from app.services.extraction_service import ExtractionService
|
| 34 |
from app.services.text_cleaner_service import TextCleanerService
|
|
|
|
| 35 |
|
| 36 |
router = APIRouter()
|
| 37 |
_logger = get_logger(__name__)
|
|
@@ -225,12 +225,9 @@ async def convert_url(
|
|
| 225 |
if body.return_json:
|
| 226 |
try:
|
| 227 |
_logger.info("Fetching URL for JSON extraction: %s", body.url)
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
raw_data = resp.content
|
| 232 |
-
if len(raw_data) > _MAX_UPLOAD_BYTES:
|
| 233 |
-
raise HTTPException(status_code=413, detail={"success": False, "message": f"File exceeds {_settings.max_upload_mb} MB limit."})
|
| 234 |
|
| 235 |
loop = asyncio.get_running_loop()
|
| 236 |
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_stream, raw_data, filename)
|
|
@@ -253,9 +250,11 @@ async def convert_url(
|
|
| 253 |
)
|
| 254 |
_logger.info("Request completed for URL %s", body.url)
|
| 255 |
return response
|
| 256 |
-
except
|
| 257 |
_logger.error("Failed to fetch URL %s: %s", body.url, exc)
|
| 258 |
-
|
|
|
|
|
|
|
| 259 |
|
| 260 |
loop = asyncio.get_running_loop()
|
| 261 |
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_url, body.url)
|
|
|
|
| 6 |
from typing import Annotated, Any, Dict, Optional
|
| 7 |
from urllib.parse import urlparse
|
| 8 |
|
|
|
|
| 9 |
from fastapi import (
|
| 10 |
APIRouter,
|
| 11 |
Depends,
|
|
|
|
| 31 |
from app.services.converter_service import ConverterService
|
| 32 |
from app.services.extraction_service import ExtractionService
|
| 33 |
from app.services.text_cleaner_service import TextCleanerService
|
| 34 |
+
from app.utils.http_utils import DownloadError, download_url
|
| 35 |
|
| 36 |
router = APIRouter()
|
| 37 |
_logger = get_logger(__name__)
|
|
|
|
| 225 |
if body.return_json:
|
| 226 |
try:
|
| 227 |
_logger.info("Fetching URL for JSON extraction: %s", body.url)
|
| 228 |
+
raw_data, _ = await download_url(
|
| 229 |
+
body.url, timeout_seconds=30.0, max_size_bytes=_MAX_UPLOAD_BYTES
|
| 230 |
+
)
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
loop = asyncio.get_running_loop()
|
| 233 |
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_stream, raw_data, filename)
|
|
|
|
| 250 |
)
|
| 251 |
_logger.info("Request completed for URL %s", body.url)
|
| 252 |
return response
|
| 253 |
+
except DownloadError as exc:
|
| 254 |
_logger.error("Failed to fetch URL %s: %s", body.url, exc)
|
| 255 |
+
status_code = 413 if exc.is_size_error else 400
|
| 256 |
+
message = f"File exceeds {_settings.max_upload_mb} MB limit." if exc.is_size_error else f"Failed to fetch URL: {exc}"
|
| 257 |
+
raise HTTPException(status_code=status_code, detail={"success": False, "message": message})
|
| 258 |
|
| 259 |
loop = asyncio.get_running_loop()
|
| 260 |
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_url, body.url)
|
app/api/v1/json_extract.py
CHANGED
|
@@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException
|
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
| 9 |
from app.core.logger import get_logger
|
|
|
|
| 10 |
from app.services.json_service import extract_json
|
| 11 |
|
| 12 |
logger = get_logger(__name__)
|
|
@@ -75,7 +76,7 @@ async def extract_json_endpoint(
|
|
| 75 |
|
| 76 |
effective_limit = 1 if body.mode == "first" else body.limit
|
| 77 |
|
| 78 |
-
result =
|
| 79 |
|
| 80 |
elapsed = round((time.perf_counter() - start) * 1000, 3)
|
| 81 |
|
|
|
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
| 9 |
from app.core.logger import get_logger
|
| 10 |
+
from app.core.thread_pool import run_in_executor
|
| 11 |
from app.services.json_service import extract_json
|
| 12 |
|
| 13 |
logger = get_logger(__name__)
|
|
|
|
| 76 |
|
| 77 |
effective_limit = 1 if body.mode == "first" else body.limit
|
| 78 |
|
| 79 |
+
result = await run_in_executor(extract_json, body.content, limit=effective_limit)
|
| 80 |
|
| 81 |
elapsed = round((time.perf_counter() - start) * 1000, 3)
|
| 82 |
|
app/api/v1/reconcile.py
CHANGED
|
@@ -13,6 +13,7 @@ from pydantic import BaseModel
|
|
| 13 |
|
| 14 |
from app.config import get_settings
|
| 15 |
from app.core.logger import get_logger
|
|
|
|
| 16 |
from app.services.reconciliation_service import (
|
| 17 |
SUPPORTED_EXTENSIONS,
|
| 18 |
analyze_duplicates,
|
|
@@ -144,17 +145,14 @@ def _parse_column_mapping_model(mappings: Optional[List[ColumnMapping]]) -> Opti
|
|
| 144 |
return {m.destination: m.source for m in mappings}
|
| 145 |
|
| 146 |
|
| 147 |
-
|
| 148 |
source_file: bytes,
|
| 149 |
dest_file: bytes,
|
| 150 |
source_ext: str,
|
| 151 |
dest_ext: str,
|
| 152 |
-
column_mapping: Optional[Dict[str, str]]
|
| 153 |
-
pair_index: int
|
| 154 |
) -> Dict[str, Any]:
|
| 155 |
-
if source_ext != dest_ext:
|
| 156 |
-
return _failed_result(pair_index, f"File type mismatch. Source: {source_ext}, Destination: {dest_ext}")
|
| 157 |
-
|
| 158 |
try:
|
| 159 |
df_src = read_to_dataframe(source_file, source_ext, f"pair-{pair_index}")
|
| 160 |
df_dst = read_to_dataframe(dest_file, dest_ext, f"pair-{pair_index}")
|
|
@@ -201,6 +199,28 @@ async def _process_file_pair(
|
|
| 201 |
return _failed_result(pair_index, str(e))
|
| 202 |
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
@router.post(
|
| 205 |
"/reconcile/files",
|
| 206 |
response_model=ReconciliationResponse,
|
|
|
|
| 13 |
|
| 14 |
from app.config import get_settings
|
| 15 |
from app.core.logger import get_logger
|
| 16 |
+
from app.core.thread_pool import run_in_executor
|
| 17 |
from app.services.reconciliation_service import (
|
| 18 |
SUPPORTED_EXTENSIONS,
|
| 19 |
analyze_duplicates,
|
|
|
|
| 145 |
return {m.destination: m.source for m in mappings}
|
| 146 |
|
| 147 |
|
| 148 |
+
def _process_pair_sync(
|
| 149 |
source_file: bytes,
|
| 150 |
dest_file: bytes,
|
| 151 |
source_ext: str,
|
| 152 |
dest_ext: str,
|
| 153 |
+
column_mapping: Optional[Dict[str, str]],
|
| 154 |
+
pair_index: int,
|
| 155 |
) -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
| 156 |
try:
|
| 157 |
df_src = read_to_dataframe(source_file, source_ext, f"pair-{pair_index}")
|
| 158 |
df_dst = read_to_dataframe(dest_file, dest_ext, f"pair-{pair_index}")
|
|
|
|
| 199 |
return _failed_result(pair_index, str(e))
|
| 200 |
|
| 201 |
|
| 202 |
+
async def _process_file_pair(
|
| 203 |
+
source_file: bytes,
|
| 204 |
+
dest_file: bytes,
|
| 205 |
+
source_ext: str,
|
| 206 |
+
dest_ext: str,
|
| 207 |
+
column_mapping: Optional[Dict[str, str]] = None,
|
| 208 |
+
pair_index: int = 0,
|
| 209 |
+
) -> Dict[str, Any]:
|
| 210 |
+
if source_ext != dest_ext:
|
| 211 |
+
return _failed_result(pair_index, f"File type mismatch. Source: {source_ext}, Destination: {dest_ext}")
|
| 212 |
+
|
| 213 |
+
return await run_in_executor(
|
| 214 |
+
_process_pair_sync,
|
| 215 |
+
source_file,
|
| 216 |
+
dest_file,
|
| 217 |
+
source_ext,
|
| 218 |
+
dest_ext,
|
| 219 |
+
column_mapping,
|
| 220 |
+
pair_index,
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
@router.post(
|
| 225 |
"/reconcile/files",
|
| 226 |
response_model=ReconciliationResponse,
|
app/api/v1/semantic_router.py
CHANGED
|
@@ -5,6 +5,7 @@ import time
|
|
| 5 |
from fastapi import APIRouter, Depends
|
| 6 |
|
| 7 |
from app.api.deps import get_embeddings_service
|
|
|
|
| 8 |
from app.models.schemas import SemanticRouterRequest, SemanticRouterResponse
|
| 9 |
from app.services.embeddings_service import EmbeddingService
|
| 10 |
from app.services.semantic_router_service import SemanticRouterService
|
|
@@ -24,7 +25,7 @@ async def route_query(
|
|
| 24 |
start = time.perf_counter()
|
| 25 |
svc = SemanticRouterService(embedding_service)
|
| 26 |
routes_dict = [r.model_dump() for r in body.routes]
|
| 27 |
-
result = svc.route
|
| 28 |
elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
|
| 29 |
return SemanticRouterResponse(
|
| 30 |
success=result["success"],
|
|
|
|
| 5 |
from fastapi import APIRouter, Depends
|
| 6 |
|
| 7 |
from app.api.deps import get_embeddings_service
|
| 8 |
+
from app.core.thread_pool import run_in_executor
|
| 9 |
from app.models.schemas import SemanticRouterRequest, SemanticRouterResponse
|
| 10 |
from app.services.embeddings_service import EmbeddingService
|
| 11 |
from app.services.semantic_router_service import SemanticRouterService
|
|
|
|
| 25 |
start = time.perf_counter()
|
| 26 |
svc = SemanticRouterService(embedding_service)
|
| 27 |
routes_dict = [r.model_dump() for r in body.routes]
|
| 28 |
+
result = await run_in_executor(svc.route, body.query, routes_dict, body.threshold)
|
| 29 |
elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
|
| 30 |
return SemanticRouterResponse(
|
| 31 |
success=result["success"],
|
app/api/v1/sql_validator.py
CHANGED
|
@@ -4,6 +4,7 @@ import time
|
|
| 4 |
|
| 5 |
from fastapi import APIRouter
|
| 6 |
|
|
|
|
| 7 |
from app.models.schemas import SqlValidationRequest, SqlValidationResponse
|
| 8 |
from app.services.sql_validator_service import SqlValidatorService
|
| 9 |
|
|
@@ -20,7 +21,7 @@ async def validate_sql(
|
|
| 20 |
body: SqlValidationRequest,
|
| 21 |
):
|
| 22 |
start = time.perf_counter()
|
| 23 |
-
result = _service.validate
|
| 24 |
elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
|
| 25 |
return SqlValidationResponse(
|
| 26 |
success=True,
|
|
|
|
| 4 |
|
| 5 |
from fastapi import APIRouter
|
| 6 |
|
| 7 |
+
from app.core.thread_pool import run_in_executor
|
| 8 |
from app.models.schemas import SqlValidationRequest, SqlValidationResponse
|
| 9 |
from app.services.sql_validator_service import SqlValidatorService
|
| 10 |
|
|
|
|
| 21 |
body: SqlValidationRequest,
|
| 22 |
):
|
| 23 |
start = time.perf_counter()
|
| 24 |
+
result = await run_in_executor(_service.validate, body.query, body.dialect)
|
| 25 |
elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
|
| 26 |
return SqlValidationResponse(
|
| 27 |
success=True,
|
app/api/v1/system.py
CHANGED
|
@@ -24,6 +24,7 @@ from app.core.constants import (
|
|
| 24 |
WEB_EXTENSIONS,
|
| 25 |
)
|
| 26 |
from app.core.logger import get_logger
|
|
|
|
| 27 |
from app.models.schemas import (
|
| 28 |
HealthResponse,
|
| 29 |
InfoResponse,
|
|
@@ -136,6 +137,16 @@ def _build_archive(data_dir: Path) -> io.BytesIO:
|
|
| 136 |
return buf
|
| 137 |
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
def _get_data_dir() -> Path:
|
| 140 |
raw = _settings.data_dir
|
| 141 |
p = Path(raw)
|
|
@@ -150,7 +161,7 @@ async def download_backup():
|
|
| 150 |
if not data_dir.is_dir():
|
| 151 |
raise HTTPException(status_code=404, detail="Data directory not found")
|
| 152 |
try:
|
| 153 |
-
archive =
|
| 154 |
except Exception as exc:
|
| 155 |
_logger.error("Backup creation failed: %s", exc)
|
| 156 |
raise HTTPException(status_code=500, detail=f"Backup failed: {exc}")
|
|
@@ -179,11 +190,7 @@ async def upload_and_restore(
|
|
| 179 |
|
| 180 |
restored = 0
|
| 181 |
try:
|
| 182 |
-
|
| 183 |
-
for member in tar.getmembers():
|
| 184 |
-
if member.isfile():
|
| 185 |
-
tar.extract(member, path=data_dir.parent)
|
| 186 |
-
restored += 1
|
| 187 |
except tarfile.TarError as exc:
|
| 188 |
raise HTTPException(status_code=400, detail=f"Invalid archive: {exc}")
|
| 189 |
|
|
|
|
| 24 |
WEB_EXTENSIONS,
|
| 25 |
)
|
| 26 |
from app.core.logger import get_logger
|
| 27 |
+
from app.core.thread_pool import run_in_executor
|
| 28 |
from app.models.schemas import (
|
| 29 |
HealthResponse,
|
| 30 |
InfoResponse,
|
|
|
|
| 137 |
return buf
|
| 138 |
|
| 139 |
|
| 140 |
+
def _extract_archive(content: bytes, data_dir: Path) -> int:
|
| 141 |
+
restored = 0
|
| 142 |
+
with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as tar:
|
| 143 |
+
for member in tar.getmembers():
|
| 144 |
+
if member.isfile():
|
| 145 |
+
tar.extract(member, path=data_dir.parent)
|
| 146 |
+
restored += 1
|
| 147 |
+
return restored
|
| 148 |
+
|
| 149 |
+
|
| 150 |
def _get_data_dir() -> Path:
|
| 151 |
raw = _settings.data_dir
|
| 152 |
p = Path(raw)
|
|
|
|
| 161 |
if not data_dir.is_dir():
|
| 162 |
raise HTTPException(status_code=404, detail="Data directory not found")
|
| 163 |
try:
|
| 164 |
+
archive = await run_in_executor(_build_archive, data_dir)
|
| 165 |
except Exception as exc:
|
| 166 |
_logger.error("Backup creation failed: %s", exc)
|
| 167 |
raise HTTPException(status_code=500, detail=f"Backup failed: {exc}")
|
|
|
|
| 190 |
|
| 191 |
restored = 0
|
| 192 |
try:
|
| 193 |
+
restored = await run_in_executor(_extract_archive, content, data_dir)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
except tarfile.TarError as exc:
|
| 195 |
raise HTTPException(status_code=400, detail=f"Invalid archive: {exc}")
|
| 196 |
|
app/api/v1/token_counter.py
CHANGED
|
@@ -4,6 +4,7 @@ import time
|
|
| 4 |
|
| 5 |
from fastapi import APIRouter
|
| 6 |
|
|
|
|
| 7 |
from app.models.domain import count_tokens
|
| 8 |
from app.models.schemas import TokenCountRequest, TokenCountResponse
|
| 9 |
|
|
@@ -20,7 +21,7 @@ async def count_text_tokens(
|
|
| 20 |
) -> TokenCountResponse:
|
| 21 |
start = time.perf_counter()
|
| 22 |
try:
|
| 23 |
-
token_count =
|
| 24 |
elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
|
| 25 |
return TokenCountResponse(
|
| 26 |
success=True,
|
|
|
|
| 4 |
|
| 5 |
from fastapi import APIRouter
|
| 6 |
|
| 7 |
+
from app.core.thread_pool import run_in_executor
|
| 8 |
from app.models.domain import count_tokens
|
| 9 |
from app.models.schemas import TokenCountRequest, TokenCountResponse
|
| 10 |
|
|
|
|
| 21 |
) -> TokenCountResponse:
|
| 22 |
start = time.perf_counter()
|
| 23 |
try:
|
| 24 |
+
token_count = await run_in_executor(count_tokens, body.text, body.encoding)
|
| 25 |
elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
|
| 26 |
return TokenCountResponse(
|
| 27 |
success=True,
|
app/api/v1/vector_stores.py
CHANGED
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
import time
|
| 5 |
|
| 6 |
-
import httpx
|
| 7 |
from fastapi import (
|
| 8 |
APIRouter,
|
| 9 |
Depends,
|
|
@@ -33,6 +32,7 @@ from app.models.schemas import (
|
|
| 33 |
from app.services.converter_service import ConverterService
|
| 34 |
from app.services.text_cleaner_service import TextCleanerService
|
| 35 |
from app.services.vector_store_service import VectorStoreService
|
|
|
|
| 36 |
|
| 37 |
router = APIRouter()
|
| 38 |
logger = get_logger(__name__)
|
|
@@ -285,20 +285,8 @@ async def ingest_pdf_url(
|
|
| 285 |
raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
|
| 286 |
|
| 287 |
try:
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
resp.raise_for_status()
|
| 291 |
-
raw = resp.content
|
| 292 |
-
except httpx.HTTPStatusError as exc:
|
| 293 |
-
return DocumentIngestResponse(
|
| 294 |
-
success=False,
|
| 295 |
-
vector_store_id=store_id,
|
| 296 |
-
doc_id=body.doc_id,
|
| 297 |
-
chunks_ingested=0,
|
| 298 |
-
time_ms=0,
|
| 299 |
-
error=f"Failed to fetch PDF from URL: HTTP {exc.response.status_code}",
|
| 300 |
-
)
|
| 301 |
-
except httpx.RequestError as exc:
|
| 302 |
return DocumentIngestResponse(
|
| 303 |
success=False,
|
| 304 |
vector_store_id=store_id,
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import time
|
| 5 |
|
|
|
|
| 6 |
from fastapi import (
|
| 7 |
APIRouter,
|
| 8 |
Depends,
|
|
|
|
| 32 |
from app.services.converter_service import ConverterService
|
| 33 |
from app.services.text_cleaner_service import TextCleanerService
|
| 34 |
from app.services.vector_store_service import VectorStoreService
|
| 35 |
+
from app.utils.http_utils import DownloadError, download_url
|
| 36 |
|
| 37 |
router = APIRouter()
|
| 38 |
logger = get_logger(__name__)
|
|
|
|
| 285 |
raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
|
| 286 |
|
| 287 |
try:
|
| 288 |
+
raw, _ = await download_url(body.url, timeout_seconds=30.0)
|
| 289 |
+
except DownloadError as exc:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
return DocumentIngestResponse(
|
| 291 |
success=False,
|
| 292 |
vector_store_id=store_id,
|
app/api/verify.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from fastapi import APIRouter, HTTPException, Query
|
| 4 |
|
|
|
|
| 5 |
from app.services.verify_service import VerifyService
|
| 6 |
|
| 7 |
router = APIRouter(tags=["Verify"])
|
|
@@ -13,7 +14,7 @@ async def verify_phone(
|
|
| 13 |
number: str = Query(..., description="Phone number (E.164 format like +14155552671, or local with country_code)"),
|
| 14 |
country_code: str | None = Query(None, description="ISO 3166-1 alpha-2 country code (e.g. US, IN, GB)"),
|
| 15 |
):
|
| 16 |
-
result = _service.verify_phone
|
| 17 |
if not result.valid:
|
| 18 |
raise HTTPException(status_code=400, detail=result.error)
|
| 19 |
return result.dict()
|
|
|
|
| 2 |
|
| 3 |
from fastapi import APIRouter, HTTPException, Query
|
| 4 |
|
| 5 |
+
from app.core.thread_pool import run_in_executor
|
| 6 |
from app.services.verify_service import VerifyService
|
| 7 |
|
| 8 |
router = APIRouter(tags=["Verify"])
|
|
|
|
| 14 |
number: str = Query(..., description="Phone number (E.164 format like +14155552671, or local with country_code)"),
|
| 15 |
country_code: str | None = Query(None, description="ISO 3166-1 alpha-2 country code (e.g. US, IN, GB)"),
|
| 16 |
):
|
| 17 |
+
result = await run_in_executor(_service.verify_phone, number, country_code)
|
| 18 |
if not result.valid:
|
| 19 |
raise HTTPException(status_code=400, detail=result.error)
|
| 20 |
return result.dict()
|
app/config.py
CHANGED
|
@@ -16,6 +16,9 @@ class Settings(BaseSettings):
|
|
| 16 |
|
| 17 |
app_name: str = "All API Collection"
|
| 18 |
app_version: str = "1.0.0"
|
|
|
|
|
|
|
|
|
|
| 19 |
environment: str = "production"
|
| 20 |
host: str = "0.0.0.0"
|
| 21 |
port: int = 7860
|
|
@@ -197,6 +200,29 @@ DEFAULT_TOP_P = 0.9
|
|
| 197 |
DEFAULT_STREAM = False
|
| 198 |
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
@lru_cache(maxsize=1)
|
| 201 |
def get_settings() -> Settings:
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
app_name: str = "All API Collection"
|
| 18 |
app_version: str = "1.0.0"
|
| 19 |
+
|
| 20 |
+
# Global per-client-IP rate limit (requests / minute). Set to 0 to disable.
|
| 21 |
+
rate_limit_per_minute: int = 60
|
| 22 |
environment: str = "production"
|
| 23 |
host: str = "0.0.0.0"
|
| 24 |
port: int = 7860
|
|
|
|
| 200 |
DEFAULT_STREAM = False
|
| 201 |
|
| 202 |
|
| 203 |
+
_INSECURE_SECRETS = frozenset({"changeme", "changeme-jwt-secret", "dev-secret-change-me"})
|
| 204 |
+
|
| 205 |
+
_PRODUCTION_ENVIRONMENTS = frozenset({"production", "prod"})
|
| 206 |
+
|
| 207 |
+
_REQUIRED_SECRETS = {
|
| 208 |
+
"api_key": "API_KEY",
|
| 209 |
+
"jwt_secret_key": "JWT_SECRET_KEY",
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
@lru_cache(maxsize=1)
|
| 214 |
def get_settings() -> Settings:
|
| 215 |
+
settings = Settings()
|
| 216 |
+
if settings.environment.lower() in _PRODUCTION_ENVIRONMENTS:
|
| 217 |
+
missing = [
|
| 218 |
+
env_name
|
| 219 |
+
for field, env_name in _REQUIRED_SECRETS.items()
|
| 220 |
+
if not getattr(settings, field) or getattr(settings, field) in _INSECURE_SECRETS
|
| 221 |
+
]
|
| 222 |
+
if missing:
|
| 223 |
+
raise RuntimeError(
|
| 224 |
+
"Refusing to start in production with insecure/default secrets: "
|
| 225 |
+
+ ", ".join(missing)
|
| 226 |
+
+ ". Set strong values in your environment (see .env.example)."
|
| 227 |
+
)
|
| 228 |
+
return settings
|
app/core/auth/deps.py
CHANGED
|
@@ -1,18 +1,16 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
-
from
|
| 5 |
-
from typing import Annotated, List, Optional
|
| 6 |
|
| 7 |
import jwt
|
| 8 |
from argon2 import PasswordHasher
|
| 9 |
from fastapi import Depends, HTTPException, Request, status
|
| 10 |
|
| 11 |
from app.config import get_settings
|
| 12 |
-
from app.core.auth.models import
|
| 13 |
from app.core.auth.models import _utcnow as _now
|
| 14 |
from app.services.supabase import AuthRepository, SupabaseClient, get_supabase_client
|
| 15 |
-
from app.services.supabase.repositories import _row_to_user
|
| 16 |
from app.services.supabase.client import create_supabase_client
|
| 17 |
|
| 18 |
logger = logging.getLogger("auth")
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
+
from typing import Annotated, List
|
|
|
|
| 5 |
|
| 6 |
import jwt
|
| 7 |
from argon2 import PasswordHasher
|
| 8 |
from fastapi import Depends, HTTPException, Request, status
|
| 9 |
|
| 10 |
from app.config import get_settings
|
| 11 |
+
from app.core.auth.models import User, _uuid
|
| 12 |
from app.core.auth.models import _utcnow as _now
|
| 13 |
from app.services.supabase import AuthRepository, SupabaseClient, get_supabase_client
|
|
|
|
| 14 |
from app.services.supabase.client import create_supabase_client
|
| 15 |
|
| 16 |
logger = logging.getLogger("auth")
|
app/core/banner.py
DELETED
|
@@ -1,26 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import pyfiglet
|
| 4 |
-
|
| 5 |
-
from app.config import get_settings
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def get_banner() -> str:
|
| 9 |
-
settings = get_settings()
|
| 10 |
-
banner = pyfiglet.figlet_format("AgentDeck", font="slant")
|
| 11 |
-
lines = [
|
| 12 |
-
banner,
|
| 13 |
-
f" {settings.app_name} v{settings.app_version}",
|
| 14 |
-
f" Environment: {settings.environment or 'production'}",
|
| 15 |
-
]
|
| 16 |
-
if settings.supabase_url and settings.supabase_service_role_key:
|
| 17 |
-
lines.append(" Supabase: connected")
|
| 18 |
-
else:
|
| 19 |
-
lines.append(" Supabase: not configured")
|
| 20 |
-
if settings.redis_url:
|
| 21 |
-
lines.append(" Redis: connected")
|
| 22 |
-
else:
|
| 23 |
-
lines.append(" Redis: not configured (degraded mode)")
|
| 24 |
-
lines.append("")
|
| 25 |
-
lines.append("=" * 78)
|
| 26 |
-
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/security.py
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from fastapi import HTTPException, Security, status
|
| 4 |
-
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 5 |
-
|
| 6 |
-
from app.config import get_settings
|
| 7 |
-
from app.core.logger import get_logger
|
| 8 |
-
|
| 9 |
-
_logger = get_logger(__name__)
|
| 10 |
-
_security = HTTPBearer(auto_error=False)
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
async def require_api_key(
|
| 14 |
-
credentials: HTTPAuthorizationCredentials | None = Security(_security),
|
| 15 |
-
) -> str:
|
| 16 |
-
settings = get_settings()
|
| 17 |
-
token = settings.api_key
|
| 18 |
-
|
| 19 |
-
if not credentials:
|
| 20 |
-
_logger.warning("Missing Authorization header")
|
| 21 |
-
raise HTTPException(
|
| 22 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 23 |
-
detail="Missing Authorization header",
|
| 24 |
-
)
|
| 25 |
-
|
| 26 |
-
if credentials.credentials != token:
|
| 27 |
-
_logger.warning("Invalid API key provided")
|
| 28 |
-
raise HTTPException(
|
| 29 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 30 |
-
detail="Invalid API key",
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
return credentials.credentials
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/thread_pool.py
CHANGED
|
@@ -1,10 +1,26 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import concurrent.futures
|
| 4 |
import os
|
|
|
|
| 5 |
|
| 6 |
_MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 7 |
|
| 8 |
thread_pool = concurrent.futures.ThreadPoolExecutor(
|
| 9 |
max_workers=_MAX_WORKERS, thread_name_prefix="shared"
|
| 10 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
import concurrent.futures
|
| 5 |
import os
|
| 6 |
+
from typing import Any, Callable
|
| 7 |
|
| 8 |
_MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 9 |
|
| 10 |
thread_pool = concurrent.futures.ThreadPoolExecutor(
|
| 11 |
max_workers=_MAX_WORKERS, thread_name_prefix="shared"
|
| 12 |
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def run_in_executor(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
| 16 |
+
"""Run a blocking/cpu-bound callable on the shared thread pool.
|
| 17 |
+
|
| 18 |
+
Centralises the repeated ``loop.run_in_executor(thread_pool, ...)`` pattern so
|
| 19 |
+
async routes never block the event loop on sync I/O or CPU work. Uses the
|
| 20 |
+
shared pool so a bounded number of threads is reused across the application.
|
| 21 |
+
"""
|
| 22 |
+
if kwargs:
|
| 23 |
+
return await asyncio.get_running_loop().run_in_executor(
|
| 24 |
+
thread_pool, lambda: fn(*args, **kwargs)
|
| 25 |
+
)
|
| 26 |
+
return await asyncio.get_running_loop().run_in_executor(thread_pool, fn, *args)
|
app/core/vector_store/deps.py
CHANGED
|
@@ -1,9 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
-
from typing import Optional
|
| 5 |
|
| 6 |
-
from app.config import get_settings
|
| 7 |
from app.services.supabase import SupabaseClient, get_supabase_client
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
|
|
|
| 4 |
|
|
|
|
| 5 |
from app.services.supabase import SupabaseClient, get_supabase_client
|
| 6 |
|
| 7 |
logger = logging.getLogger(__name__)
|
app/services/auth_service.py
CHANGED
|
@@ -23,6 +23,7 @@ from app.core.auth.schemas import (
|
|
| 23 |
UpdateProfileSchema,
|
| 24 |
UserProfile,
|
| 25 |
)
|
|
|
|
| 26 |
from app.services.supabase import AuthRepository, SupabaseClient
|
| 27 |
|
| 28 |
logger = logging.getLogger("auth_service")
|
|
@@ -34,6 +35,10 @@ def _token_key(raw: str) -> str:
|
|
| 34 |
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
class AuthService:
|
| 38 |
|
| 39 |
@staticmethod
|
|
@@ -56,7 +61,7 @@ class AuthService:
|
|
| 56 |
email=schema.email,
|
| 57 |
username=schema.username,
|
| 58 |
full_name=schema.full_name,
|
| 59 |
-
password_hash=
|
| 60 |
created_at=now,
|
| 61 |
updated_at=now,
|
| 62 |
)
|
|
@@ -82,7 +87,7 @@ class AuthService:
|
|
| 82 |
detail=f"Account locked until {user.locked_until.isoformat()}",
|
| 83 |
)
|
| 84 |
|
| 85 |
-
if not AuthService.
|
| 86 |
user.failed_login_attempts += 1
|
| 87 |
if user.failed_login_attempts >= _settings.max_login_attempts:
|
| 88 |
user.locked_until = _now() + timedelta(minutes=_settings.lockout_minutes)
|
|
@@ -99,8 +104,8 @@ class AuthService:
|
|
| 99 |
"last_login": now,
|
| 100 |
})
|
| 101 |
|
| 102 |
-
access_token = AuthService.
|
| 103 |
-
raw_refresh, refresh_hash, token_key, expires_at = AuthService.
|
| 104 |
|
| 105 |
session_id = _uuid()
|
| 106 |
session = RefreshSession(
|
|
@@ -134,7 +139,7 @@ class AuthService:
|
|
| 134 |
)
|
| 135 |
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
| 136 |
|
| 137 |
-
if not AuthService.
|
| 138 |
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
| 139 |
|
| 140 |
user = await repo.find_user_by_id(session.user_id)
|
|
@@ -144,8 +149,8 @@ class AuthService:
|
|
| 144 |
now = _now()
|
| 145 |
await repo.revoke_session(session.id)
|
| 146 |
|
| 147 |
-
new_access = AuthService.
|
| 148 |
-
new_raw_refresh, new_hash, new_key, new_expires = AuthService.
|
| 149 |
|
| 150 |
new_session_id = _uuid()
|
| 151 |
new_session = RefreshSession(
|
|
@@ -181,12 +186,12 @@ class AuthService:
|
|
| 181 |
|
| 182 |
@staticmethod
|
| 183 |
async def change_password(db: SupabaseClient, user: User, schema: ChangePasswordSchema):
|
| 184 |
-
if not AuthService.
|
| 185 |
raise HTTPException(status_code=400, detail="Incorrect current password")
|
| 186 |
repo = AuthRepository(db)
|
| 187 |
now = _now()
|
| 188 |
await repo.update_user(user.id, {
|
| 189 |
-
"password_hash":
|
| 190 |
"password_changed_at": now,
|
| 191 |
})
|
| 192 |
|
|
@@ -195,7 +200,8 @@ class AuthService:
|
|
| 195 |
repo = AuthRepository(db)
|
| 196 |
user = await repo.find_user_by_email(schema.email)
|
| 197 |
if user:
|
| 198 |
-
reset_token =
|
|
|
|
| 199 |
{
|
| 200 |
"sub": user.id,
|
| 201 |
"type": "reset_password",
|
|
@@ -209,7 +215,8 @@ class AuthService:
|
|
| 209 |
@staticmethod
|
| 210 |
async def reset_password(db: SupabaseClient, schema: ResetPasswordSchema):
|
| 211 |
try:
|
| 212 |
-
payload =
|
|
|
|
| 213 |
schema.token,
|
| 214 |
_settings.jwt_secret_key,
|
| 215 |
algorithms=[_settings.jwt_algorithm],
|
|
@@ -227,7 +234,7 @@ class AuthService:
|
|
| 227 |
|
| 228 |
now = _now()
|
| 229 |
await repo.update_user(user_id, {
|
| 230 |
-
"password_hash":
|
| 231 |
"password_changed_at": now,
|
| 232 |
})
|
| 233 |
await repo.revoke_all_user_sessions(user_id)
|
|
@@ -278,6 +285,10 @@ class AuthService:
|
|
| 278 |
if count:
|
| 279 |
logger.info("Cleaned up %s expired sessions", count)
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
@staticmethod
|
| 282 |
def _create_access_token(user_id: str) -> str:
|
| 283 |
now = _now()
|
|
@@ -290,6 +301,10 @@ class AuthService:
|
|
| 290 |
}
|
| 291 |
return jwt.encode(payload, _settings.jwt_secret_key, algorithm=_settings.jwt_algorithm)
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
@staticmethod
|
| 294 |
def _create_refresh_token() -> tuple[str, str, str, datetime]:
|
| 295 |
token = secrets.token_urlsafe(64)
|
|
@@ -298,6 +313,10 @@ class AuthService:
|
|
| 298 |
expires_at = _now() + timedelta(days=_settings.refresh_token_expire_days)
|
| 299 |
return token, token_hash, token_key, expires_at
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
@staticmethod
|
| 302 |
def _verify_password(plain: str, hashed: str) -> bool:
|
| 303 |
try:
|
|
@@ -306,6 +325,10 @@ class AuthService:
|
|
| 306 |
except VerifyMismatchError:
|
| 307 |
return False
|
| 308 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
@staticmethod
|
| 310 |
def _verify_token(raw: str, hashed: str) -> bool:
|
| 311 |
try:
|
|
|
|
| 23 |
UpdateProfileSchema,
|
| 24 |
UserProfile,
|
| 25 |
)
|
| 26 |
+
from app.core.thread_pool import run_in_executor
|
| 27 |
from app.services.supabase import AuthRepository, SupabaseClient
|
| 28 |
|
| 29 |
logger = logging.getLogger("auth_service")
|
|
|
|
| 35 |
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
| 36 |
|
| 37 |
|
| 38 |
+
async def _hash_password(password: str) -> str:
|
| 39 |
+
return await run_in_executor(ph.hash, password)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
class AuthService:
|
| 43 |
|
| 44 |
@staticmethod
|
|
|
|
| 61 |
email=schema.email,
|
| 62 |
username=schema.username,
|
| 63 |
full_name=schema.full_name,
|
| 64 |
+
password_hash=await _hash_password(schema.password),
|
| 65 |
created_at=now,
|
| 66 |
updated_at=now,
|
| 67 |
)
|
|
|
|
| 87 |
detail=f"Account locked until {user.locked_until.isoformat()}",
|
| 88 |
)
|
| 89 |
|
| 90 |
+
if not await AuthService._verify_password_async(schema.password, user.password_hash):
|
| 91 |
user.failed_login_attempts += 1
|
| 92 |
if user.failed_login_attempts >= _settings.max_login_attempts:
|
| 93 |
user.locked_until = _now() + timedelta(minutes=_settings.lockout_minutes)
|
|
|
|
| 104 |
"last_login": now,
|
| 105 |
})
|
| 106 |
|
| 107 |
+
access_token = await AuthService._create_access_token_async(user.id)
|
| 108 |
+
raw_refresh, refresh_hash, token_key, expires_at = await AuthService._create_refresh_token_async()
|
| 109 |
|
| 110 |
session_id = _uuid()
|
| 111 |
session = RefreshSession(
|
|
|
|
| 139 |
)
|
| 140 |
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
| 141 |
|
| 142 |
+
if not await AuthService._verify_token_async(raw_refresh_token, session.token_hash):
|
| 143 |
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
| 144 |
|
| 145 |
user = await repo.find_user_by_id(session.user_id)
|
|
|
|
| 149 |
now = _now()
|
| 150 |
await repo.revoke_session(session.id)
|
| 151 |
|
| 152 |
+
new_access = await AuthService._create_access_token_async(user.id)
|
| 153 |
+
new_raw_refresh, new_hash, new_key, new_expires = await AuthService._create_refresh_token_async()
|
| 154 |
|
| 155 |
new_session_id = _uuid()
|
| 156 |
new_session = RefreshSession(
|
|
|
|
| 186 |
|
| 187 |
@staticmethod
|
| 188 |
async def change_password(db: SupabaseClient, user: User, schema: ChangePasswordSchema):
|
| 189 |
+
if not await AuthService._verify_password_async(schema.current_password, user.password_hash):
|
| 190 |
raise HTTPException(status_code=400, detail="Incorrect current password")
|
| 191 |
repo = AuthRepository(db)
|
| 192 |
now = _now()
|
| 193 |
await repo.update_user(user.id, {
|
| 194 |
+
"password_hash": await _hash_password(schema.new_password),
|
| 195 |
"password_changed_at": now,
|
| 196 |
})
|
| 197 |
|
|
|
|
| 200 |
repo = AuthRepository(db)
|
| 201 |
user = await repo.find_user_by_email(schema.email)
|
| 202 |
if user:
|
| 203 |
+
reset_token = await run_in_executor(
|
| 204 |
+
jwt.encode,
|
| 205 |
{
|
| 206 |
"sub": user.id,
|
| 207 |
"type": "reset_password",
|
|
|
|
| 215 |
@staticmethod
|
| 216 |
async def reset_password(db: SupabaseClient, schema: ResetPasswordSchema):
|
| 217 |
try:
|
| 218 |
+
payload = await run_in_executor(
|
| 219 |
+
jwt.decode,
|
| 220 |
schema.token,
|
| 221 |
_settings.jwt_secret_key,
|
| 222 |
algorithms=[_settings.jwt_algorithm],
|
|
|
|
| 234 |
|
| 235 |
now = _now()
|
| 236 |
await repo.update_user(user_id, {
|
| 237 |
+
"password_hash": await _hash_password(schema.new_password),
|
| 238 |
"password_changed_at": now,
|
| 239 |
})
|
| 240 |
await repo.revoke_all_user_sessions(user_id)
|
|
|
|
| 285 |
if count:
|
| 286 |
logger.info("Cleaned up %s expired sessions", count)
|
| 287 |
|
| 288 |
+
@staticmethod
|
| 289 |
+
async def _create_access_token_async(user_id: str) -> str:
|
| 290 |
+
return await run_in_executor(AuthService._create_access_token, user_id)
|
| 291 |
+
|
| 292 |
@staticmethod
|
| 293 |
def _create_access_token(user_id: str) -> str:
|
| 294 |
now = _now()
|
|
|
|
| 301 |
}
|
| 302 |
return jwt.encode(payload, _settings.jwt_secret_key, algorithm=_settings.jwt_algorithm)
|
| 303 |
|
| 304 |
+
@staticmethod
|
| 305 |
+
async def _create_refresh_token_async() -> tuple[str, str, str, datetime]:
|
| 306 |
+
return await run_in_executor(AuthService._create_refresh_token)
|
| 307 |
+
|
| 308 |
@staticmethod
|
| 309 |
def _create_refresh_token() -> tuple[str, str, str, datetime]:
|
| 310 |
token = secrets.token_urlsafe(64)
|
|
|
|
| 313 |
expires_at = _now() + timedelta(days=_settings.refresh_token_expire_days)
|
| 314 |
return token, token_hash, token_key, expires_at
|
| 315 |
|
| 316 |
+
@staticmethod
|
| 317 |
+
async def _verify_password_async(plain: str, hashed: str) -> bool:
|
| 318 |
+
return await run_in_executor(AuthService._verify_password, plain, hashed)
|
| 319 |
+
|
| 320 |
@staticmethod
|
| 321 |
def _verify_password(plain: str, hashed: str) -> bool:
|
| 322 |
try:
|
|
|
|
| 325 |
except VerifyMismatchError:
|
| 326 |
return False
|
| 327 |
|
| 328 |
+
@staticmethod
|
| 329 |
+
async def _verify_token_async(raw: str, hashed: str) -> bool:
|
| 330 |
+
return await run_in_executor(AuthService._verify_token, raw, hashed)
|
| 331 |
+
|
| 332 |
@staticmethod
|
| 333 |
def _verify_token(raw: str, hashed: str) -> bool:
|
| 334 |
try:
|
app/services/code_executor_service.py
CHANGED
|
@@ -168,17 +168,9 @@ class CodeExecutorService:
|
|
| 168 |
) -> dict:
|
| 169 |
return run_subprocess(cmd, timeout, self._max_output_bytes)
|
| 170 |
|
| 171 |
-
async def check_runtimes(self) -> dict[str, str]:
|
| 172 |
-
status = {}
|
| 173 |
-
for lang, runtime in [("python", "python3"), ("javascript", "node")]:
|
| 174 |
-
path = shutil.which(runtime)
|
| 175 |
-
status[lang] = f"found at {path}" if path else "missing"
|
| 176 |
-
return status
|
| 177 |
-
|
| 178 |
@staticmethod
|
| 179 |
def _filename_for(language: str) -> str:
|
| 180 |
-
return {"python": "code.py", "javascript": "code.js"
|
| 181 |
-
}[language]
|
| 182 |
|
| 183 |
@staticmethod
|
| 184 |
def _build_command(language: str, run_dir: Path, filename: str) -> list[str]:
|
|
|
|
| 168 |
) -> dict:
|
| 169 |
return run_subprocess(cmd, timeout, self._max_output_bytes)
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
@staticmethod
|
| 172 |
def _filename_for(language: str) -> str:
|
| 173 |
+
return {"python": "code.py", "javascript": "code.js"}[language]
|
|
|
|
| 174 |
|
| 175 |
@staticmethod
|
| 176 |
def _build_command(language: str, run_dir: Path, filename: str) -> list[str]:
|
app/services/csv_analysis_service.py
CHANGED
|
@@ -10,7 +10,6 @@ import time
|
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 12 |
|
| 13 |
-
from app.services.code_executor_service import CodeSanitizer
|
| 14 |
from app.services.dataset_metadata_service import extract_metadata
|
| 15 |
from app.utils.http_utils import download_url
|
| 16 |
from app.utils.subprocess_utils import run_subprocess
|
|
@@ -184,85 +183,3 @@ async def execute_csv_chat_blocks(
|
|
| 184 |
|
| 185 |
async def get_dataset_info(source: Union[str, bytes, Any]) -> Dict[str, Any]:
|
| 186 |
return await extract_metadata(source)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
async def analyze_csv_dataset(
|
| 190 |
-
source: Union[str, bytes],
|
| 191 |
-
code: str,
|
| 192 |
-
timeout: int = 30,
|
| 193 |
-
) -> Dict[str, Any]:
|
| 194 |
-
data, _ = await _resolve_source(source)
|
| 195 |
-
|
| 196 |
-
if not data:
|
| 197 |
-
return {"success": False, "output": "", "error": "No data provided", "execution_time_ms": None}
|
| 198 |
-
|
| 199 |
-
sanitized, err = CodeSanitizer.sanitize(code, "python")
|
| 200 |
-
if not sanitized:
|
| 201 |
-
return {"success": False, "output": "", "error": err, "execution_time_ms": None}
|
| 202 |
-
|
| 203 |
-
async with _semaphore:
|
| 204 |
-
run_dir = None
|
| 205 |
-
start = time.monotonic()
|
| 206 |
-
try:
|
| 207 |
-
run_dir = Path(tempfile.mkdtemp())
|
| 208 |
-
csv_path = run_dir / "data.csv"
|
| 209 |
-
csv_path.write_bytes(data)
|
| 210 |
-
|
| 211 |
-
loader = (
|
| 212 |
-
"import pandas as pd, numpy as np\n"
|
| 213 |
-
f"df = pd.read_csv(r'{csv_path}')\n"
|
| 214 |
-
)
|
| 215 |
-
full_code = loader + code
|
| 216 |
-
|
| 217 |
-
code_path = run_dir / "analysis.py"
|
| 218 |
-
code_path.write_text(full_code, encoding="utf-8")
|
| 219 |
-
|
| 220 |
-
cmd = [_PYTHON, str(code_path)]
|
| 221 |
-
result = await asyncio.to_thread(_run_subprocess, cmd, timeout, _MAX_OUTPUT_BYTES)
|
| 222 |
-
|
| 223 |
-
elapsed_ms = (time.monotonic() - start) * 1000
|
| 224 |
-
|
| 225 |
-
return {
|
| 226 |
-
"success": result["exit_code"] == 0 and not result["timed_out"],
|
| 227 |
-
"output": result["stdout"],
|
| 228 |
-
"error": result["stderr"] or None,
|
| 229 |
-
"execution_time_ms": round(elapsed_ms, 2),
|
| 230 |
-
"timed_out": result["timed_out"],
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
except FileNotFoundError:
|
| 234 |
-
elapsed_ms = (time.monotonic() - start) * 1000
|
| 235 |
-
return {"success": False, "output": "", "error": f"Python runtime ({_PYTHON}) not found", "execution_time_ms": round(elapsed_ms, 2)}
|
| 236 |
-
except Exception as exc:
|
| 237 |
-
elapsed_ms = (time.monotonic() - start) * 1000
|
| 238 |
-
logger.exception("CSV analysis execution error")
|
| 239 |
-
return {"success": False, "output": "", "error": f"Execution error: {exc}", "execution_time_ms": round(elapsed_ms, 2)}
|
| 240 |
-
finally:
|
| 241 |
-
if run_dir and run_dir.exists():
|
| 242 |
-
shutil.rmtree(run_dir, ignore_errors=True)
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
async def create_csv_chart(
|
| 246 |
-
source: Union[str, bytes],
|
| 247 |
-
code: str,
|
| 248 |
-
timeout: int = 30,
|
| 249 |
-
) -> Dict[str, Any]:
|
| 250 |
-
preamble = (
|
| 251 |
-
"import matplotlib\nmatplotlib.use('Agg')\n"
|
| 252 |
-
"import matplotlib.pyplot as plt\nimport seaborn as sns\nimport pandas as pd\n"
|
| 253 |
-
)
|
| 254 |
-
postamble = (
|
| 255 |
-
"\n\nfrom io import BytesIO\nimport base64\n"
|
| 256 |
-
"_buf = BytesIO()\n"
|
| 257 |
-
"plt.savefig(_buf, format='png', bbox_inches='tight', dpi=150)\n"
|
| 258 |
-
"_buf.seek(0)\n"
|
| 259 |
-
"print(base64.b64encode(_buf.read()).decode(), end='')\n"
|
| 260 |
-
"plt.close('all')\n"
|
| 261 |
-
)
|
| 262 |
-
|
| 263 |
-
full_code = preamble + code + postamble
|
| 264 |
-
result = await analyze_csv_dataset(source, full_code, timeout)
|
| 265 |
-
|
| 266 |
-
if result["success"]:
|
| 267 |
-
return {"success": True, "image_base64": result.get("output", "") or "", "error": None, "execution_time_ms": result["execution_time_ms"]}
|
| 268 |
-
return {"success": False, "image_base64": None, "error": result.get("error") or "Chart generation failed or produced no output", "execution_time_ms": result["execution_time_ms"]}
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 12 |
|
|
|
|
| 13 |
from app.services.dataset_metadata_service import extract_metadata
|
| 14 |
from app.utils.http_utils import download_url
|
| 15 |
from app.utils.subprocess_utils import run_subprocess
|
|
|
|
| 183 |
|
| 184 |
async def get_dataset_info(source: Union[str, bytes, Any]) -> Dict[str, Any]:
|
| 185 |
return await extract_metadata(source)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/services/embeddings_service.py
CHANGED
|
@@ -48,10 +48,6 @@ class EmbeddingService:
|
|
| 48 |
self._loaded_dimensions.append(dimension)
|
| 49 |
_logger.info("Loaded embedding model dim=%s (device=%s)", dimension, self._device)
|
| 50 |
|
| 51 |
-
def load_all_models(self) -> None:
|
| 52 |
-
for dim in _MODEL_MAP:
|
| 53 |
-
self.load_model(dim)
|
| 54 |
-
|
| 55 |
def generate_embedding(self, text: List[str], dimension: int) -> List[List[float]]:
|
| 56 |
if dimension not in self._models:
|
| 57 |
raise ValueError(f"Model for dimension {dimension} not loaded")
|
|
|
|
| 48 |
self._loaded_dimensions.append(dimension)
|
| 49 |
_logger.info("Loaded embedding model dim=%s (device=%s)", dimension, self._device)
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def generate_embedding(self, text: List[str], dimension: int) -> List[List[float]]:
|
| 52 |
if dimension not in self._models:
|
| 53 |
raise ValueError(f"Model for dimension {dimension} not loaded")
|
app/services/gcs_service.py
CHANGED
|
@@ -15,6 +15,7 @@ from pydantic import BaseModel, ValidationError
|
|
| 15 |
|
| 16 |
from app.config import get_settings
|
| 17 |
from app.core.logger import get_logger
|
|
|
|
| 18 |
from app.utils.http_utils import SharedAsyncClient
|
| 19 |
|
| 20 |
_logger = get_logger(__name__)
|
|
@@ -236,7 +237,7 @@ class GCSService:
|
|
| 236 |
return token
|
| 237 |
|
| 238 |
async def _fetch_access_token(self, creds: GCSCredentials) -> Tuple[str, int]:
|
| 239 |
-
assertion = self._build_signed_jwt
|
| 240 |
body = urlencode({
|
| 241 |
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
| 242 |
"assertion": assertion,
|
|
@@ -1039,8 +1040,12 @@ class GCSService:
|
|
| 1039 |
response_content_type: Optional[str] = None,
|
| 1040 |
response_disposition: Optional[str] = None,
|
| 1041 |
) -> Tuple[str, int]:
|
| 1042 |
-
return
|
| 1043 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1044 |
expires_in_seconds=expires_in_seconds,
|
| 1045 |
response_content_type=response_content_type,
|
| 1046 |
response_disposition=response_disposition,
|
|
@@ -1055,8 +1060,12 @@ class GCSService:
|
|
| 1055 |
expires_in_seconds: Optional[int] = None,
|
| 1056 |
content_type: Optional[str] = None,
|
| 1057 |
) -> Tuple[str, int]:
|
| 1058 |
-
return
|
| 1059 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
expires_in_seconds=expires_in_seconds,
|
| 1061 |
content_type=content_type,
|
| 1062 |
)
|
|
|
|
| 15 |
|
| 16 |
from app.config import get_settings
|
| 17 |
from app.core.logger import get_logger
|
| 18 |
+
from app.core.thread_pool import run_in_executor
|
| 19 |
from app.utils.http_utils import SharedAsyncClient
|
| 20 |
|
| 21 |
_logger = get_logger(__name__)
|
|
|
|
| 237 |
return token
|
| 238 |
|
| 239 |
async def _fetch_access_token(self, creds: GCSCredentials) -> Tuple[str, int]:
|
| 240 |
+
assertion = await run_in_executor(self._build_signed_jwt, creds)
|
| 241 |
body = urlencode({
|
| 242 |
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
| 243 |
"assertion": assertion,
|
|
|
|
| 1040 |
response_content_type: Optional[str] = None,
|
| 1041 |
response_disposition: Optional[str] = None,
|
| 1042 |
) -> Tuple[str, int]:
|
| 1043 |
+
return await run_in_executor(
|
| 1044 |
+
self.sign_url_v4,
|
| 1045 |
+
creds,
|
| 1046 |
+
"GET",
|
| 1047 |
+
bucket,
|
| 1048 |
+
name,
|
| 1049 |
expires_in_seconds=expires_in_seconds,
|
| 1050 |
response_content_type=response_content_type,
|
| 1051 |
response_disposition=response_disposition,
|
|
|
|
| 1060 |
expires_in_seconds: Optional[int] = None,
|
| 1061 |
content_type: Optional[str] = None,
|
| 1062 |
) -> Tuple[str, int]:
|
| 1063 |
+
return await run_in_executor(
|
| 1064 |
+
self.sign_url_v4,
|
| 1065 |
+
creds,
|
| 1066 |
+
"PUT",
|
| 1067 |
+
bucket,
|
| 1068 |
+
name,
|
| 1069 |
expires_in_seconds=expires_in_seconds,
|
| 1070 |
content_type=content_type,
|
| 1071 |
)
|
app/services/google_oauth_service.py
CHANGED
|
@@ -10,6 +10,7 @@ import jwt
|
|
| 10 |
|
| 11 |
from app.config import get_settings
|
| 12 |
from app.core.logger import get_logger
|
|
|
|
| 13 |
from app.models.schemas import GoogleOAuthUserInfo
|
| 14 |
from app.utils.http_utils import SharedAsyncClient
|
| 15 |
|
|
@@ -318,7 +319,8 @@ class GoogleOAuthService:
|
|
| 318 |
raise
|
| 319 |
|
| 320 |
try:
|
| 321 |
-
payload =
|
|
|
|
| 322 |
id_token,
|
| 323 |
key=public_key,
|
| 324 |
algorithms=["RS256"],
|
|
@@ -352,29 +354,6 @@ class GoogleOAuthService:
|
|
| 352 |
# Userinfo (fallback profile source)
|
| 353 |
# ------------------------------------------------------------------
|
| 354 |
|
| 355 |
-
async def fetch_userinfo(self, access_token: str) -> GoogleOAuthUserInfo:
|
| 356 |
-
client = await self._get_client()
|
| 357 |
-
try:
|
| 358 |
-
response = await client.get(
|
| 359 |
-
_settings.google_oauth_userinfo_url,
|
| 360 |
-
headers={"Authorization": f"Bearer {access_token}"},
|
| 361 |
-
)
|
| 362 |
-
response.raise_for_status()
|
| 363 |
-
except httpx.HTTPStatusError as exc:
|
| 364 |
-
if exc.response.status_code in (401, 403):
|
| 365 |
-
raise GoogleOAuthError(
|
| 366 |
-
"Access token is invalid or has expired.", status_code=401
|
| 367 |
-
) from exc
|
| 368 |
-
raise GoogleOAuthError(
|
| 369 |
-
"Failed to fetch Google user profile.", status_code=502
|
| 370 |
-
) from exc
|
| 371 |
-
except httpx.RequestError as exc:
|
| 372 |
-
raise GoogleOAuthError(
|
| 373 |
-
"Failed to reach Google userinfo endpoint.", status_code=502
|
| 374 |
-
) from exc
|
| 375 |
-
|
| 376 |
-
return self._user_info_from_claims(response.json())
|
| 377 |
-
|
| 378 |
@staticmethod
|
| 379 |
def _user_info_from_claims(payload: Dict[str, Any]) -> GoogleOAuthUserInfo:
|
| 380 |
return GoogleOAuthUserInfo(
|
|
|
|
| 10 |
|
| 11 |
from app.config import get_settings
|
| 12 |
from app.core.logger import get_logger
|
| 13 |
+
from app.core.thread_pool import run_in_executor
|
| 14 |
from app.models.schemas import GoogleOAuthUserInfo
|
| 15 |
from app.utils.http_utils import SharedAsyncClient
|
| 16 |
|
|
|
|
| 319 |
raise
|
| 320 |
|
| 321 |
try:
|
| 322 |
+
payload = await run_in_executor(
|
| 323 |
+
jwt.decode,
|
| 324 |
id_token,
|
| 325 |
key=public_key,
|
| 326 |
algorithms=["RS256"],
|
|
|
|
| 354 |
# Userinfo (fallback profile source)
|
| 355 |
# ------------------------------------------------------------------
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
@staticmethod
|
| 358 |
def _user_info_from_claims(payload: Dict[str, Any]) -> GoogleOAuthUserInfo:
|
| 359 |
return GoogleOAuthUserInfo(
|
app/services/google_scope_service.py
CHANGED
|
@@ -73,9 +73,6 @@ class GoogleScopeService:
|
|
| 73 |
async def load_all_plans(self) -> Dict[str, Dict[str, Any]]:
|
| 74 |
return self._plans
|
| 75 |
|
| 76 |
-
async def load_default_state(self) -> Dict[str, List[str]]:
|
| 77 |
-
return {pid: _default_scopes(plan) for pid, plan in self._plans.items()}
|
| 78 |
-
|
| 79 |
async def load_state(self) -> Dict[str, List[str]]:
|
| 80 |
return self._state()
|
| 81 |
|
|
|
|
| 73 |
async def load_all_plans(self) -> Dict[str, Dict[str, Any]]:
|
| 74 |
return self._plans
|
| 75 |
|
|
|
|
|
|
|
|
|
|
| 76 |
async def load_state(self) -> Dict[str, List[str]]:
|
| 77 |
return self._state()
|
| 78 |
|
app/services/ocr_service.py
CHANGED
|
@@ -119,9 +119,3 @@ def ocr_pdf(source: Union[str, bytes], *, dpi: int = 150) -> str:
|
|
| 119 |
class OCRService:
|
| 120 |
def __init__(self) -> None:
|
| 121 |
self._engine = _get_engine()
|
| 122 |
-
|
| 123 |
-
def image_to_text(self, source, text_score: float = 0.5) -> str:
|
| 124 |
-
return ocr_image(source, text_score=text_score)
|
| 125 |
-
|
| 126 |
-
def pdf_to_text(self, source: Union[str, bytes], dpi: int = 150) -> str:
|
| 127 |
-
return ocr_pdf(source, dpi=dpi)
|
|
|
|
| 119 |
class OCRService:
|
| 120 |
def __init__(self) -> None:
|
| 121 |
self._engine = _get_engine()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/services/reconciliation_service.py
CHANGED
|
@@ -4,10 +4,8 @@ import asyncio
|
|
| 4 |
import io
|
| 5 |
import os
|
| 6 |
import re
|
| 7 |
-
import time
|
| 8 |
import unicodedata
|
| 9 |
from collections import Counter
|
| 10 |
-
from datetime import datetime
|
| 11 |
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 12 |
|
| 13 |
import aiohttp
|
|
@@ -23,7 +21,6 @@ DOWNLOAD_TIMEOUT = 60
|
|
| 23 |
DOWNLOAD_MAX_RETRIES = 3
|
| 24 |
DOWNLOAD_BACKOFF_FACTOR = 0.5
|
| 25 |
SUPPORTED_EXTENSIONS: Set[str] = {"csv", "xlsx", "xls", "tsv", "parquet"}
|
| 26 |
-
_MAX_FILE_SIZE = _settings.max_upload_bytes
|
| 27 |
|
| 28 |
DEFAULT_NUMERIC_KEYWORDS: Set[str] = {
|
| 29 |
"amount", "total", "debit", "credit", "tax",
|
|
@@ -37,15 +34,6 @@ class ReconciliationError(Exception):
|
|
| 37 |
class DownloadError(ReconciliationError):
|
| 38 |
pass
|
| 39 |
|
| 40 |
-
class FileTypeMismatchError(ReconciliationError):
|
| 41 |
-
pass
|
| 42 |
-
|
| 43 |
-
class SchemaMismatchError(ReconciliationError):
|
| 44 |
-
pass
|
| 45 |
-
|
| 46 |
-
class EmptyDatasetError(ReconciliationError):
|
| 47 |
-
pass
|
| 48 |
-
|
| 49 |
class UnsupportedFormatError(ReconciliationError):
|
| 50 |
pass
|
| 51 |
|
|
@@ -253,168 +241,3 @@ def analyze_missing_data(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols
|
|
| 253 |
"difference": abs(src_nulls - dst_nulls)
|
| 254 |
})
|
| 255 |
return results
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
def _build_failed_pair_result(job_id: str, src_url: str, dst_url: str, started_at: str, status: str, errors: List[str], schema: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 259 |
-
return {
|
| 260 |
-
"job_id": job_id,
|
| 261 |
-
"source_file": src_url,
|
| 262 |
-
"destination_file": dst_url,
|
| 263 |
-
"started_at": started_at,
|
| 264 |
-
"completed_at": datetime.utcnow().isoformat(),
|
| 265 |
-
"processing_time_ms": 0,
|
| 266 |
-
"status": status,
|
| 267 |
-
"summary": {"overall_status": status, "overall_match_percentage": 0.0},
|
| 268 |
-
"schema": schema or {"fully_match": False, "source_columns": 0, "destination_columns": 0},
|
| 269 |
-
"columns": [], "numeric_columns": [], "date_columns": [],
|
| 270 |
-
"duplicates": {}, "missing_values": [],
|
| 271 |
-
"errors": errors,
|
| 272 |
-
"warnings": []
|
| 273 |
-
}
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
async def process_pair(
|
| 277 |
-
src_url: str,
|
| 278 |
-
dst_url: str,
|
| 279 |
-
src_data: bytes,
|
| 280 |
-
dst_data: bytes,
|
| 281 |
-
ext: str,
|
| 282 |
-
job_id: str,
|
| 283 |
-
column_mapping: Optional[Dict[str, str]] = None,
|
| 284 |
-
numeric_keywords: Optional[Set[str]] = None,
|
| 285 |
-
) -> Dict[str, Any]:
|
| 286 |
-
start_time = time.time()
|
| 287 |
-
started_at = datetime.utcnow().isoformat()
|
| 288 |
-
errors = []
|
| 289 |
-
warnings = []
|
| 290 |
-
status = "MATCH"
|
| 291 |
-
|
| 292 |
-
try:
|
| 293 |
-
df_src = read_to_dataframe(src_data, ext, job_id)
|
| 294 |
-
df_dst = read_to_dataframe(dst_data, ext, job_id)
|
| 295 |
-
|
| 296 |
-
if df_src.empty or df_dst.empty:
|
| 297 |
-
raise EmptyDatasetError("One or both datasets are empty.")
|
| 298 |
-
|
| 299 |
-
df_src = normalize_dataframe(df_src.copy())
|
| 300 |
-
df_dst = normalize_dataframe(df_dst.copy())
|
| 301 |
-
df_src.infer_objects()
|
| 302 |
-
df_dst.infer_objects()
|
| 303 |
-
|
| 304 |
-
if column_mapping:
|
| 305 |
-
df_dst = apply_column_mapping(df_dst, column_mapping)
|
| 306 |
-
|
| 307 |
-
schema_report = compare_schemas(df_src, df_dst)
|
| 308 |
-
if not schema_report["fully_match"]:
|
| 309 |
-
status = "SCHEMA_MISMATCH"
|
| 310 |
-
warnings.append("Schema mismatch detected.")
|
| 311 |
-
|
| 312 |
-
common_cols = df_src.columns.intersection(df_dst.columns).tolist()
|
| 313 |
-
|
| 314 |
-
identical_rows, _, missing_rows, extra_rows = compare_rows(df_src, df_dst, common_cols)
|
| 315 |
-
|
| 316 |
-
if missing_rows > 0 or extra_rows > 0:
|
| 317 |
-
status = "PARTIAL_MATCH" if status == "MATCH" else status
|
| 318 |
-
|
| 319 |
-
col_reports = reconcile_columns(df_src, df_dst, common_cols)
|
| 320 |
-
num_reports = reconcile_numeric_columns(df_src, df_dst, common_cols, numeric_keywords)
|
| 321 |
-
date_reports = reconcile_date_columns(df_src, df_dst, common_cols)
|
| 322 |
-
|
| 323 |
-
dup_report = analyze_duplicates(df_src, df_dst)
|
| 324 |
-
missing_report = analyze_missing_data(df_src, df_dst, common_cols)
|
| 325 |
-
|
| 326 |
-
full_cols = sum(1 for c in col_reports if c["status"] == "fully_match")
|
| 327 |
-
part_cols = sum(1 for c in col_reports if c["status"] == "partial_match")
|
| 328 |
-
|
| 329 |
-
if part_cols > 0 and status == "MATCH":
|
| 330 |
-
status = "PARTIAL_MATCH"
|
| 331 |
-
|
| 332 |
-
total_max_rows = max(len(df_src), len(df_dst))
|
| 333 |
-
match_pct = (identical_rows / total_max_rows * 100) if total_max_rows > 0 else 100.0
|
| 334 |
-
|
| 335 |
-
summary = {
|
| 336 |
-
"total_columns": len(common_cols),
|
| 337 |
-
"fully_matched_columns": full_cols,
|
| 338 |
-
"partially_matched_columns": part_cols,
|
| 339 |
-
"unmatched_columns": len(col_reports) - full_cols - part_cols,
|
| 340 |
-
"total_rows_source": len(df_src),
|
| 341 |
-
"total_rows_destination": len(df_dst),
|
| 342 |
-
"matching_rows": identical_rows,
|
| 343 |
-
"partial_rows": 0,
|
| 344 |
-
"unmatched_rows": missing_rows + extra_rows,
|
| 345 |
-
"duplicate_rows": dup_report["duplicate_difference"],
|
| 346 |
-
"overall_match_percentage": round(match_pct, 2),
|
| 347 |
-
"overall_status": status
|
| 348 |
-
}
|
| 349 |
-
except Exception as e:
|
| 350 |
-
_logger.exception(f"Job {job_id} failed during processing.", extra={"correlation_id": job_id})
|
| 351 |
-
status = "FAILED"
|
| 352 |
-
errors.append(str(e))
|
| 353 |
-
summary = {"overall_status": "FAILED", "overall_match_percentage": 0.0}
|
| 354 |
-
schema_report, col_reports, num_reports, date_reports, dup_report, missing_report = {}, [], [], [], {}, []
|
| 355 |
-
|
| 356 |
-
return {
|
| 357 |
-
"job_id": job_id,
|
| 358 |
-
"source_file": src_url,
|
| 359 |
-
"destination_file": dst_url,
|
| 360 |
-
"started_at": started_at,
|
| 361 |
-
"completed_at": datetime.utcnow().isoformat(),
|
| 362 |
-
"processing_time_ms": round((time.time() - start_time) * 1000, 2),
|
| 363 |
-
"status": status,
|
| 364 |
-
"summary": summary,
|
| 365 |
-
"schema": schema_report,
|
| 366 |
-
"columns": col_reports,
|
| 367 |
-
"numeric_columns": num_reports,
|
| 368 |
-
"date_columns": date_reports,
|
| 369 |
-
"duplicates": dup_report,
|
| 370 |
-
"missing_values": missing_report,
|
| 371 |
-
"errors": errors,
|
| 372 |
-
"warnings": warnings
|
| 373 |
-
}
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
async def reconcile_pair(
|
| 377 |
-
pair: Dict[str, Any],
|
| 378 |
-
job_id: str,
|
| 379 |
-
numeric_keywords: Optional[Set[str]] = None,
|
| 380 |
-
) -> Dict[str, Any]:
|
| 381 |
-
src_url = pair["source"]
|
| 382 |
-
dst_url = pair["destination"]
|
| 383 |
-
started_at = datetime.utcnow().isoformat()
|
| 384 |
-
|
| 385 |
-
src_ext = extract_extension(src_url)
|
| 386 |
-
dst_ext = extract_extension(dst_url)
|
| 387 |
-
|
| 388 |
-
if src_ext not in SUPPORTED_EXTENSIONS or dst_ext not in SUPPORTED_EXTENSIONS:
|
| 389 |
-
return _build_failed_pair_result(
|
| 390 |
-
job_id, src_url, dst_url, started_at, "FAILED",
|
| 391 |
-
[f"Unsupported format. Source: {src_ext}, Dest: {dst_ext}"]
|
| 392 |
-
)
|
| 393 |
-
|
| 394 |
-
if src_ext != dst_ext:
|
| 395 |
-
return _build_failed_pair_result(
|
| 396 |
-
job_id, src_url, dst_url, started_at, "FILE_TYPE_MISMATCH",
|
| 397 |
-
[f"File type mismatch. Source: {src_ext}, Dest: {dst_ext}"]
|
| 398 |
-
)
|
| 399 |
-
|
| 400 |
-
column_mapping: Optional[Dict[str, str]] = pair.get("column_mapping")
|
| 401 |
-
|
| 402 |
-
async with aiohttp.ClientSession() as session:
|
| 403 |
-
try:
|
| 404 |
-
src_data, dst_data = await asyncio.gather(
|
| 405 |
-
download_file_with_retry(session, src_url, job_id),
|
| 406 |
-
download_file_with_retry(session, dst_url, job_id)
|
| 407 |
-
)
|
| 408 |
-
except DownloadError as e:
|
| 409 |
-
_logger.error(f"Download failed for job {job_id}: {str(e)}", extra={"correlation_id": job_id})
|
| 410 |
-
return _build_failed_pair_result(
|
| 411 |
-
job_id, src_url, dst_url, started_at, "FAILED", [str(e)]
|
| 412 |
-
)
|
| 413 |
-
|
| 414 |
-
if len(src_data) > _MAX_FILE_SIZE or len(dst_data) > _MAX_FILE_SIZE:
|
| 415 |
-
return _build_failed_pair_result(
|
| 416 |
-
job_id, src_url, dst_url, started_at, "FAILED",
|
| 417 |
-
["File size exceeds maximum allowed limit"]
|
| 418 |
-
)
|
| 419 |
-
|
| 420 |
-
return await process_pair(src_url, dst_url, src_data, dst_data, src_ext, job_id, column_mapping, numeric_keywords)
|
|
|
|
| 4 |
import io
|
| 5 |
import os
|
| 6 |
import re
|
|
|
|
| 7 |
import unicodedata
|
| 8 |
from collections import Counter
|
|
|
|
| 9 |
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 10 |
|
| 11 |
import aiohttp
|
|
|
|
| 21 |
DOWNLOAD_MAX_RETRIES = 3
|
| 22 |
DOWNLOAD_BACKOFF_FACTOR = 0.5
|
| 23 |
SUPPORTED_EXTENSIONS: Set[str] = {"csv", "xlsx", "xls", "tsv", "parquet"}
|
|
|
|
| 24 |
|
| 25 |
DEFAULT_NUMERIC_KEYWORDS: Set[str] = {
|
| 26 |
"amount", "total", "debit", "credit", "tax",
|
|
|
|
| 34 |
class DownloadError(ReconciliationError):
|
| 35 |
pass
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
class UnsupportedFormatError(ReconciliationError):
|
| 38 |
pass
|
| 39 |
|
|
|
|
| 241 |
"difference": abs(src_nulls - dst_nulls)
|
| 242 |
})
|
| 243 |
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/services/scheduler_service.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
-
import hashlib
|
| 5 |
-
import json
|
| 6 |
import re
|
| 7 |
import secrets
|
| 8 |
import uuid
|
|
@@ -456,7 +454,7 @@ class HttpExecutionEngine:
|
|
| 456 |
error_message=None if success else f"HTTP {response.status_code}",
|
| 457 |
)
|
| 458 |
|
| 459 |
-
except httpx.TimeoutException
|
| 460 |
duration_ms = (datetime.now(UTC) - started).total_seconds() * 1000
|
| 461 |
return HttpExecutionResult(
|
| 462 |
success=False, duration_ms=duration_ms,
|
|
@@ -955,10 +953,6 @@ class SchedulerService:
|
|
| 955 |
if result.exception_type in ("TimeoutException", "ExecutionTimeout"):
|
| 956 |
exec_status = ExecutionStatus.TIMEOUT
|
| 957 |
|
| 958 |
-
headers_hash = hashlib.sha256(
|
| 959 |
-
json.dumps(job.get("headers") or {}, sort_keys=True).encode()
|
| 960 |
-
).hexdigest() if job.get("headers") else ""
|
| 961 |
-
|
| 962 |
history_data = {
|
| 963 |
"id": str(uuid.uuid4()),
|
| 964 |
"job_id": job["id"],
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
|
|
|
| 4 |
import re
|
| 5 |
import secrets
|
| 6 |
import uuid
|
|
|
|
| 454 |
error_message=None if success else f"HTTP {response.status_code}",
|
| 455 |
)
|
| 456 |
|
| 457 |
+
except httpx.TimeoutException:
|
| 458 |
duration_ms = (datetime.now(UTC) - started).total_seconds() * 1000
|
| 459 |
return HttpExecutionResult(
|
| 460 |
success=False, duration_ms=duration_ms,
|
|
|
|
| 953 |
if result.exception_type in ("TimeoutException", "ExecutionTimeout"):
|
| 954 |
exec_status = ExecutionStatus.TIMEOUT
|
| 955 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
history_data = {
|
| 957 |
"id": str(uuid.uuid4()),
|
| 958 |
"job_id": job["id"],
|
app/services/supabase/client.py
CHANGED
|
@@ -96,14 +96,6 @@ class SupabaseClient:
|
|
| 96 |
return result.data[0]
|
| 97 |
return None
|
| 98 |
|
| 99 |
-
async def execute_sql(self, sql: str) -> Any:
|
| 100 |
-
result = await asyncio.to_thread(
|
| 101 |
-
self.client.rpc,
|
| 102 |
-
"exec_sql",
|
| 103 |
-
{"sql": sql},
|
| 104 |
-
)
|
| 105 |
-
return result
|
| 106 |
-
|
| 107 |
async def find_one(
|
| 108 |
self, table: str, column: str, value: Any,
|
| 109 |
columns: str = "*",
|
|
|
|
| 96 |
return result.data[0]
|
| 97 |
return None
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
async def find_one(
|
| 100 |
self, table: str, column: str, value: Any,
|
| 101 |
columns: str = "*",
|
app/services/supabase/repositories.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import json
|
| 4 |
import logging
|
| 5 |
-
from datetime import datetime
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
|
| 8 |
-
from app.core.auth.models import RefreshSession, User
|
| 9 |
from app.core.auth.models import _utcnow as _now
|
| 10 |
from app.core.vector_store.models import VectorStoreIndex
|
| 11 |
from app.services.supabase.client import SupabaseClient
|
|
@@ -132,15 +131,6 @@ class AuthRepository:
|
|
| 132 |
)
|
| 133 |
return [r["name"] for r in rows if "name" in r]
|
| 134 |
|
| 135 |
-
async def get_user_permissions(self, user_id: str) -> List[str]:
|
| 136 |
-
rows = await self._client.select_in(
|
| 137 |
-
"permissions",
|
| 138 |
-
"id",
|
| 139 |
-
[],
|
| 140 |
-
columns="code",
|
| 141 |
-
)
|
| 142 |
-
return [r["code"] for r in rows if "code" in r]
|
| 143 |
-
|
| 144 |
async def create_refresh_session(self, session: RefreshSession) -> None:
|
| 145 |
data = {
|
| 146 |
"id": session.id,
|
|
@@ -226,12 +216,6 @@ class VectorStoreRepository:
|
|
| 226 |
rows = await self._client.select("vector_store_index", order=("created_at", False))
|
| 227 |
return [VectorStoreIndex.from_dict(r) for r in rows]
|
| 228 |
|
| 229 |
-
async def find_by_id(self, store_id: str) -> Optional[VectorStoreIndex]:
|
| 230 |
-
row = await self._client.find_one("vector_store_index", "store_id", store_id)
|
| 231 |
-
if row is None:
|
| 232 |
-
return None
|
| 233 |
-
return VectorStoreIndex.from_dict(row)
|
| 234 |
-
|
| 235 |
async def upsert(self, index: VectorStoreIndex) -> None:
|
| 236 |
data = {
|
| 237 |
"store_id": index.store_id,
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import logging
|
| 4 |
+
from datetime import datetime
|
| 5 |
from typing import Any, Dict, List, Optional
|
| 6 |
|
| 7 |
+
from app.core.auth.models import RefreshSession, User
|
| 8 |
from app.core.auth.models import _utcnow as _now
|
| 9 |
from app.core.vector_store.models import VectorStoreIndex
|
| 10 |
from app.services.supabase.client import SupabaseClient
|
|
|
|
| 131 |
)
|
| 132 |
return [r["name"] for r in rows if "name" in r]
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
async def create_refresh_session(self, session: RefreshSession) -> None:
|
| 135 |
data = {
|
| 136 |
"id": session.id,
|
|
|
|
| 216 |
rows = await self._client.select("vector_store_index", order=("created_at", False))
|
| 217 |
return [VectorStoreIndex.from_dict(r) for r in rows]
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
async def upsert(self, index: VectorStoreIndex) -> None:
|
| 220 |
data = {
|
| 221 |
"store_id": index.store_id,
|
app/services/url_shortener_service.py
CHANGED
|
@@ -575,7 +575,7 @@ class Storage:
|
|
| 575 |
|
| 576 |
def get_campaign_total_clicks(self, campaign_id: str) -> int:
|
| 577 |
links = _run_async(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
| 578 |
-
return sum(
|
| 579 |
|
| 580 |
def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
|
| 581 |
client = self._get_client()
|
|
@@ -607,8 +607,8 @@ class Storage:
|
|
| 607 |
client = self._get_client()
|
| 608 |
links = _run_async(client.select("url_shortener_links", eq=("owner_id", owner_id)))
|
| 609 |
link_count = len(links)
|
| 610 |
-
total_clicks = sum(
|
| 611 |
-
active = sum(1 for
|
| 612 |
return {
|
| 613 |
"owner_id": owner_id,
|
| 614 |
"name": row["name"],
|
|
|
|
| 575 |
|
| 576 |
def get_campaign_total_clicks(self, campaign_id: str) -> int:
|
| 577 |
links = _run_async(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
| 578 |
+
return sum(link.get("click_count") or 0 for link in links)
|
| 579 |
|
| 580 |
def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
|
| 581 |
client = self._get_client()
|
|
|
|
| 607 |
client = self._get_client()
|
| 608 |
links = _run_async(client.select("url_shortener_links", eq=("owner_id", owner_id)))
|
| 609 |
link_count = len(links)
|
| 610 |
+
total_clicks = sum(link.get("click_count") or 0 for link in links)
|
| 611 |
+
active = sum(1 for link in links if link.get("is_active"))
|
| 612 |
return {
|
| 613 |
"owner_id": owner_id,
|
| 614 |
"name": row["name"],
|
app/services/vector_store_service.py
CHANGED
|
@@ -25,10 +25,6 @@ _EMBEDDING_DIM = 384
|
|
| 25 |
_MAX_WORKERS = min(16, (os.cpu_count() or 1) + 4)
|
| 26 |
|
| 27 |
|
| 28 |
-
def _run_sync(fn, *args, **kwargs):
|
| 29 |
-
return fn(*args, **kwargs)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
class VectorStoreRecord:
|
| 33 |
def __init__(
|
| 34 |
self,
|
|
@@ -258,44 +254,6 @@ class VectorStoreService:
|
|
| 258 |
items.append(item)
|
| 259 |
return items
|
| 260 |
|
| 261 |
-
def _fetch_documents_sync(self, store_id: str, ids: List[str]) -> Dict[str, Any]:
|
| 262 |
-
col = self._get_collection(store_id)
|
| 263 |
-
if col is None:
|
| 264 |
-
record = self._stores.get(store_id)
|
| 265 |
-
if record is None:
|
| 266 |
-
raise ValueError(f"Vector store {store_id} not found")
|
| 267 |
-
col = self._open_or_create_collection_sync(store_id, record.path)
|
| 268 |
-
|
| 269 |
-
internal_ids = []
|
| 270 |
-
for doc_id in ids:
|
| 271 |
-
fetched = col.fetch(ids=[doc_id])
|
| 272 |
-
if doc_id in fetched:
|
| 273 |
-
internal_ids.append(doc_id)
|
| 274 |
-
continue
|
| 275 |
-
for i in range(0, 1024):
|
| 276 |
-
chunk_id = f"{doc_id}_{i}"
|
| 277 |
-
fetched = col.fetch(ids=[chunk_id])
|
| 278 |
-
if chunk_id in fetched:
|
| 279 |
-
internal_ids.append(chunk_id)
|
| 280 |
-
else:
|
| 281 |
-
break
|
| 282 |
-
break
|
| 283 |
-
|
| 284 |
-
if not internal_ids:
|
| 285 |
-
return {}
|
| 286 |
-
|
| 287 |
-
fetched = col.fetch(ids=internal_ids)
|
| 288 |
-
result = {}
|
| 289 |
-
for k, v in fetched.items():
|
| 290 |
-
result[k] = {
|
| 291 |
-
"id": v.id,
|
| 292 |
-
"text": v.field("text") if hasattr(v, "field") else "",
|
| 293 |
-
"doc_id": v.field("doc_id") if hasattr(v, "field") else "",
|
| 294 |
-
"chunk_index": v.field("chunk_index") if hasattr(v, "field") else 0,
|
| 295 |
-
"source": v.field("source") if hasattr(v, "field") else "",
|
| 296 |
-
}
|
| 297 |
-
return result
|
| 298 |
-
|
| 299 |
def _delete_documents_sync(
|
| 300 |
self,
|
| 301 |
store_id: str,
|
|
@@ -478,11 +436,6 @@ class VectorStoreService:
|
|
| 478 |
elapsed = (time.perf_counter() - start) * 1000
|
| 479 |
return items, elapsed
|
| 480 |
|
| 481 |
-
async def fetch_documents(self, store_id: str, ids: List[str]) -> Dict[str, Any]:
|
| 482 |
-
return await self._run_sync_fn(
|
| 483 |
-
lambda: self._fetch_documents_sync(store_id, ids)
|
| 484 |
-
)
|
| 485 |
-
|
| 486 |
async def delete_documents(
|
| 487 |
self,
|
| 488 |
store_id: str,
|
|
|
|
| 25 |
_MAX_WORKERS = min(16, (os.cpu_count() or 1) + 4)
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
class VectorStoreRecord:
|
| 29 |
def __init__(
|
| 30 |
self,
|
|
|
|
| 254 |
items.append(item)
|
| 255 |
return items
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
def _delete_documents_sync(
|
| 258 |
self,
|
| 259 |
store_id: str,
|
|
|
|
| 436 |
elapsed = (time.perf_counter() - start) * 1000
|
| 437 |
return items, elapsed
|
| 438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
async def delete_documents(
|
| 440 |
self,
|
| 441 |
store_id: str,
|
app/services/webhook_socket_service.py
CHANGED
|
@@ -160,9 +160,3 @@ def get_manager() -> ChannelManager:
|
|
| 160 |
if _manager is None:
|
| 161 |
_manager = ChannelManager()
|
| 162 |
return _manager
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def init_manager(default_buffer: int = 0) -> ChannelManager:
|
| 166 |
-
global _manager
|
| 167 |
-
_manager = ChannelManager(default_buffer=default_buffer)
|
| 168 |
-
return _manager
|
|
|
|
| 160 |
if _manager is None:
|
| 161 |
_manager = ChannelManager()
|
| 162 |
return _manager
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/utils/http_utils.py
CHANGED
|
@@ -155,28 +155,28 @@ async def download_url(
|
|
| 155 |
try:
|
| 156 |
session = await get_shared_aiohttp_session()
|
| 157 |
async with session.get(url, timeout=timeout) as resp:
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
raise DownloadError(
|
| 163 |
-
f"
|
| 164 |
-
f"limit is {max_size_bytes}",
|
| 165 |
is_size_error=True,
|
| 166 |
)
|
|
|
|
| 167 |
|
| 168 |
-
|
| 169 |
-
total = 0
|
| 170 |
-
async for chunk in resp.content.iter_chunked(chunk_size):
|
| 171 |
-
total += len(chunk)
|
| 172 |
-
if max_size_bytes and total > max_size_bytes:
|
| 173 |
-
raise DownloadError(
|
| 174 |
-
f"Download exceeded {max_size_bytes} bytes",
|
| 175 |
-
is_size_error=True,
|
| 176 |
-
)
|
| 177 |
-
chunks.append(chunk)
|
| 178 |
-
|
| 179 |
-
data = b"".join(chunks)
|
| 180 |
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
| 181 |
raise DownloadError(f"Download failed for {url}: {exc}") from exc
|
| 182 |
|
|
|
|
| 155 |
try:
|
| 156 |
session = await get_shared_aiohttp_session()
|
| 157 |
async with session.get(url, timeout=timeout) as resp:
|
| 158 |
+
if resp.status != 200:
|
| 159 |
+
raise DownloadError(f"HTTP {resp.status} when fetching {url}")
|
| 160 |
+
|
| 161 |
+
if max_size_bytes and resp.content_length and resp.content_length > max_size_bytes:
|
| 162 |
+
raise DownloadError(
|
| 163 |
+
f"Remote file advertises {resp.content_length} bytes, "
|
| 164 |
+
f"limit is {max_size_bytes}",
|
| 165 |
+
is_size_error=True,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
chunks: List[bytes] = []
|
| 169 |
+
total = 0
|
| 170 |
+
async for chunk in resp.content.iter_chunked(chunk_size):
|
| 171 |
+
total += len(chunk)
|
| 172 |
+
if max_size_bytes and total > max_size_bytes:
|
| 173 |
raise DownloadError(
|
| 174 |
+
f"Download exceeded {max_size_bytes} bytes",
|
|
|
|
| 175 |
is_size_error=True,
|
| 176 |
)
|
| 177 |
+
chunks.append(chunk)
|
| 178 |
|
| 179 |
+
data = b"".join(chunks)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
| 181 |
raise DownloadError(f"Download failed for {url}: {exc}") from exc
|
| 182 |
|
requirements.txt
CHANGED
|
@@ -1,76 +1,66 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
pydantic
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
numpy>=1.26.0
|
| 10 |
-
rapidocr-onnxruntime>=1.4.4
|
| 11 |
-
onnxruntime>=1.18.0
|
| 12 |
-
pillow>=10.0.0
|
| 13 |
|
| 14 |
-
#
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
#
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
|
| 38 |
-
#
|
| 39 |
-
|
| 40 |
-
email-validator>=2.1.0
|
| 41 |
-
slowapi>=0.1.9
|
| 42 |
|
| 43 |
-
#
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
#
|
| 49 |
-
|
|
|
|
| 50 |
|
| 51 |
-
#
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
#
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
|
| 59 |
-
#
|
| 60 |
-
|
| 61 |
-
text-unidecode>=1.3
|
| 62 |
-
Unidecode>=1.3.8
|
| 63 |
-
|
| 64 |
-
# JSON Schema validation
|
| 65 |
-
jsonschema>=4.21.0
|
| 66 |
-
|
| 67 |
-
# JSONPath extraction
|
| 68 |
-
jsonpath-ng>=1.7.0
|
| 69 |
-
|
| 70 |
-
# QR code generation (testing)
|
| 71 |
-
qrcode[pil]>=8.0
|
| 72 |
-
|
| 73 |
-
# Scheduler
|
| 74 |
-
apscheduler>=3.10.4
|
| 75 |
-
croniter>=2.0.0
|
| 76 |
-
pytz>=2024.1
|
|
|
|
| 1 |
+
# --- Core framework ---
|
| 2 |
+
fastapi==0.115.5
|
| 3 |
+
uvicorn[standard]==0.34.0
|
| 4 |
+
pydantic==2.13.4
|
| 5 |
+
pydantic-settings==2.6.1
|
| 6 |
+
python-multipart==0.0.20
|
| 7 |
+
httpx==0.28.1
|
| 8 |
+
aiohttp==3.11.13
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
# --- Media / file processing ---
|
| 11 |
+
numpy==1.26.4
|
| 12 |
+
pillow==10.3.0
|
| 13 |
+
opencv-python-headless==4.12.0.88
|
| 14 |
+
pypdfium2==4.30.0
|
| 15 |
+
rapidocr-onnxruntime==1.4.4
|
| 16 |
+
onnxruntime==1.20.1
|
| 17 |
+
markitdown[all]==0.1.5
|
| 18 |
|
| 19 |
+
# --- Data / analysis ---
|
| 20 |
+
pandas==3.0.1
|
| 21 |
+
matplotlib==3.9.2
|
| 22 |
+
seaborn==0.13.2
|
| 23 |
|
| 24 |
+
# --- ML / embeddings / OCR models ---
|
| 25 |
+
zvec==0.4.0
|
| 26 |
+
sentence-transformers==3.4.1
|
| 27 |
+
transformers==4.50.2
|
| 28 |
+
torch==2.5.1
|
| 29 |
+
einops==0.8.2
|
| 30 |
|
| 31 |
+
# --- NLP ---
|
| 32 |
+
spacy==3.8.3
|
| 33 |
+
phonenumbers==9.0.33
|
| 34 |
+
sqlglot==25.31.4
|
| 35 |
+
tiktoken==0.7.0
|
| 36 |
+
clean-text==0.7.1
|
| 37 |
+
text-unidecode==1.3
|
| 38 |
+
Unidecode==1.4.0
|
| 39 |
+
chardet==5.2.0
|
| 40 |
|
| 41 |
+
# --- Web scraping / search ---
|
| 42 |
+
scrapling[all]==0.4.7
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
# --- Auth / security ---
|
| 45 |
+
PyJWT==2.10.1
|
| 46 |
+
cryptography==43.0.3
|
| 47 |
+
argon2-cffi==25.1.0
|
| 48 |
+
email-validator==2.3.0
|
| 49 |
+
slowapi==0.1.9
|
| 50 |
|
| 51 |
+
# --- Storage / cache / DB clients ---
|
| 52 |
+
supabase==2.13.0
|
| 53 |
+
redis==5.2.1
|
| 54 |
|
| 55 |
+
# --- Validation / data access ---
|
| 56 |
+
jsonschema==4.23.0
|
| 57 |
+
jsonpath-ng==1.8.0
|
| 58 |
+
qrcode[pil]==8.2
|
| 59 |
|
| 60 |
+
# --- Scheduler ---
|
| 61 |
+
apscheduler==3.11.0
|
| 62 |
+
croniter==6.2.4
|
| 63 |
+
pytz==2024.2
|
| 64 |
|
| 65 |
+
# --- Misc ---
|
| 66 |
+
pyfiglet==1.0.4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|