Show bounding boxes for chunks in inspector

This commit is contained in:
Yiorgis Gozadinos 2025-12-01 12:31:02 +02:00
parent 6ab990b2ed
commit 0d8691342a
No known key found for this signature in database
10 changed files with 483 additions and 141 deletions

View file

@ -8,6 +8,45 @@
## [0.20.0] - 2025-11-28 ## [0.20.0] - 2025-11-28
### Added
- **DoclingDocument Storage**: Full DoclingDocument JSON is now stored with each document, enabling rich context and visual grounding
- Documents store the complete DoclingDocument structure (JSON) and schema version
- Chunks store metadata with JSON pointer references (`doc_item_refs`), semantic labels, section headings, and page numbers
- New `ChunkMetadata` model for structured chunk provenance: `doc_item_refs`, `headings`, `labels`, `page_numbers`
- `Document.get_docling_document()` method to parse stored DoclingDocument
- `ChunkMetadata.resolve_doc_items()` to resolve JSON pointer refs to actual DocItem objects
- `ChunkMetadata.resolve_bounding_boxes()` for visual grounding with page coordinates
- LRU cache (100 documents) for parsed DoclingDocument objects to avoid repeated JSON parsing
- **Enhanced Search Results**: `search()` and `expand_context()` now return full provenance information
- `SearchResult` includes `page_numbers`, `headings`, `labels`, and `doc_item_refs`
- QA and research agents use provenance for better citations (page numbers, section headings)
- **Inspector Visual Grounding**: New visual grounding modal in the database inspector
- View page images with highlighted bounding boxes for chunks
- Keyboard navigation between pages (←/→ arrows)
- Access from both main detail view and search results
- Requires `textual-image` dependency
### Changed
- **BREAKING: Chunker Interface**: `DocumentChunker.chunk()` now returns `list[ChunkWithMetadata]` instead of `list[str]`
- `ChunkWithMetadata` combines chunk text with `ChunkMetadata` (refs, labels, headings, page_numbers)
- All chunker implementations updated: `DoclingLocalChunker`, `DoclingServeChunker`
- **Page Image Generation**: `generate_page_images=True` is now the default for local docling converter
- Required for visual grounding features
- docling-serve already generates page images by default
- **QA Prompts**: Updated to use page numbers and section headings in citations when available
### Migration
This release requires a database rebuild to populate the new DoclingDocument fields:
```bash
haiku-rag rebuild
```
Existing documents without DoclingDocument data will work but won't have provenance information. The `rebuild` command re-processes all documents to populate the new fields.
## [0.19.6] - 2025-12-03 ## [0.19.6] - 2025-12-03
### Changed ### Changed

View file

