From 81be7877dad66e3026a7322a1bfe86318110bc0c Mon Sep 17 00:00:00 2001 From: andyne13 Date: Tue, 21 Apr 2026 15:02:58 +0200 Subject: [PATCH 1/9] core: add generic Registry[T] pattern Type-safe registry for pluggable components. Each domain (embedder, reranker, llm, vlm, chunking, parser) will have its own Registry instance. Implementations register via @registry.register("name") decorator and are instantiated via registry.create("name", **kwargs). Includes RegistryError with helpful message listing available implementations when a lookup fails. --- openrag/core/utils/registry.py | 76 ++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 openrag/core/utils/registry.py diff --git a/openrag/core/utils/registry.py b/openrag/core/utils/registry.py new file mode 100644 index 000000000..f810ca7da --- /dev/null +++ b/openrag/core/utils/registry.py @@ -0,0 +1,76 @@ +"""Generic registry pattern for pluggable components. + +Usage: + from openrag.core.utils.registry import Registry + + embedder_registry: Registry[Embedder] = Registry("embedder") + + @embedder_registry.register("vllm") + class VLLMEmbedder(Embedder): + ... + + instance = embedder_registry.create("vllm", endpoint="http://...") +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Generic, Type, TypeVar + +T = TypeVar("T") + + +class RegistryError(Exception): + """Raised when a registry lookup fails.""" + + pass + + +class Registry(Generic[T]): + """Generic type-safe registry mapping string names to component classes. + + Each component domain (embedder, reranker, llm, vlm, chunking, parser) + has its own Registry instance. Implementations register via the + ``@registry.register("name")`` decorator and are instantiated via + ``registry.create("name", **kwargs)``. + """ + + def __init__(self, kind: str) -> None: + self._kind = kind + self._registry: dict[str, Type[T]] = {} + + def register(self, name: str) -> Callable[[Type[T]], Type[T]]: + """Decorator to register a class under *name*.""" + + def decorator(cls: Type[T]) -> Type[T]: + self._registry[name] = cls + return cls + + return decorator + + def create(self, name: str, **kwargs: Any) -> T: + """Instantiate a registered class by name.""" + cls = self._registry.get(name) + if cls is None: + available = ", ".join(sorted(self._registry)) + raise RegistryError( + f"{self._kind} '{name}' not found. Available: [{available}]" + ) + return cls(**kwargs) + + def get_class(self, name: str) -> Type[T]: + """Return the registered class without instantiating.""" + cls = self._registry.get(name) + if cls is None: + available = ", ".join(sorted(self._registry)) + raise RegistryError( + f"{self._kind} '{name}' not found. Available: [{available}]" + ) + return cls + + def list_registered(self) -> list[str]: + """Return sorted list of registered names.""" + return sorted(self._registry) + + def __contains__(self, name: str) -> bool: + return name in self._registry From 237d9fc0a04cfa7ab4327f988923e0b50128b1ce Mon Sep 17 00:00:00 2001 From: andyne13 Date: Tue, 21 Apr 2026 15:22:15 +0200 Subject: [PATCH 2/9] core: add unified exception hierarchy Consolidates all exception classes into core/utils/exceptions.py. Preserves the existing OpenRAGError API (message, code, status_code, to_dict()) and all existing VDB/Embedding subclasses for backward compatibility. Adds new exception categories for the hexagonal architecture: - ConfigError, RegistryError, PipelineError - AuthError, AuthenticationError (401) - ValidationError (422), NotFoundError (404) with domain subtypes - QuotaExceededError (429) - ServiceUnavailableError (503), CircuitBreakerOpenError - InferenceError with LLMParsingError (502), timeout (504), connection (503) - StorageError with MilvusError, PostgresError Status codes preserved from existing codebase for backward compat. Will be moved to api/error_handlers.py mapping in Phase 10. --- openrag/core/utils/exceptions.py | 366 +++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 openrag/core/utils/exceptions.py diff --git a/openrag/core/utils/exceptions.py b/openrag/core/utils/exceptions.py new file mode 100644 index 000000000..4ada66822 --- /dev/null +++ b/openrag/core/utils/exceptions.py @@ -0,0 +1,366 @@ +"""Unified exception hierarchy for OpenRAG. + +All exceptions inherit from OpenRAGError and carry a machine-readable +``code``, an HTTP ``status_code``, and an optional ``extra`` dict. + +The hierarchy is organised by concern: + + OpenRAGError + +-- ConfigError + +-- RegistryError + +-- PipelineError + +-- AuthError + | +-- AuthenticationError (401) + +-- ValidationError (422) + +-- NotFoundError (404) + | +-- DocumentNotFoundError + | +-- PartitionNotFoundError + | +-- UserNotFoundError + +-- QuotaExceededError (429) + +-- ServiceUnavailableError (503) + | +-- CircuitBreakerOpenError + +-- InferenceError (503) + | +-- LLMParsingError (502) + | +-- InferenceTimeoutError (504) + | +-- InferenceConnectionError (503) + +-- StorageError (500) + | +-- MilvusError + | +-- PostgresError + +-- EmbeddingError (500) + | +-- EmbeddingAPIError + | +-- EmbeddingResponseError (422) + | +-- UnexpectedEmbeddingError + +-- VDBError (500) + +-- VDBConnectionError (503) + +-- VDBInsertError (422) + +-- VDBDeleteError (422) + +-- VDBSearchError (422) + +-- VDBFileIDAlreadyExistsError (409) + +-- VDBPartitionNotFound (404) + +-- VDBFileNotFoundError (404) + +-- VDBUserNotFound (404) + +-- VDBMembershipNotFound (404) + +-- VDBSchemaMigrationRequiredError (503) + +-- VDBCreateOrLoadCollectionError (422) + +-- UnexpectedVDBError (500) +""" + +from __future__ import annotations + + +# --------------------------------------------------------------------------- +# Root +# --------------------------------------------------------------------------- + +class OpenRAGError(Exception): + """Base class for all OpenRAG exceptions. + + Preserves the existing API: message, code, status_code, to_dict(). + """ + + def __init__( + self, + message: str, + code: str = "OPENRAG_ERROR", + status_code: int = 500, + **kwargs, + ): + self.message = message + self.code = code + self.status_code = status_code + self.extra = kwargs or {} + super().__init__(f"{self.code}: {self.message}") + + def to_dict(self) -> dict: + return { + "detail": f"[{self.code}]: {self.message}", + "extra": self.extra, + } + + +# --------------------------------------------------------------------------- +# Config & registry +# --------------------------------------------------------------------------- + +class ConfigError(OpenRAGError): + """Configuration-related errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="CONFIG_ERROR", status_code=500, **kwargs) + + +class RegistryError(OpenRAGError): + """Registry lookup errors (unknown component name).""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="REGISTRY_ERROR", status_code=500, **kwargs) + + +class PipelineError(OpenRAGError): + """Pipeline execution errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="PIPELINE_ERROR", status_code=500, **kwargs) + + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- + +class AuthError(OpenRAGError): + """Authentication / authorization errors.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="AUTH_ERROR", status_code=403, **kwargs) + + +class AuthenticationError(AuthError): + """Missing or invalid credentials. Maps to HTTP 401.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, **kwargs) + self.code = "AUTHENTICATION_ERROR" + self.status_code = 401 + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +class ValidationError(OpenRAGError): + """Input validation or business rule violation. Maps to HTTP 422.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VALIDATION_ERROR", status_code=422, **kwargs) + + +# --------------------------------------------------------------------------- +# Not found +# --------------------------------------------------------------------------- + +class NotFoundError(OpenRAGError): + """Requested resource not found. Maps to HTTP 404.""" + + def __init__(self, message: str, code: str = "NOT_FOUND", **kwargs): + super().__init__(message, code=code, status_code=404, **kwargs) + + +class DocumentNotFoundError(NotFoundError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="DOCUMENT_NOT_FOUND", **kwargs) + + +class PartitionNotFoundError(NotFoundError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="PARTITION_NOT_FOUND", **kwargs) + + +class UserNotFoundError(NotFoundError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="USER_NOT_FOUND", **kwargs) + + +# --------------------------------------------------------------------------- +# Quota +# --------------------------------------------------------------------------- + +class QuotaExceededError(OpenRAGError): + """File quota exceeded. Maps to HTTP 429.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="QUOTA_EXCEEDED", status_code=429, **kwargs) + + +# --------------------------------------------------------------------------- +# Infrastructure — service availability +# --------------------------------------------------------------------------- + +class ServiceUnavailableError(OpenRAGError): + """External service unavailable after retry exhaustion. Maps to HTTP 503.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="SERVICE_UNAVAILABLE", status_code=503, **kwargs) + + +class CircuitBreakerOpenError(ServiceUnavailableError): + """Circuit breaker is open. Maps to HTTP 503.""" + + def __init__(self, service_type: str, **kwargs): + self.service_type = service_type + super().__init__( + f"Circuit breaker open for {service_type} — service unavailable", + **kwargs, + ) + self.code = "CIRCUIT_BREAKER_OPEN" + + +# --------------------------------------------------------------------------- +# Inference +# --------------------------------------------------------------------------- + +class InferenceError(OpenRAGError): + """Base for all inference service failures. Maps to HTTP 503.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="INFERENCE_ERROR", status_code=503, **kwargs) + + +class LLMParsingError(InferenceError): + """LLM returned invalid JSON. Maps to HTTP 502.""" + + def __init__(self, raw_response: str, parse_error: str | None = None, **kwargs): + self.raw_response = raw_response[:500] + self.parse_error = parse_error + super().__init__( + f"LLM returned invalid JSON: {self.raw_response[:100]}...", + **kwargs, + ) + self.code = "LLM_PARSING_ERROR" + self.status_code = 502 + + +class InferenceTimeoutError(InferenceError): + """Inference request timed out. Maps to HTTP 504.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, **kwargs) + self.code = "INFERENCE_TIMEOUT" + self.status_code = 504 + + +class InferenceConnectionError(InferenceError): + """Cannot reach inference service. Maps to HTTP 503.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, **kwargs) + self.code = "INFERENCE_CONNECTION_ERROR" + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +class StorageError(OpenRAGError): + """Base for storage failures. Maps to HTTP 500.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="STORAGE_ERROR", status_code=500, **kwargs) + + +class MilvusError(StorageError): + """Milvus-specific failures.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, **kwargs) + self.code = "MILVUS_ERROR" + + +class PostgresError(StorageError): + """Postgres-specific failures.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, **kwargs) + self.code = "POSTGRES_ERROR" + + +# --------------------------------------------------------------------------- +# Embedding (preserves existing OpenRAG exception classes) +# --------------------------------------------------------------------------- + +class EmbeddingError(OpenRAGError): + """Base exception for all embedding-related errors.""" + + def __init__(self, message: str, code: str = "EMBEDDING_ERROR", status_code: int = 500, **kwargs): + super().__init__(message, code=code, status_code=status_code, **kwargs) + + +class EmbeddingAPIError(EmbeddingError): + """API error with the embedding provider.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="EMBEDDING_API_ERROR", status_code=500, **kwargs) + + +class EmbeddingResponseError(EmbeddingError): + """Invalid or unexpected response from embedding provider.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="EMBEDDING_RESPONSE_ERROR", status_code=422, **kwargs) + + +class UnexpectedEmbeddingError(EmbeddingError): + """Unexpected error in embedding operations.""" + + def __init__(self, message: str, **kwargs): + super().__init__(message, code="EMBEDDING_UNEXPECTED_ERROR", status_code=500, **kwargs) + + +# --------------------------------------------------------------------------- +# Vector database (preserves existing OpenRAG exception classes) +# --------------------------------------------------------------------------- + +class VDBError(OpenRAGError): + """Base exception for all vector database-related errors.""" + + def __init__(self, message: str, code: str = "VDB_ERROR", status_code: int = 500, **kwargs): + super().__init__(message, code=code, status_code=status_code, **kwargs) + + +class VDBConnectionError(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_CONNECTION_ERROR", status_code=503, **kwargs) + + +class VDBCreateOrLoadCollectionError(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_COLLECTION_ERROR", status_code=422, **kwargs) + + +class VDBInsertError(VDBError): + def __init__(self, message: str, status_code: int = 422, **kwargs): + super().__init__(message, code="VDB_INSERT_ERROR", status_code=status_code, **kwargs) + + +class VDBFileIDAlreadyExistsError(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_FILE_ALREADY_EXISTS", status_code=409, **kwargs) + + +class VDBDeleteError(VDBError): + def __init__(self, message: str, status_code: int = 422, **kwargs): + super().__init__(message, code="VDB_DELETE_ERROR", status_code=status_code, **kwargs) + + +class VDBSearchError(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_SEARCH_ERROR", status_code=422, **kwargs) + + +class VDBPartitionNotFound(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_PARTITION_NOT_FOUND", status_code=404, **kwargs) + + +class VDBFileNotFoundError(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_FILE_NOT_FOUND", status_code=404, **kwargs) + + +class VDBUserNotFound(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_USER_NOT_FOUND", status_code=404, **kwargs) + + +class VDBMembershipNotFound(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_MEMBERSHIP_NOT_FOUND", status_code=404, **kwargs) + + +class VDBSchemaMigrationRequiredError(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_SCHEMA_MIGRATION_REQUIRED", status_code=503, **kwargs) + + +class UnexpectedVDBError(VDBError): + def __init__(self, message: str, **kwargs): + super().__init__(message, code="VDB_UNEXPECTED_ERROR", status_code=500, **kwargs) From e32af9939ba04734386a475f1c92b996b0b51c79 Mon Sep 17 00:00:00 2001 From: andyne13 Date: Tue, 21 Apr 2026 15:24:50 +0200 Subject: [PATCH 3/9] shim: re-export exceptions from core for backward compatibility Updates utils/exceptions/{__init__,base,vectordb,embeddings}.py to re-export from openrag.core.utils.exceptions. All existing consumer imports continue to work unchanged. New code should import from openrag.core.utils.exceptions directly. These shims will be removed in Phase 12. --- openrag/utils/exceptions/__init__.py | 4 +- openrag/utils/exceptions/base.py | 46 ++------ openrag/utils/exceptions/embeddings.py | 39 ++----- openrag/utils/exceptions/vectordb.py | 146 +++---------------------- 4 files changed, 33 insertions(+), 202 deletions(-) diff --git a/openrag/utils/exceptions/__init__.py b/openrag/utils/exceptions/__init__.py index 9b5ed21c9..857408bdd 100644 --- a/openrag/utils/exceptions/__init__.py +++ b/openrag/utils/exceptions/__init__.py @@ -1 +1,3 @@ -from .base import * +# Re-export from canonical location for backward compatibility. +# New code should import from openrag.core.utils.exceptions directly. +from openrag.core.utils.exceptions import * # noqa: F401,F403 diff --git a/openrag/utils/exceptions/base.py b/openrag/utils/exceptions/base.py index 32a3be8fd..927bb617f 100644 --- a/openrag/utils/exceptions/base.py +++ b/openrag/utils/exceptions/base.py @@ -1,39 +1,7 @@ -from fastapi import status - - -class OpenRAGError(Exception): - """Base class for all OpenRAG exceptions.""" - - def __init__( - self, - message: str, - code: str, - status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, - **kwargs, - ): - self.message = message - self.code = code - self.status_code = status_code - self.extra = kwargs or {} - super().__init__(f"{self.code}: {self.message}") - - def to_dict(self) -> dict: - return { - "detail": f"[{self.code}]: {self.message}", - "extra": self.extra, - } - - -# Subclass exceptions for specific error types -class EmbeddingError(OpenRAGError): - """Base exception for all embedding-related errors.""" - - def __init__(self, message, code, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, **kwargs): - super().__init__(message, code, status_code, **kwargs) - - -class VDBError(OpenRAGError): - """Base exception for all vector database-related errors.""" - - def __init__(self, message, code, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, **kwargs): - super().__init__(message, code, status_code, **kwargs) +# Re-export from canonical location for backward compatibility. +# New code should import from openrag.core.utils.exceptions directly. +from openrag.core.utils.exceptions import ( # noqa: F401 + EmbeddingError, + OpenRAGError, + VDBError, +) diff --git a/openrag/utils/exceptions/embeddings.py b/openrag/utils/exceptions/embeddings.py index 7f8b808a7..ae5fee12c 100644 --- a/openrag/utils/exceptions/embeddings.py +++ b/openrag/utils/exceptions/embeddings.py @@ -1,32 +1,7 @@ -from .base import EmbeddingError - - -class EmbeddingAPIError(EmbeddingError): - """Raised when there's an API error with the embedding provider.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="EMBEDDING_API_ERROR", - status_code=500, - **kwargs, - ) - - -class EmbeddingResponseError(EmbeddingError): - """Raised when the response from the embedding provider is invalid or unexpected.""" - - def __init__(self, message: str, **kwargs): - super().__init__(message=message, code="EMBEDDING_RESPONSE_ERROR", status_code=422, **kwargs) - - -class UnexpectedEmbeddingError(EmbeddingError): - """Raised for unexpected errors in embedding operations.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="EMBEDDING_UNEXPECTED_ERROR", - status_code=500, - **kwargs, - ) +# Re-export from canonical location for backward compatibility. +# New code should import from openrag.core.utils.exceptions directly. +from openrag.core.utils.exceptions import ( # noqa: F401 + EmbeddingAPIError, + EmbeddingResponseError, + UnexpectedEmbeddingError, +) diff --git a/openrag/utils/exceptions/vectordb.py b/openrag/utils/exceptions/vectordb.py index e9bf7cbc1..65b216b3e 100644 --- a/openrag/utils/exceptions/vectordb.py +++ b/openrag/utils/exceptions/vectordb.py @@ -1,130 +1,16 @@ -from .base import VDBError - - -class VDBConnectionError(VDBError): - """Raised when connection to vector database fails.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="VDB_CONNECTION_ERROR", - status_code=503, - **kwargs, - ) - - -class VDBCreateOrLoadCollectionError(VDBError): - """Raised when there's an issue with collection operations.""" - - def __init__(self, message: str, **kwargs): - super().__init__(message=message, code="VDB_COLLECTION_ERROR", status_code=422, **kwargs) - - -class VDBInsertError(VDBError): - """Raised when data insertion fails.""" - - def __init__(self, message: str, status_code: int = 422, **kwargs): - super().__init__(message=message, code="VDB_INSERT_ERROR", status_code=status_code, **kwargs) - - -class VDBFileIDAlreadyExistsError(VDBError): - """Raised when a file already exists in the vector database.""" - - def __init__(self, message: str, **kwargs): - super().__init__(message=message, code="VDB_FILE_ALREADY_EXISTS", status_code=409, **kwargs) - - -class VDBDeleteError(VDBError): - """Raised when data deletion fails.""" - - def __init__( - self, - message: str, - status_code=422, - **kwargs, - ): - super().__init__(message=message, code="VDB_DELETE_ERROR", status_code=status_code, **kwargs) - - -class VDBSearchError(VDBError): - """Raised when vector search fails.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="VDB_SEARCH_ERROR", - status_code=422, - **kwargs, - ) - - -class VDBPartitionNotFound(VDBError): - """Raised when a partition is not found in the vector database.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="VDB_PARTITION_NOT_FOUND", - status_code=404, - **kwargs, - ) - - -class VDBFileNotFoundError(VDBError): - """Raised when a file is not found in the vector database.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="VDB_FILE_NOT_FOUND", - status_code=404, - **kwargs, - ) - - -class VDBUserNotFound(VDBError): - """Raised when a user is not found in the vector database.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="VDB_USER_NOT_FOUND", - status_code=404, - **kwargs, - ) - - -class VDBMembershipNotFound(VDBError): - """Raised when a partition membership is not found in the vector database.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="VDB_MEMBERSHIP_NOT_FOUND", - status_code=404, - **kwargs, - ) - - -class VDBSchemaMigrationRequiredError(VDBError): - """Raised when the collection schema version does not match the expected version.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="VDB_SCHEMA_MIGRATION_REQUIRED", - status_code=503, - **kwargs, - ) - - -class UnexpectedVDBError(VDBError): - """Raised for unexpected errors in vector database operations.""" - - def __init__(self, message: str, **kwargs): - super().__init__( - message=message, - code="VDB_UNEXPECTED_ERROR", - status_code=500, - **kwargs, - ) +# Re-export from canonical location for backward compatibility. +# New code should import from openrag.core.utils.exceptions directly. +from openrag.core.utils.exceptions import ( # noqa: F401 + UnexpectedVDBError, + VDBConnectionError, + VDBCreateOrLoadCollectionError, + VDBDeleteError, + VDBFileIDAlreadyExistsError, + VDBFileNotFoundError, + VDBInsertError, + VDBMembershipNotFound, + VDBPartitionNotFound, + VDBSchemaMigrationRequiredError, + VDBSearchError, + VDBUserNotFound, +) From eb6bbfe1a033ad1758eb343a26a37dfb916cc5c1 Mon Sep 17 00:00:00 2001 From: andyne13 Date: Tue, 21 Apr 2026 15:25:36 +0200 Subject: [PATCH 4/9] cleanup: remove core/catalog/, update decision log CatalogStore ABC will live in core/ports/catalog_store.py (alongside the repository ABCs it composes), not in a separate core/catalog/ folder. Decision logged with Phase 1 entries. --- REFACTORING_DECISION_LOG.md | 17 +++++++++++++++++ openrag/core/catalog/__init__.py | 0 2 files changed, 17 insertions(+) delete mode 100644 openrag/core/catalog/__init__.py diff --git a/REFACTORING_DECISION_LOG.md b/REFACTORING_DECISION_LOG.md index 6ae9bf75f..8cf2a8003 100644 --- a/REFACTORING_DECISION_LOG.md +++ b/REFACTORING_DECISION_LOG.md @@ -57,6 +57,23 @@ match reality, then record the reasoning here. ## Template for future entries +## Phase 1 — Registry + Exceptions (2026-04-21) + +**1. Exceptions keep HTTP status_code on the class (OpenRAG style), not in +a separate error handler mapping (mandragora style).** +- Why: Existing code reads `exc.status_code` in multiple places. Switching + to a pure domain exception + API-layer mapping dict would require changing + every consumer now, which is unnecessary churn in Phase 1. +- Alternative considered: mandragora's pattern (bare exceptions in core/, + status code mapping in api/error_handlers.py). Cleaner for hexagonal + purity but rejected for backward compatibility. +- Follow-up: strip status codes from core exceptions in Phase 10 when + api/error_handlers.py is built. The error handler will own the mapping. + +--- + +## Template for future entries + ``` ## Phase N — [short title] ([YYYY-MM-DD]) diff --git a/openrag/core/catalog/__init__.py b/openrag/core/catalog/__init__.py deleted file mode 100644 index e69de29bb..000000000 From 0f530da36ca7a8061929eb17b764522d746e64a6 Mon Sep 17 00:00:00 2001 From: andyne13 Date: Tue, 21 Apr 2026 15:29:11 +0200 Subject: [PATCH 5/9] core: add text sanitization utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure text cleaning functions moved from components/indexer/utils/text_sanitizer.py. No infrastructure imports — only re and unicodedata. Includes sanitize_text(), clean_markdown_table_spacing(), and sanitize_extracted_text(). --- openrag/core/utils/text.py | 97 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 openrag/core/utils/text.py diff --git a/openrag/core/utils/text.py b/openrag/core/utils/text.py new file mode 100644 index 000000000..b4ddf4540 --- /dev/null +++ b/openrag/core/utils/text.py @@ -0,0 +1,97 @@ +"""Text sanitization utilities for cleaning extracted text. + +Pure functions — no infrastructure imports. Used by chunking, indexing, +and document processing pipelines. + +Moved from: components/indexer/utils/text_sanitizer.py +""" + +import re +import unicodedata + + +def sanitize_text( + text: str, + normalize_whitespace: bool = True, + remove_control_chars: bool = True, + remove_zero_width_chars: bool = True, + max_consecutive_newlines: int = 2, + normalize_unicode: bool = True, +) -> str: + """Sanitize text by removing useless characters and normalizing whitespace. + + Performs comprehensive text cleaning including: + - Removing or normalizing control characters + - Removing zero-width spaces and invisible characters + - Normalizing excessive whitespace (spaces, tabs) + - Limiting consecutive newlines + - Unicode normalization + + Args: + text: The input text to sanitize + normalize_whitespace: If True, normalize spaces and tabs to single spaces + remove_control_chars: If True, remove control characters (except \\n, \\r, \\t) + remove_zero_width_chars: If True, remove zero-width spaces and similar chars + max_consecutive_newlines: Maximum number of consecutive newlines to keep (0 = unlimited) + normalize_unicode: If True, normalize unicode to NFC form + + Returns: + Sanitized text string + """ + if not text: + return text + + if normalize_unicode: + text = unicodedata.normalize("NFC", text) + + if remove_zero_width_chars: + text = re.sub(r"[\u200B-\u200D\u2060\uFEFF]", "", text) + + if remove_control_chars: + text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]", "", text) + + if normalize_whitespace: + text = re.sub(r" {2,}", " ", text) + text = re.sub(r"\t+", " ", text) + text = re.sub(r"(?m)^ +", "", text) + text = re.sub(r"(?m) +$", "", text) + + text = re.sub(r"\r\n", "\n", text) + text = re.sub(r"\r", "\n", text) + + if max_consecutive_newlines > 0: + pattern = r"\n{" + str(max_consecutive_newlines + 1) + r",}" + replacement = "\n" * max_consecutive_newlines + text = re.sub(pattern, replacement, text) + + text = text.strip() + return text + + +def clean_markdown_table_spacing(markdown_table: str) -> str: + """Normalize spacing inside a markdown table. + + Trims each cell while keeping table shape intact. + """ + cleaned_lines = [] + + for line in markdown_table.strip().split("\n"): + if "|" not in line: + cleaned_lines.append(line.strip()) + continue + + parts = line.split("|") + cleaned_cells = [cell.strip() for cell in parts] + new_line = "| " + " | ".join(cleaned_cells[1:-1]) + " |" + cleaned_lines.append(new_line) + + return "\n".join(cleaned_lines) + + +def sanitize_extracted_text(text: str) -> str: + """Convenience function for sanitizing text extracted from documents. + + Applies default sanitization settings suitable for text extraction + endpoints and general document processing. + """ + return sanitize_text(text) From db4d6325c43a451d000c3d8a7b2efad9531ed43f Mon Sep 17 00:00:00 2001 From: andyne13 Date: Tue, 21 Apr 2026 15:29:35 +0200 Subject: [PATCH 6/9] core: add filename sanitization utilities Pure filename functions extracted from components/indexer/utils/files.py. Only the infrastructure-free parts: sanitize_filename() and make_unique_filename(). The rest (save_file_to_disk, serialize_file) stays in the old location until Phase 5+. --- openrag/core/utils/filename.py | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 openrag/core/utils/filename.py diff --git a/openrag/core/utils/filename.py b/openrag/core/utils/filename.py new file mode 100644 index 000000000..7fe852970 --- /dev/null +++ b/openrag/core/utils/filename.py @@ -0,0 +1,49 @@ +"""Filename sanitization and generation utilities. + +Pure functions — no infrastructure imports. + +Extracted from: components/indexer/utils/files.py (pure parts only). +""" + +import re +import secrets +import time +from pathlib import Path + + +def sanitize_filename(filename: str) -> str: + """Sanitize a filename by removing special characters. + + Keeps only word characters and underscores. Hyphens are converted + to underscores. Multiple underscores are collapsed. + + Args: + filename: Original filename (with extension) + + Returns: + Sanitized filename with extension preserved + """ + path = Path(filename) + name = path.stem + ext = path.suffix + + name = re.sub(r"[^\w\-]", "_", name) + name = name.replace("-", "_") + name = re.sub(r"_+", "_", name) + name = name.strip("_") + + return name + ext + + +def make_unique_filename(filename: str) -> str: + """Generate a unique filename by prepending timestamp + random hex. + + Args: + filename: Original filename + + Returns: + Unique filename like "1713700000000_a1b2_original.pdf" + """ + ts = int(time.time() * 1000) + rand = secrets.token_hex(2) + return f"{ts}_{rand}_{filename}" From 4197df4387cfbbc9ad712fe08fbf0da1e3762196 Mon Sep 17 00:00:00 2001 From: andyne13 Date: Tue, 21 Apr 2026 15:29:57 +0200 Subject: [PATCH 7/9] core: add external resource error detection Pure utility for detecting when errors originate from external HTTP resources (VLM image fetches, etc.) rather than internal failures. Moved from utils/external_resource_errors.py. --- openrag/core/utils/external_errors.py | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 openrag/core/utils/external_errors.py diff --git a/openrag/core/utils/external_errors.py b/openrag/core/utils/external_errors.py new file mode 100644 index 000000000..e70a75d73 --- /dev/null +++ b/openrag/core/utils/external_errors.py @@ -0,0 +1,52 @@ +"""Utilities for detecting external resource access errors. + +When VLM models fetch external image URLs, HTTP errors (403, 404, etc.) +from remote servers get wrapped in InternalServerError, which is +misleading. This module detects such errors for better logging. + +Pure functions — no infrastructure imports. + +Moved from: utils/external_resource_errors.py +""" + +import re + +EXTERNAL_ERROR_CODES = frozenset( + { + # 4xx client errors + "400", "401", "403", "404", "405", "408", "410", "429", "451", + # 5xx gateway errors + "502", "503", "504", + } +) + +EXTERNAL_ERROR_INDICATORS = ( + "ClientResponseError", + "HTTPError", + "ConnectionError", + "TimeoutError", + "SSLError", +) + + +def is_external_resource_error(error: Exception) -> tuple[bool, str, str]: + """Check if an error is caused by an external resource access issue. + + Returns: + (is_external_error, status_code, url) — status_code and url are + empty strings if not detected. + """ + error_str = str(error) + + status_code = "" + for match in re.finditer(r"\b([45]\d{2})\b", error_str): + if match.group(1) in EXTERNAL_ERROR_CODES: + status_code = match.group(1) + break + + url_match = re.search(r"https?://[^\s'\"\)>]+", error_str) + url = url_match.group(0) if url_match else "" + + has_indicator = any(ind in error_str for ind in EXTERNAL_ERROR_INDICATORS) + + return bool(status_code) or has_indicator, status_code, url From 85ce61295517e2135b89189a92edc89223c788fb Mon Sep 17 00:00:00 2001 From: andyne13 Date: Tue, 21 Apr 2026 15:45:18 +0200 Subject: [PATCH 8/9] style: fix ruff lint errors in core/utils --- openrag/core/utils/exceptions.py | 1 - openrag/core/utils/registry.py | 16 ++++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/openrag/core/utils/exceptions.py b/openrag/core/utils/exceptions.py index 4ada66822..1f3302dd8 100644 --- a/openrag/core/utils/exceptions.py +++ b/openrag/core/utils/exceptions.py @@ -47,7 +47,6 @@ from __future__ import annotations - # --------------------------------------------------------------------------- # Root # --------------------------------------------------------------------------- diff --git a/openrag/core/utils/registry.py b/openrag/core/utils/registry.py index f810ca7da..32b4fd7e7 100644 --- a/openrag/core/utils/registry.py +++ b/openrag/core/utils/registry.py @@ -15,18 +15,14 @@ class VLLMEmbedder(Embedder): from __future__ import annotations from collections.abc import Callable -from typing import Any, Generic, Type, TypeVar - -T = TypeVar("T") +from typing import Any class RegistryError(Exception): """Raised when a registry lookup fails.""" - pass - -class Registry(Generic[T]): +class Registry[T]: """Generic type-safe registry mapping string names to component classes. Each component domain (embedder, reranker, llm, vlm, chunking, parser) @@ -37,12 +33,12 @@ class Registry(Generic[T]): def __init__(self, kind: str) -> None: self._kind = kind - self._registry: dict[str, Type[T]] = {} + self._registry: dict[str, type[T]] = {} - def register(self, name: str) -> Callable[[Type[T]], Type[T]]: + def register(self, name: str) -> Callable[[type[T]], type[T]]: """Decorator to register a class under *name*.""" - def decorator(cls: Type[T]) -> Type[T]: + def decorator(cls: type[T]) -> type[T]: self._registry[name] = cls return cls @@ -58,7 +54,7 @@ def create(self, name: str, **kwargs: Any) -> T: ) return cls(**kwargs) - def get_class(self, name: str) -> Type[T]: + def get_class(self, name: str) -> type[T]: """Return the registered class without instantiating.""" cls = self._registry.get(name) if cls is None: From 97b5b9e65dc42937c7bec13fc3df8b81138b151a Mon Sep 17 00:00:00 2001 From: andyne13 Date: Tue, 21 Apr 2026 15:53:09 +0200 Subject: [PATCH 9/9] style: apply ruff formatting to core/utils --- openrag/core/utils/exceptions.py | 11 +++++++++++ openrag/core/utils/external_errors.py | 14 ++++++++++++-- openrag/core/utils/registry.py | 8 ++------ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/openrag/core/utils/exceptions.py b/openrag/core/utils/exceptions.py index 1f3302dd8..25df37303 100644 --- a/openrag/core/utils/exceptions.py +++ b/openrag/core/utils/exceptions.py @@ -51,6 +51,7 @@ # Root # --------------------------------------------------------------------------- + class OpenRAGError(Exception): """Base class for all OpenRAG exceptions. @@ -81,6 +82,7 @@ def to_dict(self) -> dict: # Config & registry # --------------------------------------------------------------------------- + class ConfigError(OpenRAGError): """Configuration-related errors.""" @@ -106,6 +108,7 @@ def __init__(self, message: str, **kwargs): # Auth # --------------------------------------------------------------------------- + class AuthError(OpenRAGError): """Authentication / authorization errors.""" @@ -126,6 +129,7 @@ def __init__(self, message: str, **kwargs): # Validation # --------------------------------------------------------------------------- + class ValidationError(OpenRAGError): """Input validation or business rule violation. Maps to HTTP 422.""" @@ -137,6 +141,7 @@ def __init__(self, message: str, **kwargs): # Not found # --------------------------------------------------------------------------- + class NotFoundError(OpenRAGError): """Requested resource not found. Maps to HTTP 404.""" @@ -163,6 +168,7 @@ def __init__(self, message: str, **kwargs): # Quota # --------------------------------------------------------------------------- + class QuotaExceededError(OpenRAGError): """File quota exceeded. Maps to HTTP 429.""" @@ -174,6 +180,7 @@ def __init__(self, message: str, **kwargs): # Infrastructure — service availability # --------------------------------------------------------------------------- + class ServiceUnavailableError(OpenRAGError): """External service unavailable after retry exhaustion. Maps to HTTP 503.""" @@ -197,6 +204,7 @@ def __init__(self, service_type: str, **kwargs): # Inference # --------------------------------------------------------------------------- + class InferenceError(OpenRAGError): """Base for all inference service failures. Maps to HTTP 503.""" @@ -239,6 +247,7 @@ def __init__(self, message: str, **kwargs): # Storage # --------------------------------------------------------------------------- + class StorageError(OpenRAGError): """Base for storage failures. Maps to HTTP 500.""" @@ -266,6 +275,7 @@ def __init__(self, message: str, **kwargs): # Embedding (preserves existing OpenRAG exception classes) # --------------------------------------------------------------------------- + class EmbeddingError(OpenRAGError): """Base exception for all embedding-related errors.""" @@ -298,6 +308,7 @@ def __init__(self, message: str, **kwargs): # Vector database (preserves existing OpenRAG exception classes) # --------------------------------------------------------------------------- + class VDBError(OpenRAGError): """Base exception for all vector database-related errors.""" diff --git a/openrag/core/utils/external_errors.py b/openrag/core/utils/external_errors.py index e70a75d73..3cd24460b 100644 --- a/openrag/core/utils/external_errors.py +++ b/openrag/core/utils/external_errors.py @@ -14,9 +14,19 @@ EXTERNAL_ERROR_CODES = frozenset( { # 4xx client errors - "400", "401", "403", "404", "405", "408", "410", "429", "451", + "400", + "401", + "403", + "404", + "405", + "408", + "410", + "429", + "451", # 5xx gateway errors - "502", "503", "504", + "502", + "503", + "504", } ) diff --git a/openrag/core/utils/registry.py b/openrag/core/utils/registry.py index 32b4fd7e7..22bdef4df 100644 --- a/openrag/core/utils/registry.py +++ b/openrag/core/utils/registry.py @@ -49,9 +49,7 @@ def create(self, name: str, **kwargs: Any) -> T: cls = self._registry.get(name) if cls is None: available = ", ".join(sorted(self._registry)) - raise RegistryError( - f"{self._kind} '{name}' not found. Available: [{available}]" - ) + raise RegistryError(f"{self._kind} '{name}' not found. Available: [{available}]") return cls(**kwargs) def get_class(self, name: str) -> type[T]: @@ -59,9 +57,7 @@ def get_class(self, name: str) -> type[T]: cls = self._registry.get(name) if cls is None: available = ", ".join(sorted(self._registry)) - raise RegistryError( - f"{self._kind} '{name}' not found. Available: [{available}]" - ) + raise RegistryError(f"{self._kind} '{name}' not found. Available: [{available}]") return cls def list_registered(self) -> list[str]: