vector-inspector 0.2.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.
Files changed (32) hide show
  1. vector_inspector/__init__.py +3 -0
  2. vector_inspector/__main__.py +4 -0
  3. vector_inspector/core/__init__.py +1 -0
  4. vector_inspector/core/connections/__init__.py +7 -0
  5. vector_inspector/core/connections/base_connection.py +233 -0
  6. vector_inspector/core/connections/chroma_connection.py +384 -0
  7. vector_inspector/core/connections/qdrant_connection.py +723 -0
  8. vector_inspector/core/connections/template_connection.py +346 -0
  9. vector_inspector/main.py +21 -0
  10. vector_inspector/services/__init__.py +1 -0
  11. vector_inspector/services/backup_restore_service.py +286 -0
  12. vector_inspector/services/filter_service.py +72 -0
  13. vector_inspector/services/import_export_service.py +287 -0
  14. vector_inspector/services/settings_service.py +60 -0
  15. vector_inspector/services/visualization_service.py +116 -0
  16. vector_inspector/ui/__init__.py +1 -0
  17. vector_inspector/ui/components/__init__.py +1 -0
  18. vector_inspector/ui/components/backup_restore_dialog.py +350 -0
  19. vector_inspector/ui/components/filter_builder.py +370 -0
  20. vector_inspector/ui/components/item_dialog.py +118 -0
  21. vector_inspector/ui/components/loading_dialog.py +30 -0
  22. vector_inspector/ui/main_window.py +288 -0
  23. vector_inspector/ui/views/__init__.py +1 -0
  24. vector_inspector/ui/views/collection_browser.py +112 -0
  25. vector_inspector/ui/views/connection_view.py +423 -0
  26. vector_inspector/ui/views/metadata_view.py +555 -0
  27. vector_inspector/ui/views/search_view.py +268 -0
  28. vector_inspector/ui/views/visualization_view.py +245 -0
  29. vector_inspector-0.2.0.dist-info/METADATA +382 -0
  30. vector_inspector-0.2.0.dist-info/RECORD +32 -0
  31. vector_inspector-0.2.0.dist-info/WHEEL +4 -0
  32. vector_inspector-0.2.0.dist-info/entry_points.txt +5 -0