@ -68,6 +68,7 @@ class InspectorApp(App): # type: ignore[misc]
BINDINGS = [ BINDINGS = [
Binding("q", "quit", "Quit", show=True), Binding("q", "quit", "Quit", show=True),
Binding("/", "search", "Search", show=True), Binding("/", "search", "Search", show=True),
Binding("v", "show_visual", "Visual", show=True),
] ]
def __init__(self, db_path: Path): def __init__(self, db_path: Path):
@ -93,9 +94,7 @@ class InspectorApp(App): # type: ignore[misc]
doc_list = self.query_one(DocumentList) doc_list = self.query_one(DocumentList)
await doc_list.load_documents(self.client) await doc_list.load_documents(self.client)
# Focus the document list view doc_list.list_view.focus()
if doc_list.list_view:
doc_list.list_view.focus()
async def on_unmount(self) -> None: async def on_unmount(self) -> None:
"""Clean up when unmounting.""" """Clean up when unmounting."""
@ -106,9 +105,8 @@ class InspectorApp(App): # type: ignore[misc]
"""Helper to select a chunk after refresh.""" """Helper to select a chunk after refresh."""
for idx, c in enumerate(chunk_list.chunks): for idx, c in enumerate(chunk_list.chunks):
if c.id == chunk_id: if c.id == chunk_id:
if chunk_list.list_view: chunk_list.list_view.index = idx
chunk_list.list_view.index = idx chunk_list.list_view.focus()
chunk_list.list_view.focus()
break break
async def action_search(self) -> None: async def action_search(self) -> None:
@ -135,8 +133,7 @@ class InspectorApp(App): # type: ignore[misc]
# Find and select the document # Find and select the document
for idx, d in enumerate(doc_list.documents): for idx, d in enumerate(doc_list.documents):
if d.id == chunk.document_id: if d.id == chunk.document_id:
if doc_list.list_view: doc_list.list_view.index = idx
doc_list.list_view.index = idx
break break
# Load chunks for this document # Load chunks for this document
@ -179,6 +176,43 @@ class InspectorApp(App): # type: ignore[misc]
detail_view = self.query_one(DetailView) detail_view = self.query_one(DetailView)
await detail_view.show_chunk(message.chunk) await detail_view.show_chunk(message.chunk)
async def action_show_visual(self) -> None:
"""Show visual grounding for the currently selected chunk."""
if not self.client:
return
chunk_list = self.query_one(ChunkList)
idx = chunk_list.list_view.index
if idx is None or idx >= len(chunk_list.chunks):
return
chunk = chunk_list.chunks[idx]
if not chunk.document_id:
return
document = await self.client.get_document_by_id(chunk.document_id)
if not document:
return
docling_doc = document.get_docling_document()
if not docling_doc:
self.notify("No DoclingDocument available", severity="warning")
return
meta = chunk.get_chunk_metadata()
bounding_boxes = meta.resolve_bounding_boxes(docling_doc)
from haiku.rag.inspector.widgets.visual_modal import VisualGroundingModal
await self.push_screen(
VisualGroundingModal(
docling_document=docling_doc,
bounding_boxes=bounding_boxes,
page_numbers=meta.page_numbers,
document_uri=chunk.document_uri,
)
)
def run_inspector(db_path: Path | None = None) -> None: def run_inspector(db_path: Path | None = None) -> None:
"""Run the inspector TUI. """Run the inspector TUI.

View file

@ -1,5 +1,6 @@
from haiku.rag.inspector.widgets.chunk_list import ChunkList from haiku.rag.inspector.widgets.chunk_list import ChunkList
from haiku.rag.inspector.widgets.detail_view import DetailView from haiku.rag.inspector.widgets.detail_view import DetailView
from haiku.rag.inspector.widgets.document_list import DocumentList from haiku.rag.inspector.widgets.document_list import DocumentList
from haiku.rag.inspector.widgets.visual_modal import VisualGroundingModal
__all__ = ["ChunkList", "DetailView", "DocumentList"] __all__ = ["ChunkList", "DetailView", "DocumentList", "VisualGroundingModal"]

View file

