From 72ef18e39db9739ee569a8a25ce7717cf5c7a7c7 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Wed, 19 Aug 2026 15:32:51 +0300 Subject: [PATCH] Reject unknown and out-of-range configuration values Every section inherited plain BaseModel, so unknown keys were dropped silently: providers.docling_serve.timeout was documented for months while being ignored, and a typo in any setting took the default. Sections now derive from ConfigModel, which forbids extras, so a stale or misspelled key fails with its path. This already found search.context_radius in a live app config and providers.vllm in soliplex's example. converter, chunker and chunker_type are Literals. Sizes, limits, dimensions, token budgets, attempt counts and breaker thresholds must be positive; retention, delays, intervals and cooldowns non-negative; similarity_threshold within 0-1; port within 0-65535. port 0 keeps its OS-assigned meaning and worker_count allows 0 for an API-and-reaper-only process. get_reranker caught ImportError and returned None, so a configured reranker whose extra was missing silently disappeared. It now propagates. raise_missing_extra names the install command and re-raises when the failure came from inside an installed package, so a broken transitive import is not reported as a missing one. zeroentropy imported bare and now guards like the others. The haiku.rag package declares the jina extra. jina-local already worked there through cross-encoder's transitive transformers and torch; the resolved package set is unchanged, but the support is now promised rather than inherited. Provider fields stay unconstrained: get_model ends in a pass-through to pydantic-ai for any provider it supports, so a Literal there would reject valid configurations. --- CHANGELOG.md | 5 + docs/installation.md | 2 +- haiku_rag_slim/haiku/rag/config/models.py | 125 ++++++++++-------- .../haiku/rag/reranking/__init__.py | 57 ++++---- haiku_rag_slim/haiku/rag/reranking/cohere.py | 12 +- .../haiku/rag/reranking/cross_encoder.py | 11 +- .../haiku/rag/reranking/jina_local.py | 11 +- .../haiku/rag/reranking/zeroentropy.py | 7 +- haiku_rag_slim/haiku/rag/utils.py | 17 ++- pyproject.toml | 2 +- tests/test_chunker.py | 5 +- tests/test_config.py | 71 ++++++++++ tests/test_converters.py | 3 +- tests/test_reranker.py | 49 +++++++ uv.lock | 4 +- 15 files changed, 274 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47256b91..bdd5aeda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added +- The `haiku.rag` package declares the `jina` extra, so `provider: jina-local` is supported by declaration rather than through `cross-encoder`'s transitive `transformers` and `torch`. Raises the full package's torch floor to 2.0. - `providers.docling_serve.timeout` (default 300 seconds), forwarded to the docling-serve client's per-request timeout. - `evaluations run --filter/-f CLAUSE`: SQL `WHERE` clause over document columns, applied to the retrieval benchmark's searches and to every capability search during QA. Recorded as `document_filter` in experiment metadata. - `mtrag_clapnq` / `mtrag_clapnq_rewrite` / `mtrag_clapnq_live` / `mtrag_clapnq_live_uncompacted` evaluation datasets: multi-turn QA with gold-prefix and live-session conversation replay, live arms with and without `EvidenceCompactionCapability`, Recall@k/nDCG@k retrieval metrics, eligibility-aware citation scoring, refusal precision/recall, per-turn tool-traffic attributes, and `citation_status` / `turn_citation_status` eval attributes. @@ -14,6 +15,10 @@ ### Changed +- Configuration sections reject unknown keys. A typo or a setting that has been renamed or removed now fails validation with its path (`providers.docling_serve.bogus: Extra inputs are not permitted`) instead of being silently ignored. +- `processing.converter`, `processing.chunker` and `processing.chunker_type` are constrained to their supported values, so an unsupported one fails at load rather than at first use. +- Numeric settings carry bounds: sizes, limits, dimensions, token budgets, attempt counts, breaker thresholds and `min_chunks` must be positive; retention, delays, intervals and cooldowns non-negative; `doctor.duplicates.similarity_threshold` within 0-1; `ingester.api.port` within 0-65535 (0 keeps its OS-assigned meaning); `ingester.workers.worker_count` allows 0 for an API-and-reaper-only process. +- A configured reranker whose optional dependency is missing raises instead of silently disabling reranking, and names the extra to install (`uv pip install 'haiku.rag-slim[cohere]'`). A failure raised from inside an installed dependency propagates untouched rather than being reported as a missing package. - Multi-table writes (document create, update, batch import, cascade delete) go through `Store.write_transaction()`. Rollback restores in `RESTORE_TABLE_ORDER` and is shielded from cancellation, so a cancelled write rolls back instead of committing part of itself. `Store.restore_table_versions()` is removed. - Search enrichment, the multimodal reranker's picture fetch, and context expansion each issue a fixed number of `document_items` queries regardless of how many documents a result set spans, instead of one set per document. `expand_with_items` takes the items to expand from rather than fetching them, and `DocumentItemRepository` gains `resolve_refs_grouped`, `get_items_in_ranges`, `get_pictures_grouped` and `get_caption_picture_refs_grouped`. The superseded methods are removed: `resolve_refs`, `get_items_in_range`, `get_caption_picture_refs`, `get_text_for_refs` and `get_all_items_grouped`. `get_pictures_grouped` returns each picture's text alongside its bytes under `with_text`, off by default so the reranker's blob fetch does not read a column it discards. - Eval judge pinned to `qwen3.8`: `DEFAULT_JUDGE_MODEL` is `ollama:qwen3.8`, and the reference configs use `Inferact/Qwen3.8-27B-NVFP4` with `extra_body.chat_template_kwargs.reasoning_effort: low`. Results in `docs/benchmarks.md` were judged by `Qwen3.6-35B-A3B-NVFP4` and are not re-judged. diff --git a/docs/installation.md b/docs/installation.md index 5645eee5..923058a1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,7 +10,7 @@ uv pip install haiku.rag ``` -The full package pulls the `docling`, `voyageai`, `cohere`, `zeroentropy`, `cross-encoder` and `tui` extras: +The full package pulls the `docling`, `voyageai`, `cohere`, `zeroentropy`, `cross-encoder`, `jina` and `tui` extras: - **Document processing** (Docling) - PDF, DOCX, PPTX, images, and 40+ file formats - **Embedding providers** - VoyageAI and Cohere - **Rerankers** - local cross-encoders, local Jina, Cohere, Zero Entropy diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index b122ae8d..baf71b49 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -6,7 +6,17 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida from haiku.rag.utils import get_default_data_dir -class ModelConfig(BaseModel): +class ConfigModel(BaseModel): + """Base for every configuration section. + + Unknown keys are rejected so a typo or a setting that has been renamed or + removed fails at load instead of being silently ignored. + """ + + model_config = ConfigDict(extra="forbid") + + +class ModelConfig(ConfigModel): """Configuration for a language model. Attributes: @@ -30,12 +40,12 @@ class ModelConfig(BaseModel): enable_thinking: bool | None = None temperature: float | None = None - max_tokens: int | None = None + max_tokens: int | None = Field(default=None, gt=0) vision: bool = False extra_body: dict | None = None -class EmbeddingModelConfig(BaseModel): +class EmbeddingModelConfig(ConfigModel): """Configuration for an embedding model. Attributes: @@ -50,18 +60,18 @@ class EmbeddingModelConfig(BaseModel): provider: str = "ollama" name: str = "qwen3-embedding:4b" - vector_dim: int = 2560 + vector_dim: int = Field(default=2560, gt=0) base_url: str | None = None multimodal: bool = False -class StorageConfig(BaseModel): +class StorageConfig(ConfigModel): data_dir: Path = Field(default_factory=get_default_data_dir) auto_vacuum: bool = True - vacuum_retention_seconds: int = 86400 + vacuum_retention_seconds: int = Field(default=86400, ge=0) -class LanceDBConfig(BaseModel): +class LanceDBConfig(ConfigModel): """LanceDB connection settings. read_consistency_interval_seconds bounds how stale a reader may be. None @@ -79,12 +89,12 @@ class LanceDBConfig(BaseModel): metadata_cache_size_bytes: int | None = Field(default=None, ge=0) -class EmbeddingsConfig(BaseModel): +class EmbeddingsConfig(ConfigModel): model: EmbeddingModelConfig = Field(default_factory=EmbeddingModelConfig) - batch_size: int = 512 + batch_size: int = Field(default=512, gt=0) -class RerankingConfig(BaseModel): +class RerankingConfig(ConfigModel): """Configuration for reranking search results. Attributes: @@ -97,7 +107,7 @@ class RerankingConfig(BaseModel): multimodal: bool = False -class QAConfig(BaseModel): +class QAConfig(ConfigModel): model: ModelConfig = Field( default_factory=lambda: ModelConfig( provider="ollama", @@ -106,10 +116,10 @@ class QAConfig(BaseModel): temperature=0.3, ) ) - max_searches: int = 5 + max_searches: int = Field(default=5, ge=0) -class AnalysisConfig(BaseModel): +class AnalysisConfig(ConfigModel): """Driving model and sandbox limits for the analysis capability. ``model`` defaults to ``None``, meaning "no override — use ``qa.model``." @@ -118,12 +128,12 @@ class AnalysisConfig(BaseModel): (e.g. a stronger model for computational tasks).""" model: ModelConfig | None = None - code_timeout: float = 60.0 - max_output_chars: int = 50_000 - max_executions: int = 15 + code_timeout: float = Field(default=60.0, gt=0) + max_output_chars: int = Field(default=50_000, gt=0) + max_executions: int = Field(default=15, ge=0) -class DuplicateDetectionConfig(BaseModel): +class DuplicateDetectionConfig(ConfigModel): """Thresholds for doctor's near-duplicate document detection. Detection clusters whole documents whose embedding centroids are nearly @@ -132,17 +142,17 @@ class DuplicateDetectionConfig(BaseModel): documents too small to compare meaningfully. """ - similarity_threshold: float = 0.97 - min_chunks: int = 3 + similarity_threshold: float = Field(default=0.97, ge=0.0, le=1.0) + min_chunks: int = Field(default=3, gt=0) -class DoctorConfig(BaseModel): +class DoctorConfig(ConfigModel): duplicates: DuplicateDetectionConfig = Field( default_factory=DuplicateDetectionConfig ) -class PictureDescriptionConfig(BaseModel): +class PictureDescriptionConfig(ConfigModel): """How the VLM runs over each picture when it runs at all. Activation lives on ``ProcessingConfig.pictures`` — these fields only @@ -156,11 +166,11 @@ class PictureDescriptionConfig(BaseModel): temperature=0.0, ) ) - timeout: int = 90 - max_tokens: int = 200 + timeout: int = Field(default=90, gt=0) + max_tokens: int = Field(default=200, gt=0) -class ConversionOptions(BaseModel): +class ConversionOptions(ConfigModel): """Options for document conversion.""" # OCR options @@ -177,7 +187,7 @@ class ConversionOptions(BaseModel): table_cell_matching: bool = True # Image options - images_scale: float = 2.0 + images_scale: float = Field(default=2.0, gt=0) generate_page_images: bool = True # Fetch images referenced by URL in HTML and Markdown inputs. @@ -192,11 +202,11 @@ class ConversionOptions(BaseModel): PicturesMode = Literal["none", "description", "image"] -class ProcessingConfig(BaseModel): - chunk_size: int = 256 - converter: str = "docling-local" - chunker: str = "docling-local" - chunker_type: str = "hybrid" +class ProcessingConfig(ConfigModel): + chunk_size: int = Field(default=256, gt=0) + converter: Literal["docling-local", "docling-serve"] = "docling-local" + chunker: Literal["docling-local", "docling-serve"] = "docling-local" + chunker_type: Literal["hybrid", "hierarchical"] = "hybrid" chunking_tokenizer: str = "Qwen/Qwen3-Embedding-0.6B" chunking_merge_peers: bool = True chunking_use_markdown_tables: bool = False @@ -226,7 +236,7 @@ class ProcessingConfig(BaseModel): - ``"image"``: docling generates picture images and stores them in ``document_items.picture_data``; no VLM runs at ingest. """ - min_picture_size: int = 64 + min_picture_size: int = Field(default=64, ge=0) """Minimum pixel size (smaller side) for a picture to become a picture chunk. Smaller pictures — icons, bullets, decorative graphics — are not embedded or indexed; their bytes stay in ``document_items`` for context @@ -247,14 +257,14 @@ class ProcessingConfig(BaseModel): ) -class SearchConfig(BaseModel): - limit: int = 5 - max_context_chars: int = 5000 +class SearchConfig(ConfigModel): + limit: int = Field(default=5, gt=0) + max_context_chars: int = Field(default=5000, gt=0) vector_index_metric: Literal["cosine", "l2", "dot"] = "cosine" - vector_refine_factor: int = 30 + vector_refine_factor: int = Field(default=30, gt=0) -class OllamaConfig(BaseModel): +class OllamaConfig(ConfigModel): base_url: str = Field( default_factory=lambda: __import__("os").environ.get( "OLLAMA_BASE_URL", "http://localhost:11434" @@ -262,21 +272,22 @@ class OllamaConfig(BaseModel): ) -class CircuitBreakerConfig(BaseModel): +class CircuitBreakerConfig(ConfigModel): """Breaker over repeated failures of a single target. Stops callers from hammering a target that's persistently failing (an ingester source, a docling-serve instance).""" failure_threshold: int = Field( - default=5, description="Consecutive failures before the breaker opens." + default=5, gt=0, description="Consecutive failures before the breaker opens." ) cooldown_s: float = Field( default=600.0, + ge=0, description="How long the breaker stays open before allowing a probe.", ) -class DoclingServeConfig(BaseModel): +class DoclingServeConfig(ConfigModel): """docling-serve endpoints. Accepts a single URL or a list — when a list is given, the client round-robins jobs across the URLs, fails a request over to another instance when one crashes or returns 5xx, and trips a per-instance @@ -292,6 +303,7 @@ class DoclingServeConfig(BaseModel): api_key: str = "" max_attempts: int = Field( default=3, + gt=0, description="Max attempts per request across the fleet before giving up; " "each retry fails over to another instance.", ) @@ -314,12 +326,12 @@ class DoclingServeConfig(BaseModel): return list(self.base_url) or ["http://localhost:5001"] -class ProvidersConfig(BaseModel): +class ProvidersConfig(ConfigModel): ollama: OllamaConfig = Field(default_factory=OllamaConfig) docling_serve: DoclingServeConfig = Field(default_factory=DoclingServeConfig) -class PromptsConfig(BaseModel): +class PromptsConfig(ConfigModel): domain_preamble: str = "" picture_description: str = ( "Describe this image for a blind user. " @@ -329,7 +341,7 @@ class PromptsConfig(BaseModel): ) -class EvaluationsConfig(BaseModel): +class EvaluationsConfig(ConfigModel): """Settings consumed only by the `evaluations` package.""" judge: ModelConfig | None = Field( @@ -342,7 +354,7 @@ class EvaluationsConfig(BaseModel): ) -class QueueConfig(BaseModel): +class QueueConfig(ConfigModel): """Job queue for the production ingester. Defaults to a filesystem SQLite file; set `dburi` to point it at a database server instead.""" @@ -357,29 +369,31 @@ class QueueConfig(BaseModel): ) retention_days: int | None = Field( default=30, + ge=0, description="Delete succeeded/dead jobs whose completed_at is older " "than this many days. The reaper enforces it on reaper_interval_s. " "None disables pruning (keep all terminal rows).", ) -class RetryPolicyConfig(BaseModel): +class RetryPolicyConfig(ConfigModel): """Per-job retry policy. Per-source override is allowed under SourceConfig.retry so a flaky source doesn't drag the rest of the queue.""" - max_attempts: int = 5 - base_delay_s: float = 2.0 - max_delay_s: float = 300.0 + max_attempts: int = Field(default=5, gt=0) + base_delay_s: float = Field(default=2.0, ge=0) + max_delay_s: float = Field(default=300.0, ge=0) jitter: float = Field(default=0.25, ge=0.0, le=1.0) -class WorkerConfig(BaseModel): +class WorkerConfig(ConfigModel): # Reject unknown keys so a renamed/removed setting (e.g. the former # claim_timeout_s) fails loudly instead of being silently ignored. model_config = ConfigDict(extra="forbid", validate_assignment=True) worker_count: int = Field( default=4, + ge=0, description="Number of async worker tasks pulling from the queue. " "Each worker holds at most one job at a time, so worker_count is " "also the maximum number of concurrent in-flight jobs. Size to the " @@ -388,6 +402,7 @@ class WorkerConfig(BaseModel): ) poll_idle_interval_s: float = Field( default=1.0, + gt=0, description="How long an idle worker waits between empty claim_next " "polls. Lower = lower latency picking up new jobs, higher = less " "queue churn when the queue is usually empty.", @@ -411,12 +426,14 @@ class WorkerConfig(BaseModel): ) reaper_interval_s: int = Field( default=60, + gt=0, description="How often the reaper scans for stale claims. Shorter " "lowers the worst-case recovery time after a worker crash.", ) retry: RetryPolicyConfig = Field(default_factory=RetryPolicyConfig) shutdown_grace_s: float = Field( default=60.0, + ge=0, description="On SIGINT/SIGTERM, how long to wait for in-flight jobs to " "finish before forcing cancellation. Cancelled jobs are released back " "to `queued` for immediate re-claim.", @@ -432,7 +449,7 @@ class WorkerConfig(BaseModel): return self -class APIConfig(BaseModel): +class APIConfig(ConfigModel): """HTTP control plane settings for the ingester.""" # Validate on assignment so CLI overrides (e.g. --root-path) run the same @@ -441,7 +458,7 @@ class APIConfig(BaseModel): enabled: bool = True host: str = "127.0.0.1" - port: int = 8765 + port: int = Field(default=8765, ge=0, le=65535) auth_token: str | None = None root_path: str = Field( default="", @@ -465,7 +482,7 @@ class APIConfig(BaseModel): return trimmed -class _SourceBase(BaseModel): +class _SourceBase(ConfigModel): """Fields common to every source. `id` is optional; if omitted the source derives a deterministic id from its target (root path / bucket+prefix / user-supplied tag).""" @@ -474,6 +491,7 @@ class _SourceBase(BaseModel): delete_orphans: bool = True poll_interval_s: float = Field( default=300.0, + gt=0, description="How often discover() runs. FS additionally uses watchfiles " "for push events between sweeps.", ) @@ -485,6 +503,7 @@ class _SourceBase(BaseModel): circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig) max_file_size: int | None = Field( default=None, + gt=0, description="Maximum file size in bytes to fetch. Files larger than " "this are rejected with a PermanentError. None = no limit.", ) @@ -562,7 +581,7 @@ SourceConfig = Annotated[ ] -class IngesterConfig(BaseModel): +class IngesterConfig(ConfigModel): """Production ingester settings.""" sources: list[SourceConfig] = [] @@ -571,7 +590,7 @@ class IngesterConfig(BaseModel): api: APIConfig = Field(default_factory=APIConfig) -class AppConfig(BaseModel): +class AppConfig(ConfigModel): environment: str = "production" storage: StorageConfig = Field(default_factory=StorageConfig) lancedb: LanceDBConfig = Field(default_factory=LanceDBConfig) diff --git a/haiku_rag_slim/haiku/rag/reranking/__init__.py b/haiku_rag_slim/haiku/rag/reranking/__init__.py index eca838cc..e98807b9 100644 --- a/haiku_rag_slim/haiku/rag/reranking/__init__.py +++ b/haiku_rag_slim/haiku/rag/reranking/__init__.py @@ -3,8 +3,12 @@ from haiku.rag.reranking.base import RerankerBase def get_reranker(config: AppConfig | None = None) -> RerankerBase | None: - """Build the configured reranker, or None if reranking is disabled or its - optional dependency is not installed.""" + """Build the configured reranker, or None if reranking is disabled. + + A configured reranker whose optional dependency is missing raises: the + provider modules import their dependency at module scope and name the + extra to install. + """ config = config if config is not None else get_config() model = config.reranking.model if model is None: @@ -13,43 +17,38 @@ def get_reranker(config: AppConfig | None = None) -> RerankerBase | None: if config.reranking.multimodal and model.provider != "vllm": raise ValueError("reranking.multimodal is only supported on the vllm provider") - try: - if model.provider == "cohere": - from haiku.rag.reranking.cohere import CohereReranker + if model.provider == "cohere": + from haiku.rag.reranking.cohere import CohereReranker - return CohereReranker(model.name) + return CohereReranker(model.name) - if model.provider == "vllm": - if not model.base_url: - raise ValueError("vLLM reranker requires base_url in reranking.model") - from haiku.rag.reranking.vllm import VLLMReranker + if model.provider == "vllm": + if not model.base_url: + raise ValueError("vLLM reranker requires base_url in reranking.model") + from haiku.rag.reranking.vllm import VLLMReranker - return VLLMReranker(model.name, model.base_url) + return VLLMReranker(model.name, model.base_url) - if model.provider == "zeroentropy": - from haiku.rag.reranking.zeroentropy import ZeroEntropyReranker + if model.provider == "zeroentropy": + from haiku.rag.reranking.zeroentropy import ZeroEntropyReranker - return ZeroEntropyReranker(model.name or "zerank-1") + return ZeroEntropyReranker(model.name or "zerank-1") - if model.provider == "jina": - from haiku.rag.reranking.jina import JinaReranker + if model.provider == "jina": + from haiku.rag.reranking.jina import JinaReranker - return JinaReranker(model.name or "jina-reranker-v3") + return JinaReranker(model.name or "jina-reranker-v3") - if model.provider == "jina-local": - from haiku.rag.reranking.jina_local import JinaLocalReranker + if model.provider == "jina-local": + from haiku.rag.reranking.jina_local import JinaLocalReranker - return JinaLocalReranker(model.name or "jinaai/jina-reranker-v3") + return JinaLocalReranker(model.name or "jinaai/jina-reranker-v3") - if model.provider == "cross-encoder": - if not model.name: - raise ValueError( - "cross-encoder reranker requires name in reranking.model" - ) - from haiku.rag.reranking.cross_encoder import CrossEncoderReranker + if model.provider == "cross-encoder": + if not model.name: + raise ValueError("cross-encoder reranker requires name in reranking.model") + from haiku.rag.reranking.cross_encoder import CrossEncoderReranker - return CrossEncoderReranker(model.name) - except ImportError: # pragma: no cover - return None + return CrossEncoderReranker(model.name) raise ValueError(f"Unknown reranking provider: {model.provider}") diff --git a/haiku_rag_slim/haiku/rag/reranking/cohere.py b/haiku_rag_slim/haiku/rag/reranking/cohere.py index 29442052..fef87fe6 100644 --- a/haiku_rag_slim/haiku/rag/reranking/cohere.py +++ b/haiku_rag_slim/haiku/rag/reranking/cohere.py @@ -1,12 +1,12 @@ -from haiku.rag.reranking.base import RerankerBase -from haiku.rag.store.models.chunk import Chunk +from haiku.rag.utils import raise_missing_extra try: import cohere -except ImportError as e: # pragma: no cover - raise ImportError( - "cohere is not installed. Please install it with `pip install cohere` or use the cohere optional dependency." - ) from e +except ModuleNotFoundError as e: # pragma: no cover + raise_missing_extra("cohere", "cohere", e) + +from haiku.rag.reranking.base import RerankerBase +from haiku.rag.store.models.chunk import Chunk class CohereReranker(RerankerBase): # pragma: no cover diff --git a/haiku_rag_slim/haiku/rag/reranking/cross_encoder.py b/haiku_rag_slim/haiku/rag/reranking/cross_encoder.py index a27f3954..58bb0a87 100644 --- a/haiku_rag_slim/haiku/rag/reranking/cross_encoder.py +++ b/haiku_rag_slim/haiku/rag/reranking/cross_encoder.py @@ -1,14 +1,15 @@ import asyncio import math +from haiku.rag.utils import raise_missing_extra + try: import torch from sentence_transformers import CrossEncoder -except ImportError as e: # pragma: no cover - raise ImportError( - "sentence-transformers is not installed. Install it with " - "`pip install sentence-transformers` or use the cross-encoder optional dependency." - ) from e +except ModuleNotFoundError as e: # pragma: no cover + if e.name not in ("torch", "sentence_transformers"): + raise + raise_missing_extra(e.name, "cross-encoder", e) from haiku.rag.reranking.base import RerankerBase from haiku.rag.store.models.chunk import Chunk diff --git a/haiku_rag_slim/haiku/rag/reranking/jina_local.py b/haiku_rag_slim/haiku/rag/reranking/jina_local.py index 39a7ccee..93f34a99 100644 --- a/haiku_rag_slim/haiku/rag/reranking/jina_local.py +++ b/haiku_rag_slim/haiku/rag/reranking/jina_local.py @@ -1,12 +1,13 @@ import asyncio +from haiku.rag.utils import raise_missing_extra + try: from transformers import AutoModel -except ImportError as e: # pragma: no cover - raise ImportError( - "transformers is not installed. Please install it with `pip install transformers torch` " - "or use the jina optional dependency." - ) from e +except ModuleNotFoundError as e: # pragma: no cover + if e.name not in ("torch", "transformers"): + raise + raise_missing_extra(e.name, "jina", e) from haiku.rag.reranking.base import RerankerBase from haiku.rag.store.models.chunk import Chunk diff --git a/haiku_rag_slim/haiku/rag/reranking/zeroentropy.py b/haiku_rag_slim/haiku/rag/reranking/zeroentropy.py index 633fa769..3acebba7 100644 --- a/haiku_rag_slim/haiku/rag/reranking/zeroentropy.py +++ b/haiku_rag_slim/haiku/rag/reranking/zeroentropy.py @@ -1,4 +1,9 @@ -from zeroentropy import AsyncZeroEntropy +from haiku.rag.utils import raise_missing_extra + +try: + from zeroentropy import AsyncZeroEntropy +except ModuleNotFoundError as e: # pragma: no cover + raise_missing_extra("zeroentropy", "zeroentropy", e) from haiku.rag.reranking.base import RerankerBase from haiku.rag.store.models.chunk import Chunk diff --git a/haiku_rag_slim/haiku/rag/utils.py b/haiku_rag_slim/haiku/rag/utils.py index cac1896a..19c77332 100644 --- a/haiku_rag_slim/haiku/rag/utils.py +++ b/haiku_rag_slim/haiku/rag/utils.py @@ -2,7 +2,7 @@ import math import sys from importlib import metadata from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, NoReturn, cast from packaging.version import Version, parse @@ -430,6 +430,21 @@ async def _render_picture( return RichImage(pil) +def raise_missing_extra(module: str, extra: str, exc: ModuleNotFoundError) -> NoReturn: + """Report `module` as a missing optional dependency, naming its extra. + + Re-raises `exc` untouched when the failure came from inside an installed + package rather than from `module` itself, so a broken transitive import is + not misreported as "not installed". + """ + if exc.name != module: + raise exc + raise ImportError( + f"{module} is not installed. Install it with " + f"`uv pip install 'haiku.rag-slim[{extra}]'`." + ) from exc + + def get_default_data_dir() -> Path: """Get the user data directory for the current system platform. diff --git a/pyproject.toml b/pyproject.toml index e6836e02..5cdc8688 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ classifiers = [ ] dependencies = [ - "haiku.rag-slim[docling,voyageai,cohere,zeroentropy,tui,cross-encoder]==0.75.0", + "haiku.rag-slim[docling,voyageai,cohere,zeroentropy,tui,cross-encoder,jina]==0.75.0", ] [project.urls] diff --git a/tests/test_chunker.py b/tests/test_chunker.py index b7dfa9fa..cd5db021 100644 --- a/tests/test_chunker.py +++ b/tests/test_chunker.py @@ -115,7 +115,8 @@ def test_get_chunker_docling_local(): def test_get_chunker_invalid(): """Test factory raises error for invalid chunker.""" config = AppConfig() - config.processing.chunker = "invalid-chunker" + # Deliberately past validation: pins the factory's defensive raise. + config.processing.chunker = "invalid-chunker" # ty: ignore[invalid-assignment] with pytest.raises(ValueError, match="Unsupported chunker"): get_chunker(config) @@ -164,7 +165,7 @@ async def test_local_chunker_hierarchical(qa_corpus: list[dict[str, str]]): def test_local_chunker_invalid_type(): """Test DoclingLocalChunker raises error for invalid chunker_type.""" config = AppConfig() - config.processing.chunker_type = "invalid-type" + config.processing.chunker_type = "invalid-type" # ty: ignore[invalid-assignment] with pytest.raises(ValueError, match="Unsupported chunker_type"): DoclingLocalChunker(config) diff --git a/tests/test_config.py b/tests/test_config.py index fc431bc2..ef1de525 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ from pathlib import Path import pytest import yaml +from pydantic import ValidationError from haiku.rag.config import AppConfig, set_config from haiku.rag.config.loader import ( @@ -622,3 +623,73 @@ def test_set_config_reaches_the_factories(monkeypatch, tmp_path): assert HaikuRAG(tmp_path / "db")._config is cfg assert Store(tmp_path / "db", create=True)._config is cfg + + +@pytest.mark.parametrize( + "data, bad_key", + [ + ({"bogus": 1}, "bogus"), + ({"search": {"bogus": 1}}, "search.bogus"), + ( + {"processing": {"conversion_options": {"bogus": 1}}}, + "processing.conversion_options.bogus", + ), + ( + {"providers": {"docling_serve": {"bogus": 1}}}, + "providers.docling_serve.bogus", + ), + ({"qa": {"model": {"bogus": 1}}}, "qa.model.bogus"), + ({"ingester": {"queue": {"bogus": 1}}}, "ingester.queue.bogus"), + ( + {"ingester": {"sources": [{"type": "fs", "root": "/tmp", "bogus": 1}]}}, + "ingester.sources.0.fs.bogus", + ), + ], +) +def test_unknown_keys_are_rejected(data, bad_key): + with pytest.raises(ValidationError) as excinfo: + AppConfig.model_validate(data) + + errors = excinfo.value.errors() + assert any(err["type"] == "extra_forbidden" for err in errors) + locations = {".".join(str(part) for part in err["loc"]) for err in errors} + assert bad_key in locations + + +@pytest.mark.parametrize( + "data", + [ + {"processing": {"converter": "docling-loca"}}, + {"processing": {"chunker": "docling-remote"}}, + {"processing": {"chunker_type": "semantic"}}, + ], +) +def test_finite_switches_reject_unknown_values(data): + with pytest.raises(ValidationError): + AppConfig.model_validate(data) + + +@pytest.mark.parametrize( + "data", + [ + {"search": {"limit": 0}}, + {"search": {"max_context_chars": 0}}, + {"embeddings": {"batch_size": 0}}, + {"embeddings": {"model": {"vector_dim": 0}}}, + {"processing": {"chunk_size": 0}}, + {"storage": {"vacuum_retention_seconds": -1}}, + {"analysis": {"code_timeout": 0}}, + {"doctor": {"duplicates": {"similarity_threshold": 1.5}}}, + {"ingester": {"workers": {"worker_count": -1}}}, + {"ingester": {"api": {"port": 70000}}}, + {"ingester": {"queue": {"retention_days": -1}}}, + {"ingester": {"sources": [{"type": "fs", "root": "/tmp", "max_file_size": 0}]}}, + {"qa": {"model": {"max_tokens": 0}}}, + {"providers": {"docling_serve": {"max_attempts": 0}}}, + {"providers": {"docling_serve": {"circuit_breaker": {"failure_threshold": 0}}}}, + {"providers": {"docling_serve": {"circuit_breaker": {"cooldown_s": -1}}}}, + ], +) +def test_out_of_range_numbers_are_rejected(data): + with pytest.raises(ValidationError): + AppConfig.model_validate(data) diff --git a/tests/test_converters.py b/tests/test_converters.py index 441eb2a0..9d6f07fb 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -245,7 +245,8 @@ class TestConverterFactory: def test_invalid_converter_raises_error(self): """Test that invalid converter name raises ValueError.""" config = AppConfig() - config.processing.converter = "invalid-converter" + # Deliberately past validation: pins the factory's defensive raise. + config.processing.converter = "invalid-converter" # ty: ignore[invalid-assignment] with pytest.raises(ValueError, match="Unsupported converter provider"): get_converter(config) diff --git a/tests/test_reranker.py b/tests/test_reranker.py index eb4046eb..77dcb5fe 100644 --- a/tests/test_reranker.py +++ b/tests/test_reranker.py @@ -7,6 +7,7 @@ from haiku.rag.config.models import AppConfig, ModelConfig, RerankingConfig from haiku.rag.reranking import get_reranker from haiku.rag.reranking.base import RerankerBase from haiku.rag.store.models.chunk import Chunk +from haiku.rag.utils import raise_missing_extra # Providers whose constructor loads a model in-process. Factory-routing tests # patch the loader so they assert dispatch without paying the model load. @@ -547,3 +548,51 @@ async def test_cross_encoder_reranks_via_model_ranking(monkeypatch): stub_logit = 1.0 - last_index / 10 assert reranked[0][1] == pytest.approx(1.0 / (1.0 + math.exp(-stub_logit))) assert isinstance(reranker._reranker.activation_fn, torch.nn.Identity) + + +def test_missing_reranker_dependency_raises(monkeypatch): + """A configured reranker whose extra is not installed must fail, not + silently disable reranking.""" + import sys + + monkeypatch.setitem(sys.modules, "haiku.rag.reranking.zeroentropy", None) + + config = AppConfig( + reranking=RerankingConfig( + model=ModelConfig(provider="zeroentropy", name="zerank-1") + ) + ) + + with pytest.raises(ImportError): + get_reranker(config) + + +def test_missing_extra_names_the_install_command(): + """The error tells the operator exactly what to install.""" + exc = ModuleNotFoundError("No module named 'cohere'", name="cohere") + + with pytest.raises(ImportError) as excinfo: + raise_missing_extra("cohere", "cohere", exc) + + message = str(excinfo.value) + assert "haiku.rag-slim[cohere]" in message + assert excinfo.value.__cause__ is exc + + +def test_failure_inside_an_installed_package_is_not_reported_as_missing(): + """A broken transitive import must propagate untouched instead of claiming + the package is not installed.""" + exc = ModuleNotFoundError("No module named 'torch._C'", name="torch._C") + + with pytest.raises(ModuleNotFoundError) as excinfo: + raise_missing_extra("sentence_transformers", "cross-encoder", exc) + + assert excinfo.value is exc + + +def test_installed_reranker_extra_is_importable(): + """The guard must not fire for a dependency that is installed: the module + imports and the reranker is constructible.""" + import haiku.rag.reranking.cohere as cohere_module + + assert cohere_module.CohereReranker is not None diff --git a/uv.lock b/uv.lock index 99dac8fa..38dd9106 100644 --- a/uv.lock +++ b/uv.lock @@ -1580,7 +1580,7 @@ name = "haiku-rag" version = "0.75.0" source = { editable = "." } dependencies = [ - { name = "haiku-rag-slim", extra = ["cohere", "cross-encoder", "docling", "tui", "voyageai", "zeroentropy"] }, + { name = "haiku-rag-slim", extra = ["cohere", "cross-encoder", "docling", "jina", "tui", "voyageai", "zeroentropy"] }, ] [package.optional-dependencies] @@ -1616,7 +1616,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "haiku-rag-slim", extras = ["cross-encoder"], marker = "extra == 'cross-encoder'", editable = "haiku_rag_slim" }, - { name = "haiku-rag-slim", extras = ["docling", "voyageai", "cohere", "zeroentropy", "tui", "cross-encoder"], editable = "haiku_rag_slim" }, + { name = "haiku-rag-slim", extras = ["docling", "voyageai", "cohere", "zeroentropy", "tui", "cross-encoder", "jina"], editable = "haiku_rag_slim" }, { name = "haiku-rag-slim", extras = ["ingester"], marker = "extra == 'ingester'", editable = "haiku_rag_slim" }, { name = "haiku-rag-slim", extras = ["s3"], marker = "extra == 's3'", editable = "haiku_rag_slim" }, { name = "textual", marker = "extra == 'tui'", specifier = ">=8.2.4" },