diff --git a/docs/cli.md b/docs/cli.md index 79ae0fbb..ae89da6e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -126,6 +126,26 @@ haiku-rag settings ## Maintenance +### Info (Read-only) + +Display database metadata without upgrading or modifying it: + +```bash +haiku-rag info [--db /path/to/your.lancedb] +``` + +Shows: +- path to the database +- stored haiku.rag version (from settings) +- embeddings provider/model and vector dimension +- number of documents +- table versions per table (documents, chunks) + +At the end, a separate “Versions” section lists runtime package versions: +- haiku.rag +- lancedb +- docling + ### Vacuum (Optimize and Cleanup) Reduce disk usage by optimizing and pruning old table versions across all tables: diff --git a/src/haiku/rag/app.py b/src/haiku/rag/app.py index 18a0d1f6..3f7c3c20 100644 --- a/src/haiku/rag/app.py +++ b/src/haiku/rag/app.py @@ -1,4 +1,6 @@ import asyncio +import json +from importlib.metadata import version as pkg_version from pathlib import Path from rich.console import Console @@ -25,6 +27,117 @@ class HaikuRAGApp: self.db_path = db_path self.console = Console() + async def info(self): + """Display read-only information about the database without modifying it.""" + + import lancedb + + # Basic: show path + self.console.print("[bold]haiku.rag database info[/bold]") + self.console.print( + f" [repr.attrib_name]path[/repr.attrib_name]: {self.db_path}" + ) + + if not self.db_path.exists(): + self.console.print("[red]Database path does not exist.[/red]") + 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 + + try: + ldb_version = pkg_version("lancedb") + except Exception: + ldb_version = "unknown" + try: + hr_version = pkg_version("haiku.rag") + except Exception: + hr_version = "unknown" + try: + docling_version = pkg_version("docling") + except Exception: + docling_version = "unknown" + + # Read settings (if present) to find stored haiku.rag version and embedding config + 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)) + embed_provider = data.get("EMBEDDINGS_PROVIDER") + embed_model = data.get("EMBEDDINGS_MODEL") + vector_dim = ( + int(data.get("EMBEDDINGS_VECTOR_DIM")) # pyright: ignore[reportArgumentType] + if data.get("EMBEDDINGS_VECTOR_DIM") is not None + else None + ) + + num_docs = 0 + if "documents" in table_names: + docs_tbl = db.open_table("documents") + num_docs = int(docs_tbl.count_rows()) # type: ignore[attr-defined] + + # 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 + ) + + 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" + ) + self.console.print( + f" [repr.attrib_name]documents[/repr.attrib_name]: {num_docs}" + ) + self.console.print( + f" [repr.attrib_name]versions (documents)[/repr.attrib_name]: {doc_versions}" + ) + self.console.print( + f" [repr.attrib_name]versions (chunks)[/repr.attrib_name]: {chunk_versions}" + ) + self.console.rule() + self.console.print("[bold]Versions[/bold]") + self.console.print( + f" [repr.attrib_name]haiku.rag[/repr.attrib_name]: {hr_version}" + ) + self.console.print( + f" [repr.attrib_name]lancedb[/repr.attrib_name]: {ldb_version}" + ) + self.console.print( + f" [repr.attrib_name]docling[/repr.attrib_name]: {docling_version}" + ) + async def list_documents(self): async with HaikuRAG(db_path=self.db_path) as self.client: documents = await self.client.list_documents() @@ -36,7 +149,7 @@ class HaikuRAGApp: doc = await self.client.create_document(text) self._rich_print_document(doc, truncate=True) self.console.print( - f"[b]Document with id [cyan]{doc.id}[/cyan] added successfully.[/b]" + f"[bold green]Document {doc.id} added successfully.[/bold green]" ) async def add_document_from_source(self, source: str, title: str | None = None): @@ -44,7 +157,7 @@ class HaikuRAGApp: doc = await self.client.create_document_from_source(source, title=title) self._rich_print_document(doc, truncate=True) self.console.print( - f"[b]Document with id [cyan]{doc.id}[/cyan] added successfully.[/b]" + f"[bold green]Document {doc.id} added successfully.[/bold green]" ) async def get_document(self, doc_id: str): @@ -59,7 +172,9 @@ class HaikuRAGApp: async with HaikuRAG(db_path=self.db_path) as self.client: deleted = await self.client.delete_document(doc_id) if deleted: - self.console.print(f"[b]Document {doc_id} deleted successfully.[/b]") + self.console.print( + f"[bold green]Document {doc_id} deleted successfully.[/bold green]" + ) else: self.console.print( f"[yellow]Document with id {doc_id} not found.[/yellow]" @@ -69,7 +184,7 @@ class HaikuRAGApp: async with HaikuRAG(db_path=self.db_path) as self.client: results = await self.client.search(query, limit=limit) if not results: - self.console.print("[red]No results found.[/red]") + self.console.print("[yellow]No results found.[/yellow]") return for chunk, score in results: self._rich_print_search_result(chunk, score) @@ -202,14 +317,16 @@ class HaikuRAGApp: return self.console.print( - f"[b]Rebuilding database with {total_docs} documents...[/b]" + f"[bold cyan]Rebuilding database with {total_docs} documents...[/bold cyan]" ) with Progress() as progress: task = progress.add_task("Rebuilding...", total=total_docs) async for _ in client.rebuild_database(): progress.update(task, advance=1) - self.console.print("[b]Database rebuild completed successfully.[/b]") + 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]") @@ -218,7 +335,9 @@ class HaikuRAGApp: try: async with HaikuRAG(db_path=self.db_path, skip_validation=True) as client: await client.vacuum() - self.console.print("[b]Vacuum completed successfully.[/b]") + self.console.print( + "[bold green]Vacuum completed successfully.[/bold green]" + ) except Exception as e: self.console.print(f"[red]Error during vacuum: {e}[/red]") @@ -240,7 +359,9 @@ class HaikuRAGApp: else: display_value = field_value - self.console.print(f" [cyan]{field_name}[/cyan]: {display_value}") + self.console.print( + f" [repr.attrib_name]{field_name}[/repr.attrib_name]: {display_value}" + ) def _rich_print_document(self, doc: Document, truncate: bool = False): """Format a document for display.""" diff --git a/src/haiku/rag/cli.py b/src/haiku/rag/cli.py index 66aa733c..85327677 100644 --- a/src/haiku/rag/cli.py +++ b/src/haiku/rag/cli.py @@ -347,6 +347,20 @@ def vacuum( asyncio.run(app.vacuum()) +@cli.command("info", help="Show read-only database info (no upgrades or writes)") +def info( + db: Path = typer.Option( + Config.DEFAULT_DATA_DIR / "haiku.rag.lancedb", + "--db", + help="Path to the LanceDB database file", + ), +): + from haiku.rag.app import HaikuRAGApp + + app = HaikuRAGApp(db_path=db) + asyncio.run(app.info()) + + @cli.command( "serve", help="Start the haiku.rag MCP server (by default in streamable HTTP mode)" ) diff --git a/src/haiku/rag/migration.py b/src/haiku/rag/migration.py index d5b19198..a0e8fce6 100644 --- a/src/haiku/rag/migration.py +++ b/src/haiku/rag/migration.py @@ -51,7 +51,7 @@ class SQLiteToLanceDBMigrator: sqlite_conn.enable_load_extension(True) sqlite_vec.load(sqlite_conn) - self.console.print("[blue]Loaded sqlite-vec extension[/blue]") + self.console.print("[cyan]Loaded sqlite-vec extension[/cyan]") except Exception as e: self.console.print( f"[yellow]Warning: Could not load sqlite-vec extension: {e}[/yellow]" @@ -92,7 +92,7 @@ class SQLiteToLanceDBMigrator: sqlite_conn.close() # Optimize and cleanup using centralized vacuum - self.console.print("[blue]Optimizing LanceDB...[/blue]") + self.console.print("[cyan]Optimizing LanceDB...[/cyan]") try: lance_store.vacuum() self.console.print("[green]✅ Optimization completed[/green]") diff --git a/tests/test_app.py b/tests/test_app.py index ce0db165..b2d4a7f2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -57,7 +57,7 @@ async def test_add_document_from_text(app: HaikuRAGApp, monkeypatch): mock_client.create_document.assert_called_once_with("test document") mock_rich_print.assert_called_once_with(mock_doc, truncate=True) mock_print.assert_called_once_with( - "[b]Document with id [cyan]1[/cyan] added successfully.[/b]" + "[bold green]Document 1 added successfully.[/bold green]" ) @@ -83,7 +83,7 @@ async def test_add_document_from_source(app: HaikuRAGApp, monkeypatch): ) mock_rich_print.assert_called_once_with(mock_doc, truncate=True) mock_print.assert_called_once_with( - "[b]Document with id [cyan]1[/cyan] added successfully.[/b]" + "[bold green]Document 1 added successfully.[/bold green]" ) @@ -135,7 +135,9 @@ async def test_delete_document(app: HaikuRAGApp, monkeypatch): await app.delete_document("1") mock_client.delete_document.assert_called_once_with("1") - mock_print.assert_called_once_with("[b]Document 1 deleted successfully.[/b]") + mock_print.assert_called_once_with( + "[bold green]Document 1 deleted successfully.[/bold green]" + ) @pytest.mark.asyncio @@ -170,7 +172,7 @@ async def test_search_no_results(app: HaikuRAGApp, monkeypatch): await app.search("query") mock_client.search.assert_called_once_with("query", limit=5) - mock_print.assert_called_once_with("[red]No results found.[/red]") + mock_print.assert_called_once_with("[yellow]No results found.[/yellow]") @pytest.mark.asyncio diff --git a/tests/test_cli.py b/tests/test_cli.py index 7c2b4a80..6efb5eb2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -144,6 +144,16 @@ def test_ask_with_cite(): result = runner.invoke(cli, ["ask", "What is Python?", "--cite"]) assert result.exit_code == 0 - mock_app_instance.ask.assert_called_once_with( - question="What is Python?", cite=True - ) + mock_app_instance.ask.assert_called_once_with(question="What is Python?", cite=True) + + +def test_info(): + with patch("haiku.rag.app.HaikuRAGApp") as mock_app: + mock_app_instance = MagicMock() + mock_app_instance.info = AsyncMock() + mock_app.return_value = mock_app_instance + + result = runner.invoke(cli, ["info"]) + + assert result.exit_code == 0 + mock_app_instance.info.assert_called_once() diff --git a/tests/test_info.py b/tests/test_info.py new file mode 100644 index 00000000..4a408420 --- /dev/null +++ b/tests/test_info.py @@ -0,0 +1,79 @@ +import json + +import pytest + +from haiku.rag.app import HaikuRAGApp + + +@pytest.mark.asyncio +async def test_app_info_outputs_and_read_only(temp_db_path, capsys): + # Build a minimal LanceDB with settings, documents, and chunks without using Store + import lancedb + from lancedb.pydantic import LanceModel, Vector + from pydantic import Field + + db = lancedb.connect(temp_db_path) + + class SettingsRecord(LanceModel): + id: str = Field(default="settings") + settings: str = Field(default="{}") + + class DocumentRecord(LanceModel): + id: str + content: str + + class ChunkRecord(LanceModel): + id: str + document_id: str + content: str + vector: Vector(3) # type: ignore + + settings_tbl = db.create_table("settings", schema=SettingsRecord) + docs_tbl = db.create_table("documents", schema=DocumentRecord) + chunks_tbl = db.create_table("chunks", schema=ChunkRecord) + + # Insert one of each + settings_tbl.add( + [ + SettingsRecord( + id="settings", + settings=json.dumps( + { + "version": "1.2.3", + "EMBEDDINGS_PROVIDER": "openai", + "EMBEDDINGS_MODEL": "text-embedding-3-small", + "EMBEDDINGS_VECTOR_DIM": 3, + } + ), + ) + ] + ) + docs_tbl.add([DocumentRecord(id="doc-1", content="hello")]) + chunks_tbl.add( + [ChunkRecord(id="c1", document_id="doc-1", content="c", vector=[0.1, 0.2, 0.3])] + ) + + # Capture versions before + before_versions = { + "settings": int(settings_tbl.version), + "documents": int(docs_tbl.version), + "chunks": int(chunks_tbl.version), + } + + app = HaikuRAGApp(db_path=temp_db_path) + await app.info() + + out = capsys.readouterr().out + # Validate expected content substrings + assert f"path: \n{temp_db_path}" in out + assert "haiku.rag version (db): 1.2.3" in out + assert "embeddings: openai/text-embedding-3-small (dim: 3)" in out + assert "lancedb:" in out + assert "documents: 1" in out + + # Verify no versions changed (read-only) + # Re-open to ensure fresh view + db2 = lancedb.connect(temp_db_path) + assert int(db2.open_table("settings").version) == before_versions["settings"] + assert int(db2.open_table("documents").version) == before_versions["documents"] + assert int(db2.open_table("chunks").version) == before_versions["chunks"]