@ -23,36 +23,24 @@ class ChunkList(VerticalScroll):
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.chunks: list[Chunk] = [] self.chunks: list[Chunk] = []
self.list_view: ListView | None = None self.list_view = ListView()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Compose the chunk list.""" """Compose the chunk list."""
yield Static("[bold]Chunks[/bold]", classes="title") yield Static("[bold]Chunks[/bold]", classes="title")
self.list_view = ListView()
yield self.list_view yield self.list_view
async def load_chunks_for_document( async def load_chunks_for_document(
self, client: HaikuRAG, document_id: str self, client: HaikuRAG, document_id: str
) -> None: ) -> None:
"""Load chunks for a specific document. """Load chunks for a specific document."""
Args:
client: HaikuRAG client instance
document_id: ID of the document to load chunks for
"""
if self.list_view is None:
return
self.chunks = await client.chunk_repository.get_by_document_id(document_id) self.chunks = await client.chunk_repository.get_by_document_id(document_id)
# Clear existing items
await self.list_view.clear() await self.list_view.clear()
# Add chunk items
for chunk in self.chunks: for chunk in self.chunks:
first_line = chunk.content.split("\n")[0] first_line = chunk.content.split("\n")[0]
item = ListItem(Static(f"[{chunk.order}] {first_line}")) await self.list_view.append(
await self.list_view.append(item) ListItem(Static(f"[{chunk.order}] {first_line}"))
)
@on(ListView.Highlighted) @on(ListView.Highlighted)
@on(ListView.Selected) @on(ListView.Selected)
@ -60,8 +48,8 @@ class ChunkList(VerticalScroll):
self, event: ListView.Highlighted | ListView.Selected self, event: ListView.Highlighted | ListView.Selected
) -> None: ) -> None:
"""Handle chunk selection (arrow keys or Enter).""" """Handle chunk selection (arrow keys or Enter)."""
if event.list_view == self.list_view: if event.list_view != self.list_view:
idx = event.list_view.index return
if idx is not None and 0 <= idx < len(self.chunks): idx = event.list_view.index
chunk = self.chunks[idx] if idx is not None and 0 <= idx < len(self.chunks):
self.post_message(self.ChunkSelected(chunk)) self.post_message(self.ChunkSelected(self.chunks[idx]))

View file

@ -1,8 +1,19 @@
from typing import Protocol
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import VerticalScroll from textual.containers import VerticalScroll
from textual.widgets import Markdown, Static from textual.widgets import Markdown, Static
from haiku.rag.store.models import Chunk, Document from haiku.rag.store.models import Chunk, Document, SearchResult
class ProvenanceData(Protocol):
"""Protocol for objects that have provenance metadata."""
page_numbers: list[int]
headings: list[str] | None
labels: list[str]
doc_item_refs: list[str]
class DetailView(VerticalScroll): class DetailView(VerticalScroll):
@ -12,81 +23,108 @@ class DetailView(VerticalScroll):
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.title_widget: Static | None = None
self.content_widget: Markdown | None = None
def compose(self) -> ComposeResult:
"""Compose the detail view."""
self.title_widget = Static("[bold]Detail View[/bold]", classes="title") self.title_widget = Static("[bold]Detail View[/bold]", classes="title")
yield self.title_widget
self.content_widget = Markdown("") self.content_widget = Markdown("")
self.content_widget.can_focus = True self.content_widget.can_focus = True
def compose(self) -> ComposeResult:
yield self.title_widget
yield self.content_widget yield self.content_widget
def _format_provenance(self, prov: ProvenanceData) -> list[str]:
"""Format provenance metadata as markdown lines."""
parts: list[str] = []
if prov.page_numbers:
pages_str = ", ".join(str(p) for p in prov.page_numbers)
parts.append(f"**Page(s):** {pages_str}")
if prov.headings:
headings_str = " > ".join(prov.headings)
parts.append(f"**Section:** {headings_str}")
if prov.labels:
labels_str = ", ".join(prov.labels)
parts.append(f"**Labels:** {labels_str}")
if prov.doc_item_refs:
refs_str = ", ".join(prov.doc_item_refs[:5])
if len(prov.doc_item_refs) > 5:
refs_str += f" ... (+{len(prov.doc_item_refs) - 5} more)"
parts.append(f"**DocItem Refs:** `{refs_str}`")
return parts
async def show_document(self, document: Document) -> None: async def show_document(self, document: Document) -> None:
"""Display document details. """Display document details."""
title = document.title or document.uri or "Untitled Document"
self.title_widget.update(f"[bold]Document: {title}[/bold]")
Args: content_parts: list[str] = []
document: Document to display if document.id:
""" content_parts.append(f"**ID:** `{document.id}`")
if self.title_widget and self.content_widget: if document.uri:
title = document.title or document.uri or "Untitled Document" content_parts.append(f"**URI:** `{document.uri}`")
self.title_widget.update(f"[bold]Document: {title}[/bold]") if document.metadata:
metadata_str = "\n".join(
f" - {k}: {v}" for k, v in document.metadata.items()
)
content_parts.append(f"**Metadata:**\n{metadata_str}")
if document.created_at:
content_parts.append(f"**Created:** {document.created_at}")
if document.updated_at:
content_parts.append(f"**Updated:** {document.updated_at}")
# Build markdown content content_parts.append("\n---\n")
content_parts = [] content_parts.append(document.content)
if document.id: await self.content_widget.update("\n\n".join(content_parts))
content_parts.append(f"**ID:** `{document.id}`")
if document.uri:
content_parts.append(f"**URI:** `{document.uri}`")
if document.metadata:
metadata_str = "\n".join(
f" - {k}: {v}" for k, v in document.metadata.items()
)
content_parts.append(f"**Metadata:**\n{metadata_str}")
if document.created_at:
content_parts.append(f"**Created:** {document.created_at}")
if document.updated_at:
content_parts.append(f"**Updated:** {document.updated_at}")
content_parts.append("\n---\n")
content_parts.append(document.content)
await self.content_widget.update("\n\n".join(content_parts))
async def show_chunk(self, chunk: Chunk) -> None: async def show_chunk(self, chunk: Chunk) -> None:
"""Display chunk details. """Display chunk details."""
self.title_widget.update(f"[bold]Chunk {chunk.order}[/bold]")
Args: content_parts: list[str] = []
chunk: Chunk to display if chunk.id:
""" content_parts.append(f"**ID:** `{chunk.id}`")
if self.title_widget and self.content_widget: if chunk.document_id:
self.title_widget.update(f"[bold]Chunk {chunk.order}[/bold]") content_parts.append(f"**Document ID:** `{chunk.document_id}`")
if chunk.document_title:
content_parts.append(f"**Document Title:** {chunk.document_title}")
if chunk.document_uri:
content_parts.append(f"**Document URI:** `{chunk.document_uri}`")
content_parts.append(f"**Order:** {chunk.order}")
# Build markdown content chunk_meta = chunk.get_chunk_metadata()
content_parts = [] content_parts.extend(self._format_provenance(chunk_meta))
if chunk.id: if chunk.embedding:
content_parts.append(f"**ID:** `{chunk.id}`") content_parts.append(f"**Embedding:** {len(chunk.embedding)} dimensions")
if chunk.document_id:
content_parts.append(f"**Document ID:** `{chunk.document_id}`")
if chunk.document_title:
content_parts.append(f"**Document Title:** {chunk.document_title}")
if chunk.document_uri:
content_parts.append(f"**Document URI:** `{chunk.document_uri}`")
content_parts.append(f"**Order:** {chunk.order}")
if chunk.metadata:
metadata_str = "\n".join(
f" - {k}: {v}" for k, v in chunk.metadata.items()
)
content_parts.append(f"**Metadata:**\n{metadata_str}")
if chunk.embedding:
content_parts.append(
f"**Embedding:** {len(chunk.embedding)} dimensions"
)
content_parts.append("\n---\n") content_parts.append("\n---\n")
content_parts.append(chunk.content) content_parts.append(chunk.content)
await self.content_widget.update("\n\n".join(content_parts)) await self.content_widget.update("\n\n".join(content_parts))
async def show_search_result(
self, chunk: Chunk, search_result: SearchResult
) -> None:
"""Display chunk details with search result metadata."""
self.title_widget.update(f"[bold]Chunk {chunk.order}[/bold]")
content_parts: list[str] = []
if chunk.id:
content_parts.append(f"**ID:** `{chunk.id}`")
if chunk.document_id:
content_parts.append(f"**Document ID:** `{chunk.document_id}`")
if search_result.document_title:
content_parts.append(f"**Document Title:** {search_result.document_title}")
if search_result.document_uri:
content_parts.append(f"**Document URI:** `{search_result.document_uri}`")
content_parts.append(f"**Order:** {chunk.order}")
content_parts.append(f"**Score:** {search_result.score:.4f}")
content_parts.extend(self._format_provenance(search_result))
if chunk.embedding:
content_parts.append(f"**Embedding:** {len(chunk.embedding)} dimensions")
content_parts.append("\n---\n")
content_parts.append(chunk.content)
await self.content_widget.update("\n\n".join(content_parts))

View file

@ -23,33 +23,20 @@ class DocumentList(VerticalScroll):
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.documents: list[Document] = [] self.documents: list[Document] = []
self.list_view: ListView | None = None self.list_view = ListView()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Compose the document list.""" """Compose the document list."""
yield Static("[bold]Documents[/bold]", classes="title") yield Static("[bold]Documents[/bold]", classes="title")
self.list_view = ListView()
yield self.list_view yield self.list_view
async def load_documents(self, client: HaikuRAG) -> None: async def load_documents(self, client: HaikuRAG) -> None:
"""Load all documents from the database. """Load all documents from the database."""
Args:
client: HaikuRAG client instance
"""
if self.list_view is None:
return
self.documents = await client.list_documents(limit=None) self.documents = await client.list_documents(limit=None)
# Clear existing items
await self.list_view.clear() await self.list_view.clear()
# Add document items
for doc in self.documents: for doc in self.documents:
title = doc.title or doc.uri or doc.id title = doc.title or doc.uri or doc.id
item = ListItem(Static(f"{title}")) await self.list_view.append(ListItem(Static(f"{title}")))
await self.list_view.append(item)
@on(ListView.Highlighted) @on(ListView.Highlighted)
@on(ListView.Selected) @on(ListView.Selected)
@ -57,8 +44,8 @@ class DocumentList(VerticalScroll):
self, event: ListView.Highlighted | ListView.Selected self, event: ListView.Highlighted | ListView.Selected
) -> None: ) -> None:
"""Handle document selection (arrow keys or Enter).""" """Handle document selection (arrow keys or Enter)."""
if event.list_view == self.list_view: if event.list_view != self.list_view:
idx = event.list_view.index return
if idx is not None and 0 <= idx < len(self.documents): idx = event.list_view.index
document = self.documents[idx] if idx is not None and 0 <= idx < len(self.documents):
self.post_message(self.DocumentSelected(document)) self.post_message(self.DocumentSelected(self.documents[idx]))

