Merge pull request #72 from ggozad/feat/info-command
info CLI command, displays basic info about a db
This commit is contained in:
commit
13b3ab3cf7
7 changed files with 263 additions and 17 deletions
20
docs/cli.md
20
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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
79
tests/test_info.py
Normal 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"]
|
||||
Loading…
Reference in a new issue