Merge pull request #72 from ggozad/feat/info-command

info CLI command, displays basic info about a db
This commit is contained in:
Yiorgis Gozadinos 2025-09-23 12:44:50 +03:00 committed by GitHub
commit 13b3ab3cf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 263 additions and 17 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

79
tests/test_info.py Normal file
View file

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