Merge remote-tracking branch 'origin/master' into fix/qdrant-doc-id-keyword-index
This commit is contained in:
@@ -553,9 +553,10 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# For PDF files, also fetch the highlighted page image from Qdrant if available
|
||||
# This is useful for clients that want to show a pre-rendered image
|
||||
highlighted_page_image = None
|
||||
# For PDF files, also fetch the chunk's bounding box from Qdrant if
|
||||
# available so the client can overlay a highlight on top of a
|
||||
# render-on-demand page image (Deck #76).
|
||||
chunk_bbox = None
|
||||
page_number = chunk_context.page_number
|
||||
|
||||
if doc_type == "file":
|
||||
@@ -563,7 +564,6 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Query for this specific chunk's highlighted image
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
@@ -585,19 +585,19 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
),
|
||||
limit=1,
|
||||
with_vectors=False,
|
||||
with_payload=["highlighted_page_image", "page_number"],
|
||||
with_payload=["chunk_bbox", "page_number"],
|
||||
)
|
||||
|
||||
if points_response[0]:
|
||||
payload = points_response[0][0].payload
|
||||
if payload:
|
||||
highlighted_page_image = payload.get("highlighted_page_image")
|
||||
chunk_bbox = payload.get("chunk_bbox")
|
||||
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
|
||||
if payload.get("page_number") is not None:
|
||||
page_number = payload.get("page_number")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||
logger.warning(f"Failed to fetch chunk bbox: {e}")
|
||||
|
||||
# Build response
|
||||
response_data = {
|
||||
@@ -612,8 +612,8 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
"total_chunks": chunk_context.total_chunks,
|
||||
}
|
||||
|
||||
if highlighted_page_image:
|
||||
response_data["highlighted_page_image"] = highlighted_page_image
|
||||
if chunk_bbox:
|
||||
response_data["chunk_bbox"] = chunk_bbox
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
|
||||
@@ -125,12 +125,13 @@ from nextcloud_mcp_server.server import (
|
||||
)
|
||||
from nextcloud_mcp_server.server.auth_tools import register_auth_tools
|
||||
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
||||
from nextcloud_mcp_server.vector import processor_task, scanner_task
|
||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
oauth_processor_task,
|
||||
user_manager_task,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.processor import processor_task
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
from nextcloud_mcp_server.vector.scanner import scanner_task
|
||||
from nextcloud_mcp_server.vector.webhook_receiver import handle_nextcloud_webhook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -607,8 +607,10 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
f"after_len={len(chunk_context.after_context)}"
|
||||
)
|
||||
|
||||
# For PDF files, also fetch the highlighted page image from Qdrant
|
||||
highlighted_page_image = None
|
||||
# For PDF files, also fetch the chunk bbox from Qdrant so the client
|
||||
# can overlay a highlight on top of a render-on-demand page image
|
||||
# (Deck #76).
|
||||
chunk_bbox = None
|
||||
page_number = None
|
||||
if doc_type == "file":
|
||||
try:
|
||||
@@ -616,7 +618,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
qdrant_client = await get_qdrant_client()
|
||||
username = request.user.display_name
|
||||
|
||||
# Query for this specific chunk's highlighted image
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
@@ -638,22 +639,20 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
),
|
||||
limit=1,
|
||||
with_vectors=False,
|
||||
with_payload=["highlighted_page_image", "page_number"],
|
||||
with_payload=["chunk_bbox", "page_number"],
|
||||
)
|
||||
|
||||
points = points_response[0]
|
||||
if points and points[0].payload:
|
||||
highlighted_page_image = points[0].payload.get(
|
||||
"highlighted_page_image"
|
||||
)
|
||||
chunk_bbox = points[0].payload.get("chunk_bbox")
|
||||
page_number = points[0].payload.get("page_number")
|
||||
if highlighted_page_image:
|
||||
if chunk_bbox:
|
||||
logger.info(
|
||||
f"Found highlighted image for chunk: "
|
||||
f"page={page_number}, image_size={len(highlighted_page_image)}"
|
||||
f"Found chunk bbox: page={page_number}, "
|
||||
f"rects={len(chunk_bbox)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||
logger.warning(f"Failed to fetch chunk bbox: {e}")
|
||||
|
||||
# Return response compatible with frontend expectations
|
||||
response_data: dict = {
|
||||
@@ -665,9 +664,8 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
"has_more_after": chunk_context.has_after_truncation,
|
||||
}
|
||||
|
||||
# Add image data if available
|
||||
if highlighted_page_image:
|
||||
response_data["highlighted_page_image"] = highlighted_page_image
|
||||
if chunk_bbox:
|
||||
response_data["chunk_bbox"] = chunk_bbox
|
||||
response_data["page_number"] = page_number
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
@@ -77,11 +77,25 @@ _DEFAULTS: dict[str, Any] = {
|
||||
# Ollama
|
||||
"ollama_base_url": None,
|
||||
"ollama_embedding_model": "nomic-embed-text",
|
||||
"ollama_generation_model": None,
|
||||
"ollama_verify_ssl": True,
|
||||
# OpenAI
|
||||
"openai_api_key": None,
|
||||
"openai_base_url": None,
|
||||
"openai_embedding_model": "text-embedding-3-small",
|
||||
"openai_generation_model": None,
|
||||
# Bedrock (AWS)
|
||||
"aws_region": None,
|
||||
"aws_access_key_id": None,
|
||||
"aws_secret_access_key": None,
|
||||
"bedrock_embedding_model": None,
|
||||
"bedrock_generation_model": None,
|
||||
# Mistral
|
||||
"mistral_api_key": None,
|
||||
"mistral_embedding_model": "mistral-embed",
|
||||
"mistral_base_url": None,
|
||||
# Simple (fallback) embedding dimension
|
||||
"simple_embedding_dimension": 384,
|
||||
# Document chunking
|
||||
"document_chunk_size": 2048,
|
||||
"document_chunk_overlap": 200,
|
||||
@@ -486,15 +500,32 @@ class Settings:
|
||||
qdrant_api_key: str | None = None
|
||||
qdrant_collection: str = "nextcloud_content"
|
||||
|
||||
# Ollama settings (for embeddings)
|
||||
# Ollama settings (embeddings + optional generation)
|
||||
ollama_base_url: str | None = None
|
||||
ollama_embedding_model: str = "nomic-embed-text"
|
||||
ollama_generation_model: str | None = None
|
||||
ollama_verify_ssl: bool = True
|
||||
|
||||
# OpenAI settings (for embeddings)
|
||||
# OpenAI settings (embeddings + optional generation)
|
||||
openai_api_key: str | None = None
|
||||
openai_base_url: str | None = None
|
||||
openai_embedding_model: str = "text-embedding-3-small"
|
||||
openai_generation_model: str | None = None
|
||||
|
||||
# Bedrock (AWS) settings — boto3 also reads these from its credential chain
|
||||
aws_region: str | None = None
|
||||
aws_access_key_id: str | None = None
|
||||
aws_secret_access_key: str | None = None
|
||||
bedrock_embedding_model: str | None = None
|
||||
bedrock_generation_model: str | None = None
|
||||
|
||||
# Mistral settings (embeddings only)
|
||||
mistral_api_key: str | None = None
|
||||
mistral_embedding_model: str = "mistral-embed"
|
||||
mistral_base_url: str | None = None
|
||||
|
||||
# Simple (fallback) provider — dimension when no real provider configured
|
||||
simple_embedding_dimension: int = 384
|
||||
|
||||
# Document chunking settings (for vector embeddings)
|
||||
document_chunk_size: int = 2048 # Characters per chunk
|
||||
@@ -573,23 +604,32 @@ class Settings:
|
||||
Get the active embedding model name based on provider priority.
|
||||
|
||||
Priority order (same as ProviderRegistry):
|
||||
1. OpenAI - if OPENAI_API_KEY is set
|
||||
2. Ollama - if OLLAMA_BASE_URL is set
|
||||
3. Simple - fallback (returns "simple-384")
|
||||
1. Bedrock - if AWS_REGION or BEDROCK_EMBEDDING_MODEL is set
|
||||
2. OpenAI - if OPENAI_API_KEY is set
|
||||
3. Mistral - if MISTRAL_API_KEY is set
|
||||
4. Ollama - if OLLAMA_BASE_URL is set
|
||||
5. Simple - fallback (returns "simple-{dimension}")
|
||||
|
||||
Returns:
|
||||
Active embedding model name
|
||||
"""
|
||||
# Check OpenAI first (higher priority than Ollama in registry)
|
||||
if (
|
||||
self.aws_region
|
||||
or self.bedrock_embedding_model
|
||||
or self.bedrock_generation_model
|
||||
):
|
||||
return self.bedrock_embedding_model or "bedrock-default"
|
||||
|
||||
if self.openai_api_key:
|
||||
return self.openai_embedding_model
|
||||
|
||||
# Check Ollama
|
||||
if self.mistral_api_key:
|
||||
return self.mistral_embedding_model
|
||||
|
||||
if self.ollama_base_url:
|
||||
return self.ollama_embedding_model
|
||||
|
||||
# Fallback to simple provider indicator
|
||||
return "simple-384"
|
||||
return f"simple-{self.simple_embedding_dimension}"
|
||||
|
||||
def get_collection_name(self) -> str:
|
||||
"""
|
||||
@@ -835,11 +875,25 @@ def get_settings() -> Settings:
|
||||
# Ollama settings
|
||||
"ollama_base_url": "OLLAMA_BASE_URL",
|
||||
"ollama_embedding_model": "OLLAMA_EMBEDDING_MODEL",
|
||||
"ollama_generation_model": "OLLAMA_GENERATION_MODEL",
|
||||
"ollama_verify_ssl": "OLLAMA_VERIFY_SSL",
|
||||
# OpenAI settings
|
||||
"openai_api_key": "OPENAI_API_KEY",
|
||||
"openai_base_url": "OPENAI_BASE_URL",
|
||||
"openai_embedding_model": "OPENAI_EMBEDDING_MODEL",
|
||||
"openai_generation_model": "OPENAI_GENERATION_MODEL",
|
||||
# Bedrock (AWS) settings
|
||||
"aws_region": "AWS_REGION",
|
||||
"aws_access_key_id": "AWS_ACCESS_KEY_ID",
|
||||
"aws_secret_access_key": "AWS_SECRET_ACCESS_KEY",
|
||||
"bedrock_embedding_model": "BEDROCK_EMBEDDING_MODEL",
|
||||
"bedrock_generation_model": "BEDROCK_GENERATION_MODEL",
|
||||
# Mistral settings
|
||||
"mistral_api_key": "MISTRAL_API_KEY",
|
||||
"mistral_embedding_model": "MISTRAL_EMBEDDING_MODEL",
|
||||
"mistral_base_url": "MISTRAL_BASE_URL",
|
||||
# Simple provider
|
||||
"simple_embedding_dimension": "SIMPLE_EMBEDDING_DIMENSION",
|
||||
# Document chunking settings
|
||||
"document_chunk_size": "DOCUMENT_CHUNK_SIZE",
|
||||
"document_chunk_overlap": "DOCUMENT_CHUNK_OVERLAP",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from .anthropic import AnthropicProvider
|
||||
from .base import Provider
|
||||
from .bedrock import BedrockProvider
|
||||
from .mistral import MistralProvider
|
||||
from .ollama import OllamaProvider
|
||||
from .openai import OpenAIProvider
|
||||
from .registry import get_provider, reset_provider
|
||||
@@ -13,6 +14,7 @@ __all__ = [
|
||||
"OllamaProvider",
|
||||
"OpenAIProvider",
|
||||
"AnthropicProvider",
|
||||
"MistralProvider",
|
||||
"SimpleProvider",
|
||||
"BedrockProvider",
|
||||
"get_provider",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Shared rate-limit retry helper for provider modules.
|
||||
|
||||
OpenAI and Mistral both retry on 429 with the same exponential-backoff curve;
|
||||
extracting the loop here keeps the two provider modules thin and lets future
|
||||
providers (Bedrock throttling, etc.) reuse the same primitive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import wraps
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import anyio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_RETRIES = 5
|
||||
INITIAL_RETRY_DELAY = 2.0
|
||||
MAX_RETRY_DELAY = 60.0
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def retry_on_rate_limit(
|
||||
exception_type: type[BaseException],
|
||||
is_rate_limit: Callable[[BaseException], bool] = lambda _exc: True,
|
||||
*,
|
||||
provider_name: str = "provider",
|
||||
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
||||
"""Build a decorator that retries on rate-limit exceptions.
|
||||
|
||||
Args:
|
||||
exception_type: Catch this exception class (e.g. ``openai.RateLimitError``,
|
||||
``mistralai.client.errors.SDKError``).
|
||||
is_rate_limit: Predicate that decides whether a caught exception is
|
||||
actually a rate-limit (vs. some other error of the same class).
|
||||
Defaults to "always True" — appropriate when ``exception_type`` is
|
||||
already a rate-limit-specific class.
|
||||
provider_name: Used in log messages so operators can tell which
|
||||
provider exhausted retries.
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
last_error: BaseException | None = None
|
||||
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except exception_type as e:
|
||||
if not is_rate_limit(e):
|
||||
raise
|
||||
last_error = e
|
||||
if attempt < MAX_RETRIES:
|
||||
logger.warning(
|
||||
"%s rate limit hit (attempt %d/%d), retrying in %.1fs...",
|
||||
provider_name,
|
||||
attempt,
|
||||
MAX_RETRIES,
|
||||
retry_delay,
|
||||
)
|
||||
await anyio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
|
||||
logger.error(
|
||||
"%s rate limit exceeded after %d attempts", provider_name, MAX_RETRIES
|
||||
)
|
||||
if last_error is None: # pragma: no cover — loop above always sets this
|
||||
raise RuntimeError("retry loop exited without capturing an error")
|
||||
raise last_error
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Mistral provider for embeddings.
|
||||
|
||||
Currently supports embeddings only (``mistral-embed``, 1024-dim). Generation
|
||||
can be added later if needed; see ADR-015.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
# mistralai 2.x ships no top-level __init__.py, so `from mistralai import …`
|
||||
# raises ImportError. The canonical public paths are `mistralai.client` (which
|
||||
# re-exports the SDK class via `client/__init__.py`) and `mistralai.client.errors`
|
||||
# (which lazy-loads SDKError). There is no `mistralai.models` subpackage either.
|
||||
from mistralai.client import Mistral
|
||||
from mistralai.client.errors import SDKError
|
||||
|
||||
from ._retry import retry_on_rate_limit
|
||||
from .base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Well-known Mistral embedding model dimensions
|
||||
MISTRAL_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
||||
"mistral-embed": 1024,
|
||||
}
|
||||
|
||||
# Conservative chunk size for batch embeddings. Mistral allows large batches,
|
||||
# but we keep this in line with sibling providers (OpenAI=100, Ollama=32).
|
||||
BATCH_SIZE = 64
|
||||
|
||||
_NO_EMBEDDING_MODEL_MSG = "Embedding not supported - no embedding_model configured"
|
||||
|
||||
|
||||
def _is_rate_limit(exc: BaseException) -> bool:
|
||||
"""True only for HTTP 429 SDKErrors."""
|
||||
return getattr(exc, "status_code", None) == 429
|
||||
|
||||
|
||||
_retry_429 = retry_on_rate_limit(
|
||||
SDKError, is_rate_limit=_is_rate_limit, provider_name="Mistral"
|
||||
)
|
||||
|
||||
|
||||
class MistralProvider(Provider):
|
||||
"""
|
||||
Mistral provider — embeddings only.
|
||||
|
||||
Uses the official ``mistralai`` SDK. Lazy dimension detection mirrors the
|
||||
OpenAI provider: known models populate the cached dimension at construction
|
||||
time; unknown models get their dimension detected on the first ``embed()``
|
||||
call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
embedding_model: str | None = "mistral-embed",
|
||||
base_url: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the Mistral provider.
|
||||
|
||||
Args:
|
||||
api_key: Mistral API key.
|
||||
embedding_model: Embedding model ID (default: ``mistral-embed``).
|
||||
Pass ``None`` to disable embeddings (the provider will then
|
||||
support no capabilities, which is mostly useful for tests).
|
||||
base_url: Optional base URL override (e.g. proxies, on-prem).
|
||||
"""
|
||||
self.embedding_model = embedding_model
|
||||
self._dimension: int | None = None
|
||||
|
||||
self.client = Mistral(api_key=api_key, server_url=base_url)
|
||||
|
||||
if embedding_model and embedding_model in MISTRAL_EMBEDDING_DIMENSIONS:
|
||||
self._dimension = MISTRAL_EMBEDDING_DIMENSIONS[embedding_model]
|
||||
|
||||
logger.info(
|
||||
"Initialized Mistral provider: base_url=%s, embedding_model=%s, "
|
||||
"dimension=%s",
|
||||
base_url or "default",
|
||||
embedding_model,
|
||||
self._dimension,
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
return self.embedding_model is not None
|
||||
|
||||
@property
|
||||
def supports_generation(self) -> bool:
|
||||
return False
|
||||
|
||||
@_retry_429
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""Generate an embedding for a single text."""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(_NO_EMBEDDING_MODEL_MSG)
|
||||
|
||||
assert self.embedding_model is not None
|
||||
response = await self.client.embeddings.create_async(
|
||||
model=self.embedding_model,
|
||||
inputs=[text],
|
||||
)
|
||||
|
||||
if not response.data or response.data[0].embedding is None:
|
||||
raise RuntimeError(
|
||||
f"Mistral embeddings API returned no embedding for model "
|
||||
f"{self.embedding_model}"
|
||||
)
|
||||
|
||||
embedding = response.data[0].embedding
|
||||
|
||||
if self._dimension is None:
|
||||
self._dimension = len(embedding)
|
||||
logger.info(
|
||||
"Detected embedding dimension: %d for model %s",
|
||||
self._dimension,
|
||||
self.embedding_model,
|
||||
)
|
||||
|
||||
return embedding
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Generate embeddings for multiple texts, chunking by ``BATCH_SIZE``."""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(_NO_EMBEDDING_MODEL_MSG)
|
||||
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
all_embeddings: list[list[float]] = []
|
||||
for i in range(0, len(texts), BATCH_SIZE):
|
||||
batch = texts[i : i + BATCH_SIZE]
|
||||
batch_embeddings = await self._embed_batch_request(batch)
|
||||
all_embeddings.extend(batch_embeddings)
|
||||
|
||||
if self._dimension is None and batch_embeddings:
|
||||
self._dimension = len(batch_embeddings[0])
|
||||
logger.info(
|
||||
"Detected embedding dimension: %d for model %s",
|
||||
self._dimension,
|
||||
self.embedding_model,
|
||||
)
|
||||
|
||||
return all_embeddings
|
||||
|
||||
@_retry_429
|
||||
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
|
||||
"""Single batch request with rate-limit retry."""
|
||||
assert self.embedding_model is not None
|
||||
response = await self.client.embeddings.create_async(
|
||||
model=self.embedding_model,
|
||||
inputs=batch,
|
||||
)
|
||||
|
||||
# Defensive: response.data items have Optional fields. Sort by index
|
||||
# (default 0 if missing) and reject None embeddings explicitly.
|
||||
sorted_data = sorted(response.data or [], key=lambda x: x.index or 0)
|
||||
result: list[list[float]] = []
|
||||
for item in sorted_data:
|
||||
if item.embedding is None:
|
||||
raise RuntimeError(
|
||||
f"Mistral embeddings API returned a null embedding for "
|
||||
f"model {self.embedding_model}"
|
||||
)
|
||||
result.append(item.embedding)
|
||||
|
||||
if len(result) != len(batch):
|
||||
raise RuntimeError(
|
||||
f"Mistral embeddings API returned {len(result)} embeddings "
|
||||
f"for {len(batch)} inputs"
|
||||
)
|
||||
return result
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(_NO_EMBEDDING_MODEL_MSG)
|
||||
|
||||
if self._dimension is None:
|
||||
raise RuntimeError(
|
||||
f"Embedding dimension not detected yet for model "
|
||||
f"{self.embedding_model}. Call embed() first or use a known "
|
||||
"model."
|
||||
)
|
||||
return self._dimension
|
||||
|
||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||
raise NotImplementedError(
|
||||
"MistralProvider does not support generation. "
|
||||
"Use OpenAI, Anthropic, or Bedrock for text generation."
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
# The mistralai 2.x client (Speakeasy-generated) does not expose a
|
||||
# public close()/aclose() — only the async-context-manager protocol
|
||||
# (__aenter__/__aexit__). Calling __aexit__ directly is internal API
|
||||
# and brittle across SDK patch versions; the underlying httpx client
|
||||
# is closed during garbage collection, so we leave this as a no-op.
|
||||
return None
|
||||
@@ -7,46 +7,17 @@ Supports:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
import anyio
|
||||
from openai import AsyncOpenAI, RateLimitError
|
||||
|
||||
from ._retry import retry_on_rate_limit
|
||||
from .base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rate limit retry configuration
|
||||
MAX_RETRIES = 5
|
||||
INITIAL_RETRY_DELAY = 2.0 # seconds
|
||||
MAX_RETRY_DELAY = 60.0 # seconds
|
||||
|
||||
|
||||
def retry_on_rate_limit(func):
|
||||
"""Decorator to retry on OpenAI rate limit errors with exponential backoff."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except RateLimitError as e:
|
||||
last_error = e
|
||||
if attempt < MAX_RETRIES:
|
||||
logger.warning(
|
||||
f"Rate limit hit (attempt {attempt}/{MAX_RETRIES}), "
|
||||
f"retrying in {retry_delay:.1f}s..."
|
||||
)
|
||||
await anyio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
|
||||
logger.error(f"Rate limit exceeded after {MAX_RETRIES} attempts")
|
||||
raise last_error # type: ignore[misc]
|
||||
|
||||
return wrapper
|
||||
# OpenAI's RateLimitError is itself a 429-specific class, so the default
|
||||
# is_rate_limit predicate ("always True") matches the previous behavior.
|
||||
_retry_429 = retry_on_rate_limit(RateLimitError, provider_name="OpenAI")
|
||||
|
||||
|
||||
# Well-known embedding dimensions for OpenAI models
|
||||
@@ -106,9 +77,12 @@ class OpenAIProvider(Provider):
|
||||
self._dimension = OPENAI_EMBEDDING_DIMENSIONS[embedding_model]
|
||||
|
||||
logger.info(
|
||||
f"Initialized OpenAI provider: base_url={base_url or 'default'} "
|
||||
f"(embedding_model={embedding_model}, generation_model={generation_model}, "
|
||||
f"dimension={self._dimension})"
|
||||
"Initialized OpenAI provider: base_url=%s "
|
||||
"(embedding_model=%s, generation_model=%s, dimension=%s)",
|
||||
base_url or "default",
|
||||
embedding_model,
|
||||
generation_model,
|
||||
self._dimension,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -121,7 +95,7 @@ class OpenAIProvider(Provider):
|
||||
"""Whether this provider supports text generation."""
|
||||
return self.generation_model is not None
|
||||
|
||||
@retry_on_rate_limit
|
||||
@_retry_429
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding vector for text.
|
||||
@@ -152,8 +126,9 @@ class OpenAIProvider(Provider):
|
||||
if self._dimension is None:
|
||||
self._dimension = len(embedding)
|
||||
logger.info(
|
||||
f"Detected embedding dimension: {self._dimension} "
|
||||
f"for model {self.embedding_model}"
|
||||
"Detected embedding dimension: %d for model %s",
|
||||
self._dimension,
|
||||
self.embedding_model,
|
||||
)
|
||||
|
||||
return embedding
|
||||
@@ -196,13 +171,14 @@ class OpenAIProvider(Provider):
|
||||
if self._dimension is None and batch_embeddings:
|
||||
self._dimension = len(batch_embeddings[0])
|
||||
logger.info(
|
||||
f"Detected embedding dimension: {self._dimension} "
|
||||
f"for model {self.embedding_model}"
|
||||
"Detected embedding dimension: %d for model %s",
|
||||
self._dimension,
|
||||
self.embedding_model,
|
||||
)
|
||||
|
||||
return all_embeddings
|
||||
|
||||
@retry_on_rate_limit
|
||||
@_retry_429
|
||||
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
|
||||
"""Make a single batch embedding request with retry logic."""
|
||||
assert self.embedding_model is not None # Type narrowing
|
||||
@@ -237,7 +213,7 @@ class OpenAIProvider(Provider):
|
||||
)
|
||||
return self._dimension
|
||||
|
||||
@retry_on_rate_limit
|
||||
@_retry_429
|
||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||
"""
|
||||
Generate text from a prompt.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Provider registry and factory for auto-detection and instantiation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..config import get_settings
|
||||
from .base import Provider
|
||||
from .bedrock import BedrockProvider
|
||||
from .mistral import MistralProvider
|
||||
from .ollama import OllamaProvider
|
||||
from .openai import OpenAIProvider
|
||||
from .simple import SimpleProvider
|
||||
@@ -16,117 +17,112 @@ class ProviderRegistry:
|
||||
"""
|
||||
Registry for provider auto-detection and instantiation.
|
||||
|
||||
Checks environment variables in priority order and creates appropriate provider:
|
||||
1. Bedrock (AWS_REGION + BEDROCK_*_MODEL)
|
||||
2. OpenAI (OPENAI_API_KEY)
|
||||
3. Ollama (OLLAMA_BASE_URL)
|
||||
4. Simple (fallback for testing/development)
|
||||
Reads configuration via dynaconf-backed Settings (see ``config.py``).
|
||||
Checks provider settings in priority order and creates the appropriate
|
||||
provider:
|
||||
|
||||
1. Bedrock (``AWS_REGION`` or ``BEDROCK_*_MODEL``)
|
||||
2. OpenAI (``OPENAI_API_KEY``)
|
||||
3. Mistral (``MISTRAL_API_KEY``)
|
||||
4. Ollama (``OLLAMA_BASE_URL``)
|
||||
5. Simple (fallback for testing/development)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_provider() -> Provider:
|
||||
"""
|
||||
Auto-detect and create provider based on environment variables.
|
||||
Auto-detect and create provider based on configured settings.
|
||||
|
||||
Settings are sourced via :func:`nextcloud_mcp_server.config.get_settings`,
|
||||
which reads from settings files and environment variables (env vars
|
||||
always win, see ADR-024/025).
|
||||
|
||||
Priority order:
|
||||
1. Bedrock - if AWS_REGION or BEDROCK_EMBEDDING_MODEL is set
|
||||
2. OpenAI - if OPENAI_API_KEY is set
|
||||
3. Ollama - if OLLAMA_BASE_URL is set
|
||||
4. Simple - fallback for testing/development
|
||||
|
||||
1. Bedrock - if ``aws_region`` or ``bedrock_embedding_model`` is set
|
||||
2. OpenAI - if ``openai_api_key`` is set
|
||||
3. Mistral - if ``mistral_api_key`` is set
|
||||
4. Ollama - if ``ollama_base_url`` is set
|
||||
5. Simple - fallback for testing/development
|
||||
|
||||
Returns:
|
||||
Provider instance
|
||||
|
||||
Environment Variables:
|
||||
Bedrock:
|
||||
- AWS_REGION: AWS region (e.g., "us-east-1")
|
||||
- AWS_ACCESS_KEY_ID: AWS access key (optional, uses credential chain)
|
||||
- AWS_SECRET_ACCESS_KEY: AWS secret key (optional)
|
||||
- BEDROCK_EMBEDDING_MODEL: Model ID for embeddings (e.g., "amazon.titan-embed-text-v2:0")
|
||||
- BEDROCK_GENERATION_MODEL: Model ID for text generation (e.g., "anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
|
||||
OpenAI:
|
||||
- OPENAI_API_KEY: OpenAI API key (or GITHUB_TOKEN for GitHub Models)
|
||||
- OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
|
||||
- OPENAI_EMBEDDING_MODEL: Model for embeddings (default: "text-embedding-3-small")
|
||||
- OPENAI_GENERATION_MODEL: Model for text generation (e.g., "gpt-4o-mini")
|
||||
|
||||
Ollama:
|
||||
- OLLAMA_BASE_URL: Ollama API base URL (e.g., "http://localhost:11434")
|
||||
- OLLAMA_EMBEDDING_MODEL: Model for embeddings (default: "nomic-embed-text")
|
||||
- OLLAMA_GENERATION_MODEL: Model for text generation (e.g., "llama3.2:1b")
|
||||
- OLLAMA_VERIFY_SSL: Verify SSL certificates (default: "true")
|
||||
|
||||
Simple (no configuration needed, fallback):
|
||||
- SIMPLE_EMBEDDING_DIMENSION: Embedding dimension (default: 384)
|
||||
"""
|
||||
# 1. Check for Bedrock
|
||||
aws_region = os.getenv("AWS_REGION")
|
||||
bedrock_embedding_model = os.getenv("BEDROCK_EMBEDDING_MODEL")
|
||||
bedrock_generation_model = os.getenv("BEDROCK_GENERATION_MODEL")
|
||||
settings = get_settings()
|
||||
|
||||
if aws_region or bedrock_embedding_model or bedrock_generation_model:
|
||||
# 1. Bedrock
|
||||
if (
|
||||
settings.aws_region
|
||||
or settings.bedrock_embedding_model
|
||||
or settings.bedrock_generation_model
|
||||
):
|
||||
logger.info(
|
||||
f"Using Bedrock provider: region={aws_region}, "
|
||||
f"embedding_model={bedrock_embedding_model}, "
|
||||
f"generation_model={bedrock_generation_model}"
|
||||
"Using Bedrock provider: region=%s, embedding_model=%s, "
|
||||
"generation_model=%s",
|
||||
settings.aws_region,
|
||||
settings.bedrock_embedding_model,
|
||||
settings.bedrock_generation_model,
|
||||
)
|
||||
return BedrockProvider(
|
||||
region_name=aws_region,
|
||||
embedding_model=bedrock_embedding_model,
|
||||
generation_model=bedrock_generation_model,
|
||||
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
region_name=settings.aws_region,
|
||||
embedding_model=settings.bedrock_embedding_model,
|
||||
generation_model=settings.bedrock_generation_model,
|
||||
aws_access_key_id=settings.aws_access_key_id,
|
||||
aws_secret_access_key=settings.aws_secret_access_key,
|
||||
)
|
||||
|
||||
# 2. Check for OpenAI
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY")
|
||||
if openai_api_key:
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
embedding_model = os.getenv(
|
||||
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
|
||||
)
|
||||
generation_model = os.getenv("OPENAI_GENERATION_MODEL")
|
||||
|
||||
# 2. OpenAI
|
||||
if settings.openai_api_key:
|
||||
logger.info(
|
||||
f"Using OpenAI provider: base_url={base_url or 'default'}, "
|
||||
f"embedding_model={embedding_model}, "
|
||||
f"generation_model={generation_model}"
|
||||
"Using OpenAI provider: base_url=%s, embedding_model=%s, "
|
||||
"generation_model=%s",
|
||||
settings.openai_base_url or "default",
|
||||
settings.openai_embedding_model,
|
||||
settings.openai_generation_model,
|
||||
)
|
||||
return OpenAIProvider(
|
||||
api_key=openai_api_key,
|
||||
base_url=base_url,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=generation_model,
|
||||
api_key=settings.openai_api_key,
|
||||
base_url=settings.openai_base_url,
|
||||
embedding_model=settings.openai_embedding_model,
|
||||
generation_model=settings.openai_generation_model,
|
||||
)
|
||||
|
||||
# 3. Check for Ollama (local LLM)
|
||||
ollama_url = os.getenv("OLLAMA_BASE_URL")
|
||||
if ollama_url:
|
||||
embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
generation_model = os.getenv("OLLAMA_GENERATION_MODEL")
|
||||
verify_ssl = os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true"
|
||||
|
||||
# 3. Mistral
|
||||
if settings.mistral_api_key:
|
||||
logger.info(
|
||||
f"Using Ollama provider: {ollama_url}, "
|
||||
f"embedding_model={embedding_model}, "
|
||||
f"generation_model={generation_model}"
|
||||
"Using Mistral provider: base_url=%s, embedding_model=%s",
|
||||
settings.mistral_base_url or "default",
|
||||
settings.mistral_embedding_model,
|
||||
)
|
||||
return MistralProvider(
|
||||
api_key=settings.mistral_api_key,
|
||||
base_url=settings.mistral_base_url,
|
||||
embedding_model=settings.mistral_embedding_model,
|
||||
)
|
||||
|
||||
# 4. Ollama
|
||||
if settings.ollama_base_url:
|
||||
logger.info(
|
||||
"Using Ollama provider: %s, embedding_model=%s, generation_model=%s",
|
||||
settings.ollama_base_url,
|
||||
settings.ollama_embedding_model,
|
||||
settings.ollama_generation_model,
|
||||
)
|
||||
return OllamaProvider(
|
||||
base_url=ollama_url,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=generation_model,
|
||||
verify_ssl=verify_ssl,
|
||||
base_url=settings.ollama_base_url,
|
||||
embedding_model=settings.ollama_embedding_model,
|
||||
generation_model=settings.ollama_generation_model,
|
||||
verify_ssl=settings.ollama_verify_ssl,
|
||||
)
|
||||
|
||||
# 4. Fallback to Simple provider for development/testing
|
||||
dimension = int(os.getenv("SIMPLE_EMBEDDING_DIMENSION", "384"))
|
||||
# 5. Simple (fallback)
|
||||
logger.warning(
|
||||
"No provider configured (AWS_REGION, OPENAI_API_KEY, OLLAMA_BASE_URL not set). "
|
||||
"No provider configured (AWS_REGION, OPENAI_API_KEY, "
|
||||
"MISTRAL_API_KEY, OLLAMA_BASE_URL not set). "
|
||||
"Using SimpleProvider for testing/development. "
|
||||
"For production, configure Bedrock, OpenAI, or Ollama."
|
||||
"For production, configure Bedrock, OpenAI, Mistral, or Ollama."
|
||||
)
|
||||
return SimpleProvider(dimension=dimension)
|
||||
return SimpleProvider(dimension=settings.simple_embedding_dimension)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
@@ -691,6 +691,122 @@ class PDFHighlighter:
|
||||
f"Failed to delete temp directory {temp_pdf_path.parent}: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def compute_chunk_bboxes_batch(
|
||||
pdf_bytes: bytes,
|
||||
chunks: list[tuple[int, int, int, int | None, str]],
|
||||
page_boundaries: list[dict],
|
||||
full_text: str,
|
||||
) -> dict[int, tuple[list[tuple[float, float, float, float]], int]]:
|
||||
"""Compute normalized bounding boxes for chunks without rendering.
|
||||
|
||||
Lightweight alternative to highlight_chunks_batch — opens the PDF,
|
||||
locates each chunk on its assigned page using the same text-search
|
||||
path as the highlighter (`_find_chunk_bbox`), and returns
|
||||
page-normalized rectangles. Skips the get_pixmap + PIL pipeline
|
||||
entirely, so no PNG bytes are produced.
|
||||
|
||||
Args:
|
||||
pdf_bytes: PDF file bytes.
|
||||
chunks: List of (chunk_index, start_offset, end_offset,
|
||||
stored_page_number, chunk_text). chunk_index is the dict key.
|
||||
page_boundaries: Pre-computed page boundaries from the document
|
||||
processor; each entry is {"page", "start_offset", "end_offset"}.
|
||||
full_text: Full document text (for cross-page chunk handling).
|
||||
|
||||
Returns:
|
||||
dict mapping chunk_index to (normalized_bboxes, page_number).
|
||||
Each bbox is (x0, y0, x1, y1) in [0, 1] relative to page width
|
||||
and height, top-left origin. Chunks whose bbox cannot be located
|
||||
are omitted from the result.
|
||||
"""
|
||||
results: dict[int, tuple[list[tuple[float, float, float, float]], int]] = {}
|
||||
|
||||
if not chunks:
|
||||
return results
|
||||
|
||||
temp_pdf_path = None
|
||||
doc = None
|
||||
try:
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pdf_bbox_batch_"))
|
||||
temp_pdf_path = temp_dir / "pdf.pdf"
|
||||
temp_pdf_path.write_bytes(pdf_bytes)
|
||||
|
||||
doc = pymupdf.open(temp_pdf_path)
|
||||
|
||||
for (
|
||||
chunk_index,
|
||||
start_offset,
|
||||
end_offset,
|
||||
_,
|
||||
_,
|
||||
) in chunks:
|
||||
chunk_page_info = PDFHighlighter.find_chunk_page(
|
||||
start_offset, end_offset, page_boundaries
|
||||
)
|
||||
if not chunk_page_info:
|
||||
logger.debug("Chunk %s: not found on any page", chunk_index)
|
||||
continue
|
||||
|
||||
page_num = chunk_page_info["page_num"]
|
||||
page_boundary = next(
|
||||
(b for b in page_boundaries if b["page"] == page_num), None
|
||||
)
|
||||
if page_boundary is None:
|
||||
logger.debug(
|
||||
"Chunk %s: page %s not found in boundaries",
|
||||
chunk_index,
|
||||
page_num,
|
||||
)
|
||||
continue
|
||||
page_text_length = (
|
||||
page_boundary["end_offset"] - page_boundary["start_offset"]
|
||||
)
|
||||
|
||||
# Page-relative slice (handles chunks that span page boundaries)
|
||||
chunk_start_on_page = max(start_offset, page_boundary["start_offset"])
|
||||
chunk_end_on_page = min(end_offset, page_boundary["end_offset"])
|
||||
page_relative_text = full_text[chunk_start_on_page:chunk_end_on_page]
|
||||
|
||||
page = doc[page_num - 1]
|
||||
bbox = PDFHighlighter._find_chunk_bbox(
|
||||
page,
|
||||
page_relative_text,
|
||||
chunk_page_info["page_relative_start"],
|
||||
chunk_page_info["page_relative_end"],
|
||||
page_text_length,
|
||||
)
|
||||
|
||||
if bbox is None:
|
||||
continue
|
||||
|
||||
page_rect = page.rect
|
||||
w = page_rect.width or 1.0
|
||||
h = page_rect.height or 1.0
|
||||
normalized = (
|
||||
bbox[0] / w,
|
||||
bbox[1] / h,
|
||||
bbox[2] / w,
|
||||
bbox[3] / h,
|
||||
)
|
||||
results[chunk_index] = ([normalized], page_num)
|
||||
|
||||
logger.info(f"Computed bboxes for {len(results)}/{len(chunks)} chunks")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error computing chunk bboxes: {e}", exc_info=True)
|
||||
return results
|
||||
|
||||
finally:
|
||||
if doc is not None:
|
||||
doc.close()
|
||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||
try:
|
||||
shutil.rmtree(temp_pdf_path.parent)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up temp dir: {e}")
|
||||
|
||||
@staticmethod
|
||||
def highlight_chunks_batch(
|
||||
pdf_bytes: bytes,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""Vector database and background sync package."""
|
||||
"""Vector database and background sync package.
|
||||
|
||||
`processor` and `scanner` are intentionally NOT re-exported from this
|
||||
package init: they transitively import `server.semantic` ->
|
||||
`search.bm25_hybrid`, which forms an import cycle with
|
||||
`search.algorithms` -> `vector.placeholder` -> `vector/__init__`.
|
||||
Consumers that need those symbols import them from their submodules
|
||||
directly (e.g. `from nextcloud_mcp_server.vector.processor import ...`).
|
||||
"""
|
||||
|
||||
from .document_chunker import DocumentChunker
|
||||
from .processor import process_document, processor_task
|
||||
from .qdrant_client import get_qdrant_client
|
||||
from .scanner import DocumentTask, scan_user_documents, scanner_task
|
||||
|
||||
__all__ = [
|
||||
"get_qdrant_client",
|
||||
"DocumentChunker",
|
||||
"scanner_task",
|
||||
"scan_user_documents",
|
||||
"DocumentTask",
|
||||
"processor_task",
|
||||
"process_document",
|
||||
]
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
Processes documents from stream: fetches content, generates embeddings, stores in Qdrant.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
@@ -538,7 +537,11 @@ async def _index_document(
|
||||
# Initialize results containers
|
||||
dense_embeddings: list = []
|
||||
sparse_embeddings: list = []
|
||||
chunk_images: dict[int, dict] = {}
|
||||
# chunk_index -> list[(x0, y0, x1, y1)] of normalized rectangles
|
||||
# in [0, 1] relative to page width/height. The page is taken from
|
||||
# `chunk.page_number` (offset-based) and stored as `page_number`
|
||||
# in the Qdrant payload, so we don't carry an `actual_page_num` here.
|
||||
chunk_bboxes: dict[int, list[tuple[float, float, float, float]]] = {}
|
||||
|
||||
# Determine if we need PDF highlighting
|
||||
is_pdf = doc_task.doc_type == "file" and content_type == "application/pdf"
|
||||
@@ -570,8 +573,8 @@ async def _index_document(
|
||||
sparse_embeddings = await bm25_service.encode_batch(chunk_texts)
|
||||
|
||||
async def generate_highlights():
|
||||
"""Generate highlighted page images for PDF chunks (CPU-bound)."""
|
||||
nonlocal chunk_images
|
||||
"""Compute chunk bounding boxes for PDF chunks (CPU-bound, no rendering)."""
|
||||
nonlocal chunk_bboxes
|
||||
if not is_pdf:
|
||||
return
|
||||
|
||||
@@ -579,64 +582,42 @@ async def _index_document(
|
||||
assert content_bytes is not None
|
||||
|
||||
with trace_operation(
|
||||
"vector_sync.generate_highlights",
|
||||
"vector_sync.compute_chunk_bboxes",
|
||||
attributes={
|
||||
"vector_sync.chunk_count": len(chunks),
|
||||
"vector_sync.pdf_size": len(content_bytes),
|
||||
},
|
||||
):
|
||||
# Build chunk data for batch processing
|
||||
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
|
||||
chunk_data: list[tuple[int, int, int, int | None, str]] = [
|
||||
(i, chunk.start_offset, chunk.end_offset, chunk.page_number, chunk.text)
|
||||
for i, chunk in enumerate(chunks)
|
||||
if chunk.page_number is not None
|
||||
]
|
||||
|
||||
# Get pre-computed page boundaries from document processor
|
||||
page_boundaries = file_metadata.get("page_boundaries")
|
||||
if not page_boundaries:
|
||||
logger.warning("No page boundaries available, skipping highlighting")
|
||||
logger.warning(
|
||||
"No page boundaries available, skipping bbox computation"
|
||||
)
|
||||
return
|
||||
|
||||
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
|
||||
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
|
||||
|
||||
logger.info(
|
||||
f"Batch generating highlighted page images for {len(chunk_data)} PDF chunks"
|
||||
)
|
||||
logger.info(f"Computing chunk bboxes for {len(chunk_data)} PDF chunks")
|
||||
|
||||
# Run CPU-bound highlighting in thread pool
|
||||
# Pass pre-computed page boundaries and full text to avoid re-processing the PDF
|
||||
batch_results = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
lambda: PDFHighlighter.highlight_chunks_batch(
|
||||
lambda: PDFHighlighter.compute_chunk_bboxes_batch(
|
||||
pdf_bytes=content_bytes,
|
||||
chunks=chunk_data,
|
||||
page_boundaries=page_boundaries_list,
|
||||
full_text=content,
|
||||
color="yellow",
|
||||
zoom=2.0,
|
||||
)
|
||||
)
|
||||
|
||||
# Convert results to storage format
|
||||
for chunk_index, (
|
||||
png_bytes,
|
||||
actual_page_num,
|
||||
highlight_count,
|
||||
) in batch_results.items():
|
||||
image_base64 = base64.b64encode(png_bytes).decode("utf-8")
|
||||
chunk_images[chunk_index] = {
|
||||
"image": image_base64,
|
||||
"page": actual_page_num,
|
||||
"highlights": highlight_count,
|
||||
"size": len(png_bytes),
|
||||
}
|
||||
for chunk_index, (bboxes, _) in batch_results.items():
|
||||
chunk_bboxes[chunk_index] = bboxes
|
||||
|
||||
logger.info(
|
||||
f"Generated {len(chunk_images)}/{len(chunks)} highlighted page images "
|
||||
f"(avg {sum(img['size'] for img in chunk_images.values()) // max(len(chunk_images), 1):,} bytes)"
|
||||
)
|
||||
logger.info(f"Computed bboxes for {len(chunk_bboxes)}/{len(chunks)} chunks")
|
||||
|
||||
# Run all embedding/highlighting operations in parallel
|
||||
# - Dense embeddings: I/O bound (API call)
|
||||
@@ -752,16 +733,11 @@ async def _index_document(
|
||||
if doc_task.doc_type == "deck_card"
|
||||
else {}
|
||||
),
|
||||
# Highlighted page image (PDF only)
|
||||
**(
|
||||
{
|
||||
"highlighted_page_image": chunk_images[i]["image"],
|
||||
"highlighted_page_number": chunk_images[i]["page"],
|
||||
"highlight_count": chunk_images[i]["highlights"],
|
||||
}
|
||||
if i in chunk_images
|
||||
else {}
|
||||
),
|
||||
# Chunk bbox (PDF only) — normalized rectangles in [0,1]
|
||||
# relative to page width/height. Replaces the legacy
|
||||
# `highlighted_page_image` (Deck #76). The page number
|
||||
# comes from `page_number` (set above for PDF chunks).
|
||||
**({"chunk_bbox": chunk_bboxes[i]} if i in chunk_bboxes else {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -780,15 +756,16 @@ async def _index_document(
|
||||
f"Failed to delete placeholder for {doc_task.doc_type}_{doc_task.doc_id}: {e}"
|
||||
)
|
||||
|
||||
# Upsert to Qdrant in batches to avoid timeout with large payloads
|
||||
# Each batch is limited to avoid WriteTimeout when sending large image payloads
|
||||
BATCH_SIZE = 10 # ~2MB per batch with images
|
||||
# Upsert to Qdrant in batches. Now that we no longer embed PNG payloads,
|
||||
# per-point payloads are small (chunk text + small metadata), so we can
|
||||
# safely use a larger batch size.
|
||||
BATCH_SIZE = 100
|
||||
with trace_operation(
|
||||
"vector_sync.qdrant_upsert",
|
||||
attributes={
|
||||
"vector_sync.point_count": len(points),
|
||||
"vector_sync.collection": settings.get_collection_name(),
|
||||
"vector_sync.images_count": len(chunk_images),
|
||||
"vector_sync.bboxes_count": len(chunk_bboxes),
|
||||
"vector_sync.batch_size": BATCH_SIZE,
|
||||
},
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user