Merge pull request #561 from ggozad/feat/strict-config

Reject unknown and out-of-range configuration values
This commit is contained in:
Yiorgis Gozadinos 2026-08-19 15:42:36 +03:00 committed by GitHub
commit 058a820e14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 274 additions and 107 deletions

View file

@ -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.

View file

@ -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

View file

@ -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)

View file

@ -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}")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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]

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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" },