View file

@ -8,7 +8,7 @@ from textual.widgets import Input, ListItem, ListView, Static
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.inspector.widgets.detail_view import DetailView from haiku.rag.inspector.widgets.detail_view import DetailView
from haiku.rag.store.models import Chunk from haiku.rag.store.models import Chunk, SearchResult
class SearchModal(Screen): class SearchModal(Screen):
@ -16,6 +16,7 @@ class SearchModal(Screen):
BINDINGS = [ BINDINGS = [
Binding("escape", "dismiss", "Close", show=True), Binding("escape", "dismiss", "Close", show=True),
Binding("v", "show_visual", "Visual", show=True),
] ]
CSS = """ CSS = """
@ -65,6 +66,7 @@ class SearchModal(Screen):
super().__init__() super().__init__()
self.client = client self.client = client
self.chunks: list[Chunk] = [] self.chunks: list[Chunk] = []
self.search_results: list[SearchResult] = []
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Compose the search screen.""" """Compose the search screen."""
@ -99,29 +101,42 @@ class SearchModal(Screen):
status_label.update("Searching...") status_label.update("Searching...")
try: try:
# Perform search # Perform search using client API
results = await self.client.chunk_repository.search( self.search_results = await self.client.search(query=query, limit=50)
query=query, limit=50, search_type="hybrid"
)
self.chunks = [chunk for chunk, _score in results] # Get chunks for the results
self.chunks = []
for result in self.search_results:
if result.chunk_id:
chunk = await self.client.chunk_repository.get_by_id(
result.chunk_id
)
if chunk:
self.chunks.append(chunk)
# Clear and populate results # Clear and populate results
await list_view.clear() await list_view.clear()
for chunk, score in results: for result in self.search_results:
first_line = chunk.content.split("\n")[0] first_line = result.content.split("\n")[0][:60]
score_str = f"{score:.2f}" if score else "N/A" score_str = f"{result.score:.2f}"
item = ListItem(Static(f"[{score_str}] {first_line}")) # Add page info if available
page_info = ""
if result.page_numbers:
pages = ", ".join(str(p) for p in result.page_numbers[:3])
page_info = f" (p.{pages})"
item = ListItem(Static(f"[{score_str}]{page_info} {first_line}"))
await list_view.append(item) await list_view.append(item)
# Update status # Update status
status_label.update(f"Found {len(self.chunks)} results") status_label.update(f"Found {len(self.chunks)} results")
# Select first result, show in detail view, and focus list # Select first result, show in detail view, and focus list
if self.chunks: if self.chunks and self.search_results:
list_view.index = 0 list_view.index = 0
detail_view = self.query_one("#search-detail", DetailView) detail_view = self.query_one("#search-detail", DetailView)
await detail_view.show_chunk(self.chunks[0]) await detail_view.show_search_result(
self.chunks[0], self.search_results[0]
)
list_view.focus() list_view.focus()
except Exception as e: except Exception as e:
status_label.update(f"Error: {str(e)}") status_label.update(f"Error: {str(e)}")
@ -129,23 +144,61 @@ class SearchModal(Screen):
async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
"""Handle chunk navigation (arrow keys).""" """Handle chunk navigation (arrow keys)."""
list_view = self.query_one("#search-results", ListView) list_view = self.query_one("#search-results", ListView)
if event.list_view == list_view and event.item is not None: if event.list_view != list_view or event.item is None:
idx = event.list_view.index return
if idx is not None and 0 <= idx < len(self.chunks): idx = event.list_view.index
chunk = self.chunks[idx] detail_view = self.query_one("#search-detail", DetailView)
detail_view = self.query_one("#search-detail", DetailView) await detail_view.show_search_result(self.chunks[idx], self.search_results[idx]) # type: ignore[index]
await detail_view.show_chunk(chunk)
async def on_list_view_selected(self, event: ListView.Selected) -> None: async def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle chunk selection (Enter key).""" """Handle chunk selection (Enter key)."""
list_view = self.query_one("#search-results", ListView) list_view = self.query_one("#search-results", ListView)
if event.list_view == list_view: if event.list_view != list_view:
idx = event.list_view.index return
if idx is not None and 0 <= idx < len(self.chunks): idx = event.list_view.index
chunk = self.chunks[idx] self.post_message(self.ChunkSelected(self.chunks[idx])) # type: ignore[index]
self.post_message(self.ChunkSelected(chunk)) self.app.pop_screen()
self.app.pop_screen()
async def action_dismiss(self, result=None) -> None: async def action_dismiss(self, result=None) -> None:
"""Close the search screen."""
self.app.pop_screen() self.app.pop_screen()
async def action_show_visual(self) -> None:
"""Show visual grounding for the current chunk."""
list_view = self.query_one("#search-results", ListView)
idx = list_view.index
if idx is None or not self.search_results:
return
search_result = self.search_results[idx]
if not search_result.document_id or not search_result.chunk_id:
return
status_label = self.query_one("#status-label", Static)
document = await self.client.get_document_by_id(search_result.document_id)
if not document:
status_label.update("[yellow]Document not found[/yellow]")
return
docling_doc = document.get_docling_document()
if not docling_doc:
status_label.update(
"[yellow]No DoclingDocument available for visual[/yellow]"
)
return
chunk = await self.client.chunk_repository.get_by_id(search_result.chunk_id)
bounding_boxes = []
if chunk:
meta = chunk.get_chunk_metadata()
bounding_boxes = meta.resolve_bounding_boxes(docling_doc)
from haiku.rag.inspector.widgets.visual_modal import VisualGroundingModal
modal = VisualGroundingModal(
docling_document=docling_doc,
bounding_boxes=bounding_boxes,
page_numbers=search_result.page_numbers,
document_uri=search_result.document_uri,
)
await self.app.push_screen(modal)

