haiku.rag-slim 0.16.0__py3-none-any.whl → 0.24.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of haiku.rag-slim might be problematic. Click here for more details.
- haiku/rag/app.py +430 -72
- haiku/rag/chunkers/__init__.py +31 -0
- haiku/rag/chunkers/base.py +31 -0
- haiku/rag/chunkers/docling_local.py +164 -0
- haiku/rag/chunkers/docling_serve.py +179 -0
- haiku/rag/cli.py +207 -24
- haiku/rag/cli_chat.py +489 -0
- haiku/rag/client.py +1251 -266
- haiku/rag/config/__init__.py +16 -10
- haiku/rag/config/loader.py +5 -44
- haiku/rag/config/models.py +126 -17
- haiku/rag/converters/__init__.py +31 -0
- haiku/rag/converters/base.py +63 -0
- haiku/rag/converters/docling_local.py +193 -0
- haiku/rag/converters/docling_serve.py +229 -0
- haiku/rag/converters/text_utils.py +237 -0
- haiku/rag/embeddings/__init__.py +123 -24
- haiku/rag/embeddings/voyageai.py +175 -20
- haiku/rag/graph/__init__.py +0 -11
- haiku/rag/graph/agui/__init__.py +8 -2
- haiku/rag/graph/agui/cli_renderer.py +1 -1
- haiku/rag/graph/agui/emitter.py +219 -31
- haiku/rag/graph/agui/server.py +20 -62
- haiku/rag/graph/agui/stream.py +1 -2
- haiku/rag/graph/research/__init__.py +5 -2
- haiku/rag/graph/research/dependencies.py +12 -126
- haiku/rag/graph/research/graph.py +390 -135
- haiku/rag/graph/research/models.py +91 -112
- haiku/rag/graph/research/prompts.py +99 -91
- haiku/rag/graph/research/state.py +35 -27
- haiku/rag/inspector/__init__.py +8 -0
- haiku/rag/inspector/app.py +259 -0
- haiku/rag/inspector/widgets/__init__.py +6 -0
- haiku/rag/inspector/widgets/chunk_list.py +100 -0
- haiku/rag/inspector/widgets/context_modal.py +89 -0
- haiku/rag/inspector/widgets/detail_view.py +130 -0
- haiku/rag/inspector/widgets/document_list.py +75 -0
- haiku/rag/inspector/widgets/info_modal.py +209 -0
- haiku/rag/inspector/widgets/search_modal.py +183 -0
- haiku/rag/inspector/widgets/visual_modal.py +126 -0
- haiku/rag/mcp.py +106 -102
- haiku/rag/monitor.py +33 -9
- haiku/rag/providers/__init__.py +5 -0
- haiku/rag/providers/docling_serve.py +108 -0
- haiku/rag/qa/__init__.py +12 -10
- haiku/rag/qa/agent.py +43 -61
- haiku/rag/qa/prompts.py +35 -57
- haiku/rag/reranking/__init__.py +9 -6
- haiku/rag/reranking/base.py +1 -1
- haiku/rag/reranking/cohere.py +5 -4
- haiku/rag/reranking/mxbai.py +5 -2
- haiku/rag/reranking/vllm.py +3 -4
- haiku/rag/reranking/zeroentropy.py +6 -5
- haiku/rag/store/__init__.py +2 -1
- haiku/rag/store/engine.py +242 -42
- haiku/rag/store/exceptions.py +4 -0
- haiku/rag/store/models/__init__.py +8 -2
- haiku/rag/store/models/chunk.py +190 -0
- haiku/rag/store/models/document.py +46 -0
- haiku/rag/store/repositories/chunk.py +141 -121
- haiku/rag/store/repositories/document.py +25 -84
- haiku/rag/store/repositories/settings.py +11 -14
- haiku/rag/store/upgrades/__init__.py +19 -3
- haiku/rag/store/upgrades/v0_10_1.py +1 -1
- haiku/rag/store/upgrades/v0_19_6.py +65 -0
- haiku/rag/store/upgrades/v0_20_0.py +68 -0
- haiku/rag/store/upgrades/v0_23_1.py +100 -0
- haiku/rag/store/upgrades/v0_9_3.py +3 -3
- haiku/rag/utils.py +371 -146
- {haiku_rag_slim-0.16.0.dist-info → haiku_rag_slim-0.24.0.dist-info}/METADATA +15 -12
- haiku_rag_slim-0.24.0.dist-info/RECORD +78 -0
- {haiku_rag_slim-0.16.0.dist-info → haiku_rag_slim-0.24.0.dist-info}/WHEEL +1 -1
- haiku/rag/chunker.py +0 -65
- haiku/rag/embeddings/base.py +0 -25
- haiku/rag/embeddings/ollama.py +0 -28
- haiku/rag/embeddings/openai.py +0 -26
- haiku/rag/embeddings/vllm.py +0 -29
- haiku/rag/graph/agui/events.py +0 -254
- haiku/rag/graph/common/__init__.py +0 -5
- haiku/rag/graph/common/models.py +0 -42
- haiku/rag/graph/common/nodes.py +0 -265
- haiku/rag/graph/common/prompts.py +0 -46
- haiku/rag/graph/common/utils.py +0 -44
- haiku/rag/graph/deep_qa/__init__.py +0 -1
- haiku/rag/graph/deep_qa/dependencies.py +0 -27
- haiku/rag/graph/deep_qa/graph.py +0 -243
- haiku/rag/graph/deep_qa/models.py +0 -20
- haiku/rag/graph/deep_qa/prompts.py +0 -59
- haiku/rag/graph/deep_qa/state.py +0 -56
- haiku/rag/graph/research/common.py +0 -87
- haiku/rag/reader.py +0 -135
- haiku_rag_slim-0.16.0.dist-info/RECORD +0 -71
- {haiku_rag_slim-0.16.0.dist-info → haiku_rag_slim-0.24.0.dist-info}/entry_points.txt +0 -0
- {haiku_rag_slim-0.16.0.dist-info → haiku_rag_slim-0.24.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from importlib.metadata import version as pkg_version
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Vertical, VerticalScroll
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from haiku.rag.utils import format_bytes
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from haiku.rag.client import HaikuRAG
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InfoModal(ModalScreen): # pragma: no cover
|
|
19
|
+
"""Modal screen for displaying database information."""
|
|
20
|
+
|
|
21
|
+
BINDINGS = [
|
|
22
|
+
Binding("escape", "dismiss", "Close", show=True),
|
|
23
|
+
Binding("i", "dismiss", "Close", show=True),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
CSS = """
|
|
27
|
+
InfoModal {
|
|
28
|
+
align: center middle;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#info-container {
|
|
32
|
+
width: 80;
|
|
33
|
+
height: auto;
|
|
34
|
+
max-height: 80%;
|
|
35
|
+
background: $surface;
|
|
36
|
+
border: solid $primary;
|
|
37
|
+
padding: 1 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#info-header {
|
|
41
|
+
height: auto;
|
|
42
|
+
margin-bottom: 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#info-content {
|
|
46
|
+
height: auto;
|
|
47
|
+
max-height: 100%;
|
|
48
|
+
}
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, client: "HaikuRAG", db_path: Path):
|
|
52
|
+
super().__init__()
|
|
53
|
+
self.client = client
|
|
54
|
+
self.db_path = db_path
|
|
55
|
+
self._content_widget = Static("Loading...")
|
|
56
|
+
|
|
57
|
+
def compose(self) -> ComposeResult:
|
|
58
|
+
with Vertical(id="info-container"):
|
|
59
|
+
yield Static("[bold]Database Info[/bold]", id="info-header")
|
|
60
|
+
with VerticalScroll(id="info-content"):
|
|
61
|
+
yield self._content_widget
|
|
62
|
+
|
|
63
|
+
async def on_mount(self) -> None:
|
|
64
|
+
"""Load and display database info."""
|
|
65
|
+
import lancedb
|
|
66
|
+
|
|
67
|
+
lines: list[str] = []
|
|
68
|
+
|
|
69
|
+
# Path
|
|
70
|
+
lines.append(f"[bold $accent]path[/bold $accent]: {self.db_path}")
|
|
71
|
+
|
|
72
|
+
if not self.db_path.exists():
|
|
73
|
+
lines.append("[red]Database path does not exist.[/red]")
|
|
74
|
+
self._content_widget.update("\n".join(lines))
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Connect to get table info
|
|
78
|
+
try:
|
|
79
|
+
db = lancedb.connect(self.db_path)
|
|
80
|
+
table_names = set(db.table_names())
|
|
81
|
+
except Exception as e:
|
|
82
|
+
lines.append(f"[red]Failed to open database: {e}[/red]")
|
|
83
|
+
self._content_widget.update("\n".join(lines))
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Get versions
|
|
87
|
+
try:
|
|
88
|
+
ldb_version = pkg_version("lancedb")
|
|
89
|
+
except Exception:
|
|
90
|
+
ldb_version = "unknown"
|
|
91
|
+
try:
|
|
92
|
+
hr_version = pkg_version("haiku.rag-slim")
|
|
93
|
+
except Exception:
|
|
94
|
+
hr_version = "unknown"
|
|
95
|
+
try:
|
|
96
|
+
docling_version = pkg_version("docling")
|
|
97
|
+
except Exception:
|
|
98
|
+
docling_version = "unknown"
|
|
99
|
+
|
|
100
|
+
# Get stats from store
|
|
101
|
+
table_stats = self.client.store.get_stats()
|
|
102
|
+
|
|
103
|
+
# Read settings
|
|
104
|
+
stored_version = "unknown"
|
|
105
|
+
embed_provider: str | None = None
|
|
106
|
+
embed_model: str | None = None
|
|
107
|
+
vector_dim: int | None = None
|
|
108
|
+
|
|
109
|
+
if "settings" in table_names:
|
|
110
|
+
settings_tbl = db.open_table("settings")
|
|
111
|
+
arrow = settings_tbl.search().where("id = 'settings'").limit(1).to_arrow()
|
|
112
|
+
rows = arrow.to_pylist() if arrow is not None else []
|
|
113
|
+
if rows:
|
|
114
|
+
raw = rows[0].get("settings") or "{}"
|
|
115
|
+
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
|
116
|
+
stored_version = str(data.get("version", stored_version))
|
|
117
|
+
embeddings = data.get("embeddings", {})
|
|
118
|
+
embed_model_obj = embeddings.get("model", {})
|
|
119
|
+
embed_provider = embed_model_obj.get("provider")
|
|
120
|
+
embed_model = embed_model_obj.get("name")
|
|
121
|
+
vector_dim = embed_model_obj.get("vector_dim")
|
|
122
|
+
|
|
123
|
+
num_docs = table_stats["documents"].get("num_rows", 0)
|
|
124
|
+
doc_bytes = table_stats["documents"].get("total_bytes", 0)
|
|
125
|
+
|
|
126
|
+
num_chunks = table_stats["chunks"].get("num_rows", 0)
|
|
127
|
+
chunk_bytes = table_stats["chunks"].get("total_bytes", 0)
|
|
128
|
+
|
|
129
|
+
has_vector_index = table_stats["chunks"].get("has_vector_index", False)
|
|
130
|
+
num_indexed_rows = table_stats["chunks"].get("num_indexed_rows", 0)
|
|
131
|
+
num_unindexed_rows = table_stats["chunks"].get("num_unindexed_rows", 0)
|
|
132
|
+
|
|
133
|
+
# Table versions
|
|
134
|
+
doc_versions = (
|
|
135
|
+
len(list(db.open_table("documents").list_versions()))
|
|
136
|
+
if "documents" in table_names
|
|
137
|
+
else 0
|
|
138
|
+
)
|
|
139
|
+
chunk_versions = (
|
|
140
|
+
len(list(db.open_table("chunks").list_versions()))
|
|
141
|
+
if "chunks" in table_names
|
|
142
|
+
else 0
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Build output
|
|
146
|
+
lines.append(
|
|
147
|
+
f"[bold $accent]haiku.rag version (db)[/bold $accent]: {stored_version}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if embed_provider or embed_model or vector_dim:
|
|
151
|
+
provider_part = embed_provider or "unknown"
|
|
152
|
+
model_part = embed_model or "unknown"
|
|
153
|
+
dim_part = f"{vector_dim}" if vector_dim is not None else "unknown"
|
|
154
|
+
lines.append(
|
|
155
|
+
f"[bold $accent]embeddings[/bold $accent]: "
|
|
156
|
+
f"{provider_part}/{model_part} (dim: {dim_part})"
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
lines.append("[bold $accent]embeddings[/bold $accent]: unknown")
|
|
160
|
+
|
|
161
|
+
lines.append(
|
|
162
|
+
f"[bold $accent]documents[/bold $accent]: {num_docs} ({format_bytes(doc_bytes)})"
|
|
163
|
+
)
|
|
164
|
+
lines.append(
|
|
165
|
+
f"[bold $accent]chunks[/bold $accent]: {num_chunks} ({format_bytes(chunk_bytes)})"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Vector index info
|
|
169
|
+
if has_vector_index:
|
|
170
|
+
lines.append("[bold $accent]vector index[/bold $accent]: ✓ exists")
|
|
171
|
+
lines.append(
|
|
172
|
+
f"[bold $accent]indexed chunks[/bold $accent]: {num_indexed_rows}"
|
|
173
|
+
)
|
|
174
|
+
if num_unindexed_rows > 0:
|
|
175
|
+
lines.append(
|
|
176
|
+
f"[bold $accent]unindexed chunks[/bold $accent]: [yellow]{num_unindexed_rows}[/yellow]"
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
lines.append(
|
|
180
|
+
f"[bold $accent]unindexed chunks[/bold $accent]: {num_unindexed_rows}"
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
if num_chunks >= 256:
|
|
184
|
+
lines.append(
|
|
185
|
+
"[bold $accent]vector index[/bold $accent]: [yellow]✗ not created[/yellow]"
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
lines.append(
|
|
189
|
+
f"[bold $accent]vector index[/bold $accent]: ✗ not created "
|
|
190
|
+
f"(need {256 - num_chunks} more chunks)"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
lines.append(
|
|
194
|
+
f"[bold $accent]versions (documents)[/bold $accent]: {doc_versions}"
|
|
195
|
+
)
|
|
196
|
+
lines.append(
|
|
197
|
+
f"[bold $accent]versions (chunks)[/bold $accent]: {chunk_versions}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
lines.append("")
|
|
201
|
+
lines.append("[bold]Versions[/bold]")
|
|
202
|
+
lines.append(f"[bold $accent]haiku.rag[/bold $accent]: {hr_version}")
|
|
203
|
+
lines.append(f"[bold $accent]lancedb[/bold $accent]: {ldb_version}")
|
|
204
|
+
lines.append(f"[bold $accent]docling[/bold $accent]: {docling_version}")
|
|
205
|
+
|
|
206
|
+
self._content_widget.update("\n".join(lines))
|
|
207
|
+
|
|
208
|
+
async def action_dismiss(self, result=None) -> None:
|
|
209
|
+
self.app.pop_screen()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from textual import on
|
|
2
|
+
from textual.app import ComposeResult
|
|
3
|
+
from textual.binding import Binding
|
|
4
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
5
|
+
from textual.message import Message
|
|
6
|
+
from textual.screen import Screen
|
|
7
|
+
from textual.widgets import Input, ListItem, ListView, Static
|
|
8
|
+
|
|
9
|
+
from haiku.rag.client import HaikuRAG
|
|
10
|
+
from haiku.rag.inspector.widgets.detail_view import DetailView
|
|
11
|
+
from haiku.rag.store.models import Chunk, SearchResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SearchModal(Screen): # pragma: no cover
|
|
15
|
+
"""Screen for searching chunks."""
|
|
16
|
+
|
|
17
|
+
BINDINGS = [
|
|
18
|
+
Binding("escape", "dismiss", "Close", show=True),
|
|
19
|
+
Binding("v", "show_visual", "Visual", show=True),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
CSS = """
|
|
23
|
+
SearchModal {
|
|
24
|
+
background: $surface;
|
|
25
|
+
layout: vertical;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#search-header {
|
|
29
|
+
dock: top;
|
|
30
|
+
height: auto;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#search-content {
|
|
34
|
+
height: 1fr;
|
|
35
|
+
width: 100%;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#search-results-container {
|
|
39
|
+
width: 1fr;
|
|
40
|
+
border: solid $primary;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#search-detail {
|
|
44
|
+
width: 2fr;
|
|
45
|
+
border: solid $accent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ListItem {
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ListItem Static {
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
text-overflow: ellipsis;
|
|
55
|
+
}
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
class ChunkSelected(Message):
|
|
59
|
+
"""Message sent when a chunk is selected from search results."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, chunk: Chunk) -> None:
|
|
62
|
+
super().__init__()
|
|
63
|
+
self.chunk = chunk
|
|
64
|
+
|
|
65
|
+
def __init__(self, client: HaikuRAG):
|
|
66
|
+
super().__init__()
|
|
67
|
+
self.client = client
|
|
68
|
+
self.chunks: list[Chunk] = []
|
|
69
|
+
self.search_results: list[SearchResult] = []
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
"""Compose the search screen."""
|
|
73
|
+
with Vertical(id="search-header"):
|
|
74
|
+
yield Static("[bold]Search Chunks[/bold]")
|
|
75
|
+
yield Input(placeholder="Enter search query...", id="search-input")
|
|
76
|
+
yield Static("", id="status-label")
|
|
77
|
+
with Horizontal(id="search-content"):
|
|
78
|
+
with VerticalScroll(id="search-results-container"):
|
|
79
|
+
yield ListView(id="search-results")
|
|
80
|
+
yield DetailView(id="search-detail")
|
|
81
|
+
|
|
82
|
+
async def on_mount(self) -> None:
|
|
83
|
+
"""Focus the search input when mounted."""
|
|
84
|
+
status_label = self.query_one("#status-label", Static)
|
|
85
|
+
status_label.update("Type query and press Enter to search")
|
|
86
|
+
search_input = self.query_one("#search-input", Input)
|
|
87
|
+
search_input.focus()
|
|
88
|
+
|
|
89
|
+
@on(Input.Submitted, "#search-input")
|
|
90
|
+
async def search_submitted(self, event: Input.Submitted) -> None:
|
|
91
|
+
"""Handle search query submission."""
|
|
92
|
+
query = event.value.strip()
|
|
93
|
+
if query:
|
|
94
|
+
await self.run_search(query)
|
|
95
|
+
|
|
96
|
+
async def run_search(self, query: str) -> None:
|
|
97
|
+
"""Perform the search."""
|
|
98
|
+
status_label = self.query_one("#status-label", Static)
|
|
99
|
+
list_view = self.query_one("#search-results", ListView)
|
|
100
|
+
|
|
101
|
+
status_label.update("Searching...")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
# Perform search using client API
|
|
105
|
+
self.search_results = await self.client.search(query=query, limit=50)
|
|
106
|
+
|
|
107
|
+
# Get chunks for the results
|
|
108
|
+
self.chunks = []
|
|
109
|
+
for result in self.search_results:
|
|
110
|
+
if result.chunk_id:
|
|
111
|
+
chunk = await self.client.chunk_repository.get_by_id(
|
|
112
|
+
result.chunk_id
|
|
113
|
+
)
|
|
114
|
+
if chunk:
|
|
115
|
+
self.chunks.append(chunk)
|
|
116
|
+
|
|
117
|
+
# Clear and populate results
|
|
118
|
+
await list_view.clear()
|
|
119
|
+
for result in self.search_results:
|
|
120
|
+
first_line = result.content.split("\n")[0][:60]
|
|
121
|
+
score_str = f"{result.score:.2f}"
|
|
122
|
+
# Add page info if available
|
|
123
|
+
page_info = ""
|
|
124
|
+
if result.page_numbers:
|
|
125
|
+
pages = ", ".join(str(p) for p in result.page_numbers[:3])
|
|
126
|
+
page_info = f" (p.{pages})"
|
|
127
|
+
item = ListItem(Static(f"[{score_str}]{page_info} {first_line}"))
|
|
128
|
+
await list_view.append(item)
|
|
129
|
+
|
|
130
|
+
# Update status
|
|
131
|
+
status_label.update(f"Found {len(self.chunks)} results")
|
|
132
|
+
|
|
133
|
+
# Select first result, show in detail view, and focus list
|
|
134
|
+
if self.chunks and self.search_results:
|
|
135
|
+
list_view.index = 0
|
|
136
|
+
detail_view = self.query_one("#search-detail", DetailView)
|
|
137
|
+
await detail_view.show_search_result(
|
|
138
|
+
self.chunks[0], self.search_results[0]
|
|
139
|
+
)
|
|
140
|
+
list_view.focus()
|
|
141
|
+
except Exception as e:
|
|
142
|
+
status_label.update(f"Error: {str(e)}")
|
|
143
|
+
|
|
144
|
+
async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
145
|
+
"""Handle chunk navigation (arrow keys)."""
|
|
146
|
+
list_view = self.query_one("#search-results", ListView)
|
|
147
|
+
if event.list_view != list_view or event.item is None:
|
|
148
|
+
return
|
|
149
|
+
idx = event.list_view.index
|
|
150
|
+
detail_view = self.query_one("#search-detail", DetailView)
|
|
151
|
+
await detail_view.show_search_result(self.chunks[idx], self.search_results[idx]) # type: ignore[index]
|
|
152
|
+
|
|
153
|
+
async def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
154
|
+
"""Handle chunk selection (Enter key)."""
|
|
155
|
+
list_view = self.query_one("#search-results", ListView)
|
|
156
|
+
if event.list_view != list_view:
|
|
157
|
+
return
|
|
158
|
+
idx = event.list_view.index
|
|
159
|
+
self.post_message(self.ChunkSelected(self.chunks[idx])) # type: ignore[index]
|
|
160
|
+
self.app.pop_screen()
|
|
161
|
+
|
|
162
|
+
async def action_dismiss(self, result=None) -> None:
|
|
163
|
+
self.app.pop_screen()
|
|
164
|
+
|
|
165
|
+
async def action_show_visual(self) -> None:
|
|
166
|
+
"""Show visual grounding for the current chunk."""
|
|
167
|
+
list_view = self.query_one("#search-results", ListView)
|
|
168
|
+
idx = list_view.index
|
|
169
|
+
if idx is None or not self.chunks:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
chunk = self.chunks[idx]
|
|
173
|
+
|
|
174
|
+
from haiku.rag.inspector.widgets.visual_modal import VisualGroundingModal
|
|
175
|
+
|
|
176
|
+
# Use app's _switch_modal to close this modal before opening visual
|
|
177
|
+
await self.app._switch_modal( # type: ignore[attr-defined]
|
|
178
|
+
VisualGroundingModal(
|
|
179
|
+
chunk=chunk,
|
|
180
|
+
client=self.client,
|
|
181
|
+
document_uri=chunk.document_uri,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.containers import Horizontal, Vertical
|
|
6
|
+
from textual.screen import Screen
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
from textual_image.widget import Image as TextualImage
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from PIL.Image import Image as PILImage
|
|
13
|
+
|
|
14
|
+
from haiku.rag.client import HaikuRAG
|
|
15
|
+
from haiku.rag.store.models import Chunk
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VisualGroundingModal(Screen): # pragma: no cover
|
|
19
|
+
"""Modal screen for displaying visual grounding with bounding boxes."""
|
|
20
|
+
|
|
21
|
+
BINDINGS = [
|
|
22
|
+
Binding("escape", "dismiss", "Close", show=True),
|
|
23
|
+
Binding("v", "dismiss", "Close", show=True),
|
|
24
|
+
Binding("left", "prev_page", "Previous Page"),
|
|
25
|
+
Binding("right", "next_page", "Next Page"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
CSS = """
|
|
29
|
+
VisualGroundingModal {
|
|
30
|
+
background: $surface;
|
|
31
|
+
layout: vertical;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#visual-header {
|
|
35
|
+
dock: top;
|
|
36
|
+
height: auto;
|
|
37
|
+
padding: 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#visual-content {
|
|
41
|
+
height: 1fr;
|
|
42
|
+
width: 100%;
|
|
43
|
+
align: center middle;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#visual-content Image {
|
|
47
|
+
width: auto;
|
|
48
|
+
height: 100%;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#page-nav {
|
|
52
|
+
dock: bottom;
|
|
53
|
+
height: auto;
|
|
54
|
+
padding: 1;
|
|
55
|
+
}
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
chunk: "Chunk",
|
|
61
|
+
client: "HaikuRAG",
|
|
62
|
+
document_uri: str | None = None,
|
|
63
|
+
):
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.chunk = chunk
|
|
66
|
+
self.client = client
|
|
67
|
+
self.document_uri = document_uri or chunk.document_uri
|
|
68
|
+
self.images: list[PILImage] = []
|
|
69
|
+
self.current_page_idx = 0
|
|
70
|
+
self._image_widget: Widget = Static("Loading...", id="image-display")
|
|
71
|
+
self._page_info = Static("", id="page-info")
|
|
72
|
+
|
|
73
|
+
def compose(self) -> ComposeResult:
|
|
74
|
+
uri_display = self.document_uri or "Document"
|
|
75
|
+
with Vertical(id="visual-header"):
|
|
76
|
+
yield Static(f"[bold]Visual Grounding[/bold] - {uri_display}")
|
|
77
|
+
with Horizontal(id="visual-content"):
|
|
78
|
+
yield self._image_widget
|
|
79
|
+
with Horizontal(id="page-nav"):
|
|
80
|
+
yield self._page_info
|
|
81
|
+
|
|
82
|
+
async def on_mount(self) -> None:
|
|
83
|
+
"""Load images and display the first page."""
|
|
84
|
+
self.images = await self.client.visualize_chunk(self.chunk)
|
|
85
|
+
await self._render_current_page()
|
|
86
|
+
|
|
87
|
+
async def _render_current_page(self) -> None:
|
|
88
|
+
"""Render the current page."""
|
|
89
|
+
if not self.images:
|
|
90
|
+
if isinstance(self._image_widget, Static):
|
|
91
|
+
self._image_widget.update(
|
|
92
|
+
"[yellow]No page images available[/yellow]\n"
|
|
93
|
+
"This document was converted without page images."
|
|
94
|
+
)
|
|
95
|
+
self._page_info.update("")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
self._page_info.update(
|
|
99
|
+
f"Page {self.current_page_idx + 1}/{len(self.images)} - Use ←/→ to navigate"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
image = self.images[self.current_page_idx]
|
|
104
|
+
new_widget = TextualImage(image, id="rendered-image")
|
|
105
|
+
await self._image_widget.remove()
|
|
106
|
+
content = self.query_one("#visual-content", Horizontal)
|
|
107
|
+
await content.mount(new_widget)
|
|
108
|
+
self._image_widget = new_widget
|
|
109
|
+
except Exception as e:
|
|
110
|
+
if isinstance(self._image_widget, Static):
|
|
111
|
+
self._image_widget.update(f"[red]Error: {e}[/red]")
|
|
112
|
+
|
|
113
|
+
async def action_dismiss(self, result=None) -> None:
|
|
114
|
+
self.app.pop_screen()
|
|
115
|
+
|
|
116
|
+
async def action_prev_page(self) -> None:
|
|
117
|
+
"""Navigate to the previous page."""
|
|
118
|
+
if self.current_page_idx > 0:
|
|
119
|
+
self.current_page_idx -= 1
|
|
120
|
+
await self._render_current_page()
|
|
121
|
+
|
|
122
|
+
async def action_next_page(self) -> None:
|
|
123
|
+
"""Navigate to the next page."""
|
|
124
|
+
if self.current_page_idx < len(self.images) - 1:
|
|
125
|
+
self.current_page_idx += 1
|
|
126
|
+
await self._render_current_page()
|