diff --git a/haiku_rag_slim/haiku/rag/doctor.py b/haiku_rag_slim/haiku/rag/doctor.py index 49ea665e..e6708456 100644 --- a/haiku_rag_slim/haiku/rag/doctor.py +++ b/haiku_rag_slim/haiku/rag/doctor.py @@ -17,7 +17,6 @@ from haiku.rag.config.models import ( ) from haiku.rag.store.engine import Store, connect_lancedb from haiku.rag.store.info import get_database_stats -from haiku.rag.store.repositories.settings import SettingsRepository from haiku.rag.store.schema import REQUIRED_TABLES from haiku.rag.store.upgrades import get_pending_upgrades @@ -725,7 +724,7 @@ async def run_db_checks( .select(["id", "vector", "document_id"]) .to_arrow() ) - stored = await SettingsRepository(store).get_current_settings() + stored = store.stored_settings stored_dim = stored.get("embeddings", {}).get("model", {}).get("vector_dim") actual_dim = arrow.schema.field("vector").type.list_size results.append(_check_vector_dimension(stored_dim, actual_dim)) diff --git a/haiku_rag_slim/haiku/rag/inspector/widgets/info_modal.py b/haiku_rag_slim/haiku/rag/inspector/widgets/info_modal.py index c18d309b..db1e3a23 100644 --- a/haiku_rag_slim/haiku/rag/inspector/widgets/info_modal.py +++ b/haiku_rag_slim/haiku/rag/inspector/widgets/info_modal.py @@ -1,5 +1,4 @@ import asyncio -import json from pathlib import Path from typing import TYPE_CHECKING @@ -51,24 +50,14 @@ async def database_lines(client: "HaikuRAG") -> list[str]: except Exception as e: return [f"[red]Failed to open database: {e}[/red]"] - stored_version = "unknown" - embed_provider: str | None = None - embed_model: str | None = None - vector_dim: int | None = None - - if stats["settings"]["exists"]: - settings_tbl = await db.open_table("settings") - arrow = await settings_tbl.query().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") + # The store read these on open; a second query would re-read and re-parse + # the same blob. + settings = client.store.stored_settings + stored_version = str(settings.get("version", "unknown")) + embed_model_obj = settings.get("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") num_docs = stats["documents"].get("num_rows", 0) num_chunks = stats["chunks"].get("num_rows", 0) diff --git a/haiku_rag_slim/haiku/rag/store/engine.py b/haiku_rag_slim/haiku/rag/store/engine.py index e2f09c04..a5396fa6 100644 --- a/haiku_rag_slim/haiku/rag/store/engine.py +++ b/haiku_rag_slim/haiku/rag/store/engine.py @@ -215,6 +215,10 @@ class Store: # Create embedder (sync — no LanceDB needed) self.embedder = get_embedder(config=self._config) self.stored_embedding: tuple[str | None, str | None, int | None] | None = None + # The settings blob as of open, so reporting on a database costs no + # second read of it. Not refreshed by a later write, the same as + # `stored_embedding`. + self.stored_settings: dict = {} async def _initialize(self): """Perform async initialization: connect to LanceDB, init tables, validate.""" @@ -228,30 +232,30 @@ class Store: existing_tables = (await self.db.list_tables()).tables is_new_db = self._is_new_db or not existing_tables - stored_settings: dict = {} if not is_new_db and "settings" in existing_tables: self.settings_table = await self.db.open_table("settings") - stored_settings = await self._read_stored_settings() + self.stored_settings = await self._read_stored_settings() # An existing database's chunks can only be read with the dimension they # were written at. - stored_vector_dim = _stored_vector_dim(stored_settings) - self.stored_embedding = _stored_embedding(stored_settings) + stored_vector_dim = _stored_vector_dim(self.stored_settings) + self.stored_embedding = _stored_embedding(self.stored_settings) chunk_vector_dim = stored_vector_dim or self.embedder._vector_dim self.ChunkRecord: type[ChunkRecordBase] = create_chunk_model(chunk_vector_dim) # Initialize tables (creates them if they don't exist). For an existing # DB this raises MigrationRequiredError up front when migrations are # pending, before creating any newly-introduced table. - await self._init_tables(is_new_db, existing_tables, stored_settings) + await self._init_tables(is_new_db, existing_tables, self.stored_settings) # Set version for new databases. if is_new_db and not self._read_only: await self._set_initial_version() + self.stored_settings = await self._read_stored_settings() # Validate config compatibility after connection is established if not self._skip_validation: - await self._validate_configuration(stored_settings) + await self._validate_configuration(self.stored_settings) async def __aenter__(self): # If _initialize connects to LanceDB but then fails (e.g. migration diff --git a/tests/store/test_engine.py b/tests/store/test_engine.py index 229c9e5b..958a87d5 100644 --- a/tests/store/test_engine.py +++ b/tests/store/test_engine.py @@ -4,6 +4,32 @@ from haiku.rag.store.engine import Store from haiku.rag.store.info import get_database_stats +class TestStoredSettings: + @pytest.mark.asyncio + async def test_a_new_database_carries_the_version_it_was_created_with( + self, temp_db_path + ): + """Creating writes the settings row, so the store has to report it + rather than the emptiness it opened on.""" + from importlib import metadata + + async with Store(temp_db_path, create=True) as store: + assert store.stored_settings["version"] == metadata.version( + "haiku.rag-slim" + ) + + @pytest.mark.asyncio + async def test_an_existing_database_carries_its_stored_settings(self, temp_db_path): + """Read once on open: reporting on a database reads them from here + instead of querying the settings table again.""" + async with Store(temp_db_path, create=True) as store: + written = store.stored_settings + + async with Store(temp_db_path) as store: + assert store.stored_settings == written + assert store.stored_settings["embeddings"]["model"]["vector_dim"] > 0 + + class TestGetDatabaseStats: @pytest.mark.asyncio async def test_empty_database_stats(self, temp_db_path): diff --git a/tests/test_inspector.py b/tests/test_inspector.py index 95aebb0e..02947a48 100644 --- a/tests/test_inspector.py +++ b/tests/test_inspector.py @@ -403,6 +403,7 @@ class TestReportingReusesTheConnection: client = MagicMock() client.store.db = connection client.store.db_path = tmp_path + client.store.stored_settings = {} client.store._connection_mode = ConnectionMode.LOCAL lines = await database_lines(client) @@ -410,6 +411,39 @@ class TestReportingReusesTheConnection: assert asked == [connection] assert any("documents" in line for line in lines) + @pytest.mark.asyncio + async def test_settings_come_from_the_store_that_parsed_them( + self, tmp_path, monkeypatch + ): + """The store read and parsed the settings blob on open, so reporting + reads it from there instead of querying the settings table again.""" + from haiku.rag.inspector.widgets.info_modal import database_lines + from haiku.rag.store.engine import ConnectionMode + + async def fake_stats(db): # noqa: ARG001 + return { + "documents": {"num_rows": 1}, + "document_meta": {"num_rows": 1}, + "chunks": {"num_rows": 1}, + } + + monkeypatch.setattr("haiku.rag.store.info.get_database_stats", fake_stats) + + client = MagicMock() + client.store.db_path = tmp_path + client.store._connection_mode = ConnectionMode.LOCAL + client.store.stored_settings = { + "version": "1.2.3", + "embeddings": { + "model": {"provider": "ollama", "name": "embed", "vector_dim": 7} + }, + } + + lines = await database_lines(client) + + assert any("1.2.3" in line for line in lines) + assert any("ollama/embed (dim: 7)" in line for line in lines) + class TestReportingEachDatabase: @pytest.mark.asyncio