View file

@ -0,0 +1,187 @@
from copy import deepcopy
from typing import TYPE_CHECKING
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Static
from textual_image.widget import Image as TextualImage
from haiku.rag.store.models import BoundingBox
if TYPE_CHECKING:
from docling_core.types.doc.document import DoclingDocument
from PIL.Image import Image as PILImage
class VisualGroundingModal(Screen):
"""Modal screen for displaying visual grounding with bounding boxes."""
BINDINGS = [
Binding("escape", "dismiss", "Close", show=True),
Binding("left", "prev_page", "Previous Page"),
Binding("right", "next_page", "Next Page"),
]
CSS = """
VisualGroundingModal {
background: $surface;
layout: vertical;
}
#visual-header {
dock: top;
height: auto;
padding: 1;
}
#visual-content {
height: 1fr;
width: 100%;
align: center middle;
}
#visual-content Image {
width: auto;
height: 100%;
}
#page-nav {
dock: bottom;
height: auto;
padding: 1;
}
"""
def __init__(
self,
docling_document: "DoclingDocument",
bounding_boxes: list[BoundingBox],
page_numbers: list[int],
document_uri: str | None = None,
):
super().__init__()
self.docling_document = docling_document
self.document_uri = document_uri
self.bounding_boxes = bounding_boxes
self.page_numbers = sorted(set(page_numbers)) if page_numbers else []
self.current_page_idx = 0
self._image_widget: Widget = Static("Loading...", id="image-display")
self._page_info = Static("", id="page-info")
def compose(self) -> ComposeResult:
uri_display = self.document_uri or self.docling_document.name or "Document"
with Vertical(id="visual-header"):
yield Static(f"[bold]Visual Grounding[/bold] - {uri_display}")
with Horizontal(id="visual-content"):
yield self._image_widget
with Horizontal(id="page-nav"):
yield self._page_info
async def on_mount(self) -> None:
"""Load and display the first page."""
await self._render_current_page()
async def _render_current_page(self) -> None:
"""Render the current page with bounding boxes."""
if not self.page_numbers:
if isinstance(self._image_widget, Static):
self._image_widget.update("[red]No page information available[/red]")
self._page_info.update("")
return
current_page = self.page_numbers[self.current_page_idx]
self._page_info.update(
f"Page {current_page} "
f"({self.current_page_idx + 1}/{len(self.page_numbers)}) "
f"- Use ←/→ to navigate"
)
try:
image = self._render_page_with_boxes(current_page)
if image:
new_widget = TextualImage(image, id="rendered-image")
await self._image_widget.remove()
content = self.query_one("#visual-content", Horizontal)
await content.mount(new_widget)
self._image_widget = new_widget
elif isinstance(self._image_widget, Static):
self._image_widget.update(
"[yellow]No page image available[/yellow]\n"
"This document was converted without page images."
)
except Exception as e:
if isinstance(self._image_widget, Static):
self._image_widget.update(f"[red]Error: {e}[/red]")
def _render_page_with_boxes(self, page_no: int) -> "PILImage | None":
"""Render a page from DoclingDocument with bounding boxes."""
from PIL import ImageDraw
# Get the page from DoclingDocument
if page_no not in self.docling_document.pages:
return None
page = self.docling_document.pages[page_no]
if page.image is None:
return None
pil_image = page.image.pil_image
if pil_image is None:
return None
# Get page dimensions
page_height = page.size.height
# Calculate scale factor (image pixels vs document coordinates)
scale_x = pil_image.width / page.size.width
scale_y = pil_image.height / page.size.height
# Get bounding boxes for this page
page_boxes = [bb for bb in self.bounding_boxes if bb.page_no == page_no]
if page_boxes:
# Draw bounding boxes
image = deepcopy(pil_image)
draw = ImageDraw.Draw(image, "RGBA")
for bbox in page_boxes:
# Convert from document coordinates to image coordinates
# Document coords are typically bottom-left origin
# PIL uses top-left origin
x0 = bbox.left * scale_x
y0 = (page_height - bbox.top) * scale_y # Flip Y
x1 = bbox.right * scale_x
y1 = (page_height - bbox.bottom) * scale_y # Flip Y
# Ensure proper ordering (y0 should be less than y1 for PIL)
if y0 > y1:
y0, y1 = y1, y0
# Draw filled rectangle with transparency
fill_color = (255, 255, 0, 80) # Yellow with transparency
outline_color = (255, 165, 0, 255) # Orange outline
draw.rectangle([(x0, y0), (x1, y1)], fill=fill_color, outline=None)
draw.rectangle([(x0, y0), (x1, y1)], outline=outline_color, width=3)
return image
return pil_image
async def action_dismiss(self, result=None) -> None:
self.app.pop_screen()
async def action_prev_page(self) -> None:
"""Navigate to the previous page."""
if self.current_page_idx > 0:
self.current_page_idx -= 1
await self._render_current_page()
async def action_next_page(self) -> None:
"""Navigate to the next page."""
if self.current_page_idx < len(self.page_numbers) - 1:
self.current_page_idx += 1
await self._render_current_page()

