diff --git a/CHANGELOG.md b/CHANGELOG.md index e595d314..08b4fe39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ - New `/api/documents` endpoint to list available documents - Frontend document selector component with search and multi-select - Demonstrates client-to-server state flow via AG-UI protocol +- **Inspector Info Modal**: New `i` keyboard shortcut opens a modal displaying database information + +### Changed + +- **Inspector Lazy Loading**: Chunks panel now loads chunks in batches of 50 with infinite scroll + - Fixes unresponsive UI when viewing documents with large numbers of chunks + - New `ChunkRepository.get_by_document_id()` pagination with `limit` and `offset` parameters + - New `ChunkRepository.count_by_document_id()` method ## [0.20.0] - 2025-12-10 diff --git a/haiku_rag_slim/haiku/rag/inspector/app.py b/haiku_rag_slim/haiku/rag/inspector/app.py index 48c39390..d3a9fa94 100644 --- a/haiku_rag_slim/haiku/rag/inspector/app.py +++ b/haiku_rag_slim/haiku/rag/inspector/app.py @@ -69,6 +69,7 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover BINDINGS = [ Binding("q", "quit", "Quit", show=True), Binding("/", "search", "Search", show=True), + Binding("i", "show_info", "Info", show=True), Binding("v", "show_visual", "Visual", show=True), Binding("c", "show_context", "Context", show=True), ] @@ -126,6 +127,13 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover if self.client: await self._switch_modal(SearchModal(self.client)) + async def action_show_info(self) -> None: + """Show database info modal.""" + if self.client: + from haiku.rag.inspector.widgets.info_modal import InfoModal + + await self._switch_modal(InfoModal(self.client, self.db_path)) + async def on_search_modal_chunk_selected( self, message: SearchModal.ChunkSelected ) -> None: diff --git a/haiku_rag_slim/haiku/rag/inspector/widgets/info_modal.py b/haiku_rag_slim/haiku/rag/inspector/widgets/info_modal.py new file mode 100644 index 00000000..91715f2f --- /dev/null +++ b/haiku_rag_slim/haiku/rag/inspector/widgets/info_modal.py @@ -0,0 +1,209 @@ +import json +from importlib.metadata import version as pkg_version +from pathlib import Path +from typing import TYPE_CHECKING + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical, VerticalScroll +from textual.screen import ModalScreen +from textual.widgets import Static + +from haiku.rag.utils import format_bytes + +if TYPE_CHECKING: + from haiku.rag.client import HaikuRAG + + +class InfoModal(ModalScreen): # pragma: no cover + """Modal screen for displaying database information.""" + + BINDINGS = [ + Binding("escape", "dismiss", "Close", show=True), + Binding("i", "dismiss", "Close", show=True), + ] + + CSS = """ + InfoModal { + align: center middle; + } + + #info-container { + width: 80; + height: auto; + max-height: 80%; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + #info-header { + height: auto; + margin-bottom: 1; + } + + #info-content { + height: auto; + max-height: 100%; + } + """ + + def __init__(self, client: "HaikuRAG", db_path: Path): + super().__init__() + self.client = client + self.db_path = db_path + self._content_widget = Static("Loading...") + + def compose(self) -> ComposeResult: + with Vertical(id="info-container"): + yield Static("[bold]Database Info[/bold]", id="info-header") + with VerticalScroll(id="info-content"): + yield self._content_widget + + async def on_mount(self) -> None: + """Load and display database info.""" + import lancedb + + lines: list[str] = [] + + # Path + lines.append(f"[bold $accent]path[/bold $accent]: {self.db_path}") + + if not self.db_path.exists(): + lines.append("[red]Database path does not exist.[/red]") + self._content_widget.update("\n".join(lines)) + return + + # Connect to get table info + try: + db = lancedb.connect(self.db_path) + table_names = set(db.table_names()) + except Exception as e: + lines.append(f"[red]Failed to open database: {e}[/red]") + self._content_widget.update("\n".join(lines)) + return + + # Get versions + try: + ldb_version = pkg_version("lancedb") + except Exception: + ldb_version = "unknown" + try: + hr_version = pkg_version("haiku.rag-slim") + except Exception: + hr_version = "unknown" + try: + docling_version = pkg_version("docling") + except Exception: + docling_version = "unknown" + + # Get stats from store + table_stats = self.client.store.get_stats() + + # Read settings + 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") + + num_docs = table_stats["documents"].get("num_rows", 0) + doc_bytes = table_stats["documents"].get("total_bytes", 0) + + num_chunks = table_stats["chunks"].get("num_rows", 0) + chunk_bytes = table_stats["chunks"].get("total_bytes", 0) + + has_vector_index = table_stats["chunks"].get("has_vector_index", False) + num_indexed_rows = table_stats["chunks"].get("num_indexed_rows", 0) + num_unindexed_rows = table_stats["chunks"].get("num_unindexed_rows", 0) + + # Table versions + 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 + ) + + # Build output + lines.append( + f"[bold $accent]haiku.rag version (db)[/bold $accent]: {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" + lines.append( + f"[bold $accent]embeddings[/bold $accent]: " + f"{provider_part}/{model_part} (dim: {dim_part})" + ) + else: + lines.append("[bold $accent]embeddings[/bold $accent]: unknown") + + lines.append( + f"[bold $accent]documents[/bold $accent]: {num_docs} ({format_bytes(doc_bytes)})" + ) + lines.append( + f"[bold $accent]chunks[/bold $accent]: {num_chunks} ({format_bytes(chunk_bytes)})" + ) + + # Vector index info + if has_vector_index: + lines.append("[bold $accent]vector index[/bold $accent]: ✓ exists") + lines.append( + f"[bold $accent]indexed chunks[/bold $accent]: {num_indexed_rows}" + ) + if num_unindexed_rows > 0: + lines.append( + f"[bold $accent]unindexed chunks[/bold $accent]: [yellow]{num_unindexed_rows}[/yellow]" + ) + else: + lines.append( + f"[bold $accent]unindexed chunks[/bold $accent]: {num_unindexed_rows}" + ) + else: + if num_chunks >= 256: + lines.append( + "[bold $accent]vector index[/bold $accent]: [yellow]✗ not created[/yellow]" + ) + else: + lines.append( + f"[bold $accent]vector index[/bold $accent]: ✗ not created " + f"(need {256 - num_chunks} more chunks)" + ) + + lines.append( + f"[bold $accent]versions (documents)[/bold $accent]: {doc_versions}" + ) + lines.append( + f"[bold $accent]versions (chunks)[/bold $accent]: {chunk_versions}" + ) + + lines.append("") + lines.append("[bold]Versions[/bold]") + lines.append(f"[bold $accent]haiku.rag[/bold $accent]: {hr_version}") + lines.append(f"[bold $accent]lancedb[/bold $accent]: {ldb_version}") + lines.append(f"[bold $accent]docling[/bold $accent]: {docling_version}") + + self._content_widget.update("\n".join(lines)) + + async def action_dismiss(self, result=None) -> None: + self.app.pop_screen()