Soumik Bose commited on
Commit
bd469c1
·
1 Parent(s): 066203b

optimization 404

Browse files
.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
- def get_sql_validator_service() -> SqlValidatorService:
20
- return SqlValidatorService()
21
 
22
 
23
  def get_text_cleaner_service() -> TextCleanerService:
24
- return TextCleanerService()
25
-
26
-
27
- def get_auth_service() -> AuthService:
28
- return AuthService()
29
 
30
 
31
  def get_converter_service() -> ConverterService:
32
- return ConverterService()
33
-
34
-
35
- def get_ocr_service() -> OCRService:
36
- return OCRService()
37
 
38
 
39
  def get_extraction_service() -> ExtractionService:
40
- return ExtractionService()
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
- async with httpx.AsyncClient(timeout=30.0) as client:
51
- response = await client.get(health_url)
52
- if response.status_code == 200:
53
- _logger.info("Self-ping successful: %s", health_url)
54
- else:
55
- _logger.warning("Self-ping returned: %s - %s", health_url, response.status_code)
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(api_v1_router, prefix="/api/v1")
 
 
 
 
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
- async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
160
- resp = await client.get(url)
161
- resp.raise_for_status()
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 httpx.HTTPError as exc:
 
 
 
 
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, Depends, HTTPException, Request
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
- async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
229
- resp = await client.get(body.url)
230
- resp.raise_for_status()
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 httpx.HTTPError as exc:
257
  _logger.error("Failed to fetch URL %s: %s", body.url, exc)
258
- raise HTTPException(status_code=400, detail={"success": False, "message": f"Failed to fetch URL: {exc}"})
 
 
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 = extract_json(body.content, limit=effective_limit)
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
- async def _process_file_pair(
148
  source_file: bytes,
149
  dest_file: bytes,
150
  source_ext: str,
151
  dest_ext: str,
152
- column_mapping: Optional[Dict[str, str]] = None,
153
- pair_index: int = 0,
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(body.query, routes_dict, body.threshold)
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(body.query, body.dialect)
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 = _build_archive(data_dir)
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
- with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as tar:
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 = count_tokens(body.text, body.encoding)
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
- async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
289
- resp = await client.get(body.url)
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(number, country_code)
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
- return Settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 datetime import datetime
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 RefreshSession, User, _uuid
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=ph.hash(schema.password),
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._verify_password(schema.password, user.password_hash):
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._create_access_token(user.id)
103
- raw_refresh, refresh_hash, token_key, expires_at = AuthService._create_refresh_token()
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._verify_token(raw_refresh_token, session.token_hash):
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._create_access_token(user.id)
148
- new_raw_refresh, new_hash, new_key, new_expires = AuthService._create_refresh_token()
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._verify_password(schema.current_password, user.password_hash):
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": ph.hash(schema.new_password),
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 = jwt.encode(
 
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 = jwt.decode(
 
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": ph.hash(schema.new_password),
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(creds)
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 self.sign_url_v4(
1043
- creds, "GET", bucket, name,
 
 
 
 
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 self.sign_url_v4(
1059
- creds, "PUT", bucket, name,
 
 
 
 
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 = jwt.decode(
 
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 as exc:
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, timezone
6
  from typing import Any, Dict, List, Optional
7
 
8
- from app.core.auth.models import RefreshSession, User, _uuid
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(l.get("click_count") or 0 for l in links)
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(l.get("click_count") or 0 for l in links)
611
- active = sum(1 for l in links if l.get("is_active"))
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
- 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
 
 
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
- zvec>=0.4.0
2
- markitdown[all]>=0.1.5
3
- fastapi>=0.111.0
4
- uvicorn[standard]>=0.30.0
5
- pydantic>=2.7.0
6
- pydantic-settings>=2.0.0
7
- python-multipart>=0.0.9
8
- httpx>=0.27.0
9
- numpy>=1.26.0
10
- rapidocr-onnxruntime>=1.4.4
11
- onnxruntime>=1.18.0
12
- pillow>=10.0.0
13
 
14
- # QR / image processing
15
- opencv-python-headless>=4.9.0
16
- pypdfium2>=4.30.0
17
- pandas>=2.0.0
18
- matplotlib>=3.8.0
19
- seaborn>=0.13.0
20
- sentence-transformers==5.6.0
 
21
 
22
- aiohttp>=3.9.0
23
- aiomysql>=0.2.0
 
 
24
 
25
- # FIXED: Downgraded to 4.49.0. Transformers 5.x breaks Nomic Vision's trust_remote_code architecture.
26
- transformers==4.49.0
 
 
 
 
27
 
28
- torch==2.12.1
29
- # torchvision==0.27.1
30
- einops
31
- spacy>=3.7.0
32
- phonenumbers>=8.13.0
33
- sqlglot>=20.0.0
34
- tiktoken>=0.9.0
35
- PyJWT>=2.9.0
36
- cryptography>=42.0.0
37
 
38
- # Authentication
39
- argon2-cffi>=23.1.0
40
- email-validator>=2.1.0
41
- slowapi>=0.1.9
42
 
43
- # Async database drivers
44
- supabase>=2.0.0
45
- asyncpg>=0.31.0
46
- motor>=3.7.1
 
 
47
 
48
- # ASCII banner for startup
49
- pyfiglet>=1.0.2
 
50
 
51
- # Web scraping
52
- scrapling[all]>=0.4.0
 
 
53
 
54
- # Chat / AI
55
- redis>=5.0.0
56
- defusedxml>=0.7.1
57
- chardet>=5.2.0
58
 
59
- # Text processing
60
- clean-text>=0.6.0
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