@@ -0,0 +1,555 @@
1
+ """Metadata browsing and data view."""
2
+
3
+ from typing import Optional, Dict, Any, List
4
+ from PySide6.QtWidgets import (
5
+ QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
6
+ QTableWidgetItem, QPushButton, QLabel, QSpinBox,
7
+ QLineEdit, QComboBox, QGroupBox, QHeaderView, QMessageBox, QDialog,
8
+ QFileDialog, QMenu
9
+ )
10
+ from PySide6.QtCore import Qt, QTimer
11
+
12
+ from vector_inspector.core.connections.base_connection import VectorDBConnection
13
+ from vector_inspector.ui.components.item_dialog import ItemDialog
14
+ from vector_inspector.ui.components.loading_dialog import LoadingDialog
15
+ from vector_inspector.ui.components.filter_builder import FilterBuilder
16
+ from vector_inspector.services.import_export_service import ImportExportService
17
+ from vector_inspector.services.filter_service import apply_client_side_filters
18
+ from PySide6.QtWidgets import QApplication
19
+
20
+
21
+ class MetadataView(QWidget):
22
+ """View for browsing collection data and metadata."""
23
+
24
+ def __init__(self, connection: VectorDBConnection, parent=None):
25
+ super().__init__(parent)
26
+ self.connection = connection
27
+ self.current_collection: str = ""
28
+ self.current_data: Optional[Dict[str, Any]] = None
29
+ self.page_size = 50
30
+ self.current_page = 0
31
+ self.loading_dialog = LoadingDialog("Loading data...", self)
32
+
33
+ # Debounce timer for filter changes
34
+ self.filter_reload_timer = QTimer()
35
+ self.filter_reload_timer.setSingleShot(True)
36
+ self.filter_reload_timer.timeout.connect(self._reload_with_filters)
37
+
38
+ self._setup_ui()
39
+
40
+ def _setup_ui(self):
41
+ """Setup widget UI."""
42
+ layout = QVBoxLayout(self)
43
+
44
+ # Controls
45
+ controls_layout = QHBoxLayout()
46
+
47
+ # Pagination controls
48
+ controls_layout.addWidget(QLabel("Page:"))
49
+
50
+ self.prev_button = QPushButton("◀ Previous")
51
+ self.prev_button.clicked.connect(self._previous_page)
52
+ self.prev_button.setEnabled(False)
53
+ controls_layout.addWidget(self.prev_button)
54
+
55
+ self.page_label = QLabel("0 / 0")
56
+ controls_layout.addWidget(self.page_label)
57
+
58
+ self.next_button = QPushButton("Next ▶")
59
+ self.next_button.clicked.connect(self._next_page)
60
+ self.next_button.setEnabled(False)
61
+ controls_layout.addWidget(self.next_button)
62
+
63
+ controls_layout.addWidget(QLabel(" Items per page:"))
64
+
65
+ self.page_size_spin = QSpinBox()
66
+ self.page_size_spin.setMinimum(10)
67
+ self.page_size_spin.setMaximum(500)
68
+ self.page_size_spin.setValue(50)
69
+ self.page_size_spin.setSingleStep(10)
70
+ self.page_size_spin.valueChanged.connect(self._on_page_size_changed)
71
+ controls_layout.addWidget(self.page_size_spin)
72
+
73
+ controls_layout.addStretch()
74
+
75
+ # Refresh button
76
+ self.refresh_button = QPushButton("Refresh")
77
+ self.refresh_button.clicked.connect(self._load_data)
78
+ controls_layout.addWidget(self.refresh_button)
79
+
80
+ # Add/Delete buttons
81
+ self.add_button = QPushButton("Add Item")
82
+ self.add_button.clicked.connect(self._add_item)
83
+ controls_layout.addWidget(self.add_button)
84
+
85
+ self.delete_button = QPushButton("Delete Selected")
86
+ self.delete_button.clicked.connect(self._delete_selected)
87
+ controls_layout.addWidget(self.delete_button)
88
+
89
+ # Export button with menu
90
+ self.export_button = QPushButton("Export...")
91
+ self.export_button.setStyleSheet("QPushButton::menu-indicator { width: 0px; }")
92
+ export_menu = QMenu(self)
93
+ export_menu.addAction("Export to JSON", lambda: self._export_data("json"))
94
+ export_menu.addAction("Export to CSV", lambda: self._export_data("csv"))
95
+ export_menu.addAction("Export to Parquet", lambda: self._export_data("parquet"))
96
+ self.export_button.setMenu(export_menu)
97
+ controls_layout.addWidget(self.export_button)
98
+
99
+ # Import button with menu
100
+ self.import_button = QPushButton("Import...")
101
+ self.import_button.setStyleSheet("QPushButton::menu-indicator { width: 0px; }")
102
+ import_menu = QMenu(self)
103
+ import_menu.addAction("Import from JSON", lambda: self._import_data("json"))
104
+ import_menu.addAction("Import from CSV", lambda: self._import_data("csv"))
105
+ import_menu.addAction("Import from Parquet", lambda: self._import_data("parquet"))
106
+ self.import_button.setMenu(import_menu)
107
+ controls_layout.addWidget(self.import_button)
108
+
109
+ layout.addLayout(controls_layout)
110
+
111
+ # Filter section
112
+ filter_group = QGroupBox("Metadata Filters")
113
+ filter_group.setCheckable(True)
114
+ filter_group.setChecked(False)
115
+ filter_group_layout = QVBoxLayout()
116
+
117
+ self.filter_builder = FilterBuilder()
118
+ # Remove auto-reload on filter changes - only reload when user clicks Refresh
119
+ # self.filter_builder.filter_changed.connect(self._on_filter_changed)
120
+ # But DO reload when user presses Enter or clicks away from value input
121
+ self.filter_builder.apply_filters.connect(self._apply_filters)
122
+ filter_group_layout.addWidget(self.filter_builder)
123
+
124
+ filter_group.setLayout(filter_group_layout)
125
+ layout.addWidget(filter_group)
126
+ self.filter_group = filter_group
127
+
128
+ # Data table
129
+ self.table = QTableWidget()
130
+ self.table.setSelectionBehavior(QTableWidget.SelectRows)
131
+ self.table.setAlternatingRowColors(True)
132
+ self.table.horizontalHeader().setStretchLastSection(True)
133
+ self.table.doubleClicked.connect(self._on_row_double_clicked)
134
+ layout.addWidget(self.table)
135
+
136
+ # Status bar
137
+ self.status_label = QLabel("No collection selected")
138
+ self.status_label.setStyleSheet("color: gray;")
139
+ layout.addWidget(self.status_label)
140
+
141
+ def set_collection(self, collection_name: str):
142
+ """Set the current collection to display."""
143
+ self.current_collection = collection_name
144
+ self.current_page = 0
145
+
146
+ # Show loading dialog at the start
147
+ self.loading_dialog.show_loading("Loading collection data...")
148
+ QApplication.processEvents()
149
+
150
+ try:
151
+ # Update filter builder with supported operators
152
+ operators = self.connection.get_supported_filter_operators()
153
+ self.filter_builder.set_operators(operators)
154
+
155
+ self._load_data_internal()
156
+
157
+ # Ensure UI is fully updated before hiding loading dialog
158
+ QApplication.processEvents()
159
+ finally:
160
+ self.loading_dialog.hide_loading()
161
+
162
+ def _load_data(self):
163
+ """Load data from current collection (with loading dialog)."""
164
+ if not self.current_collection:
165
+ self.status_label.setText("No collection selected")
166
+ self.table.setRowCount(0)
167
+ return
168
+
169
+ self.loading_dialog.show_loading("Loading data from collection...")
170
+ QApplication.processEvents()
171
+ try:
172
+ self._load_data_internal()
173
+ finally:
174
+ self.loading_dialog.hide_loading()
175
+
176
+ def _load_data_internal(self):
177
+ """Internal method to load data without managing loading dialog."""
178
+ if not self.current_collection:
179
+ self.status_label.setText("No collection selected")
180
+ self.table.setRowCount(0)
181
+ return
182
+
183
+ offset = self.current_page * self.page_size
184
+
185
+ # Get filters split into server-side and client-side
186
+ server_filter = None
187
+ client_filters = []
188
+ if self.filter_group.isChecked() and self.filter_builder.has_filters():
189
+ server_filter, client_filters = self.filter_builder.get_filters_split()
190
+
191
+ data = self.connection.get_all_items(
192
+ self.current_collection,
193
+ limit=self.page_size,
194
+ offset=offset,
195
+ where=server_filter
196
+ )
197
+
198
+ # Apply client-side filters if any
199
+ if client_filters and data:
200
+ data = apply_client_side_filters(data, client_filters)
201
+
202
+ if not data:
203
+ self.status_label.setText("Failed to load data")
204
+ self.table.setRowCount(0)
205
+ return
206
+ self.current_data = data
207
+ self._populate_table(data)
208
+ self._update_pagination_controls()
209
+
210
+ # Update filter builder with available metadata fields
211
+ self._update_filter_fields(data)
212
+
213
+ def _update_filter_fields(self, data: Dict[str, Any]):
214
+ """Update filter builder with available metadata field names."""
215
+ field_names = []
216
+
217
+ # Add 'document' field if documents exist
218
+ documents = data.get("documents", [])
219
+ if documents and any(doc for doc in documents if doc):
220
+ field_names.append("document")
221
+
222
+ # Add metadata fields
223
+ metadatas = data.get("metadatas", [])
224
+ if metadatas and len(metadatas) > 0 and metadatas[0]:
225
+ # Get all unique metadata keys from the first item
226
+ metadata_keys = sorted(metadatas[0].keys())
227
+ field_names.extend(metadata_keys)
228
+
229
+ if field_names:
230
+ self.filter_builder.set_available_fields(field_names)
231
+
232
+ def _populate_table(self, data: Dict[str, Any]):
233
+ """Populate table with data."""
234
+ ids = data.get("ids", [])
235
+ documents = data.get("documents", [])
236
+ metadatas = data.get("metadatas", [])
237
+
238
+ if not ids:
239
+ self.table.setRowCount(0)
240
+ self.status_label.setText("No data in collection")
241
+ return
242
+
243
+ # Determine columns
244
+ columns = ["ID", "Document"]
245
+ if metadatas and metadatas[0]:
246
+ metadata_keys = list(metadatas[0].keys())
247
+ columns.extend(metadata_keys)
248
+
249
+ self.table.setColumnCount(len(columns))
250
+ self.table.setHorizontalHeaderLabels(columns)
251
+ self.table.setRowCount(len(ids))
252
+
253
+ # Populate rows
254
+ for row, (id_val, doc, meta) in enumerate(zip(ids, documents, metadatas)):
255
+ # ID column
256
+ self.table.setItem(row, 0, QTableWidgetItem(str(id_val)))
257
+
258
+ # Document column
259
+ doc_text = str(doc) if doc else ""
260
+ if len(doc_text) > 100:
261
+ doc_text = doc_text[:100] + "..."
262
+ self.table.setItem(row, 1, QTableWidgetItem(doc_text))
263
+
264
+ # Metadata columns
265
+ if meta:
266
+ for col_idx, key in enumerate(metadata_keys, start=2):
267
+ value = meta.get(key, "")
268
+ self.table.setItem(row, col_idx, QTableWidgetItem(str(value)))
269
+
270
+ self.table.resizeColumnsToContents()
271
+ self.status_label.setText(f"Showing {len(ids)} items")
272
+
273
+ def _update_pagination_controls(self):
274
+ """Update pagination button states."""
275
+ if not self.current_data:
276
+ return
277
+
278
+ item_count = len(self.current_data.get("ids", []))
279
+ has_more = item_count == self.page_size
280
+
281
+ self.prev_button.setEnabled(self.current_page > 0)
282
+ self.next_button.setEnabled(has_more)
283
+
284
+ # Update page label (approximate since ChromaDB doesn't give total count easily)
285
+ self.page_label.setText(f"{self.current_page + 1}")
286
+
287
+ def _previous_page(self):
288
+ """Go to previous page."""
289
+ if self.current_page > 0:
290
+ self.current_page -= 1
291
+ self._load_data()
292
+
293
+ def _next_page(self):
294
+ """Go to next page."""
295
+ self.current_page += 1
296
+ self._load_data()
297
+
298
+ def _on_page_size_changed(self, value: int):
299
+ """Handle page size change."""
300
+ self.page_size = value
301
+ self.current_page = 0
302
+ self._load_data()
303
+
304
+ def _add_item(self):
305
+ """Add a new item to the collection."""
306
+ if not self.current_collection:
307
+ QMessageBox.warning(self, "No Collection", "Please select a collection first.")
308
+ return
309
+
310
+ dialog = ItemDialog(self)
311
+
312
+ if dialog.exec() == QDialog.Accepted:
313
+ item_data = dialog.get_item_data()
314
+ if not item_data:
315
+ return
316
+
317
+ # Add item to collection
318
+ success = self.connection.add_items(
319
+ self.current_collection,
320
+ documents=[item_data["document"]],
321
+ metadatas=[item_data["metadata"]] if item_data["metadata"] else None,
322
+ ids=[item_data["id"]] if item_data["id"] else None
323
+ )
324
+
325
+ if success:
326
+ QMessageBox.information(self, "Success", "Item added successfully.")
327
+ self._load_data()
328
+ else:
329
+ QMessageBox.warning(self, "Error", "Failed to add item.")
330
+
331
+ def _delete_selected(self):
332
+ """Delete selected items."""
333
+ if not self.current_collection:
334
+ QMessageBox.warning(self, "No Collection", "Please select a collection first.")
335
+ return
336
+
337
+ selected_rows = self.table.selectionModel().selectedRows()
338
+ if not selected_rows:
339
+ QMessageBox.warning(self, "No Selection", "Please select items to delete.")
340
+ return
341
+
342
+ # Get IDs of selected items
343
+ ids_to_delete = []
344
+ for row in selected_rows:
345
+ id_item = self.table.item(row.row(), 0)
346
+ if id_item:
347
+ ids_to_delete.append(id_item.text())
348
+
349
+ # Confirm deletion
350
+ reply = QMessageBox.question(
351
+ self,
352
+ "Confirm Deletion",
353
+ f"Delete {len(ids_to_delete)} item(s)?",
354
+ QMessageBox.Yes | QMessageBox.No
355
+ )
356
+
357
+ if reply == QMessageBox.Yes:
358
+ success = self.connection.delete_items(self.current_collection, ids=ids_to_delete)
359
+ if success:
360
+ QMessageBox.information(self, "Success", "Items deleted successfully.")
361
+ self._load_data()
362
+ else:
363
+ QMessageBox.warning(self, "Error", "Failed to delete items.")
364
+
365
+ def _on_filter_changed(self):
366
+ """Handle filter changes - debounce and reload data."""
367
+ if self.filter_group.isChecked():
368
+ # Restart the timer - will only fire 500ms after last change
369
+ self.filter_reload_timer.stop()
370
+ self.filter_reload_timer.start(500) # 500ms debounce
371
+
372
+ def _reload_with_filters(self):
373
+ """Reload data with current filters (called after debounce)."""
374
+ self.current_page = 0
375
+ self._load_data()
376
+
377
+ def _apply_filters(self):
378
+ """Apply filters when user presses Enter or clicks away."""
379
+ if self.filter_group.isChecked() and self.current_collection:
380
+ self.current_page = 0
381
+ self._load_data()
382
+
383
+ def _on_row_double_clicked(self, index):
384
+ """Handle double-click on a row to edit item."""
385
+ if not self.current_collection or not self.current_data:
386
+ return
387
+
388
+ row = index.row()
389
+ if row < 0 or row >= self.table.rowCount():
390
+ return
391
+
392
+ # Get item data for this row
393
+ ids = self.current_data.get("ids", [])
394
+ documents = self.current_data.get("documents", [])
395
+ metadatas = self.current_data.get("metadatas", [])
396
+
397
+ if row >= len(ids):
398
+ return
399
+
400
+ item_data = {
401
+ "id": ids[row],
402
+ "document": documents[row] if row < len(documents) else "",
403
+ "metadata": metadatas[row] if row < len(metadatas) else {}
404
+ }
405
+
406
+ # Open edit dialog
407
+ dialog = ItemDialog(self, item_data=item_data)
408
+
409
+ if dialog.exec() == QDialog.Accepted:
410
+ updated_data = dialog.get_item_data()
411
+ if not updated_data:
412
+ return
413
+
414
+ # Update item in collection
415
+ success = self.connection.update_items(
416
+ self.current_collection,
417
+ ids=[updated_data["id"]],
418
+ documents=[updated_data["document"]] if updated_data["document"] else None,
419
+ metadatas=[updated_data["metadata"]] if updated_data["metadata"] else None
420
+ )
421
+
422
+ if success:
423
+ QMessageBox.information(self, "Success", "Item updated successfully.")
424
+ self._load_data()
425
+ else:
426
+ QMessageBox.warning(self, "Error", "Failed to update item.")
427
+
428
+ def _export_data(self, format_type: str):
429
+ """Export collection data to file."""
430
+ if not self.current_collection:
431
+ QMessageBox.warning(self, "No Collection", "Please select a collection first.")
432
+ return
433
+
434
+ # Get all data (not just current page)
435
+ self.loading_dialog.show_loading("Exporting data...")
436
+ QApplication.processEvents()
437
+
438
+ try:
439
+ # Get filter if active
440
+ where_filter = None
441
+ if self.filter_group.isChecked() and self.filter_builder.has_filters():
442
+ where_filter = self.filter_builder.get_filter()
443
+
444
+ # Fetch all data
445
+ all_data = self.connection.get_all_items(
446
+ self.current_collection,
447
+ where=where_filter
448
+ )
449
+ finally:
450
+ self.loading_dialog.hide_loading()
451
+
452
+ if not all_data or not all_data.get("ids"):
453
+ QMessageBox.warning(self, "No Data", "No data to export.")
454
+ return
455
+
456
+ # Select file path
457
+ file_filters = {
458
+ "json": "JSON Files (*.json)",
459
+ "csv": "CSV Files (*.csv)",
460
+ "parquet": "Parquet Files (*.parquet)"
461
+ }
462
+
463
+ file_path, _ = QFileDialog.getSaveFileName(
464
+ self,
465
+ f"Export to {format_type.upper()}",
466
+ f"{self.current_collection}.{format_type}",
467
+ file_filters[format_type]
468
+ )
469
+
470
+ if not file_path:
471
+ return
472
+
473
+ # Export
474
+ service = ImportExportService()
475
+ success = False
476
+
477
+ if format_type == "json":
478
+ success = service.export_to_json(all_data, file_path)
479
+ elif format_type == "csv":
480
+ success = service.export_to_csv(all_data, file_path)
481
+ elif format_type == "parquet":
482
+ success = service.export_to_parquet(all_data, file_path)
483
+
484
+ if success:
485
+ QMessageBox.information(
486
+ self,
487
+ "Export Successful",
488
+ f"Exported {len(all_data['ids'])} items to {file_path}"
489
+ )
490
+ else:
491
+ QMessageBox.warning(self, "Export Failed", "Failed to export data.")
492
+
493
+ def _import_data(self, format_type: str):
494
+ """Import data from file into collection."""
495
+ if not self.current_collection:
496
+ QMessageBox.warning(self, "No Collection", "Please select a collection first.")
497
+ return
498
+
499
+ # Select file to import
500
+ file_filters = {
501
+ "json": "JSON Files (*.json)",
502
+ "csv": "CSV Files (*.csv)",
503
+ "parquet": "Parquet Files (*.parquet)"
504
+ }
505
+
506
+ file_path, _ = QFileDialog.getOpenFileName(
507
+ self,
508
+ f"Import from {format_type.upper()}",
509
+ "",
510
+ file_filters[format_type]
511
+ )
512
+
513
+ if not file_path:
514
+ return
515
+
516
+ # Import
517
+ self.loading_dialog.show_loading("Importing data...")
518
+ QApplication.processEvents()
519
+
520
+ try:
521
+ service = ImportExportService()
522
+ imported_data = None
523
+
524
+ if format_type == "json":
525
+ imported_data = service.import_from_json(file_path)
526
+ elif format_type == "csv":
527
+ imported_data = service.import_from_csv(file_path)
528
+ elif format_type == "parquet":
529
+ imported_data = service.import_from_parquet(file_path)
530
+
531
+ if not imported_data:
532
+ QMessageBox.warning(self, "Import Failed", "Failed to parse import file.")
533
+ return
534
+
535
+ # Add items to collection
536
+ success = self.connection.add_items(
537
+ self.current_collection,
538
+ documents=imported_data["documents"],
539
+ metadatas=imported_data.get("metadatas"),
540
+ ids=imported_data.get("ids"),
541
+ embeddings=imported_data.get("embeddings")
542
+ )
543
+ finally:
544
+ self.loading_dialog.hide_loading()
545
+
546
+ if success:
547
+ QMessageBox.information(
548
+ self,
549
+ "Import Successful",
550
+ f"Imported {len(imported_data['ids'])} items."
551
+ )
552
+ self._load_data()
553
+ else:
554
+ QMessageBox.warning(self, "Import Failed", "Failed to import data.")
555
+