View file

@ -45,7 +45,7 @@ mxbai = ["mxbai-rerank>=0.1.6"]
cohere = ["cohere>=5.20.0"] cohere = ["cohere>=5.20.0"]
zeroentropy = ["zeroentropy>=0.1.0a6"] zeroentropy = ["zeroentropy>=0.1.0a6"]
# Inspector TUI # Inspector TUI
inspector = ["textual>=1.0.0"] inspector = ["textual>=1.0.0", "textual-image>=0.8.1"]
# Model providers (delegated to pydantic-ai-slim) # Model providers (delegated to pydantic-ai-slim)
anthropic = ["pydantic-ai-slim[anthropic]"] anthropic = ["pydantic-ai-slim[anthropic]"]
groq = ["pydantic-ai-slim[groq]"] groq = ["pydantic-ai-slim[groq]"]

15
uv.lock
View file

@ -1371,6 +1371,7 @@ groq = [
] ]
inspector = [ inspector = [
{ name = "textual" }, { name = "textual" },
{ name = "textual-image" },
] ]
mistral = [ mistral = [
{ name = "pydantic-ai-slim", extra = ["mistral"] }, { name = "pydantic-ai-slim", extra = ["mistral"] },
@ -1410,6 +1411,7 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.3" }, { name = "pyyaml", specifier = ">=6.0.3" },
{ name = "rich", specifier = ">=14.2.0" }, { name = "rich", specifier = ">=14.2.0" },
{ name = "textual", marker = "extra == 'inspector'", specifier = ">=1.0.0" }, { name = "textual", marker = "extra == 'inspector'", specifier = ">=1.0.0" },
{ name = "textual-image", marker = "extra == 'inspector'", specifier = ">=0.8.1" },
{ name = "typer", specifier = ">=0.19.2,<0.20.0" }, { name = "typer", specifier = ">=0.19.2,<0.20.0" },
{ name = "voyageai", marker = "extra == 'voyageai'", specifier = ">=0.3.5" }, { name = "voyageai", marker = "extra == 'voyageai'", specifier = ">=0.3.5" },
{ name = "watchfiles", specifier = ">=1.1.1" }, { name = "watchfiles", specifier = ">=1.1.1" },
@ -4671,6 +4673,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/b3/95ab646b0c908823d71e49ab8b5949ec9f33346cee3897d1af6be28a8d91/textual-6.6.0-py3-none-any.whl", hash = "sha256:5a9484bd15ee8a6fd8ac4ed4849fb25ee56bed2cecc7b8a83c4cd7d5f19515e5", size = 712606, upload-time = "2025-11-10T17:49:58.391Z" }, { url = "https://files.pythonhosted.org/packages/53/b3/95ab646b0c908823d71e49ab8b5949ec9f33346cee3897d1af6be28a8d91/textual-6.6.0-py3-none-any.whl", hash = "sha256:5a9484bd15ee8a6fd8ac4ed4849fb25ee56bed2cecc7b8a83c4cd7d5f19515e5", size = 712606, upload-time = "2025-11-10T17:49:58.391Z" },
] ]
[[package]]
name = "textual-image"
version = "0.8.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pillow" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/3e/807c5a449e9d99ba3b860acf5b83cf1da7ac46477bfe0e9e4d0149b8ed90/textual_image-0.8.4.tar.gz", hash = "sha256:d13f960da07659cfac9d9e417ca7057b3ac0c17a7827ae8e47c3b164d43776fc", size = 109056, upload-time = "2025-09-02T19:09:11.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/0e/2c3c2972ee810595089d85a51107f45e41c0642f645c24675951c69fd648/textual_image-0.8.4-py3-none-any.whl", hash = "sha256:0f0256993348f5af619c930a4839ea190525a22a56e8d69e1cf0f8e32d59fa3b", size = 109608, upload-time = "2025-09-02T19:09:10.707Z" },
]
[[package]] [[package]]
name = "tifffile" name = "tifffile"
version = "2025.10.16" version = "2025.10.16"