vector-inspector 0.3.1__py3-none-any.whl → 0.3.3__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.
- vector_inspector/core/connection_manager.py +55 -49
- vector_inspector/core/connections/base_connection.py +41 -41
- vector_inspector/core/connections/chroma_connection.py +110 -86
- vector_inspector/core/connections/pinecone_connection.py +168 -182
- vector_inspector/core/connections/qdrant_connection.py +109 -126
- vector_inspector/core/connections/qdrant_helpers/__init__.py +4 -0
- vector_inspector/core/connections/qdrant_helpers/qdrant_embedding_resolver.py +35 -0
- vector_inspector/core/connections/qdrant_helpers/qdrant_filter_builder.py +51 -0
- vector_inspector/core/connections/template_connection.py +55 -65
- vector_inspector/core/embedding_utils.py +32 -32
- vector_inspector/core/logging.py +27 -0
- vector_inspector/core/model_registry.py +4 -3
- vector_inspector/main.py +6 -2
- vector_inspector/services/backup_helpers.py +63 -0
- vector_inspector/services/backup_restore_service.py +73 -152
- vector_inspector/services/credential_service.py +33 -40
- vector_inspector/services/import_export_service.py +70 -67
- vector_inspector/services/profile_service.py +92 -94
- vector_inspector/services/settings_service.py +68 -48
- vector_inspector/services/visualization_service.py +40 -39
- vector_inspector/ui/components/splash_window.py +57 -0
- vector_inspector/ui/dialogs/cross_db_migration.py +6 -5
- vector_inspector/ui/main_window.py +200 -146
- vector_inspector/ui/views/info_panel.py +208 -127
- vector_inspector/ui/views/metadata_view.py +8 -7
- vector_inspector/ui/views/search_view.py +97 -75
- vector_inspector/ui/views/visualization_view.py +140 -97
- vector_inspector/utils/version.py +5 -0
- {vector_inspector-0.3.1.dist-info → vector_inspector-0.3.3.dist-info}/METADATA +9 -2
- {vector_inspector-0.3.1.dist-info → vector_inspector-0.3.3.dist-info}/RECORD +32 -25
- {vector_inspector-0.3.1.dist-info → vector_inspector-0.3.3.dist-info}/WHEEL +0 -0
- {vector_inspector-0.3.1.dist-info → vector_inspector-0.3.3.dist-info}/entry_points.txt +0 -0
|
@@ -18,6 +18,7 @@ from vector_inspector.services.filter_service import apply_client_side_filters
|
|
|
18
18
|
from vector_inspector.services.settings_service import SettingsService
|
|
19
19
|
from vector_inspector.core.cache_manager import get_cache_manager, CacheEntry
|
|
20
20
|
from PySide6.QtWidgets import QApplication
|
|
21
|
+
from vector_inspector.core.logging import log_info
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class DataLoadThread(QThread):
|
|
@@ -184,13 +185,13 @@ class MetadataView(QWidget):
|
|
|
184
185
|
self.current_database = database_name
|
|
185
186
|
|
|
186
187
|
# Debug: Check cache status
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
log_info("[MetadataView] Setting collection: db='%s', coll='%s'", self.current_database, collection_name)
|
|
189
|
+
log_info("[MetadataView] Cache enabled: %s", self.cache_manager.is_enabled())
|
|
189
190
|
|
|
190
191
|
# Check cache first
|
|
191
192
|
cached = self.cache_manager.get(self.current_database, self.current_collection)
|
|
192
193
|
if cached and cached.data:
|
|
193
|
-
|
|
194
|
+
log_info("[MetadataView] ✓ Cache HIT! Loading from cache.")
|
|
194
195
|
# Restore from cache
|
|
195
196
|
self.current_page = 0
|
|
196
197
|
self.current_data = cached.data
|
|
@@ -208,7 +209,7 @@ class MetadataView(QWidget):
|
|
|
208
209
|
self.status_label.setText(f"✓ Loaded from cache - {len(cached.data.get('ids', []))} items")
|
|
209
210
|
return
|
|
210
211
|
|
|
211
|
-
|
|
212
|
+
log_info("[MetadataView] ✗ Cache MISS. Loading from database...")
|
|
212
213
|
# Not in cache, load from database
|
|
213
214
|
self.current_page = 0
|
|
214
215
|
|
|
@@ -284,16 +285,16 @@ class MetadataView(QWidget):
|
|
|
284
285
|
|
|
285
286
|
# Save to cache
|
|
286
287
|
if self.current_database and self.current_collection:
|
|
287
|
-
|
|
288
|
+
log_info("[MetadataView] Saving to cache: db='%s', coll='%s'", self.current_database, self.current_collection)
|
|
288
289
|
cache_entry = CacheEntry(
|
|
289
290
|
data=data,
|
|
290
291
|
scroll_position=self.table.verticalScrollBar().value(),
|
|
291
292
|
search_query=self.filter_builder.to_dict() if hasattr(self.filter_builder, 'to_dict') else ""
|
|
292
293
|
)
|
|
293
294
|
self.cache_manager.set(self.current_database, self.current_collection, cache_entry)
|
|
294
|
-
|
|
295
|
+
log_info("[MetadataView] ✓ Saved to cache. Total entries: %d", len(self.cache_manager._cache))
|
|
295
296
|
else:
|
|
296
|
-
|
|
297
|
+
log_info("[MetadataView] ✗ NOT saving to cache - db='%s', coll='%s'", self.current_database, self.current_collection)
|
|
297
298
|
|
|
298
299
|
def _on_load_error(self, error_msg: str):
|
|
299
300
|
"""Handle error from background thread."""
|
|
@@ -2,9 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
from typing import Optional, Dict, Any
|
|
4
4
|
from PySide6.QtWidgets import (
|
|
5
|
-
QWidget,
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
QWidget,
|
|
6
|
+
QVBoxLayout,
|
|
7
|
+
QHBoxLayout,
|
|
8
|
+
QTextEdit,
|
|
9
|
+
QPushButton,
|
|
10
|
+
QLabel,
|
|
11
|
+
QSpinBox,
|
|
12
|
+
QTableWidget,
|
|
13
|
+
QTableWidgetItem,
|
|
14
|
+
QGroupBox,
|
|
15
|
+
QSplitter,
|
|
16
|
+
QCheckBox,
|
|
17
|
+
QApplication,
|
|
8
18
|
)
|
|
9
19
|
from PySide6.QtCore import Qt
|
|
10
20
|
|
|
@@ -13,11 +23,12 @@ from vector_inspector.ui.components.filter_builder import FilterBuilder
|
|
|
13
23
|
from vector_inspector.ui.components.loading_dialog import LoadingDialog
|
|
14
24
|
from vector_inspector.services.filter_service import apply_client_side_filters
|
|
15
25
|
from vector_inspector.core.cache_manager import get_cache_manager, CacheEntry
|
|
26
|
+
from vector_inspector.core.logging import log_info
|
|
16
27
|
|
|
17
28
|
|
|
18
29
|
class SearchView(QWidget):
|
|
19
30
|
"""View for performing similarity searches."""
|
|
20
|
-
|
|
31
|
+
|
|
21
32
|
def __init__(self, connection: VectorDBConnection, parent=None):
|
|
22
33
|
super().__init__(parent)
|
|
23
34
|
self.connection = connection
|
|
@@ -26,108 +37,112 @@ class SearchView(QWidget):
|
|
|
26
37
|
self.search_results: Optional[Dict[str, Any]] = None
|
|
27
38
|
self.loading_dialog = LoadingDialog("Searching...", self)
|
|
28
39
|
self.cache_manager = get_cache_manager()
|
|
29
|
-
|
|
40
|
+
|
|
30
41
|
self._setup_ui()
|
|
31
|
-
|
|
42
|
+
|
|
32
43
|
def _setup_ui(self):
|
|
33
44
|
"""Setup widget UI."""
|
|
34
45
|
layout = QVBoxLayout(self)
|
|
35
|
-
|
|
46
|
+
|
|
36
47
|
# Create splitter for query and results
|
|
37
48
|
splitter = QSplitter(Qt.Vertical)
|
|
38
|
-
|
|
49
|
+
|
|
39
50
|
# Query section
|
|
40
51
|
query_widget = QWidget()
|
|
41
52
|
query_layout = QVBoxLayout(query_widget)
|
|
42
|
-
|
|
53
|
+
|
|
43
54
|
query_group = QGroupBox("Search Query")
|
|
44
55
|
query_group_layout = QVBoxLayout()
|
|
45
|
-
|
|
56
|
+
|
|
46
57
|
# Query input
|
|
47
58
|
query_group_layout.addWidget(QLabel("Enter search text:"))
|
|
48
59
|
self.query_input = QTextEdit()
|
|
49
60
|
self.query_input.setMaximumHeight(100)
|
|
50
61
|
self.query_input.setPlaceholderText("Enter text to search for similar vectors...")
|
|
51
62
|
query_group_layout.addWidget(self.query_input)
|
|
52
|
-
|
|
63
|
+
|
|
53
64
|
# Search controls
|
|
54
65
|
controls_layout = QHBoxLayout()
|
|
55
|
-
|
|
66
|
+
|
|
56
67
|
controls_layout.addWidget(QLabel("Results:"))
|
|
57
68
|
self.n_results_spin = QSpinBox()
|
|
58
69
|
self.n_results_spin.setMinimum(1)
|
|
59
70
|
self.n_results_spin.setMaximum(100)
|
|
60
71
|
self.n_results_spin.setValue(10)
|
|
61
72
|
controls_layout.addWidget(self.n_results_spin)
|
|
62
|
-
|
|
73
|
+
|
|
63
74
|
controls_layout.addStretch()
|
|
64
|
-
|
|
75
|
+
|
|
65
76
|
self.search_button = QPushButton("Search")
|
|
66
77
|
self.search_button.clicked.connect(self._perform_search)
|
|
67
78
|
self.search_button.setDefault(True)
|
|
68
79
|
controls_layout.addWidget(self.search_button)
|
|
69
|
-
|
|
80
|
+
|
|
70
81
|
query_group_layout.addLayout(controls_layout)
|
|
71
82
|
query_group.setLayout(query_group_layout)
|
|
72
83
|
query_layout.addWidget(query_group)
|
|
73
|
-
|
|
84
|
+
|
|
74
85
|
# Advanced filters section
|
|
75
86
|
filter_group = QGroupBox("Advanced Metadata Filters")
|
|
76
87
|
filter_group.setCheckable(True)
|
|
77
88
|
filter_group.setChecked(False)
|
|
78
89
|
filter_group_layout = QVBoxLayout()
|
|
79
|
-
|
|
90
|
+
|
|
80
91
|
# Filter builder
|
|
81
92
|
self.filter_builder = FilterBuilder()
|
|
82
93
|
filter_group_layout.addWidget(self.filter_builder)
|
|
83
|
-
|
|
94
|
+
|
|
84
95
|
filter_group.setLayout(filter_group_layout)
|
|
85
96
|
query_layout.addWidget(filter_group)
|
|
86
97
|
self.filter_group = filter_group
|
|
87
|
-
|
|
98
|
+
|
|
88
99
|
splitter.addWidget(query_widget)
|
|
89
|
-
|
|
100
|
+
|
|
90
101
|
# Results section
|
|
91
102
|
results_widget = QWidget()
|
|
92
103
|
results_layout = QVBoxLayout(results_widget)
|
|
93
104
|
results_layout.setContentsMargins(0, 0, 0, 0)
|
|
94
|
-
|
|
105
|
+
|
|
95
106
|
results_group = QGroupBox("Search Results")
|
|
96
107
|
results_group_layout = QVBoxLayout()
|
|
97
|
-
|
|
108
|
+
|
|
98
109
|
self.results_table = QTableWidget()
|
|
99
110
|
self.results_table.setSelectionBehavior(QTableWidget.SelectRows)
|
|
100
111
|
self.results_table.setAlternatingRowColors(True)
|
|
101
112
|
results_group_layout.addWidget(self.results_table)
|
|
102
|
-
|
|
113
|
+
|
|
103
114
|
self.results_status = QLabel("No search performed")
|
|
104
115
|
self.results_status.setStyleSheet("color: gray;")
|
|
105
116
|
results_group_layout.addWidget(self.results_status)
|
|
106
|
-
|
|
117
|
+
|
|
107
118
|
results_group.setLayout(results_group_layout)
|
|
108
119
|
results_layout.addWidget(results_group)
|
|
109
|
-
|
|
120
|
+
|
|
110
121
|
splitter.addWidget(results_widget)
|
|
111
|
-
|
|
122
|
+
|
|
112
123
|
# Set splitter proportions
|
|
113
124
|
splitter.setStretchFactor(0, 1)
|
|
114
125
|
splitter.setStretchFactor(1, 2)
|
|
115
|
-
|
|
126
|
+
|
|
116
127
|
layout.addWidget(splitter)
|
|
117
|
-
|
|
128
|
+
|
|
118
129
|
def set_collection(self, collection_name: str, database_name: str = ""):
|
|
119
130
|
"""Set the current collection to search."""
|
|
120
131
|
self.current_collection = collection_name
|
|
121
132
|
# Always update database_name if provided (even if empty string on first call)
|
|
122
133
|
if database_name: # Only update if non-empty
|
|
123
134
|
self.current_database = database_name
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
135
|
+
|
|
136
|
+
log_info(
|
|
137
|
+
"[SearchView] Setting collection: db='%s', coll='%s'",
|
|
138
|
+
self.current_database,
|
|
139
|
+
collection_name,
|
|
140
|
+
)
|
|
141
|
+
|
|
127
142
|
# Check cache first
|
|
128
143
|
cached = self.cache_manager.get(self.current_database, self.current_collection)
|
|
129
144
|
if cached:
|
|
130
|
-
|
|
145
|
+
log_info("[SearchView] ✓ Cache HIT! Restoring search state.")
|
|
131
146
|
# Restore search query and results from cache
|
|
132
147
|
if cached.search_query:
|
|
133
148
|
self.query_input.setPlainText(cached.search_query)
|
|
@@ -135,37 +150,34 @@ class SearchView(QWidget):
|
|
|
135
150
|
self.search_results = cached.search_results
|
|
136
151
|
self._display_results(cached.search_results)
|
|
137
152
|
return
|
|
138
|
-
|
|
139
|
-
|
|
153
|
+
|
|
154
|
+
log_info("[SearchView] ✗ Cache MISS or no cached search.")
|
|
140
155
|
# Not in cache, clear form
|
|
141
156
|
self.search_results = None
|
|
142
157
|
self.query_input.clear()
|
|
143
158
|
self.results_table.setRowCount(0)
|
|
144
159
|
self.results_status.setText(f"Collection: {collection_name}")
|
|
145
|
-
|
|
160
|
+
|
|
146
161
|
# Reset filters
|
|
147
162
|
self.filter_builder._clear_all()
|
|
148
163
|
self.filter_group.setChecked(False)
|
|
149
|
-
|
|
164
|
+
|
|
150
165
|
# Update filter builder with supported operators
|
|
151
166
|
operators = self.connection.get_supported_filter_operators()
|
|
152
167
|
self.filter_builder.set_operators(operators)
|
|
153
|
-
|
|
168
|
+
|
|
154
169
|
# Load metadata fields immediately (even if tab is not visible)
|
|
155
170
|
self._load_metadata_fields()
|
|
156
|
-
|
|
171
|
+
|
|
157
172
|
def _load_metadata_fields(self):
|
|
158
173
|
"""Load metadata field names from collection for filter builder."""
|
|
159
174
|
if not self.current_collection:
|
|
160
175
|
return
|
|
161
|
-
|
|
176
|
+
|
|
162
177
|
try:
|
|
163
178
|
# Get a small sample to extract field names
|
|
164
|
-
sample_data = self.connection.get_all_items(
|
|
165
|
-
|
|
166
|
-
limit=1
|
|
167
|
-
)
|
|
168
|
-
|
|
179
|
+
sample_data = self.connection.get_all_items(self.current_collection, limit=1)
|
|
180
|
+
|
|
169
181
|
if sample_data and sample_data.get("metadatas"):
|
|
170
182
|
metadatas = sample_data["metadatas"]
|
|
171
183
|
if metadatas and len(metadatas) > 0 and metadatas[0]:
|
|
@@ -173,21 +185,21 @@ class SearchView(QWidget):
|
|
|
173
185
|
self.filter_builder.set_available_fields(field_names)
|
|
174
186
|
except Exception as e:
|
|
175
187
|
# Silently ignore errors - fields can still be typed manually
|
|
176
|
-
|
|
177
|
-
|
|
188
|
+
log_info("Note: Could not auto-populate filter fields: %s", e)
|
|
189
|
+
|
|
178
190
|
def _perform_search(self):
|
|
179
191
|
"""Perform similarity search."""
|
|
180
192
|
if not self.current_collection:
|
|
181
193
|
self.results_status.setText("No collection selected")
|
|
182
194
|
return
|
|
183
|
-
|
|
195
|
+
|
|
184
196
|
query_text = self.query_input.toPlainText().strip()
|
|
185
197
|
if not query_text:
|
|
186
198
|
self.results_status.setText("Please enter search text")
|
|
187
199
|
return
|
|
188
|
-
|
|
200
|
+
|
|
189
201
|
n_results = self.n_results_spin.value()
|
|
190
|
-
|
|
202
|
+
|
|
191
203
|
# Get filters split into server-side and client-side
|
|
192
204
|
server_filter = None
|
|
193
205
|
client_filters = []
|
|
@@ -196,33 +208,37 @@ class SearchView(QWidget):
|
|
|
196
208
|
if server_filter or client_filters:
|
|
197
209
|
filter_summary = self.filter_builder.get_filter_summary()
|
|
198
210
|
self.results_status.setText(f"Searching with filters: {filter_summary}")
|
|
199
|
-
|
|
211
|
+
|
|
200
212
|
# Show loading indicator
|
|
201
213
|
self.loading_dialog.show_loading("Searching for similar vectors...")
|
|
202
214
|
QApplication.processEvents()
|
|
203
|
-
|
|
215
|
+
|
|
204
216
|
try:
|
|
205
217
|
# Always pass query_texts; provider handles embedding if needed
|
|
206
218
|
results = self.connection.query_collection(
|
|
207
219
|
self.current_collection,
|
|
208
220
|
query_texts=[query_text],
|
|
209
221
|
n_results=n_results,
|
|
210
|
-
where=server_filter
|
|
222
|
+
where=server_filter,
|
|
211
223
|
)
|
|
212
224
|
finally:
|
|
213
225
|
self.loading_dialog.hide_loading()
|
|
214
|
-
|
|
226
|
+
|
|
215
227
|
if not results:
|
|
216
228
|
self.results_status.setText("Search failed")
|
|
217
229
|
self.results_table.setRowCount(0)
|
|
218
230
|
return
|
|
219
|
-
|
|
231
|
+
|
|
220
232
|
# Check if results have the expected structure
|
|
221
|
-
if
|
|
233
|
+
if (
|
|
234
|
+
not results.get("ids")
|
|
235
|
+
or not isinstance(results["ids"], list)
|
|
236
|
+
or len(results["ids"]) == 0
|
|
237
|
+
):
|
|
222
238
|
self.results_status.setText("No results found or query failed")
|
|
223
239
|
self.results_table.setRowCount(0)
|
|
224
240
|
return
|
|
225
|
-
|
|
241
|
+
|
|
226
242
|
# Apply client-side filters if any
|
|
227
243
|
if client_filters and results:
|
|
228
244
|
# Restructure results for filtering
|
|
@@ -232,20 +248,24 @@ class SearchView(QWidget):
|
|
|
232
248
|
"metadatas": results.get("metadatas", [[]])[0],
|
|
233
249
|
}
|
|
234
250
|
filtered = apply_client_side_filters(filter_data, client_filters)
|
|
235
|
-
|
|
251
|
+
|
|
236
252
|
# Restructure back to query results format
|
|
237
253
|
results = {
|
|
238
254
|
"ids": [filtered["ids"]],
|
|
239
255
|
"documents": [filtered["documents"]],
|
|
240
256
|
"metadatas": [filtered["metadatas"]],
|
|
241
|
-
"distances": [
|
|
242
|
-
|
|
243
|
-
|
|
257
|
+
"distances": [
|
|
258
|
+
[
|
|
259
|
+
results.get("distances", [[]])[0][i]
|
|
260
|
+
for i, orig_id in enumerate(results.get("ids", [[]])[0])
|
|
261
|
+
if orig_id in filtered["ids"]
|
|
262
|
+
]
|
|
263
|
+
],
|
|
244
264
|
}
|
|
245
|
-
|
|
265
|
+
|
|
246
266
|
self.search_results = results
|
|
247
267
|
self._display_results(results)
|
|
248
|
-
|
|
268
|
+
|
|
249
269
|
# Save to cache
|
|
250
270
|
if self.current_database and self.current_collection:
|
|
251
271
|
self.cache_manager.update(
|
|
@@ -254,55 +274,57 @@ class SearchView(QWidget):
|
|
|
254
274
|
search_query=query_text,
|
|
255
275
|
search_results=results,
|
|
256
276
|
user_inputs={
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
277
|
+
"n_results": n_results,
|
|
278
|
+
"filters": self.filter_builder.to_dict()
|
|
279
|
+
if hasattr(self.filter_builder, "to_dict")
|
|
280
|
+
else {},
|
|
281
|
+
},
|
|
260
282
|
)
|
|
261
|
-
|
|
283
|
+
|
|
262
284
|
def _display_results(self, results: Dict[str, Any]):
|
|
263
285
|
"""Display search results in table."""
|
|
264
286
|
ids = results.get("ids", [[]])[0]
|
|
265
287
|
documents = results.get("documents", [[]])[0]
|
|
266
288
|
metadatas = results.get("metadatas", [[]])[0]
|
|
267
289
|
distances = results.get("distances", [[]])[0]
|
|
268
|
-
|
|
290
|
+
|
|
269
291
|
if not ids:
|
|
270
292
|
self.results_table.setRowCount(0)
|
|
271
293
|
self.results_status.setText("No results found")
|
|
272
294
|
return
|
|
273
|
-
|
|
295
|
+
|
|
274
296
|
# Determine columns
|
|
275
297
|
columns = ["Rank", "Distance", "ID", "Document"]
|
|
276
298
|
if metadatas and metadatas[0]:
|
|
277
299
|
metadata_keys = list(metadatas[0].keys())
|
|
278
300
|
columns.extend(metadata_keys)
|
|
279
|
-
|
|
301
|
+
|
|
280
302
|
self.results_table.setColumnCount(len(columns))
|
|
281
303
|
self.results_table.setHorizontalHeaderLabels(columns)
|
|
282
304
|
self.results_table.setRowCount(len(ids))
|
|
283
|
-
|
|
305
|
+
|
|
284
306
|
# Populate rows
|
|
285
307
|
for row, (id_val, doc, meta, dist) in enumerate(zip(ids, documents, metadatas, distances)):
|
|
286
308
|
# Rank
|
|
287
309
|
self.results_table.setItem(row, 0, QTableWidgetItem(str(row + 1)))
|
|
288
|
-
|
|
310
|
+
|
|
289
311
|
# Distance/similarity score
|
|
290
312
|
self.results_table.setItem(row, 1, QTableWidgetItem(f"{dist:.4f}"))
|
|
291
|
-
|
|
313
|
+
|
|
292
314
|
# ID
|
|
293
315
|
self.results_table.setItem(row, 2, QTableWidgetItem(str(id_val)))
|
|
294
|
-
|
|
316
|
+
|
|
295
317
|
# Document
|
|
296
318
|
doc_text = str(doc) if doc else ""
|
|
297
319
|
if len(doc_text) > 150:
|
|
298
320
|
doc_text = doc_text[:150] + "..."
|
|
299
321
|
self.results_table.setItem(row, 3, QTableWidgetItem(doc_text))
|
|
300
|
-
|
|
322
|
+
|
|
301
323
|
# Metadata columns
|
|
302
324
|
if meta:
|
|
303
325
|
for col_idx, key in enumerate(metadata_keys, start=4):
|
|
304
326
|
value = meta.get(key, "")
|
|
305
327
|
self.results_table.setItem(row, col_idx, QTableWidgetItem(str(value)))
|
|
306
|
-
|
|
328
|
+
|
|
307
329
|
self.results_table.resizeColumnsToContents()
|
|
308
330
|
self.results_status.setText(f"Found {len(ids)} results")
|