Spaces:
Running
Running
feat: cleanup junks
Browse files- .gitignore +2 -1
- app/api/server.py +0 -3
- app/api/v1/auth.py +1 -2
- app/api/v1/batch.py +2 -19
- app/api/v1/convert.py +2 -3
- app/api/v1/embeddings.py +2 -224
- app/api/v1/reconcile.py +6 -10
- app/api/v1/system.py +31 -3
- app/api/v1/vector_stores.py +2 -3
- app/api/v1/webhook_socket.py +2 -6
- app/config.py +0 -2
- app/core/auth/deps.py +0 -5
- app/core/banner.py +0 -1
- app/core/database/mongodb.py +1 -4
- app/core/database/mysql.py +0 -28
- app/models/__init__.py +0 -2
- app/models/schemas.py +1 -13
- app/services/auth_service.py +1 -4
- app/services/code_executor_service.py +2 -40
- app/services/converter_service.py +0 -117
- app/services/dataset_metadata_service.py +0 -19
- app/services/embeddings_service.py +0 -59
- app/services/vector_store_service.py +3 -0
- tests/test_webhook_socket.py +37 -13
.gitignore
CHANGED
|
@@ -6,6 +6,7 @@ postman_collection.json
|
|
| 6 |
# Runtime data (vector stores, SQLite DBs, model caches, etc.)
|
| 7 |
data/
|
| 8 |
*.so
|
|
|
|
| 9 |
|
| 10 |
.Python
|
| 11 |
build/
|
|
@@ -127,5 +128,5 @@ persistence/
|
|
| 127 |
local_deploy.py
|
| 128 |
test_vector_store_async.py
|
| 129 |
deploy_sdk.py
|
| 130 |
-
|
| 131 |
deploy_hf.py
|
|
|
|
| 6 |
# Runtime data (vector stores, SQLite DBs, model caches, etc.)
|
| 7 |
data/
|
| 8 |
*.so
|
| 9 |
+
test_deploy_flow.py
|
| 10 |
|
| 11 |
.Python
|
| 12 |
build/
|
|
|
|
| 128 |
local_deploy.py
|
| 129 |
test_vector_store_async.py
|
| 130 |
deploy_sdk.py
|
| 131 |
+
tests
|
| 132 |
deploy_hf.py
|
app/api/server.py
CHANGED
|
@@ -13,7 +13,6 @@ from app.core.database import pool_manager
|
|
| 13 |
from app.core.logger import get_logger
|
| 14 |
from app.core.redis_client import create_redis_client, close_redis
|
| 15 |
from app.core.scripts import load_scripts
|
| 16 |
-
from app.core.vector_store.deps import init_vector_store_db
|
| 17 |
from app.services.embeddings_service import EmbeddingService
|
| 18 |
from app.services.vector_store_service import VectorStoreService
|
| 19 |
from app.api.v1.router import api_v1_router
|
|
@@ -49,14 +48,12 @@ async def lifespan(app: FastAPI):
|
|
| 49 |
_logger.info("Authentication database initialized")
|
| 50 |
|
| 51 |
_logger.info("Initializing vector store database...")
|
| 52 |
-
await init_vector_store_db()
|
| 53 |
await _vector_store_service.init_db()
|
| 54 |
_logger.info("Vector store database initialized with %d stores", len(_vector_store_service.list_stores()))
|
| 55 |
|
| 56 |
_logger.info("Initializing embedding service (loading 384-dim model)...")
|
| 57 |
loop = asyncio.get_running_loop()
|
| 58 |
await loop.run_in_executor(None, _embedding_service.load_model, 384)
|
| 59 |
-
# await loop.run_in_executor(None, _embedding_service.load_vision_model) # DISABLED (OOM mitigation)
|
| 60 |
_logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
|
| 61 |
_logger.info("Vector store service initialized with %d existing stores", len(_vector_store_service.list_stores()))
|
| 62 |
|
|
|
|
| 13 |
from app.core.logger import get_logger
|
| 14 |
from app.core.redis_client import create_redis_client, close_redis
|
| 15 |
from app.core.scripts import load_scripts
|
|
|
|
| 16 |
from app.services.embeddings_service import EmbeddingService
|
| 17 |
from app.services.vector_store_service import VectorStoreService
|
| 18 |
from app.api.v1.router import api_v1_router
|
|
|
|
| 48 |
_logger.info("Authentication database initialized")
|
| 49 |
|
| 50 |
_logger.info("Initializing vector store database...")
|
|
|
|
| 51 |
await _vector_store_service.init_db()
|
| 52 |
_logger.info("Vector store database initialized with %d stores", len(_vector_store_service.list_stores()))
|
| 53 |
|
| 54 |
_logger.info("Initializing embedding service (loading 384-dim model)...")
|
| 55 |
loop = asyncio.get_running_loop()
|
| 56 |
await loop.run_in_executor(None, _embedding_service.load_model, 384)
|
|
|
|
| 57 |
_logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
|
| 58 |
_logger.info("Vector store service initialized with %d existing stores", len(_vector_store_service.list_stores()))
|
| 59 |
|
app/api/v1/auth.py
CHANGED
|
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 7 |
|
| 8 |
from app.config import get_settings
|
| 9 |
from app.core.auth.deps import get_current_user, get_db, get_temp_db_warning, require_application_id
|
| 10 |
-
from app.core.auth.models import
|
| 11 |
from app.core.auth.schemas import (
|
| 12 |
ChangePasswordSchema,
|
| 13 |
ForgotPasswordSchema,
|
|
@@ -24,7 +24,6 @@ from app.core.auth.schemas import (
|
|
| 24 |
UpdateProfileSchema,
|
| 25 |
UserSchemaField,
|
| 26 |
UserSchemaResponse,
|
| 27 |
-
DataResponse,
|
| 28 |
)
|
| 29 |
from app.services.auth_service import AuthService
|
| 30 |
|
|
|
|
| 7 |
|
| 8 |
from app.config import get_settings
|
| 9 |
from app.core.auth.deps import get_current_user, get_db, get_temp_db_warning, require_application_id
|
| 10 |
+
from app.core.auth.models import User
|
| 11 |
from app.core.auth.schemas import (
|
| 12 |
ChangePasswordSchema,
|
| 13 |
ForgotPasswordSchema,
|
|
|
|
| 24 |
UpdateProfileSchema,
|
| 25 |
UserSchemaField,
|
| 26 |
UserSchemaResponse,
|
|
|
|
| 27 |
)
|
| 28 |
from app.services.auth_service import AuthService
|
| 29 |
|
app/api/v1/batch.py
CHANGED
|
@@ -1,16 +1,16 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
-
import concurrent.futures
|
| 5 |
import json as json_mod
|
| 6 |
-
import os
|
| 7 |
import time
|
| 8 |
from typing import Annotated, List, Optional
|
|
|
|
| 9 |
|
| 10 |
import httpx
|
| 11 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
| 12 |
|
| 13 |
from app.api.deps import get_converter_service, get_extraction_service, get_text_cleaner_service, require_auth
|
|
|
|
| 14 |
from app.config import get_settings
|
| 15 |
from app.core.logger import get_logger
|
| 16 |
from app.models.domain import ConversionError
|
|
@@ -18,7 +18,6 @@ from app.models.schemas import (
|
|
| 18 |
BatchFileResult,
|
| 19 |
BatchResponse,
|
| 20 |
BatchUrlRequest,
|
| 21 |
-
ConversionMetadata,
|
| 22 |
)
|
| 23 |
from app.services.converter_service import ConverterService
|
| 24 |
from app.services.extraction_service import ExtractionService
|
|
@@ -29,21 +28,6 @@ _logger = get_logger(__name__)
|
|
| 29 |
_settings = get_settings()
|
| 30 |
_MAX_UPLOAD_BYTES = _settings.max_upload_bytes
|
| 31 |
_MAX_BATCH_FILES = _settings.max_batch_files
|
| 32 |
-
_MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 33 |
-
_thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS)
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def _build_metadata(result) -> ConversionMetadata:
|
| 37 |
-
return ConversionMetadata(
|
| 38 |
-
source=result.source,
|
| 39 |
-
char_count=result.char_count,
|
| 40 |
-
word_count=result.word_count,
|
| 41 |
-
line_count=result.line_count,
|
| 42 |
-
file_size_bytes=result.file_size_bytes,
|
| 43 |
-
mime_type=result.mime_type,
|
| 44 |
-
content_hash=result.content_hash,
|
| 45 |
-
token_estimate=result.token_estimate,
|
| 46 |
-
)
|
| 47 |
|
| 48 |
|
| 49 |
def _batch_result_from_error(name: str, err: ConversionError) -> BatchFileResult:
|
|
@@ -165,7 +149,6 @@ async def batch_urls(
|
|
| 165 |
|
| 166 |
async def process_single_url(url: str) -> BatchFileResult:
|
| 167 |
_logger.info("Batch processing URL: %s", url)
|
| 168 |
-
from urllib.parse import urlparse
|
| 169 |
parsed = urlparse(url)
|
| 170 |
filename = parsed.path.split("/")[-1] or "url_content"
|
| 171 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
| 4 |
import json as json_mod
|
|
|
|
| 5 |
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 get_converter_service, get_extraction_service, get_text_cleaner_service, require_auth
|
| 13 |
+
from app.api.v1.convert import _build_metadata, _thread_pool
|
| 14 |
from app.config import get_settings
|
| 15 |
from app.core.logger import get_logger
|
| 16 |
from app.models.domain import ConversionError
|
|
|
|
| 18 |
BatchFileResult,
|
| 19 |
BatchResponse,
|
| 20 |
BatchUrlRequest,
|
|
|
|
| 21 |
)
|
| 22 |
from app.services.converter_service import ConverterService
|
| 23 |
from app.services.extraction_service import ExtractionService
|
|
|
|
| 28 |
_settings = get_settings()
|
| 29 |
_MAX_UPLOAD_BYTES = _settings.max_upload_bytes
|
| 30 |
_MAX_BATCH_FILES = _settings.max_batch_files
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def _batch_result_from_error(name: str, err: ConversionError) -> BatchFileResult:
|
|
|
|
| 149 |
|
| 150 |
async def process_single_url(url: str) -> BatchFileResult:
|
| 151 |
_logger.info("Batch processing URL: %s", url)
|
|
|
|
| 152 |
parsed = urlparse(url)
|
| 153 |
filename = parsed.path.split("/")[-1] or "url_content"
|
| 154 |
|
app/api/v1/convert.py
CHANGED
|
@@ -2,14 +2,15 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import concurrent.futures
|
|
|
|
| 5 |
import json as json_mod
|
| 6 |
import os
|
| 7 |
from typing import Annotated, Any, Dict, Optional
|
| 8 |
from urllib.parse import urlparse
|
| 9 |
|
| 10 |
import httpx
|
|
|
|
| 11 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
| 12 |
-
from pydantic import BaseModel
|
| 13 |
|
| 14 |
from app.config import get_settings
|
| 15 |
from app.api.deps import (
|
|
@@ -83,7 +84,6 @@ async def _build_response(
|
|
| 83 |
if clean_content and content != result.markdown:
|
| 84 |
lines = content.splitlines()
|
| 85 |
words = content.split()
|
| 86 |
-
import hashlib
|
| 87 |
metadata = ConversionMetadata(
|
| 88 |
source=result.source,
|
| 89 |
char_count=len(content),
|
|
@@ -171,7 +171,6 @@ async def convert_file(
|
|
| 171 |
content = await loop.run_in_executor(_thread_pool, text_cleaner_service.clean, content)
|
| 172 |
|
| 173 |
if plain_text:
|
| 174 |
-
from fastapi.responses import PlainTextResponse
|
| 175 |
return PlainTextResponse(content)
|
| 176 |
|
| 177 |
response = await _build_response(
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import concurrent.futures
|
| 5 |
+
import hashlib
|
| 6 |
import json as json_mod
|
| 7 |
import os
|
| 8 |
from typing import Annotated, Any, Dict, Optional
|
| 9 |
from urllib.parse import urlparse
|
| 10 |
|
| 11 |
import httpx
|
| 12 |
+
from fastapi.responses import PlainTextResponse
|
| 13 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
|
|
|
| 14 |
|
| 15 |
from app.config import get_settings
|
| 16 |
from app.api.deps import (
|
|
|
|
| 84 |
if clean_content and content != result.markdown:
|
| 85 |
lines = content.splitlines()
|
| 86 |
words = content.split()
|
|
|
|
| 87 |
metadata = ConversionMetadata(
|
| 88 |
source=result.source,
|
| 89 |
char_count=len(content),
|
|
|
|
| 171 |
content = await loop.run_in_executor(_thread_pool, text_cleaner_service.clean, content)
|
| 172 |
|
| 173 |
if plain_text:
|
|
|
|
| 174 |
return PlainTextResponse(content)
|
| 175 |
|
| 176 |
response = await _build_response(
|
app/api/v1/embeddings.py
CHANGED
|
@@ -2,19 +2,14 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import concurrent.futures
|
| 5 |
-
# import io # DISABLED (OOM mitigation) — only used by vision
|
| 6 |
import os
|
| 7 |
import time
|
| 8 |
-
from
|
| 9 |
-
|
| 10 |
-
# import httpx # DISABLED (OOM mitigation) — only used by vision
|
| 11 |
-
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
| 12 |
-
# from PIL import Image # DISABLED (OOM mitigation)
|
| 13 |
|
| 14 |
from app.api.deps import require_auth, get_embeddings_service
|
| 15 |
from app.config import get_settings
|
| 16 |
from app.core.logger import get_logger
|
| 17 |
-
from app.models.schemas import EmbeddingItem, EmbeddingRequest, EmbeddingResponse
|
| 18 |
from app.services.embeddings_service import EmbeddingService
|
| 19 |
|
| 20 |
router = APIRouter()
|
|
@@ -22,41 +17,6 @@ _logger = get_logger(__name__)
|
|
| 22 |
_settings = get_settings()
|
| 23 |
_MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 24 |
_thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS)
|
| 25 |
-
# _MAX_VISION_ITEMS = 5 # DISABLED (OOM mitigation)
|
| 26 |
-
# _MAX_IMAGE_BYTES = 15 * 1024 * 1024 # DISABLED (OOM mitigation)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
# def _validate_image(raw: bytes, source: str) -> Image.Image: # DISABLED (OOM mitigation)
|
| 30 |
-
# # FIX: Check if the file is completely empty (0 bytes)
|
| 31 |
-
# if not raw:
|
| 32 |
-
# raise ValueError(f"File {source} is empty (0 bytes).")
|
| 33 |
-
#
|
| 34 |
-
# if len(raw) > _MAX_IMAGE_BYTES:
|
| 35 |
-
# raise ValueError(f"Image {source} exceeds 15 MB limit")
|
| 36 |
-
#
|
| 37 |
-
# try:
|
| 38 |
-
# img = Image.open(io.BytesIO(raw))
|
| 39 |
-
# img.load()
|
| 40 |
-
# if img.mode != "RGB":
|
| 41 |
-
# img = img.convert("RGB")
|
| 42 |
-
# return img
|
| 43 |
-
# except Exception as exc:
|
| 44 |
-
# raise ValueError(f"Invalid image {source}: {exc}")
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
# async def _download_image(url: str) -> bytes: # DISABLED (OOM mitigation)
|
| 48 |
-
# try:
|
| 49 |
-
# async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
| 50 |
-
# resp = await client.get(url)
|
| 51 |
-
# resp.raise_for_status()
|
| 52 |
-
# ctype = resp.headers.get("content-type", "")
|
| 53 |
-
# if not ctype.startswith("image/"):
|
| 54 |
-
# raise ValueError(f"URL {url} returned non-image Content-Type: {ctype}")
|
| 55 |
-
# return resp.content
|
| 56 |
-
# except httpx.HTTPError as exc:
|
| 57 |
-
# raise ValueError(f"Failed to download {url}: {exc}")
|
| 58 |
-
|
| 59 |
-
|
| 60 |
@router.post(
|
| 61 |
"/embeddings",
|
| 62 |
response_model=EmbeddingResponse,
|
|
@@ -126,185 +86,3 @@ async def create_embeddings(
|
|
| 126 |
)
|
| 127 |
|
| 128 |
|
| 129 |
-
# @router.post( # DISABLED (OOM mitigation)
|
| 130 |
-
# "/embeddings/vision/file",
|
| 131 |
-
# response_model=EmbeddingResponse,
|
| 132 |
-
# summary="Generate embeddings from uploaded images",
|
| 133 |
-
# )
|
| 134 |
-
# async def create_vision_embeddings_file(
|
| 135 |
-
# files: Annotated[List[UploadFile], File(description="Image files to embed (max 5)")],
|
| 136 |
-
# token: str = Depends(require_auth),
|
| 137 |
-
# embedding_service: EmbeddingService = Depends(get_embeddings_service),
|
| 138 |
-
# ) -> EmbeddingResponse:
|
| 139 |
-
# if not files:
|
| 140 |
-
# raise HTTPException(status_code=400, detail={"success": False, "message": "No files provided."})
|
| 141 |
-
# if len(files) > _MAX_VISION_ITEMS:
|
| 142 |
-
# raise HTTPException(status_code=400, detail={"success": False, "message": f"Maximum {_MAX_VISION_ITEMS} images per request."})
|
| 143 |
-
#
|
| 144 |
-
# if not embedding_service._vision_loaded:
|
| 145 |
-
# raise HTTPException(status_code=503, detail={"success": False, "message": "Vision model not loaded."})
|
| 146 |
-
#
|
| 147 |
-
# _logger.info("Vision embedding file request: files=%s", len(files))
|
| 148 |
-
#
|
| 149 |
-
# dim = embedding_service.vision_dimension
|
| 150 |
-
# start = time.perf_counter()
|
| 151 |
-
# images: List[Image.Image] = []
|
| 152 |
-
# item_results: List[EmbeddingItem] = []
|
| 153 |
-
#
|
| 154 |
-
# for f in files:
|
| 155 |
-
# t0 = time.perf_counter()
|
| 156 |
-
# try:
|
| 157 |
-
# # FIX: Guarantee the file cursor is at the beginning before reading!
|
| 158 |
-
# await f.seek(0)
|
| 159 |
-
# raw = await f.read()
|
| 160 |
-
#
|
| 161 |
-
# img = await asyncio.get_running_loop().run_in_executor(_thread_pool, _validate_image, raw, f.filename or "unknown")
|
| 162 |
-
# images.append(img)
|
| 163 |
-
# except Exception as exc:
|
| 164 |
-
# elapsed = (time.perf_counter() - t0) * 1000
|
| 165 |
-
# item_results.append(EmbeddingItem(
|
| 166 |
-
# success=False,
|
| 167 |
-
# time_ms=round(elapsed, 3),
|
| 168 |
-
# error_message=str(exc),
|
| 169 |
-
# ))
|
| 170 |
-
#
|
| 171 |
-
# if not images:
|
| 172 |
-
# total_ms = (time.perf_counter() - start) * 1000
|
| 173 |
-
# return EmbeddingResponse(
|
| 174 |
-
# success=False,
|
| 175 |
-
# time_ms=round(total_ms, 3),
|
| 176 |
-
# success_count=0,
|
| 177 |
-
# failed_count=len(item_results),
|
| 178 |
-
# error_message="No valid images could be processed.",
|
| 179 |
-
# results=item_results,
|
| 180 |
-
# )
|
| 181 |
-
#
|
| 182 |
-
# try:
|
| 183 |
-
# loop = asyncio.get_running_loop()
|
| 184 |
-
# vectors = await loop.run_in_executor(
|
| 185 |
-
# _thread_pool,
|
| 186 |
-
# embedding_service.generate_image_embedding,
|
| 187 |
-
# images,
|
| 188 |
-
# )
|
| 189 |
-
# except Exception as exc:
|
| 190 |
-
# elapsed = (time.perf_counter() - start) * 1000
|
| 191 |
-
# _logger.error("Vision embedding error: %s", exc)
|
| 192 |
-
# for _ in range(len(images) - len(item_results)):
|
| 193 |
-
# item_results.append(EmbeddingItem(success=False, time_ms=0, error_message=str(exc)))
|
| 194 |
-
# return EmbeddingResponse(
|
| 195 |
-
# success=False,
|
| 196 |
-
# time_ms=round(elapsed, 3),
|
| 197 |
-
# success_count=0,
|
| 198 |
-
# failed_count=len(item_results),
|
| 199 |
-
# error_message=str(exc),
|
| 200 |
-
# results=item_results,
|
| 201 |
-
# )
|
| 202 |
-
#
|
| 203 |
-
# total_ms = (time.perf_counter() - start) * 1000
|
| 204 |
-
# for i, vec in enumerate(vectors):
|
| 205 |
-
# item_results.append(EmbeddingItem(
|
| 206 |
-
# success=True,
|
| 207 |
-
# time_ms=round(total_ms / len(vectors), 3),
|
| 208 |
-
# embeddings=vec,
|
| 209 |
-
# dimension=dim,
|
| 210 |
-
# ))
|
| 211 |
-
#
|
| 212 |
-
# success_count = sum(1 for r in item_results if r.success)
|
| 213 |
-
# failed_count = len(item_results) - success_count
|
| 214 |
-
# _logger.info("Vision embedding success: items=%s, success=%s, failed=%s, total_ms=%s",
|
| 215 |
-
# len(item_results), success_count, failed_count, round(total_ms, 3))
|
| 216 |
-
# return EmbeddingResponse(
|
| 217 |
-
# success=failed_count == 0,
|
| 218 |
-
# time_ms=round(total_ms, 3),
|
| 219 |
-
# success_count=success_count,
|
| 220 |
-
# failed_count=failed_count,
|
| 221 |
-
# results=item_results,
|
| 222 |
-
# )
|
| 223 |
-
#
|
| 224 |
-
#
|
| 225 |
-
# @router.post( # DISABLED (OOM mitigation)
|
| 226 |
-
# "/embeddings/vision/url",
|
| 227 |
-
# response_model=EmbeddingResponse,
|
| 228 |
-
# summary="Generate embeddings from image URLs",
|
| 229 |
-
# )
|
| 230 |
-
# async def create_vision_embeddings_url(
|
| 231 |
-
# body: VisionUrlRequest,
|
| 232 |
-
# token: str = Depends(require_auth),
|
| 233 |
-
# embedding_service: EmbeddingService = Depends(get_embeddings_service),
|
| 234 |
-
# ) -> EmbeddingResponse:
|
| 235 |
-
# if not embedding_service._vision_loaded:
|
| 236 |
-
# raise HTTPException(status_code=503, detail={"success": False, "message": "Vision model not loaded."})
|
| 237 |
-
#
|
| 238 |
-
# _logger.info("Vision embedding URL request: urls=%s", len(body.urls))
|
| 239 |
-
#
|
| 240 |
-
# dim = embedding_service.vision_dimension
|
| 241 |
-
# start = time.perf_counter()
|
| 242 |
-
# images: List[Image.Image] = []
|
| 243 |
-
# item_results: List[EmbeddingItem] = []
|
| 244 |
-
#
|
| 245 |
-
# for url in body.urls:
|
| 246 |
-
# t0 = time.perf_counter()
|
| 247 |
-
# try:
|
| 248 |
-
# raw = await _download_image(url)
|
| 249 |
-
# img = await asyncio.get_running_loop().run_in_executor(_thread_pool, _validate_image, raw, url)
|
| 250 |
-
# images.append(img)
|
| 251 |
-
# except Exception as exc:
|
| 252 |
-
# elapsed = (time.perf_counter() - t0) * 1000
|
| 253 |
-
# item_results.append(EmbeddingItem(
|
| 254 |
-
# success=False,
|
| 255 |
-
# time_ms=round(elapsed, 3),
|
| 256 |
-
# error_message=str(exc),
|
| 257 |
-
# ))
|
| 258 |
-
#
|
| 259 |
-
# if not images:
|
| 260 |
-
# total_ms = (time.perf_counter() - start) * 1000
|
| 261 |
-
# return EmbeddingResponse(
|
| 262 |
-
# success=False,
|
| 263 |
-
# time_ms=round(total_ms, 3),
|
| 264 |
-
# success_count=0,
|
| 265 |
-
# failed_count=len(item_results),
|
| 266 |
-
# error_message="No valid images could be downloaded.",
|
| 267 |
-
# results=item_results,
|
| 268 |
-
# )
|
| 269 |
-
#
|
| 270 |
-
# try:
|
| 271 |
-
# loop = asyncio.get_running_loop()
|
| 272 |
-
# vectors = await loop.run_in_executor(
|
| 273 |
-
# _thread_pool,
|
| 274 |
-
# embedding_service.generate_image_embedding,
|
| 275 |
-
# images,
|
| 276 |
-
# )
|
| 277 |
-
# except Exception as exc:
|
| 278 |
-
# elapsed = (time.perf_counter() - start) * 1000
|
| 279 |
-
# _logger.error("Vision embedding error: %s", exc)
|
| 280 |
-
# for _ in range(len(images) - len(item_results)):
|
| 281 |
-
# item_results.append(EmbeddingItem(success=False, time_ms=0, error_message=str(exc)))
|
| 282 |
-
# return EmbeddingResponse(
|
| 283 |
-
# success=False,
|
| 284 |
-
# time_ms=round(elapsed, 3),
|
| 285 |
-
# success_count=0,
|
| 286 |
-
# failed_count=len(item_results),
|
| 287 |
-
# error_message=str(exc),
|
| 288 |
-
# results=item_results,
|
| 289 |
-
# )
|
| 290 |
-
#
|
| 291 |
-
# total_ms = (time.perf_counter() - start) * 1000
|
| 292 |
-
# for i, vec in enumerate(vectors):
|
| 293 |
-
# item_results.append(EmbeddingItem(
|
| 294 |
-
# success=True,
|
| 295 |
-
# time_ms=round(total_ms / len(vectors), 3),
|
| 296 |
-
# embeddings=vec,
|
| 297 |
-
# dimension=dim,
|
| 298 |
-
# ))
|
| 299 |
-
#
|
| 300 |
-
# success_count = sum(1 for r in item_results if r.success)
|
| 301 |
-
# failed_count = len(item_results) - success_count
|
| 302 |
-
# _logger.info("Vision embedding success: items=%s, success=%s, failed=%s, total_ms=%s",
|
| 303 |
-
# len(item_results), success_count, failed_count, round(total_ms, 3))
|
| 304 |
-
# return EmbeddingResponse(
|
| 305 |
-
# success=failed_count == 0,
|
| 306 |
-
# time_ms=round(total_ms, 3),
|
| 307 |
-
# success_count=success_count,
|
| 308 |
-
# failed_count=failed_count,
|
| 309 |
-
# results=item_results,
|
| 310 |
-
# )
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import concurrent.futures
|
|
|
|
| 5 |
import os
|
| 6 |
import time
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
from app.api.deps import require_auth, get_embeddings_service
|
| 10 |
from app.config import get_settings
|
| 11 |
from app.core.logger import get_logger
|
| 12 |
+
from app.models.schemas import EmbeddingItem, EmbeddingRequest, EmbeddingResponse
|
| 13 |
from app.services.embeddings_service import EmbeddingService
|
| 14 |
|
| 15 |
router = APIRouter()
|
|
|
|
| 17 |
_settings = get_settings()
|
| 18 |
_MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 19 |
_thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
@router.post(
|
| 21 |
"/embeddings",
|
| 22 |
response_model=EmbeddingResponse,
|
|
|
|
| 86 |
)
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/v1/reconcile.py
CHANGED
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import json
|
| 5 |
-
import os
|
| 6 |
import time
|
| 7 |
import uuid
|
| 8 |
from typing import Any, Dict, List, Optional
|
|
@@ -22,6 +21,7 @@ from app.services.reconciliation_service import (
|
|
| 22 |
compare_rows,
|
| 23 |
compare_schemas,
|
| 24 |
download_file_with_retry,
|
|
|
|
| 25 |
normalize_dataframe,
|
| 26 |
read_to_dataframe,
|
| 27 |
reconcile_columns,
|
|
@@ -73,10 +73,6 @@ class ReconciliationResponse(BaseModel):
|
|
| 73 |
results: List[ReconciliationPairResult]
|
| 74 |
|
| 75 |
|
| 76 |
-
def _extract_extension(filename: str) -> str:
|
| 77 |
-
return os.path.splitext(filename)[1].lstrip(".").lower()
|
| 78 |
-
|
| 79 |
-
|
| 80 |
def _failed_result(pair_index: int, error: str) -> Dict[str, Any]:
|
| 81 |
return {
|
| 82 |
"pair_index": pair_index,
|
|
@@ -246,8 +242,8 @@ async def reconcile_files(
|
|
| 246 |
continue
|
| 247 |
if src is None or dst is None:
|
| 248 |
raise HTTPException(status_code=400, detail={"success": False, "message": f"Pair {idx}: Both source and destination files required."})
|
| 249 |
-
src_ext =
|
| 250 |
-
dst_ext =
|
| 251 |
_validate_extensions(src_ext, dst_ext, idx)
|
| 252 |
pairs.append((src, dst, cm, idx, src_ext, dst_ext))
|
| 253 |
|
|
@@ -284,13 +280,13 @@ async def reconcile_urls(
|
|
| 284 |
raise HTTPException(status_code=400, detail={"success": False, "message": "At least 1 pair is required."})
|
| 285 |
|
| 286 |
for idx, pair in enumerate(body.pairs, 1):
|
| 287 |
-
_validate_extensions(
|
| 288 |
|
| 289 |
async with aiohttp.ClientSession() as session:
|
| 290 |
async def process_url_pair(pair: ReconciliationPair, idx: int) -> Dict[str, Any]:
|
| 291 |
async with semaphore_jobs:
|
| 292 |
-
src_ext =
|
| 293 |
-
dst_ext =
|
| 294 |
|
| 295 |
try:
|
| 296 |
src_data, dst_data = await asyncio.gather(
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import json
|
|
|
|
| 5 |
import time
|
| 6 |
import uuid
|
| 7 |
from typing import Any, Dict, List, Optional
|
|
|
|
| 21 |
compare_rows,
|
| 22 |
compare_schemas,
|
| 23 |
download_file_with_retry,
|
| 24 |
+
extract_extension,
|
| 25 |
normalize_dataframe,
|
| 26 |
read_to_dataframe,
|
| 27 |
reconcile_columns,
|
|
|
|
| 73 |
results: List[ReconciliationPairResult]
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
def _failed_result(pair_index: int, error: str) -> Dict[str, Any]:
|
| 77 |
return {
|
| 78 |
"pair_index": pair_index,
|
|
|
|
| 242 |
continue
|
| 243 |
if src is None or dst is None:
|
| 244 |
raise HTTPException(status_code=400, detail={"success": False, "message": f"Pair {idx}: Both source and destination files required."})
|
| 245 |
+
src_ext = extract_extension(src.filename or "")
|
| 246 |
+
dst_ext = extract_extension(dst.filename or "")
|
| 247 |
_validate_extensions(src_ext, dst_ext, idx)
|
| 248 |
pairs.append((src, dst, cm, idx, src_ext, dst_ext))
|
| 249 |
|
|
|
|
| 280 |
raise HTTPException(status_code=400, detail={"success": False, "message": "At least 1 pair is required."})
|
| 281 |
|
| 282 |
for idx, pair in enumerate(body.pairs, 1):
|
| 283 |
+
_validate_extensions(extract_extension(pair.source), extract_extension(pair.destination), idx)
|
| 284 |
|
| 285 |
async with aiohttp.ClientSession() as session:
|
| 286 |
async def process_url_pair(pair: ReconciliationPair, idx: int) -> Dict[str, Any]:
|
| 287 |
async with semaphore_jobs:
|
| 288 |
+
src_ext = extract_extension(pair.source)
|
| 289 |
+
dst_ext = extract_extension(pair.destination)
|
| 290 |
|
| 291 |
try:
|
| 292 |
src_data, dst_data = await asyncio.gather(
|
app/api/v1/system.py
CHANGED
|
@@ -8,7 +8,7 @@ import time
|
|
| 8 |
from datetime import datetime, timezone
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
-
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
| 12 |
from fastapi.responses import StreamingResponse
|
| 13 |
|
| 14 |
from app.api.deps import get_extraction_service, require_auth
|
|
@@ -40,8 +40,17 @@ _logger = get_logger(__name__)
|
|
| 40 |
_MAINTENANCE_MODE: bool = False
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def is_maintenance() -> bool:
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
@router.get("/health", response_model=HealthResponse, summary="Health check")
|
|
@@ -198,6 +207,12 @@ async def upload_and_restore(
|
|
| 198 |
async def enable_maintenance(token: str = Depends(require_auth)):
|
| 199 |
global _MAINTENANCE_MODE
|
| 200 |
_MAINTENANCE_MODE = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
_logger.warning("Maintenance mode ENABLED — all write operations blocked")
|
| 202 |
return {"success": True, "message": "Maintenance mode enabled", "maintenance": True}
|
| 203 |
|
|
@@ -206,14 +221,27 @@ async def enable_maintenance(token: str = Depends(require_auth)):
|
|
| 206 |
async def disable_maintenance(token: str = Depends(require_auth)):
|
| 207 |
global _MAINTENANCE_MODE
|
| 208 |
_MAINTENANCE_MODE = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
_logger.warning("Maintenance mode DISABLED — write operations resumed")
|
| 210 |
return {"success": True, "message": "Maintenance mode disabled", "maintenance": False}
|
| 211 |
|
| 212 |
|
| 213 |
@router.get("/maintenance", summary="Check maintenance mode status")
|
| 214 |
async def maintenance_status(token: str = Depends(require_auth)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
return {
|
| 216 |
"success": True,
|
| 217 |
"maintenance": is_maintenance(),
|
| 218 |
-
"source":
|
| 219 |
}
|
|
|
|
| 8 |
from datetime import datetime, timezone
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
| 12 |
from fastapi.responses import StreamingResponse
|
| 13 |
|
| 14 |
from app.api.deps import get_extraction_service, require_auth
|
|
|
|
| 40 |
_MAINTENANCE_MODE: bool = False
|
| 41 |
|
| 42 |
|
| 43 |
+
def _maintenance_lock_path() -> Path:
|
| 44 |
+
return _get_data_dir() / ".maintenance.lock"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
def is_maintenance() -> bool:
|
| 48 |
+
if _MAINTENANCE_MODE:
|
| 49 |
+
return True
|
| 50 |
+
if os.environ.get("APP_UNDER_MAINTENANCE", "").lower() in ("1", "true", "yes"):
|
| 51 |
+
return True
|
| 52 |
+
lock_path = _maintenance_lock_path()
|
| 53 |
+
return lock_path.is_file()
|
| 54 |
|
| 55 |
|
| 56 |
@router.get("/health", response_model=HealthResponse, summary="Health check")
|
|
|
|
| 207 |
async def enable_maintenance(token: str = Depends(require_auth)):
|
| 208 |
global _MAINTENANCE_MODE
|
| 209 |
_MAINTENANCE_MODE = True
|
| 210 |
+
lock_path = _maintenance_lock_path()
|
| 211 |
+
try:
|
| 212 |
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
| 213 |
+
lock_path.write_text(str(time.time()), encoding="utf-8")
|
| 214 |
+
except Exception as exc:
|
| 215 |
+
_logger.warning("Could not write maintenance lock file: %s", exc)
|
| 216 |
_logger.warning("Maintenance mode ENABLED — all write operations blocked")
|
| 217 |
return {"success": True, "message": "Maintenance mode enabled", "maintenance": True}
|
| 218 |
|
|
|
|
| 221 |
async def disable_maintenance(token: str = Depends(require_auth)):
|
| 222 |
global _MAINTENANCE_MODE
|
| 223 |
_MAINTENANCE_MODE = False
|
| 224 |
+
lock_path = _maintenance_lock_path()
|
| 225 |
+
try:
|
| 226 |
+
lock_path.unlink(missing_ok=True)
|
| 227 |
+
except Exception as exc:
|
| 228 |
+
_logger.warning("Could not remove maintenance lock file: %s", exc)
|
| 229 |
_logger.warning("Maintenance mode DISABLED — write operations resumed")
|
| 230 |
return {"success": True, "message": "Maintenance mode disabled", "maintenance": False}
|
| 231 |
|
| 232 |
|
| 233 |
@router.get("/maintenance", summary="Check maintenance mode status")
|
| 234 |
async def maintenance_status(token: str = Depends(require_auth)):
|
| 235 |
+
lock_path = _maintenance_lock_path()
|
| 236 |
+
source = "off"
|
| 237 |
+
if _MAINTENANCE_MODE:
|
| 238 |
+
source = "api_flag"
|
| 239 |
+
elif os.environ.get("APP_UNDER_MAINTENANCE"):
|
| 240 |
+
source = "env_var"
|
| 241 |
+
elif lock_path.is_file():
|
| 242 |
+
source = "lock_file"
|
| 243 |
return {
|
| 244 |
"success": True,
|
| 245 |
"maintenance": is_maintenance(),
|
| 246 |
+
"source": source,
|
| 247 |
}
|
app/api/v1/vector_stores.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
import time
|
| 5 |
|
|
|
|
| 6 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
| 7 |
|
| 8 |
from app.api.deps import get_vector_store_service, require_auth
|
|
@@ -128,7 +129,7 @@ async def get_vector_store(
|
|
| 128 |
embedding_dimension=stats["embedding_dimension"],
|
| 129 |
document_count=stats["document_count"],
|
| 130 |
created_at=stats["created_at"],
|
| 131 |
-
metadata=stats["
|
| 132 |
)
|
| 133 |
|
| 134 |
|
|
@@ -287,8 +288,6 @@ async def ingest_pdf_url(
|
|
| 287 |
if record is None:
|
| 288 |
raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
|
| 289 |
|
| 290 |
-
import httpx
|
| 291 |
-
|
| 292 |
try:
|
| 293 |
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
| 294 |
resp = await client.get(body.url)
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import time
|
| 5 |
|
| 6 |
+
import httpx
|
| 7 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
| 8 |
|
| 9 |
from app.api.deps import get_vector_store_service, require_auth
|
|
|
|
| 129 |
embedding_dimension=stats["embedding_dimension"],
|
| 130 |
document_count=stats["document_count"],
|
| 131 |
created_at=stats["created_at"],
|
| 132 |
+
metadata=stats["metadata"],
|
| 133 |
)
|
| 134 |
|
| 135 |
|
|
|
|
| 288 |
if record is None:
|
| 289 |
raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
|
| 290 |
|
|
|
|
|
|
|
| 291 |
try:
|
| 292 |
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
| 293 |
resp = await client.get(body.url)
|
app/api/v1/webhook_socket.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
| 4 |
import json
|
| 5 |
|
| 6 |
-
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
|
| 7 |
|
| 8 |
from app.api.deps import require_auth
|
| 9 |
from app.models.schemas import (
|
|
@@ -29,7 +30,6 @@ async def create_channel(
|
|
| 29 |
token: str = Depends(require_auth),
|
| 30 |
):
|
| 31 |
if body.channel_id and manager.get_channel(body.channel_id):
|
| 32 |
-
from fastapi import HTTPException
|
| 33 |
raise HTTPException(status_code=409, detail=f"Channel '{body.channel_id}' already exists")
|
| 34 |
|
| 35 |
ch = manager.create_channel(
|
|
@@ -73,7 +73,6 @@ async def channel_info(
|
|
| 73 |
channel_id: str,
|
| 74 |
token: str = Depends(require_auth),
|
| 75 |
):
|
| 76 |
-
from fastapi import HTTPException
|
| 77 |
ch = manager.get_channel(channel_id)
|
| 78 |
if not ch:
|
| 79 |
raise HTTPException(status_code=404, detail="Channel not found")
|
|
@@ -94,7 +93,6 @@ async def delete_channel(
|
|
| 94 |
channel_id: str,
|
| 95 |
token: str = Depends(require_auth),
|
| 96 |
):
|
| 97 |
-
from fastapi import HTTPException
|
| 98 |
if manager.delete_channel(channel_id):
|
| 99 |
return ChannelDeleteResponse(deleted=channel_id)
|
| 100 |
raise HTTPException(status_code=404, detail="Channel not found")
|
|
@@ -105,7 +103,6 @@ async def handle_webhook(
|
|
| 105 |
channel_id: str,
|
| 106 |
request: Request,
|
| 107 |
):
|
| 108 |
-
from fastapi import HTTPException
|
| 109 |
ch = manager.get_channel(channel_id)
|
| 110 |
if not ch:
|
| 111 |
raise HTTPException(status_code=404, detail="Channel not found")
|
|
@@ -174,7 +171,6 @@ async def websocket_endpoint(
|
|
| 174 |
return
|
| 175 |
|
| 176 |
if ch.secret:
|
| 177 |
-
import hmac as hmac_mod
|
| 178 |
if not hmac_mod.compare_digest(secret, ch.secret):
|
| 179 |
await websocket.accept()
|
| 180 |
await websocket.send_json({"event": "error", "message": "unauthorized"})
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
+
import hmac as hmac_mod
|
| 5 |
import json
|
| 6 |
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
| 8 |
|
| 9 |
from app.api.deps import require_auth
|
| 10 |
from app.models.schemas import (
|
|
|
|
| 30 |
token: str = Depends(require_auth),
|
| 31 |
):
|
| 32 |
if body.channel_id and manager.get_channel(body.channel_id):
|
|
|
|
| 33 |
raise HTTPException(status_code=409, detail=f"Channel '{body.channel_id}' already exists")
|
| 34 |
|
| 35 |
ch = manager.create_channel(
|
|
|
|
| 73 |
channel_id: str,
|
| 74 |
token: str = Depends(require_auth),
|
| 75 |
):
|
|
|
|
| 76 |
ch = manager.get_channel(channel_id)
|
| 77 |
if not ch:
|
| 78 |
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
| 93 |
channel_id: str,
|
| 94 |
token: str = Depends(require_auth),
|
| 95 |
):
|
|
|
|
| 96 |
if manager.delete_channel(channel_id):
|
| 97 |
return ChannelDeleteResponse(deleted=channel_id)
|
| 98 |
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
| 103 |
channel_id: str,
|
| 104 |
request: Request,
|
| 105 |
):
|
|
|
|
| 106 |
ch = manager.get_channel(channel_id)
|
| 107 |
if not ch:
|
| 108 |
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
| 171 |
return
|
| 172 |
|
| 173 |
if ch.secret:
|
|
|
|
| 174 |
if not hmac_mod.compare_digest(secret, ch.secret):
|
| 175 |
await websocket.accept()
|
| 176 |
await websocket.send_json({"event": "error", "message": "unauthorized"})
|
app/config.py
CHANGED
|
@@ -115,5 +115,3 @@ DEFAULT_STREAM = False
|
|
| 115 |
@lru_cache(maxsize=1)
|
| 116 |
def get_settings() -> Settings:
|
| 117 |
return Settings()
|
| 118 |
-
|
| 119 |
-
# Persistence test marker - v2
|
|
|
|
| 115 |
@lru_cache(maxsize=1)
|
| 116 |
def get_settings() -> Settings:
|
| 117 |
return Settings()
|
|
|
|
|
|
app/core/auth/deps.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
-
from datetime import datetime, timedelta, timezone
|
| 5 |
from typing import Annotated, Any, AsyncGenerator, Optional
|
| 6 |
|
| 7 |
import jwt
|
|
@@ -38,10 +37,6 @@ AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_co
|
|
| 38 |
ph = PasswordHasher()
|
| 39 |
|
| 40 |
|
| 41 |
-
def _now() -> datetime:
|
| 42 |
-
return datetime.now(timezone.utc)
|
| 43 |
-
|
| 44 |
-
|
| 45 |
class TempDatabaseWarning:
|
| 46 |
code: str = "TEMP_DATABASE"
|
| 47 |
message: str = (
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
|
|
|
| 4 |
from typing import Annotated, Any, AsyncGenerator, Optional
|
| 5 |
|
| 6 |
import jwt
|
|
|
|
| 37 |
ph = PasswordHasher()
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
class TempDatabaseWarning:
|
| 41 |
code: str = "TEMP_DATABASE"
|
| 42 |
message: str = (
|
app/core/banner.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
|
|
|
|
|
|
app/core/database/mongodb.py
CHANGED
|
@@ -99,7 +99,4 @@ class MongoDBExecutor(BaseExecutor):
|
|
| 99 |
async def _close_pool(self, pool: AsyncIOMotorClient) -> None:
|
| 100 |
pool.close()
|
| 101 |
|
| 102 |
-
|
| 103 |
-
if self._pool is not None:
|
| 104 |
-
return self._pool
|
| 105 |
-
return await super()._get_or_create_pool()
|
|
|
|
| 99 |
async def _close_pool(self, pool: AsyncIOMotorClient) -> None:
|
| 100 |
pool.close()
|
| 101 |
|
| 102 |
+
|
|
|
|
|
|
|
|
|
app/core/database/mysql.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import asyncio
|
| 4 |
-
import sys
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
import aiomysql
|
|
@@ -27,32 +25,6 @@ class MySQLExecutor(BaseExecutor):
|
|
| 27 |
pool_recycle=3600,
|
| 28 |
)
|
| 29 |
|
| 30 |
-
async def _get_or_create_pool(self) -> aiomysql.Pool:
|
| 31 |
-
async with self._lock:
|
| 32 |
-
if self._pool is not None:
|
| 33 |
-
return self._pool
|
| 34 |
-
|
| 35 |
-
last_error: Exception | None = None
|
| 36 |
-
for attempt in range(self._max_connection_retries):
|
| 37 |
-
try:
|
| 38 |
-
self._pool = await asyncio.wait_for(
|
| 39 |
-
self._create_pool(),
|
| 40 |
-
timeout=self._config.connection_timeout_seconds,
|
| 41 |
-
)
|
| 42 |
-
return self._pool
|
| 43 |
-
except Exception as exc:
|
| 44 |
-
last_error = exc
|
| 45 |
-
_logger.warning(
|
| 46 |
-
"MySQL connection failed (attempt %d): %s",
|
| 47 |
-
attempt + 1, exc,
|
| 48 |
-
)
|
| 49 |
-
if attempt < self._max_connection_retries - 1:
|
| 50 |
-
await asyncio.sleep(0.1 * (2**attempt))
|
| 51 |
-
|
| 52 |
-
raise RuntimeError(
|
| 53 |
-
f"Failed to connect to MySQL after {self._max_connection_retries} attempts"
|
| 54 |
-
) from last_error
|
| 55 |
-
|
| 56 |
async def _execute_queries(
|
| 57 |
self, pool: aiomysql.Pool, queries: list[str], use_transaction: bool
|
| 58 |
) -> list[StatementResult]:
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
import aiomysql
|
|
|
|
| 25 |
pool_recycle=3600,
|
| 26 |
)
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
async def _execute_queries(
|
| 29 |
self, pool: aiomysql.Pool, queries: list[str], use_transaction: bool
|
| 30 |
) -> list[StatementResult]:
|
app/models/__init__.py
CHANGED
|
@@ -15,7 +15,6 @@ from app.models.schemas import (
|
|
| 15 |
SpacyLabelsResponse,
|
| 16 |
SupportedFormatsResponse,
|
| 17 |
UrlRequest,
|
| 18 |
-
# VisionUrlRequest, # DISABLED (OOM mitigation)
|
| 19 |
)
|
| 20 |
|
| 21 |
__all__ = [
|
|
@@ -34,5 +33,4 @@ __all__ = [
|
|
| 34 |
"InfoResponse",
|
| 35 |
"SupportedFormatsResponse",
|
| 36 |
"SpacyLabelsResponse",
|
| 37 |
-
# "VisionUrlRequest", # DISABLED (OOM mitigation)
|
| 38 |
]
|
|
|
|
| 15 |
SpacyLabelsResponse,
|
| 16 |
SupportedFormatsResponse,
|
| 17 |
UrlRequest,
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
__all__ = [
|
|
|
|
| 33 |
"InfoResponse",
|
| 34 |
"SupportedFormatsResponse",
|
| 35 |
"SpacyLabelsResponse",
|
|
|
|
| 36 |
]
|
app/models/schemas.py
CHANGED
|
@@ -334,20 +334,8 @@ class EmbeddingResponse(BaseModel):
|
|
| 334 |
results: List[EmbeddingItem]
|
| 335 |
|
| 336 |
|
| 337 |
-
# class VisionUrlRequest(BaseModel): # DISABLED (OOM mitigation)
|
| 338 |
-
# urls: List[str] = Field(..., min_length=1, max_length=5, description="Array of image URLs to embed (max 5)")
|
| 339 |
-
#
|
| 340 |
-
# @field_validator("urls")
|
| 341 |
-
# @classmethod
|
| 342 |
-
# def validate_urls(cls, v: List[str]) -> List[str]:
|
| 343 |
-
# for url in v:
|
| 344 |
-
# if not url.startswith(("http://", "https://")):
|
| 345 |
-
# raise ValueError(f"Invalid URL scheme: {url}")
|
| 346 |
-
# return v
|
| 347 |
-
|
| 348 |
-
|
| 349 |
class CodeItem(BaseModel):
|
| 350 |
-
language: Literal["python", "javascript"]
|
| 351 |
code: str = Field(..., min_length=1, max_length=65536, description="Source code to execute")
|
| 352 |
|
| 353 |
|
|
|
|
| 334 |
results: List[EmbeddingItem]
|
| 335 |
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
class CodeItem(BaseModel):
|
| 338 |
+
language: Literal["python", "javascript"]
|
| 339 |
code: str = Field(..., min_length=1, max_length=65536, description="Source code to execute")
|
| 340 |
|
| 341 |
|
app/services/auth_service.py
CHANGED
|
@@ -14,6 +14,7 @@ from sqlalchemy import select, update
|
|
| 14 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
|
| 16 |
from app.config import get_settings
|
|
|
|
| 17 |
from app.core.auth.models import RefreshSession, Role, User
|
| 18 |
from app.core.auth.schemas import (
|
| 19 |
ChangePasswordSchema,
|
|
@@ -31,10 +32,6 @@ _settings = get_settings()
|
|
| 31 |
ph = PasswordHasher()
|
| 32 |
|
| 33 |
|
| 34 |
-
def _now() -> datetime:
|
| 35 |
-
return datetime.now(timezone.utc)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
def _token_key(raw: str) -> str:
|
| 39 |
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
| 40 |
|
|
|
|
| 14 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
|
| 16 |
from app.config import get_settings
|
| 17 |
+
from app.core.auth.models import _utcnow as _now
|
| 18 |
from app.core.auth.models import RefreshSession, Role, User
|
| 19 |
from app.core.auth.schemas import (
|
| 20 |
ChangePasswordSchema,
|
|
|
|
| 32 |
ph = PasswordHasher()
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def _token_key(raw: str) -> str:
|
| 36 |
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
| 37 |
|
app/services/code_executor_service.py
CHANGED
|
@@ -70,27 +70,6 @@ class CodeSanitizer:
|
|
| 70 |
(r"worker_threads", "worker_threads not allowed"),
|
| 71 |
(r"Buffer\s*\.", "Buffer not allowed"),
|
| 72 |
],
|
| 73 |
-
# "java": [ # DISABLED (OOM mitigation)
|
| 74 |
-
# (r"ProcessBuilder", "ProcessBuilder not allowed"),
|
| 75 |
-
# (r"Runtime\.exec", "Runtime.exec() not allowed"),
|
| 76 |
-
# (r"Runtime\.getRuntime\s*\(\s*\)", "Runtime.getRuntime() not allowed"),
|
| 77 |
-
# (r"System\.exit\s*\(", "System.exit() not allowed"),
|
| 78 |
-
# (r"System\.gc\s*\(", "System.gc() not allowed"),
|
| 79 |
-
# (r"File\s*\(", "File operations not allowed"),
|
| 80 |
-
# (r"FileInputStream", "FileInputStream not allowed"),
|
| 81 |
-
# (r"FileOutputStream", "FileOutputStream not allowed"),
|
| 82 |
-
# (r"FileReader", "FileReader not allowed"),
|
| 83 |
-
# (r"FileWriter", "FileWriter not allowed"),
|
| 84 |
-
# (r"Socket\s*\(", "Socket not allowed"),
|
| 85 |
-
# (r"ServerSocket\s*\(", "ServerSocket not allowed"),
|
| 86 |
-
# (r"URL\s*\(", "URL not allowed"),
|
| 87 |
-
# (r"Class\.forName", "Class.forName() not allowed"),
|
| 88 |
-
# (r"Thread\s*\(", "Thread not allowed"),
|
| 89 |
-
# (r"ThreadPoolExecutor", "ThreadPoolExecutor not allowed"),
|
| 90 |
-
# (r"Runtime\.", "Runtime not allowed"),
|
| 91 |
-
# (r"System\.getProperty", "System.getProperty not allowed"),
|
| 92 |
-
# (r"System\.getenv", "System.getenv not allowed"),
|
| 93 |
-
# ],
|
| 94 |
}
|
| 95 |
|
| 96 |
@classmethod
|
|
@@ -101,11 +80,6 @@ class CodeSanitizer:
|
|
| 101 |
return False, f"Forbidden: {message}"
|
| 102 |
return True, None
|
| 103 |
|
| 104 |
-
# @classmethod # DISABLED (OOM mitigation)
|
| 105 |
-
# def validate_java_class(cls, code: str) -> tuple[bool, Optional[str]]:
|
| 106 |
-
# if not re.search(r"public\s+class\s+Main\s*\{", code):
|
| 107 |
-
# return False, "Java code must have 'public class Main' with 'public static void main(String[] args)'"
|
| 108 |
-
# return True, None
|
| 109 |
|
| 110 |
|
| 111 |
class CodeExecutorService:
|
|
@@ -131,15 +105,6 @@ class CodeExecutorService:
|
|
| 131 |
"language": language, "timed_out": False,
|
| 132 |
}
|
| 133 |
|
| 134 |
-
# if language == "java": # DISABLED (OOM mitigation)
|
| 135 |
-
# valid, err = CodeSanitizer.validate_java_class(code)
|
| 136 |
-
# if not valid:
|
| 137 |
-
# return {
|
| 138 |
-
# "success": False, "output": "", "error": err,
|
| 139 |
-
# "exit_code": None, "execution_time_ms": None,
|
| 140 |
-
# "language": language, "timed_out": False,
|
| 141 |
-
# }
|
| 142 |
-
|
| 143 |
exec_timeout = min(timeout or self._max_execution_time, self._max_execution_time)
|
| 144 |
|
| 145 |
async with self._semaphore:
|
|
@@ -233,14 +198,14 @@ class CodeExecutorService:
|
|
| 233 |
|
| 234 |
async def check_runtimes(self) -> dict[str, str]:
|
| 235 |
status = {}
|
| 236 |
-
for lang, runtime in [("python", "python3"), ("javascript", "node")]:
|
| 237 |
path = shutil.which(runtime)
|
| 238 |
status[lang] = f"found at {path}" if path else "missing"
|
| 239 |
return status
|
| 240 |
|
| 241 |
@staticmethod
|
| 242 |
def _filename_for(language: str) -> str:
|
| 243 |
-
return {"python": "code.py", "javascript": "code.js"
|
| 244 |
}[language]
|
| 245 |
|
| 246 |
@staticmethod
|
|
@@ -251,7 +216,4 @@ class CodeExecutorService:
|
|
| 251 |
return ["python3", str(sandbox_py), str(run_dir / filename)]
|
| 252 |
elif language == "javascript":
|
| 253 |
return ["node", str(sandbox_js), str(run_dir / filename)]
|
| 254 |
-
# elif language == "java": # DISABLED (OOM mitigation)
|
| 255 |
-
# return ["sh", "-c",
|
| 256 |
-
# f"cd {run_dir} && javac Main.java 2>&1 && java -XX:CompressedClassSpaceSize=64m -Xmx96m Main 2>&1"]
|
| 257 |
return []
|
|
|
|
| 70 |
(r"worker_threads", "worker_threads not allowed"),
|
| 71 |
(r"Buffer\s*\.", "Buffer not allowed"),
|
| 72 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
| 74 |
|
| 75 |
@classmethod
|
|
|
|
| 80 |
return False, f"Forbidden: {message}"
|
| 81 |
return True, None
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
class CodeExecutorService:
|
|
|
|
| 105 |
"language": language, "timed_out": False,
|
| 106 |
}
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
exec_timeout = min(timeout or self._max_execution_time, self._max_execution_time)
|
| 109 |
|
| 110 |
async with self._semaphore:
|
|
|
|
| 198 |
|
| 199 |
async def check_runtimes(self) -> dict[str, str]:
|
| 200 |
status = {}
|
| 201 |
+
for lang, runtime in [("python", "python3"), ("javascript", "node")]:
|
| 202 |
path = shutil.which(runtime)
|
| 203 |
status[lang] = f"found at {path}" if path else "missing"
|
| 204 |
return status
|
| 205 |
|
| 206 |
@staticmethod
|
| 207 |
def _filename_for(language: str) -> str:
|
| 208 |
+
return {"python": "code.py", "javascript": "code.js"
|
| 209 |
}[language]
|
| 210 |
|
| 211 |
@staticmethod
|
|
|
|
| 216 |
return ["python3", str(sandbox_py), str(run_dir / filename)]
|
| 217 |
elif language == "javascript":
|
| 218 |
return ["node", str(sandbox_js), str(run_dir / filename)]
|
|
|
|
|
|
|
|
|
|
| 219 |
return []
|
app/services/converter_service.py
CHANGED
|
@@ -8,7 +8,6 @@ import time
|
|
| 8 |
from pathlib import Path
|
| 9 |
from urllib.parse import urlparse
|
| 10 |
|
| 11 |
-
# import httpx # DISABLED (OOM mitigation) — only used by YouTube
|
| 12 |
from markitdown import MarkItDown
|
| 13 |
|
| 14 |
from app.core.constants import IMAGE_EXTENSIONS, IMAGE_MIME_PREFIXES
|
|
@@ -19,101 +18,6 @@ from app.services.ocr_service import ocr_image, ocr_pdf
|
|
| 19 |
_logger = get_logger(__name__)
|
| 20 |
|
| 21 |
|
| 22 |
-
# def _extract_youtube_video_id(url_or_id: str) -> str: # DISABLED (OOM mitigation)
|
| 23 |
-
# if len(url_or_id) == 11 and not url_or_id.startswith("http"):
|
| 24 |
-
# return url_or_id
|
| 25 |
-
# pattern = r"(?:v=|\/)([0-9A-Za-z_-]{11}).*"
|
| 26 |
-
# match = re.search(pattern, url_or_id)
|
| 27 |
-
# if match:
|
| 28 |
-
# return match.group(1)
|
| 29 |
-
# raise ValueError(f"Could not extract a valid YouTube ID from: {url_or_id}")
|
| 30 |
-
#
|
| 31 |
-
#
|
| 32 |
-
# def _fetch_youtube_oembed(video_id: str) -> dict | None: # DISABLED (OOM mitigation)
|
| 33 |
-
# oembed_url = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json"
|
| 34 |
-
# try:
|
| 35 |
-
# resp = httpx.get(oembed_url, timeout=10.0)
|
| 36 |
-
# resp.raise_for_status()
|
| 37 |
-
# return resp.json()
|
| 38 |
-
# except Exception as exc:
|
| 39 |
-
# _logger.warning("YouTube oEmbed failed for %s: %s", video_id, exc)
|
| 40 |
-
# return None
|
| 41 |
-
#
|
| 42 |
-
#
|
| 43 |
-
# def _fetch_youtube_transcript(video_id: str) -> str | None: # DISABLED (OOM mitigation)
|
| 44 |
-
# try:
|
| 45 |
-
# import json as _json
|
| 46 |
-
# import os as _os
|
| 47 |
-
#
|
| 48 |
-
# from youtube_transcript_api import YouTubeTranscriptApi
|
| 49 |
-
# from youtube_transcript_api.formatters import TextFormatter
|
| 50 |
-
# from youtube_transcript_api._errors import (
|
| 51 |
-
# TranscriptsDisabled,
|
| 52 |
-
# NoTranscriptFound,
|
| 53 |
-
# VideoUnavailable,
|
| 54 |
-
# )
|
| 55 |
-
#
|
| 56 |
-
# kwargs: dict = {}
|
| 57 |
-
# cookies_raw = _os.environ.get("YOUTUBE_COOKIES", "").strip()
|
| 58 |
-
# if cookies_raw:
|
| 59 |
-
# try:
|
| 60 |
-
# import requests as _requests
|
| 61 |
-
# session = _requests.Session()
|
| 62 |
-
# session.cookies.update(_json.loads(cookies_raw))
|
| 63 |
-
# kwargs["http_client"] = session
|
| 64 |
-
# except Exception as exc:
|
| 65 |
-
# _logger.warning("Failed to apply YOUTUBE_COOKIES: %s", exc)
|
| 66 |
-
#
|
| 67 |
-
# ytt_api = YouTubeTranscriptApi(**kwargs)
|
| 68 |
-
# transcript_data = ytt_api.fetch(video_id, languages=["en"])
|
| 69 |
-
# formatter = TextFormatter()
|
| 70 |
-
# return formatter.format_transcript(transcript_data)
|
| 71 |
-
# except TranscriptsDisabled:
|
| 72 |
-
# _logger.warning("Transcripts disabled for video %s", video_id)
|
| 73 |
-
# except NoTranscriptFound:
|
| 74 |
-
# _logger.warning("No transcript found for video %s in language 'en'", video_id)
|
| 75 |
-
# except VideoUnavailable:
|
| 76 |
-
# _logger.warning("Video %s is unavailable, deleted, or private", video_id)
|
| 77 |
-
# except ValueError as ve:
|
| 78 |
-
# _logger.warning("Invalid video ID %s: %s", video_id, ve)
|
| 79 |
-
# except Exception as exc:
|
| 80 |
-
# _logger.warning("YouTube transcript failed for %s: %s", video_id, exc)
|
| 81 |
-
# return None
|
| 82 |
-
#
|
| 83 |
-
#
|
| 84 |
-
# def _convert_youtube(url: str) -> ConversionResult | None: # DISABLED (OOM mitigation)
|
| 85 |
-
# try:
|
| 86 |
-
# video_id = _extract_youtube_video_id(url)
|
| 87 |
-
# except ValueError:
|
| 88 |
-
# return None
|
| 89 |
-
#
|
| 90 |
-
# oembed = _fetch_youtube_oembed(video_id)
|
| 91 |
-
# transcript = _fetch_youtube_transcript(video_id)
|
| 92 |
-
#
|
| 93 |
-
# lines = ["# YouTube\n"]
|
| 94 |
-
# title = (oembed or {}).get("title", "")
|
| 95 |
-
# if title:
|
| 96 |
-
# lines.append(f"\n## {title}\n")
|
| 97 |
-
#
|
| 98 |
-
# author = (oembed or {}).get("author_name", "")
|
| 99 |
-
# if author:
|
| 100 |
-
# lines.append(f"\n- **Channel:** {author}\n")
|
| 101 |
-
#
|
| 102 |
-
# desc = (oembed or {}).get("description", "")
|
| 103 |
-
# if desc:
|
| 104 |
-
# lines.append(f"\n### Description\n{desc}\n")
|
| 105 |
-
#
|
| 106 |
-
# if transcript:
|
| 107 |
-
# lines.append(f"\n### Transcript\n{transcript}\n")
|
| 108 |
-
# else:
|
| 109 |
-
# if not title and not desc:
|
| 110 |
-
# return None
|
| 111 |
-
# lines.append("\n> No transcript available.\n")
|
| 112 |
-
#
|
| 113 |
-
# markdown = "".join(lines)
|
| 114 |
-
# return _build_result(url, markdown, 0, "text/html", 0.0)
|
| 115 |
-
|
| 116 |
-
|
| 117 |
def _is_image(ext: str, mime: str) -> bool:
|
| 118 |
return ext.lower() in IMAGE_EXTENSIONS or any(mime.startswith(p) for p in IMAGE_MIME_PREFIXES)
|
| 119 |
|
|
@@ -193,27 +97,6 @@ class ConverterService:
|
|
| 193 |
|
| 194 |
start = time.perf_counter()
|
| 195 |
|
| 196 |
-
# try: # DISABLED (OOM mitigation) — YouTube transcription
|
| 197 |
-
# _extract_youtube_video_id(url)
|
| 198 |
-
# except ValueError:
|
| 199 |
-
# pass
|
| 200 |
-
# else:
|
| 201 |
-
# result = _convert_youtube(url)
|
| 202 |
-
# if result is not None:
|
| 203 |
-
# result = ConversionResult(
|
| 204 |
-
# source=result.source,
|
| 205 |
-
# markdown=result.markdown,
|
| 206 |
-
# char_count=result.char_count,
|
| 207 |
-
# word_count=result.word_count,
|
| 208 |
-
# line_count=result.line_count,
|
| 209 |
-
# duration_ms=(time.perf_counter() - start) * 1000,
|
| 210 |
-
# file_size_bytes=result.file_size_bytes,
|
| 211 |
-
# mime_type=result.mime_type,
|
| 212 |
-
# content_hash=result.content_hash,
|
| 213 |
-
# )
|
| 214 |
-
# return result
|
| 215 |
-
# _logger.warning("YouTube conversion returned None, falling back to markitdown: %s", url)
|
| 216 |
-
|
| 217 |
try:
|
| 218 |
url_ext = Path(urlparse(url).path).suffix.lower()
|
| 219 |
if url_ext in IMAGE_EXTENSIONS:
|
|
|
|
| 8 |
from pathlib import Path
|
| 9 |
from urllib.parse import urlparse
|
| 10 |
|
|
|
|
| 11 |
from markitdown import MarkItDown
|
| 12 |
|
| 13 |
from app.core.constants import IMAGE_EXTENSIONS, IMAGE_MIME_PREFIXES
|
|
|
|
| 18 |
_logger = get_logger(__name__)
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def _is_image(ext: str, mime: str) -> bool:
|
| 22 |
return ext.lower() in IMAGE_EXTENSIONS or any(mime.startswith(p) for p in IMAGE_MIME_PREFIXES)
|
| 23 |
|
|
|
|
| 97 |
|
| 98 |
start = time.perf_counter()
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
try:
|
| 101 |
url_ext = Path(urlparse(url).path).suffix.lower()
|
| 102 |
if url_ext in IMAGE_EXTENSIONS:
|
app/services/dataset_metadata_service.py
CHANGED
|
@@ -614,23 +614,4 @@ async def extract_metadata(
|
|
| 614 |
return result
|
| 615 |
|
| 616 |
|
| 617 |
-
# ---------------------------------------------------------------------------
|
| 618 |
-
# CLI convenience
|
| 619 |
-
# ---------------------------------------------------------------------------
|
| 620 |
-
|
| 621 |
-
# if __name__ == "__main__":
|
| 622 |
-
# import json
|
| 623 |
-
# import sys
|
| 624 |
-
|
| 625 |
-
# async def _main() -> None:
|
| 626 |
-
# if len(sys.argv) < 2:
|
| 627 |
-
# print("Usage: python solution.py <file_path_or_url>")
|
| 628 |
-
# sys.exit(1)
|
| 629 |
-
|
| 630 |
-
# arg = sys.argv[1]
|
| 631 |
-
# src: FileSource = arg if _is_url(arg) else Path(arg) # type: ignore[assignment]
|
| 632 |
-
|
| 633 |
-
# result = await extract_metadata(src)
|
| 634 |
-
# print(json.dumps(result, indent=2, default=str))
|
| 635 |
|
| 636 |
-
# asyncio.run(_main())
|
|
|
|
| 614 |
return result
|
| 615 |
|
| 616 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
|
|
|
app/services/embeddings_service.py
CHANGED
|
@@ -5,25 +5,15 @@ import os
|
|
| 5 |
from typing import Dict, List, Optional
|
| 6 |
|
| 7 |
import numpy as np
|
| 8 |
-
# import torch # DISABLED (OOM mitigation) — only used by vision
|
| 9 |
-
# import torch.nn.functional as F # DISABLED (OOM mitigation)
|
| 10 |
-
# from PIL import Image # DISABLED (OOM mitigation)
|
| 11 |
from sentence_transformers import SentenceTransformer
|
| 12 |
-
# from transformers import AutoImageProcessor, AutoModel # DISABLED (OOM mitigation)
|
| 13 |
|
| 14 |
_logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
# Only 384-dim embedding is enabled. 768 and 1024 are disabled to reduce memory usage.
|
| 17 |
_MODEL_MAP: Dict[int, str] = {
|
| 18 |
384: "ibm-granite/granite-embedding-small-english-r2",
|
| 19 |
-
# 768: "nomic-ai/nomic-embed-text-v1.5", # DISABLED (OOM mitigation)
|
| 20 |
-
# 1024: "lightonai/modernbert-embed-large", # DISABLED (OOM mitigation)
|
| 21 |
}
|
| 22 |
|
| 23 |
-
# _VISION_MODEL_NAME = "nomic-ai/nomic-embed-vision-v1.5" # DISABLED (OOM mitigation)
|
| 24 |
-
# _VISION_DIMENSION = 768
|
| 25 |
-
|
| 26 |
-
|
| 27 |
class EmbeddingService:
|
| 28 |
def __init__(self, models_dir: Optional[str] = None) -> None:
|
| 29 |
self._models: Dict[int, SentenceTransformer] = {}
|
|
@@ -37,9 +27,6 @@ class EmbeddingService:
|
|
| 37 |
self._device = "cpu"
|
| 38 |
|
| 39 |
self._loaded_dimensions: List[int] = []
|
| 40 |
-
# self._vision_processor: Optional[AutoImageProcessor] = None # DISABLED (OOM mitigation)
|
| 41 |
-
# self._vision_model: Optional[AutoModel] = None # DISABLED (OOM mitigation)
|
| 42 |
-
# self._vision_loaded = False
|
| 43 |
|
| 44 |
def load_model(self, dimension: int) -> None:
|
| 45 |
if dimension in self._models:
|
|
@@ -65,35 +52,6 @@ class EmbeddingService:
|
|
| 65 |
for dim in _MODEL_MAP:
|
| 66 |
self.load_model(dim)
|
| 67 |
|
| 68 |
-
# def load_vision_model(self) -> None: # DISABLED (OOM mitigation)
|
| 69 |
-
# if self._vision_loaded:
|
| 70 |
-
# return
|
| 71 |
-
# local_path = os.path.join(self._models_dir, "vision")
|
| 72 |
-
# source = local_path if os.path.isdir(local_path) else _VISION_MODEL_NAME
|
| 73 |
-
#
|
| 74 |
-
# cfg_path = os.path.join(local_path, "config.json")
|
| 75 |
-
# if os.path.exists(cfg_path):
|
| 76 |
-
# import json
|
| 77 |
-
# with open(cfg_path) as f:
|
| 78 |
-
# d = json.load(f)
|
| 79 |
-
# if isinstance(d.get("n_inner"), float):
|
| 80 |
-
# d["n_inner"] = int(d["n_inner"])
|
| 81 |
-
# with open(cfg_path, "w") as f:
|
| 82 |
-
# json.dump(d, f, indent=2)
|
| 83 |
-
# _logger.info("Patched vision model config: n_inner float -> int")
|
| 84 |
-
#
|
| 85 |
-
# _logger.info("Loading vision embedding model from %s", source)
|
| 86 |
-
# self._vision_processor = AutoImageProcessor.from_pretrained(source)
|
| 87 |
-
# self._vision_model = AutoModel.from_pretrained(
|
| 88 |
-
# source,
|
| 89 |
-
# trust_remote_code=True,
|
| 90 |
-
# _fast_init=False,
|
| 91 |
-
# )
|
| 92 |
-
# self._vision_model.eval()
|
| 93 |
-
# self._vision_model.to(self._device)
|
| 94 |
-
# self._vision_loaded = True
|
| 95 |
-
# _logger.info("Loaded vision embedding model (device=%s)", self._device)
|
| 96 |
-
|
| 97 |
def generate_embedding(self, text: List[str], dimension: int) -> List[List[float]]:
|
| 98 |
if dimension not in self._models:
|
| 99 |
raise ValueError(f"Model for dimension {dimension} not loaded")
|
|
@@ -109,20 +67,6 @@ class EmbeddingService:
|
|
| 109 |
)
|
| 110 |
return result.tolist()
|
| 111 |
|
| 112 |
-
# def generate_image_embedding(self, images: List[Image.Image]) -> List[List[float]]: # DISABLED (OOM mitigation)
|
| 113 |
-
# if not self._vision_loaded or self._vision_model is None or self._vision_processor is None:
|
| 114 |
-
# raise ValueError("Vision model not loaded")
|
| 115 |
-
# all_embeddings: List[List[float]] = []
|
| 116 |
-
# with torch.no_grad():
|
| 117 |
-
# for image in images:
|
| 118 |
-
# inputs = self._vision_processor(image, return_tensors="pt")
|
| 119 |
-
# inputs = {k: v.to(self._device) for k, v in inputs.items()}
|
| 120 |
-
# outputs = self._vision_model(**inputs)
|
| 121 |
-
# emb = outputs.last_hidden_state[:, 0]
|
| 122 |
-
# emb = F.normalize(emb, p=2, dim=1)
|
| 123 |
-
# all_embeddings.append(emb.cpu().numpy().flatten().tolist())
|
| 124 |
-
# return all_embeddings
|
| 125 |
-
|
| 126 |
@property
|
| 127 |
def loaded_dimensions(self) -> List[int]:
|
| 128 |
return list(self._loaded_dimensions)
|
|
@@ -130,6 +74,3 @@ class EmbeddingService:
|
|
| 130 |
def is_loaded(self, dimension: int) -> bool:
|
| 131 |
return dimension in self._models
|
| 132 |
|
| 133 |
-
# @property # DISABLED (OOM mitigation)
|
| 134 |
-
# def vision_dimension(self) -> int:
|
| 135 |
-
# return _VISION_DIMENSION
|
|
|
|
| 5 |
from typing import Dict, List, Optional
|
| 6 |
|
| 7 |
import numpy as np
|
|
|
|
|
|
|
|
|
|
| 8 |
from sentence_transformers import SentenceTransformer
|
|
|
|
| 9 |
|
| 10 |
_logger = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
# Only 384-dim embedding is enabled. 768 and 1024 are disabled to reduce memory usage.
|
| 13 |
_MODEL_MAP: Dict[int, str] = {
|
| 14 |
384: "ibm-granite/granite-embedding-small-english-r2",
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
class EmbeddingService:
|
| 18 |
def __init__(self, models_dir: Optional[str] = None) -> None:
|
| 19 |
self._models: Dict[int, SentenceTransformer] = {}
|
|
|
|
| 27 |
self._device = "cpu"
|
| 28 |
|
| 29 |
self._loaded_dimensions: List[int] = []
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
def load_model(self, dimension: int) -> None:
|
| 32 |
if dimension in self._models:
|
|
|
|
| 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")
|
|
|
|
| 67 |
)
|
| 68 |
return result.tolist()
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
@property
|
| 71 |
def loaded_dimensions(self) -> List[int]:
|
| 72 |
return list(self._loaded_dimensions)
|
|
|
|
| 74 |
def is_loaded(self, dimension: int) -> bool:
|
| 75 |
return dimension in self._models
|
| 76 |
|
|
|
|
|
|
|
|
|
app/services/vector_store_service.py
CHANGED
|
@@ -522,3 +522,6 @@ class VectorStoreService:
|
|
| 522 |
self._collections.clear()
|
| 523 |
self._stores.clear()
|
| 524 |
self._thread_pool.shutdown(wait=True)
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
self._collections.clear()
|
| 523 |
self._stores.clear()
|
| 524 |
self._thread_pool.shutdown(wait=True)
|
| 525 |
+
self._thread_pool = concurrent.futures.ThreadPoolExecutor(
|
| 526 |
+
max_workers=_MAX_WORKERS, thread_name_prefix="zvec"
|
| 527 |
+
)
|
tests/test_webhook_socket.py
CHANGED
|
@@ -20,10 +20,6 @@ AUTH_HEADER = {"Authorization": f"Bearer {API_KEY}"}
|
|
| 20 |
BASE = "http://localhost:7860/api/v1"
|
| 21 |
|
| 22 |
|
| 23 |
-
# ---------------------------------------------------------------------------
|
| 24 |
-
# Unit tests for ChannelManager
|
| 25 |
-
# ---------------------------------------------------------------------------
|
| 26 |
-
|
| 27 |
class TestChannelManager:
|
| 28 |
def test_create_channel(self):
|
| 29 |
mgr = ChannelManager()
|
|
@@ -61,7 +57,6 @@ class TestChannelManager:
|
|
| 61 |
mgr = ChannelManager()
|
| 62 |
mgr.create_channel(channel_id="dup")
|
| 63 |
ch2 = mgr.create_channel(channel_id="dup")
|
| 64 |
-
# Should overwrite
|
| 65 |
assert mgr.get_channel("dup") is ch2
|
| 66 |
|
| 67 |
def test_default_buffer_from_manager(self):
|
|
@@ -113,10 +108,43 @@ class TestChannelManager:
|
|
| 113 |
sig2 = sign_payload(secret, body)
|
| 114 |
assert sig1 == sig2
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
-
# ---------------------------------------------------------------------------
|
| 118 |
-
# Integration tests (requires running server @ localhost:7860)
|
| 119 |
-
# ---------------------------------------------------------------------------
|
| 120 |
|
| 121 |
pytestmark_integration = pytest.mark.skipif(
|
| 122 |
not os.environ.get("RUN_INTEGRATION_TESTS"),
|
|
@@ -169,7 +197,7 @@ class TestWebhookSocketIntegration:
|
|
| 169 |
|
| 170 |
async def test_create_channel_no_auth(self):
|
| 171 |
resp = await self.client.post("/channels", json={})
|
| 172 |
-
assert resp.status_code
|
| 173 |
|
| 174 |
async def test_create_duplicate_channel(self):
|
| 175 |
await self._create_channel(channel_id="dup-test")
|
|
@@ -405,10 +433,6 @@ class TestWebhookSocketIntegration:
|
|
| 405 |
assert not_found.status_code == 404
|
| 406 |
|
| 407 |
|
| 408 |
-
# ---------------------------------------------------------------------------
|
| 409 |
-
# Runner (standalone)
|
| 410 |
-
# ---------------------------------------------------------------------------
|
| 411 |
-
|
| 412 |
if __name__ == "__main__":
|
| 413 |
import subprocess
|
| 414 |
import sys as _sys
|
|
|
|
| 20 |
BASE = "http://localhost:7860/api/v1"
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
class TestChannelManager:
|
| 24 |
def test_create_channel(self):
|
| 25 |
mgr = ChannelManager()
|
|
|
|
| 57 |
mgr = ChannelManager()
|
| 58 |
mgr.create_channel(channel_id="dup")
|
| 59 |
ch2 = mgr.create_channel(channel_id="dup")
|
|
|
|
| 60 |
assert mgr.get_channel("dup") is ch2
|
| 61 |
|
| 62 |
def test_default_buffer_from_manager(self):
|
|
|
|
| 108 |
sig2 = sign_payload(secret, body)
|
| 109 |
assert sig1 == sig2
|
| 110 |
|
| 111 |
+
def test_channel_info_fields(self):
|
| 112 |
+
mgr = ChannelManager()
|
| 113 |
+
mgr.create_channel(channel_id="info-test", buffer_size=5)
|
| 114 |
+
ch = mgr.get_channel("info-test")
|
| 115 |
+
assert ch.channel_id == "info-test"
|
| 116 |
+
assert ch.buffer_size == 5
|
| 117 |
+
assert ch.message_count == 0
|
| 118 |
+
assert ch.created_at > 0
|
| 119 |
+
assert ch.last_activity > 0
|
| 120 |
+
|
| 121 |
+
def test_publish_with_subscriber(self):
|
| 122 |
+
mgr = ChannelManager()
|
| 123 |
+
mgr.create_channel(channel_id="sub-test")
|
| 124 |
+
ch = mgr.get_channel("sub-test")
|
| 125 |
+
|
| 126 |
+
async def dummy():
|
| 127 |
+
return ch.channel_id
|
| 128 |
+
q = asyncio.Queue()
|
| 129 |
+
ch.subscribers[dummy] = q
|
| 130 |
+
|
| 131 |
+
result = asyncio.run(mgr.publish("sub-test", {"msg": "hello"}))
|
| 132 |
+
assert result == 1
|
| 133 |
+
assert ch.message_count == 1
|
| 134 |
+
|
| 135 |
+
def test_multiple_publishes(self):
|
| 136 |
+
mgr = ChannelManager()
|
| 137 |
+
ch = mgr.create_channel(channel_id="multi-pub", buffer_size=10)
|
| 138 |
+
for i in range(5):
|
| 139 |
+
asyncio.run(mgr.publish("multi-pub", {"n": i}))
|
| 140 |
+
assert ch.message_count == 5
|
| 141 |
+
assert len(ch.history) == 5
|
| 142 |
+
|
| 143 |
+
def test_channel_manager_defaults(self):
|
| 144 |
+
mgr = ChannelManager()
|
| 145 |
+
assert mgr.default_buffer == 0
|
| 146 |
+
assert mgr.channels == {}
|
| 147 |
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
pytestmark_integration = pytest.mark.skipif(
|
| 150 |
not os.environ.get("RUN_INTEGRATION_TESTS"),
|
|
|
|
| 197 |
|
| 198 |
async def test_create_channel_no_auth(self):
|
| 199 |
resp = await self.client.post("/channels", json={})
|
| 200 |
+
assert resp.status_code in (401, 403)
|
| 201 |
|
| 202 |
async def test_create_duplicate_channel(self):
|
| 203 |
await self._create_channel(channel_id="dup-test")
|
|
|
|
| 433 |
assert not_found.status_code == 404
|
| 434 |
|
| 435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
if __name__ == "__main__":
|
| 437 |
import subprocess
|
| 438 |
import sys as _sys
|