From 345807e69912d086be8950dd5c666f0249e717dc Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 15 Jan 2026 10:17:26 +0200 Subject: [PATCH 1/4] Remove defensive app() try/except, let errors propagate if they occur --- haiku_rag_slim/haiku/rag/app.py | 336 ++++++++++------------ haiku_rag_slim/haiku/rag/chat/__init__.py | 2 +- 2 files changed, 157 insertions(+), 181 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 0725d035..06a71ca0 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -78,12 +78,8 @@ class HaikuRAGApp: return # Connect without going through Store to avoid upgrades/validation writes - try: - db = lancedb.connect(self.db_path) - table_names = set(db.table_names()) - except Exception as e: - self.console.print(f"[red]Failed to open database: {e}[/red]") - return + db = lancedb.connect(self.db_path) + table_names = set(db.table_names()) versions = get_package_versions() @@ -418,50 +414,47 @@ class HaikuRAGApp: read_only=self.read_only, before=self.before, ) as self.client: - try: - citations = [] - if deep: - graph = build_research_graph(config=self.config) - context = ResearchContext(original_question=question) - state = ResearchState.from_config( - context=context, - config=self.config, - max_iterations=2, - confidence_threshold=0.0, - ) - state.search_filter = filter - deps = ResearchDeps(client=self.client) + citations = [] + if deep: + graph = build_research_graph(config=self.config) + context = ResearchContext(original_question=question) + state = ResearchState.from_config( + context=context, + config=self.config, + max_iterations=2, + confidence_threshold=0.0, + ) + state.search_filter = filter + deps = ResearchDeps(client=self.client) - report = await graph.run(state=state, deps=deps) + report = await graph.run(state=state, deps=deps) - self.console.print(f"[bold blue]Question:[/bold blue] {question}") - self.console.print() - if report: - self.console.print("[bold green]Answer:[/bold green]") - self.console.print(Markdown(report.executive_summary)) - if report.main_findings: - self.console.print() - self.console.print("[bold cyan]Key Findings:[/bold cyan]") - for finding in report.main_findings: - self.console.print(f"• {finding}") - if report.sources_summary: - self.console.print() - self.console.print("[bold cyan]Sources:[/bold cyan]") - self.console.print(report.sources_summary) - else: - self.console.print("[yellow]No answer generated.[/yellow]") - else: - answer, citations = await self.client.ask(question, filter=filter) - - self.console.print(f"[bold blue]Question:[/bold blue] {question}") - self.console.print() + self.console.print(f"[bold blue]Question:[/bold blue] {question}") + self.console.print() + if report: self.console.print("[bold green]Answer:[/bold green]") - self.console.print(Markdown(answer)) - if cite and citations: - for renderable in format_citations_rich(citations): - self.console.print(renderable) - except Exception as e: - self.console.print(f"[red]Error: {e}[/red]") + self.console.print(Markdown(report.executive_summary)) + if report.main_findings: + self.console.print() + self.console.print("[bold cyan]Key Findings:[/bold cyan]") + for finding in report.main_findings: + self.console.print(f"• {finding}") + if report.sources_summary: + self.console.print() + self.console.print("[bold cyan]Sources:[/bold cyan]") + self.console.print(report.sources_summary) + else: + self.console.print("[yellow]No answer generated.[/yellow]") + else: + answer, citations = await self.client.ask(question, filter=filter) + + self.console.print(f"[bold blue]Question:[/bold blue] {question}") + self.console.print() + self.console.print("[bold green]Answer:[/bold green]") + self.console.print(Markdown(answer)) + if cite and citations: + for renderable in format_citations_rich(citations): + self.console.print(renderable) async def research(self, question: str, filter: str | None = None): """Run research via the pydantic-graph pipeline. @@ -476,77 +469,73 @@ class HaikuRAGApp: read_only=self.read_only, before=self.before, ) as client: - try: - self.console.print("[bold cyan]Starting research[/bold cyan]") - self.console.print(f"[bold blue]Question:[/bold blue] {question}") + self.console.print("[bold cyan]Starting research[/bold cyan]") + self.console.print(f"[bold blue]Question:[/bold blue] {question}") + self.console.print() + + graph = build_research_graph(config=self.config) + context = ResearchContext(original_question=question) + state = ResearchState.from_config(context=context, config=self.config) + state.search_filter = filter + deps = ResearchDeps(client=client) + + report = await graph.run(state=state, deps=deps) + + if report is None: + self.console.print("[red]Research did not produce a report.[/red]") + return + + # Display the report + self.console.print("[bold green]Research Report[/bold green]") + self.console.rule() + + # Title and Executive Summary + self.console.print(f"[bold]{report.title}[/bold]") + self.console.print() + self.console.print("[bold cyan]Executive Summary:[/bold cyan]") + self.console.print(report.executive_summary) + self.console.print() + + # Confidence (from last evaluation) + if state.last_eval: + conf = state.last_eval.confidence_score # type: ignore[attr-defined] + self.console.print(f"[bold cyan]Confidence:[/bold cyan] {conf:.1%}") self.console.print() - graph = build_research_graph(config=self.config) - context = ResearchContext(original_question=question) - state = ResearchState.from_config(context=context, config=self.config) - state.search_filter = filter - deps = ResearchDeps(client=client) - - report = await graph.run(state=state, deps=deps) - - if report is None: - self.console.print("[red]Research did not produce a report.[/red]") - return - - # Display the report - self.console.print("[bold green]Research Report[/bold green]") - self.console.rule() - - # Title and Executive Summary - self.console.print(f"[bold]{report.title}[/bold]") - self.console.print() - self.console.print("[bold cyan]Executive Summary:[/bold cyan]") - self.console.print(report.executive_summary) + # Main Findings + if report.main_findings: + self.console.print("[bold cyan]Main Findings:[/bold cyan]") + for finding in report.main_findings: + self.console.print(f"• {finding}") self.console.print() - # Confidence (from last evaluation) - if state.last_eval: - conf = state.last_eval.confidence_score # type: ignore[attr-defined] - self.console.print(f"[bold cyan]Confidence:[/bold cyan] {conf:.1%}") - self.console.print() + # (Themes section removed) - # Main Findings - if report.main_findings: - self.console.print("[bold cyan]Main Findings:[/bold cyan]") - for finding in report.main_findings: - self.console.print(f"• {finding}") - self.console.print() + # Conclusions + if report.conclusions: + self.console.print("[bold cyan]Conclusions:[/bold cyan]") + for conclusion in report.conclusions: + self.console.print(f"• {conclusion}") + self.console.print() - # (Themes section removed) + # Recommendations + if report.recommendations: + self.console.print("[bold cyan]Recommendations:[/bold cyan]") + for rec in report.recommendations: + self.console.print(f"• {rec}") + self.console.print() - # Conclusions - if report.conclusions: - self.console.print("[bold cyan]Conclusions:[/bold cyan]") - for conclusion in report.conclusions: - self.console.print(f"• {conclusion}") - self.console.print() + # Limitations + if report.limitations: + self.console.print("[bold yellow]Limitations:[/bold yellow]") + for limitation in report.limitations: + self.console.print(f"• {limitation}") + self.console.print() - # Recommendations - if report.recommendations: - self.console.print("[bold cyan]Recommendations:[/bold cyan]") - for rec in report.recommendations: - self.console.print(f"• {rec}") - self.console.print() - - # Limitations - if report.limitations: - self.console.print("[bold yellow]Limitations:[/bold yellow]") - for limitation in report.limitations: - self.console.print(f"• {limitation}") - self.console.print() - - # Sources Summary - if report.sources_summary: - self.console.print("[bold cyan]Sources:[/bold cyan]") - self.console.print(report.sources_summary) - - except Exception as e: - self.console.print(f"[red]Error during research: {e}[/red]") + # Sources Summary + if report.sources_summary: + self.console.print("[bold cyan]Sources:[/bold cyan]") + self.console.print(report.sources_summary) async def rebuild(self, mode: RebuildMode = RebuildMode.FULL): async with HaikuRAG( @@ -556,89 +545,76 @@ class HaikuRAGApp: read_only=self.read_only, before=self.before, ) as client: - try: - documents = await client.list_documents() - total_docs = len(documents) + documents = await client.list_documents() + total_docs = len(documents) - if total_docs == 0: - self.console.print( - "[yellow]No documents found in database.[/yellow]" - ) - return + if total_docs == 0: + self.console.print("[yellow]No documents found in database.[/yellow]") + return - mode_desc = { - RebuildMode.FULL: "full rebuild", - RebuildMode.RECHUNK: "rechunk", - RebuildMode.EMBED_ONLY: "embed only", - }[mode] + mode_desc = { + RebuildMode.FULL: "full rebuild", + RebuildMode.RECHUNK: "rechunk", + RebuildMode.EMBED_ONLY: "embed only", + }[mode] - self.console.print( - f"[bold cyan]Rebuilding database ({mode_desc}) with {total_docs} documents...[/bold cyan]" - ) - with Progress() as progress: - task = progress.add_task("Rebuilding...", total=total_docs) - async for _ in client.rebuild_database(mode=mode): - progress.update(task, advance=1) + self.console.print( + f"[bold cyan]Rebuilding database ({mode_desc}) with {total_docs} documents...[/bold cyan]" + ) + with Progress() as progress: + task = progress.add_task("Rebuilding...", total=total_docs) + async for _ in client.rebuild_database(mode=mode): + progress.update(task, advance=1) - self.console.print( - "[bold green]Database rebuild completed successfully.[/bold green]" - ) - except Exception as e: - self.console.print(f"[red]Error rebuilding database: {e}[/red]") + self.console.print( + "[bold green]Database rebuild completed successfully.[/bold green]" + ) async def vacuum(self): """Run database maintenance: optimize and cleanup table history.""" - try: - async with HaikuRAG( - db_path=self.db_path, - config=self.config, - skip_validation=True, - read_only=self.read_only, - before=self.before, - ) as client: - await client.vacuum() - self.console.print( - "[bold green]Vacuum completed successfully.[/bold green]" - ) - except Exception as e: - self.console.print(f"[red]Error during vacuum: {e}[/red]") + async with HaikuRAG( + db_path=self.db_path, + config=self.config, + skip_validation=True, + read_only=self.read_only, + before=self.before, + ) as client: + await client.vacuum() + self.console.print("[bold green]Vacuum completed successfully.[/bold green]") async def create_index(self): """Create vector index on the chunks table.""" - try: - async with HaikuRAG( - db_path=self.db_path, - config=self.config, - skip_validation=True, - read_only=self.read_only, - before=self.before, - ) as client: - row_count = client.store.chunks_table.count_rows() - self.console.print(f"Chunks in database: {row_count}") + async with HaikuRAG( + db_path=self.db_path, + config=self.config, + skip_validation=True, + read_only=self.read_only, + before=self.before, + ) as client: + row_count = client.store.chunks_table.count_rows() + self.console.print(f"Chunks in database: {row_count}") - if row_count < 256: - self.console.print( - f"[yellow]Warning: Need at least 256 chunks to create an index (have {row_count})[/yellow]" - ) - return - - # Check if index already exists - indices = client.store.chunks_table.list_indices() - has_vector_index = any("vector" in str(idx).lower() for idx in indices) - - if has_vector_index: - self.console.print( - "[yellow]Rebuilding existing vector index...[/yellow]" - ) - else: - self.console.print("[bold]Creating vector index...[/bold]") - - client.store._ensure_vector_index() + if row_count < 256: self.console.print( - "[bold green]Vector index created successfully.[/bold green]" + f"[yellow]Warning: Need at least 256 chunks to create an index (have {row_count})[/yellow]" ) - except Exception as e: - self.console.print(f"[red]Error creating index: {e}[/red]") + return + + # Check if index already exists + indices = client.store.chunks_table.list_indices() + has_vector_index = any("vector" in str(idx).lower() for idx in indices) + + if has_vector_index: + self.console.print( + "[yellow]Rebuilding existing vector index...[/yellow]" + ) + else: + self.console.print("[bold]Creating vector index...[/bold]") + + client.store._ensure_vector_index() + self.console.print( + "[bold green]Vector index created successfully.[/bold green]" + ) async def download_models(self): """Download Docling, HuggingFace tokenizer, and Ollama models per config.""" diff --git a/haiku_rag_slim/haiku/rag/chat/__init__.py b/haiku_rag_slim/haiku/rag/chat/__init__.py index aecde32a..6045a8b2 100644 --- a/haiku_rag_slim/haiku/rag/chat/__init__.py +++ b/haiku_rag_slim/haiku/rag/chat/__init__.py @@ -16,7 +16,7 @@ def run_chat( """ try: from haiku.rag.chat.app import ChatApp - except ImportError as e: + except ImportError as e: # pragma: no cover raise ImportError( "textual is not installed. Please install it with `pip install 'haiku.rag-slim[tui]'` or use the full haiku.rag package." ) from e From fae274a04d98c89d1ac7cdadfbe496855fb4ce21 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 15 Jan 2026 10:30:45 +0200 Subject: [PATCH 2/4] Remove defensive checks in info() --- haiku_rag_slim/haiku/rag/app.py | 59 ++++++------------- .../haiku/rag/store/repositories/settings.py | 7 +-- 2 files changed, 19 insertions(+), 47 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 06a71ca0..6305bc70 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -79,7 +79,6 @@ class HaikuRAGApp: # Connect without going through Store to avoid upgrades/validation writes db = lancedb.connect(self.db_path) - table_names = set(db.table_names()) versions = get_package_versions() @@ -90,24 +89,17 @@ class HaikuRAGApp: table_stats = store.get_stats() # Read settings after Store init (migrations have run) - stored_version = "unknown" - embed_provider: str | None = None - embed_model: str | None = None - vector_dim: int | None = None - - if "settings" in table_names: - settings_tbl = db.open_table("settings") - arrow = settings_tbl.search().where("id = 'settings'").limit(1).to_arrow() - rows = arrow.to_pylist() if arrow is not None else [] - if rows: - raw = rows[0].get("settings") or "{}" - data = json.loads(raw) if isinstance(raw, str) else (raw or {}) - stored_version = str(data.get("version", stored_version)) - embeddings = data.get("embeddings", {}) - embed_model_obj = embeddings.get("model", {}) - embed_provider = embed_model_obj.get("provider") - embed_model = embed_model_obj.get("name") - vector_dim = embed_model_obj.get("vector_dim") + settings_tbl = db.open_table("settings") + arrow = settings_tbl.search().where("id = 'settings'").limit(1).to_arrow() + rows = arrow.to_pylist() + raw = rows[0].get("settings") or "{}" + data = json.loads(raw) if isinstance(raw, str) else (raw or {}) + stored_version = str(data.get("version", "unknown")) + embeddings = data.get("embeddings", {}) + embed_model_obj = embeddings.get("model", {}) + embed_provider = embed_model_obj.get("provider", "unknown") + embed_model = embed_model_obj.get("name", "unknown") + vector_dim = embed_model_obj.get("vector_dim") store.close() @@ -122,32 +114,17 @@ class HaikuRAGApp: num_unindexed_rows = table_stats["chunks"].get("num_unindexed_rows", 0) # Table versions per table (direct API) - doc_versions = ( - len(list(db.open_table("documents").list_versions())) - if "documents" in table_names - else 0 - ) - chunk_versions = ( - len(list(db.open_table("chunks").list_versions())) - if "chunks" in table_names - else 0 - ) + doc_versions = len(list(db.open_table("documents").list_versions())) + chunk_versions = len(list(db.open_table("chunks").list_versions())) self.console.print( f" [repr.attrib_name]haiku.rag version (db)[/repr.attrib_name]: {stored_version}" ) - if embed_provider or embed_model or vector_dim: - provider_part = embed_provider or "unknown" - model_part = embed_model or "unknown" - dim_part = f"{vector_dim}" if vector_dim is not None else "unknown" - self.console.print( - " [repr.attrib_name]embeddings[/repr.attrib_name]: " - f"{provider_part}/{model_part} (dim: {dim_part})" - ) - else: - self.console.print( - " [repr.attrib_name]embeddings[/repr.attrib_name]: unknown" - ) + dim_part = f"{vector_dim}" if vector_dim is not None else "unknown" + self.console.print( + " [repr.attrib_name]embeddings[/repr.attrib_name]: " + f"{embed_provider}/{embed_model} (dim: {dim_part})" + ) self.console.print( f" [repr.attrib_name]documents[/repr.attrib_name]: {num_docs} " f"({format_bytes(doc_bytes)})" diff --git a/haiku_rag_slim/haiku/rag/store/repositories/settings.py b/haiku_rag_slim/haiku/rag/store/repositories/settings.py index 4e8b8dc2..79f86e4c 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/settings.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/settings.py @@ -85,12 +85,7 @@ class SettingsRepository: if existing: # Preserve existing version if present to avoid interfering with upgrade flow - try: - existing_settings = ( - json.loads(existing[0].settings) if existing[0].settings else {} - ) - except Exception: - existing_settings = {} + existing_settings = json.loads(existing[0].settings) if "version" in existing_settings: current_config["version"] = existing_settings["version"] From bb60ad127ab2677fed86bba8069491d93cc8b086 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 15 Jan 2026 10:32:35 +0200 Subject: [PATCH 3/4] Add pragma nocover to optional dependency imports --- haiku_rag_slim/haiku/rag/embeddings/__init__.py | 2 +- haiku_rag_slim/haiku/rag/inspector/__init__.py | 2 +- haiku_rag_slim/haiku/rag/reranking/__init__.py | 8 ++++---- haiku_rag_slim/haiku/rag/reranking/cohere.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/embeddings/__init__.py b/haiku_rag_slim/haiku/rag/embeddings/__init__.py index f17416e1..53d3036f 100644 --- a/haiku_rag_slim/haiku/rag/embeddings/__init__.py +++ b/haiku_rag_slim/haiku/rag/embeddings/__init__.py @@ -128,7 +128,7 @@ def get_embedder(config: AppConfig = Config) -> EmbedderWrapper: if provider == "voyageai": try: from haiku.rag.embeddings.voyageai import VoyageAIEmbeddingModel - except ImportError: + except ImportError: # pragma: no cover raise ImportError( "VoyageAI embedder requires the 'voyageai' package. " "Please install haiku.rag with the 'voyageai' extra: " diff --git a/haiku_rag_slim/haiku/rag/inspector/__init__.py b/haiku_rag_slim/haiku/rag/inspector/__init__.py index bac3b5e8..6eebb52d 100644 --- a/haiku_rag_slim/haiku/rag/inspector/__init__.py +++ b/haiku_rag_slim/haiku/rag/inspector/__init__.py @@ -1,6 +1,6 @@ try: from haiku.rag.inspector.app import run_inspector -except ImportError as e: +except ImportError as e: # pragma: no cover raise ImportError( "textual is not installed. Please install it with `pip install 'haiku.rag-slim[tui]'` or use the full haiku.rag package." ) from e diff --git a/haiku_rag_slim/haiku/rag/reranking/__init__.py b/haiku_rag_slim/haiku/rag/reranking/__init__.py index b3502db6..b5e52c50 100644 --- a/haiku_rag_slim/haiku/rag/reranking/__init__.py +++ b/haiku_rag_slim/haiku/rag/reranking/__init__.py @@ -30,7 +30,7 @@ def get_reranker(config: AppConfig = Config) -> RerankerBase | None: os.environ["TOKENIZERS_PARALLELISM"] = "true" reranker = MxBAIReranker() - except ImportError: + except ImportError: # pragma: no cover reranker = None elif config.reranking.model and config.reranking.model.provider == "cohere": @@ -38,7 +38,7 @@ def get_reranker(config: AppConfig = Config) -> RerankerBase | None: from haiku.rag.reranking.cohere import CohereReranker reranker = CohereReranker() - except ImportError: + except ImportError: # pragma: no cover reranker = None elif config.reranking.model and config.reranking.model.provider == "vllm": @@ -49,7 +49,7 @@ def get_reranker(config: AppConfig = Config) -> RerankerBase | None: if not base_url: raise ValueError("vLLM reranker requires base_url in reranking.model") reranker = VLLMReranker(config.reranking.model.name, base_url) - except ImportError: + except ImportError: # pragma: no cover reranker = None elif config.reranking.model and config.reranking.model.provider == "zeroentropy": @@ -59,7 +59,7 @@ def get_reranker(config: AppConfig = Config) -> RerankerBase | None: # Use configured model or default to zerank-1 model = config.reranking.model.name or "zerank-1" reranker = ZeroEntropyReranker(model) - except ImportError: + except ImportError: # pragma: no cover reranker = None _reranker_cache[config_id] = reranker diff --git a/haiku_rag_slim/haiku/rag/reranking/cohere.py b/haiku_rag_slim/haiku/rag/reranking/cohere.py index 3d7786b6..9d38a631 100644 --- a/haiku_rag_slim/haiku/rag/reranking/cohere.py +++ b/haiku_rag_slim/haiku/rag/reranking/cohere.py @@ -3,7 +3,7 @@ from haiku.rag.store.models.chunk import Chunk try: import cohere -except ImportError as e: +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 From e0da58bb6c74d495a6c567516bcddb01dc0bd1b1 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 15 Jan 2026 10:44:43 +0200 Subject: [PATCH 4/4] Add tests for settings validation and app operations --- CHANGELOG.md | 4 ++ tests/test_app.py | 92 ++++++++++++++++++++++++++++++++++ tests/test_settings.py | 110 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 205 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fb7dda..0bf7d360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ - Default `None` preserves backwards compatibility (bare state emission) - **Page Image Generation Control**: New `generate_page_images` option in `ConversionOptions` to control PDF page image extraction +### Changed + +- **CLI Error Handling**: Commands (`rebuild`, `vacuum`, `create-index`, `ask`, `research`) now propagate errors with proper exit codes instead of swallowing exceptions + ### Fixed - **Embed-only rebuild with changed vector dimensions**: Fixed `haiku-rag rebuild --embed-only` failing when the configured embedding model has different dimensions than the database diff --git a/tests/test_app.py b/tests/test_app.py index 259e6f08..8443ba36 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -506,3 +506,95 @@ async def test_history_nonexistent_db(tmp_path, monkeypatch): calls = [str(c) for c in mock_print.call_args_list] assert any("does not exist" in c for c in calls) + + +@pytest.mark.asyncio +async def test_init_creates_database(tmp_path, monkeypatch): + """Test init creates a new database.""" + db_path = tmp_path / "new.lancedb" + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + assert not db_path.exists() + await app.init() + + assert db_path.exists() + calls = [str(c) for c in mock_print.call_args_list] + assert any("initialized" in c for c in calls) + + +@pytest.mark.asyncio +async def test_init_existing_database(tmp_path, monkeypatch): + """Test init with existing database shows warning.""" + from haiku.rag.store.engine import Store + + db_path = tmp_path / "existing.lancedb" + store = Store(db_path, create=True) + store.close() + + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + await app.init() + + calls = [str(c) for c in mock_print.call_args_list] + assert any("already exists" in c for c in calls) + + +@pytest.mark.asyncio +async def test_vacuum(tmp_path, monkeypatch): + """Test vacuum operation.""" + from haiku.rag.store.engine import Store + + db_path = tmp_path / "test.lancedb" + store = Store(db_path, create=True) + store.close() + + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + await app.vacuum() + + calls = [str(c) for c in mock_print.call_args_list] + assert any("Vacuum completed" in c for c in calls) + + +@pytest.mark.asyncio +async def test_create_index_insufficient_chunks(tmp_path, monkeypatch): + """Test create_index with insufficient chunks shows warning.""" + from haiku.rag.store.engine import Store + + db_path = tmp_path / "test.lancedb" + store = Store(db_path, create=True) + store.close() + + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + await app.create_index() + + calls = [str(c) for c in mock_print.call_args_list] + assert any("Need at least 256 chunks" in c for c in calls) + + +@pytest.mark.asyncio +async def test_rebuild_empty_database(tmp_path, monkeypatch): + """Test rebuild with empty database shows warning.""" + from haiku.rag.store.engine import Store + + db_path = tmp_path / "test.lancedb" + store = Store(db_path, create=True) + store.close() + + app = HaikuRAGApp(db_path=db_path) + mock_print = MagicMock() + monkeypatch.setattr(app.console, "print", mock_print) + + await app.rebuild() + + calls = [str(c) for c in mock_print.call_args_list] + assert any("No documents found" in c for c in calls) diff --git a/tests/test_settings.py b/tests/test_settings.py index 1da0cab6..c888e582 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,4 +1,7 @@ -from haiku.rag.config import Config +import pytest + +from haiku.rag.config import AppConfig, Config +from haiku.rag.store.repositories.settings import ConfigMismatchError def test_settings_table_populated_on_store_init(temp_db_path): @@ -48,3 +51,108 @@ def test_monitor_filter_patterns_config(): assert isinstance(Config.monitor.ignore_patterns, list) assert isinstance(Config.monitor.include_patterns, list) assert isinstance(Config.monitor.directories, list) + + +class TestValidateConfigCompatibility: + """Tests for validate_config_compatibility method.""" + + def test_empty_settings_saves_config(self, temp_db_path): + """When settings row is missing, validation saves current config.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + store = Store(temp_db_path, create=True, skip_validation=True) + settings_repo = SettingsRepository(store) + + # Clear settings to simulate empty state + store.settings_table.delete("id = 'settings'") + assert settings_repo.get_current_settings() == {} + + # Validation should save settings + settings_repo.validate_config_compatibility() + + # Now settings should exist + saved = settings_repo.get_current_settings() + assert saved.get("embeddings", {}).get("model", {}).get("provider") is not None + store.close() + + def test_compatible_config_no_error(self, temp_db_path): + """Compatible config does not raise error.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + store = Store(temp_db_path, create=True) + settings_repo = SettingsRepository(store) + + # Should not raise - same config + settings_repo.validate_config_compatibility() + store.close() + + def test_provider_mismatch_raises_error(self, temp_db_path): + """Different embedding provider raises ConfigMismatchError.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + # Create store with default config (ollama) + store = Store(temp_db_path, create=True) + store.close() + + # Create new config with different provider + new_config = AppConfig() + new_config.embeddings.model.provider = "openai" + + store2 = Store(temp_db_path, config=new_config, skip_validation=True) + settings_repo = SettingsRepository(store2) + + with pytest.raises(ConfigMismatchError) as exc_info: + settings_repo.validate_config_compatibility() + + assert "embedding provider" in str(exc_info.value) + assert "ollama" in str(exc_info.value) + assert "openai" in str(exc_info.value) + store2.close() + + def test_model_mismatch_raises_error(self, temp_db_path): + """Different embedding model raises ConfigMismatchError.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + # Create store with default config + store = Store(temp_db_path, create=True) + store.close() + + # Create new config with different model + new_config = AppConfig() + new_config.embeddings.model.name = "different-model" + + store2 = Store(temp_db_path, config=new_config, skip_validation=True) + settings_repo = SettingsRepository(store2) + + with pytest.raises(ConfigMismatchError) as exc_info: + settings_repo.validate_config_compatibility() + + assert "embedding model" in str(exc_info.value) + store2.close() + + def test_vector_dim_mismatch_raises_error(self, temp_db_path): + """Different vector dimension raises ConfigMismatchError.""" + from haiku.rag.store.engine import Store + from haiku.rag.store.repositories.settings import SettingsRepository + + # Create store with default config + store = Store(temp_db_path, create=True) + store.close() + + # Create new config with different vector dimension + new_config = AppConfig() + new_config.embeddings.model.vector_dim = 9999 + + store2 = Store(temp_db_path, config=new_config, skip_validation=True) + settings_repo = SettingsRepository(store2) + + with pytest.raises(ConfigMismatchError) as exc_info: + settings_repo.validate_config_compatibility() + + assert "vector dimension" in str(exc_info.value) + assert "9999" in str(exc_info.value) + store2.close()