diff --git a/.claude/context/CONTEXT.md b/.claude/context/CONTEXT.md index c73c4dad..2d1e4164 100644 --- a/.claude/context/CONTEXT.md +++ b/.claude/context/CONTEXT.md @@ -23,6 +23,11 @@ The pattern itself. - [Branch Protection Workflow](decisions/decision-branch-protection-workflow.md) — Never push directly to master; all changes via feature branches and PRs +## Implementation + +- [LanceDB Flat Schema Support](implementation/implementation-lancedb-flat-schema.md) — Handle both flat schemas (Contextus) and nested metadata with content column detection + ## Reference +- [Subtitle Test Data](reference/reference-subtitle-test-data.md) — Subtitle files as ideal test data for semantic search and embedding validation - [Version Bump Workflow](reference/reference-version-bump-workflow.md) — Files that need updating when bumping release version diff --git a/.claude/context/implementation/implementation-lancedb-flat-schema.md b/.claude/context/implementation/implementation-lancedb-flat-schema.md new file mode 100644 index 00000000..9c092b3e --- /dev/null +++ b/.claude/context/implementation/implementation-lancedb-flat-schema.md @@ -0,0 +1,121 @@ +--- +name: lancedb-flat-schema-support +description: LanceDB databases can use flat schemas (columns) or nested metadata - handle both patterns with content column detection +metadata: + type: implementation + tags: [lancedb, metadata, schema, contextus] +--- + +# LanceDB Flat Schema Support + +## Problem + +LanceDB supports two metadata storage patterns: + +1. **Nested metadata** (traditional): Single `metadata` column containing a dict/JSON +2. **Flat schema** (Contextus-style): Individual columns like `project`, `filename`, `heading`, `type`, `chunk_index` + +Vector Inspector originally only supported pattern #1, causing Contextus databases to show empty metadata. + +## Solution Pattern + +### In `get_collection_info()` + +1. **Extract from PyArrow schema first** (most reliable): + ```python + schema = tbl.schema + reserved_columns = {"id", "vector", "embedding", "_distance"} + + # Detect content column to exclude it + temp_schema = {field.name: str(field.type) for field in schema} + content_col = self._detect_content_column(name, temp_schema) + reserved_columns.add(content_col) + + # All other columns are metadata + metadata_fields = [ + field.name for field in schema + if field.name not in reserved_columns and not field.name.startswith("_") + ] + ``` + +2. **Fallback to pandas** if schema extraction fails + +3. **CRITICAL**: Must also check `count is None` in fallback condition, not just metadata/vector detection + +### In `get_all_items()` + +1. **Detect content column FIRST** to build reserved set: + ```python + schema = {col: str(dtype) for col, dtype in df.dtypes.items()} + content_col = self._detect_content_column(collection_name, schema) + reserved_columns = {"id", "vector", "embedding", "_distance", content_col} + ``` + +2. **Check for nested metadata column first**: + ```python + if "metadata" in df.columns: + # Parse nested format + raw_meta = df["metadata"].tolist() + metadatas = self._parse_metadata_list(raw_meta) + ``` + +3. **Otherwise extract flat schema columns**: + ```python + else: + metadata_columns = [ + col for col in df.columns + if col not in reserved_columns and not col.startswith("_") + ] + + if metadata_columns: + records = df[metadata_columns].to_dict('records') + metadatas = [ + {k: v for k, v in record.items() + if v is not None and (not isinstance(v, float) or not math.isnan(v))} + for record in records + ] + ``` + +## Why Exclude Content Column + +The content column (e.g., "document", "text", "content") serves a special purpose: +- It's displayed in the "Document" column of the data browser +- If also included in metadata, it appears TWICE in the table (once as "Document", once as "document") +- Users expect metadata to be *additional* fields, not duplicates of the main content + +## Edge Cases + +1. **Empty content column**: If content detection returns None or empty string, don't add it to reserved set +2. **NaN values**: Filter out `math.isnan()` values when building metadata dicts from flat schemas +3. **Count detection**: Even if schema extraction succeeds, must still fetch dataframe if `count is None` +4. **Backward compatibility**: Nested metadata format must continue to work (used by most LanceDB databases) + +## Testing + +Always test both patterns: +```python +# Flat schema (Contextus-style) +data = [ + { + "id": "1", + "vector": [0.1, 0.2, 0.3], + "document": "content", + "project": "test", + "filename": "file.md", + } +] + +# Nested metadata (traditional) +conn.add_items( + collection, + documents=["doc1"], + metadatas=[{"key": "value"}], + ids=["id1"], + embeddings=[[0.1, 0.2]] +) +``` + +## Files Modified + +- `src/vector_inspector/core/connections/lancedb_connection.py`: Both `get_collection_info()` and `get_all_items()` +- Tests: `tests/providers/lancedb/test_flat_schema_metadata.py` diff --git a/.claude/context/reference/reference-subtitle-test-data.md b/.claude/context/reference/reference-subtitle-test-data.md new file mode 100644 index 00000000..6cc9a550 --- /dev/null +++ b/.claude/context/reference/reference-subtitle-test-data.md @@ -0,0 +1,36 @@ +--- +name: subtitle-test-data +description: Subtitle files as ideal test data for semantic search and embedding validation +metadata: + type: reference + tags: [testing, embeddings, test-data, semantic-search] +--- + +## Subtitle Files for Semantic Search Testing + +Subtitle files (.srt, .vtt, .sub) make excellent test data for semantic search and embedding systems. + +### Why Subtitles Work Well + +1. **Natural Language**: Real conversational text with context and flow +2. **Sequential Context**: Lines build on each other, testing context window handling +3. **Varied Domains**: Movies, documentaries, lectures provide diverse vocabulary +4. **Timestamp Metadata**: Built-in temporal structure for testing metadata handling +5. **Manageable Size**: Individual subtitle entries are good chunk sizes (typically 1-3 sentences) +6. **Widely Available**: Easy to obtain for testing without licensing concerns + +### Characteristics for Embedding Tests + +- **Semantic Relationships**: Related dialogue lines test similarity search accuracy +- **Temporal Coherence**: Tests whether embeddings capture narrative flow +- **Speaker Attribution**: Can test multi-speaker scenarios +- **Domain Shifts**: Scene changes test how embeddings handle topic transitions + +### Usage in Vector Inspector + +Use subtitle files to validate: +- Content column detection across providers +- Embedding quality with different models (OpenAI, Ollama, HuggingFace) +- Search result relevance and ranking +- Metadata extraction and display +- Performance with realistic document collections diff --git a/.claude/context/reference/reference-version-bump-workflow.md b/.claude/context/reference/reference-version-bump-workflow.md index d3a81915..41fb1985 100644 --- a/.claude/context/reference/reference-version-bump-workflow.md +++ b/.claude/context/reference/reference-version-bump-workflow.md @@ -1,14 +1,17 @@ --- name: version-bump-workflow -description: How to bump release version in vector-inspector - files that need updating +description: Version bump checklist - always update pyproject.toml, src/vector_inspector/__init__.py __version__, and docs/RELEASE_REASON.md together when changing release version numbers metadata: type: reference - tags: [release, versioning, workflow] + tags: [release, versioning, workflow, version-bump, release-prep, changelog, pyproject, __init__, RELEASE_REASON] + trigger_files: [pyproject.toml, src/vector_inspector/__init__.py, docs/RELEASE_REASON.md] --- # Version Bump Workflow -When bumping a release version in vector-inspector, **three files** must be updated to stay in sync: +When bumping a release version in vector-inspector (changing version numbers, updating pyproject.toml version field, preparing for release), **three files** must be updated to stay in sync: + +**Critical**: Never update just pyproject.toml or just __init__.py alone — all three files must change together or the release will be incomplete. ## Files to Update @@ -76,8 +79,13 @@ def get_version(): ❌ Only updating `pyproject.toml` → `__version__` out of sync for dev mode ❌ Only updating `__init__.py` → Release workflow won't trigger ❌ Forgetting `docs/RELEASE_REASON.md` → Release has no description +❌ Updating pyproject.toml version without checking __init__.py +❌ Changing version in code but not updating release notes +❌ Editing CHANGELOG.md but forgetting to sync __version__ -✅ Update all three files together +✅ Update all three files together — pyproject.toml, __init__.py, and RELEASE_REASON.md +✅ Always check __init__.py when pyproject.toml version changes +✅ Version numbers must match across all three files ## Related diff --git a/.coverage b/.coverage new file mode 100644 index 00000000..81e8f0ea Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index bd7a75c8..fba7fd2a 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -12,12 +12,22 @@ on: - '["3.11", "3.12"]' - '["3.11"]' - '["3.12"]' + pull_request: + branches: + - master + - main push: + branches: + - master + - main paths: - 'pyproject.toml' - '.github/workflows/ci-tests.yml' - 'tests/**' +permissions: + contents: read + jobs: test: name: Test on Python ${{ matrix.python-version }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..8b967521 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,38 @@ +name: Lint + +on: + pull_request: + branches: + - master + - main + push: + branches: + - master + - main + +permissions: + contents: read + +jobs: + lint: + name: Lint with Ruff + runs-on: ubuntu-latest + continue-on-error: true # Advisory only - won't block PRs + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff linter + run: ruff check src/ tests/ --output-format=github + + - name: Run Ruff formatter check + run: ruff format --check src/ tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c3317b..2528380c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to Vector Viewer will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.4] - 2026-06-21 + +### Fixed +- **LanceDB flat schema metadata extraction**: Fixed metadata display for LanceDB databases with flat schemas (e.g., Contextus) where metadata is stored as individual columns rather than nested in a 'metadata' column +- **Duplicate document column**: Prevented content column from appearing twice in data browser (once as content, once as metadata) +- **Column width limits**: Added 600px max-width constraint to table columns to prevent excessive width + +### Changed +- LanceDB connection now extracts metadata from PyArrow schema first for better performance +- Content column (e.g., "document") is automatically detected and excluded from metadata fields + +### Added +- Comprehensive tests for flat schema metadata extraction +- Backward compatibility maintained with nested 'metadata' column format + +## [0.8.3] - 2026-06-21 + +### Changed +- Moved hashlib import to module level in base_connection.py +- Improved platform-specific monospace font rendering in provider install dialog +- Centralized content column detection logic with single return path ### Added - Phase 1 Implementation diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c114a335 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ + +## Context (claude-contextus) + +This project uses [claude-contextus](https://github.com/anthonypdawson/claude-contextus): +relevant patterns from `.claude/context/` are **retrieved and injected automatically** +on each prompt — you don't need to search for them. Injected blocks are tagged +`[contextus — …]`. + +Context capture behavior is controlled by the per-prompt injected footer — follow +its instructions exactly (it may tell you to propose, write directly, or stay silent). +New context files live under `.claude/context//` and are embedded automatically. + diff --git a/docs/RELEASE_REASON.md b/docs/RELEASE_REASON.md index c848b6ca..20b356ca 100644 --- a/docs/RELEASE_REASON.md +++ b/docs/RELEASE_REASON.md @@ -1,24 +1,22 @@ -# Release Notes (0.8.3) — June 21, 2026 +# Release Notes (0.8.4) — June 23, 2026 -Content column detection improvements and local embedding support. +Critical LanceDB metadata extraction fixes and column rendering improvements. -## Features +## Bug Fixes -- **Dynamic content column detection**: Automatically detects the best content column for each collection based on schema analysis (text/string fields, common naming patterns). Manual override support with persistence across sessions. -- **Ollama embedding integration**: Local embedding generation via Ollama HTTP API for environments where HuggingFace is blocked or unavailable. +- **LanceDB flat schema metadata extraction**: Fixed metadata display for LanceDB databases with flat schemas (e.g., Contextus) where metadata is stored as individual columns rather than nested in a 'metadata' column +- **Duplicate document column**: Prevented content column from appearing twice in data browser (once as content, once as metadata) +- **Column width limits**: Added 600px max-width constraint to table columns to prevent excessive width from breaking UI ## Improvements -- Content column configuration UI shows both auto-detected recommendation and currently active column -- Settings persistence for content column overrides per collection -- Fixed LanceDB schema detection to use PyArrow schema instead of pandas DataFrame conversion (performance improvement) -- Fixed threading deadlock in content column detection (switched from Lock to RLock) -- Platform-specific monospace fonts in UI dialogs (Menlo on macOS, Consolas on Windows) +- LanceDB connection now extracts metadata from PyArrow schema first for better performance +- Content column (e.g., "document") is automatically detected and excluded from metadata fields +- Backward compatibility maintained with nested 'metadata' column format -## Bug Fixes +## Testing -- Fixed LanceDB metadata array length mismatch in search results -- Fixed QThread leak in embedding configuration dialog -- Fixed content column cache poisoning when schema unavailable +- Added comprehensive tests for flat schema metadata extraction +- CI workflow now runs unit tests on all pull requests --- diff --git a/pyproject.toml b/pyproject.toml index c57267ea..d95ec5a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vector-inspector" -version = "0.8.3" +version = "0.8.4" description = "A comprehensive desktop application for visualizing, querying, and managing vector database data" authors = [ {name = "Anthony Dawson", email = "anthonypdawson+github@gmail.com"}, @@ -204,7 +204,26 @@ select = [ "ARG", "RUF", ] -ignore = ["E501", "I001", "UP045", "SIM105", "SIM108", "SIM117"] +ignore = [ + "E501", # Line too long (black handles this) + "I001", # Import block unsorted + "UP045", # Use Union[X, Y] instead of X | Y for py3.9 compat + "SIM105", # Use contextlib.suppress instead of try/except pass + "SIM108", # Use ternary operator + "SIM117", # Use single with statement + # Unused arguments - often required for interface compliance (Qt signals, abstract methods) + "ARG001", # Unused function argument + "ARG002", # Unused method argument + "ARG003", # Unused class method argument + "ARG004", # Unused static method argument + "ARG005", # Unused lambda argument + # Test-specific tolerances + "E741", # Ambiguous variable name (l, O, I) - common in math/test code + "RUF003", # Ambiguous unicode in comments (×, −) - intentional in documentation + "RUF002", # Ambiguous unicode in docstrings + "DTZ005", # datetime.now() without tz - not critical for cache timestamps + "B904", # raise without from - acceptable for wrapped import errors +] [tool.ruff.lint.isort] combine-as-imports = true diff --git a/src/vector_inspector/__init__.py b/src/vector_inspector/__init__.py index ef8a1ee7..8ba9038f 100644 --- a/src/vector_inspector/__init__.py +++ b/src/vector_inspector/__init__.py @@ -1,11 +1,11 @@ """Vector Inspector - A comprehensive desktop application for vector database visualization.""" -__version__ = "0.8.3" # Keep in sync with pyproject.toml for dev mode fallback +__version__ = "0.8.4" # Keep in sync with pyproject.toml for dev mode fallback def get_version(): try: - from importlib.metadata import PackageNotFoundError, version + from importlib.metadata import version return version("vector-inspector") except Exception: diff --git a/src/vector_inspector/core/cache_manager.py b/src/vector_inspector/core/cache_manager.py index ba620e04..2c7f1d2e 100644 --- a/src/vector_inspector/core/cache_manager.py +++ b/src/vector_inspector/core/cache_manager.py @@ -101,7 +101,7 @@ def invalidate(self, database: Optional[str] = None, collection: Optional[str] = self._cache.clear() elif collection is None and database is not None: # Clear all collections in the specified database - keys_to_remove = [key for key in self._cache.keys() if key[0] == database] + keys_to_remove = [key for key in self._cache if key[0] == database] for key in keys_to_remove: del self._cache[key] elif database is not None and collection is not None: diff --git a/src/vector_inspector/core/connections/base_connection.py b/src/vector_inspector/core/connections/base_connection.py index 220a9990..cff0c781 100644 --- a/src/vector_inspector/core/connections/base_connection.py +++ b/src/vector_inspector/core/connections/base_connection.py @@ -94,10 +94,9 @@ def provider_type(self) -> str: # removesuffix avoids the blanket .replace("db", "") which would corrupt # names like "LanceDBConnection" (would become "lance" instead of "lancedb"). class_name = type(self).__name__.removesuffix("Connection") - provider_name = class_name.lower() + return class_name.lower() # Return the canonical registry ID (e.g. "chromadb") so that # provider_type can be matched directly against ProviderInfo.id. - return provider_name @property def supports_configurable_vector_size(self) -> bool: @@ -129,7 +128,7 @@ def _detect_content_column( collection_name: str, schema: dict[str, str] | None = None, override: str | None = None, - skip_cache: bool = False + skip_cache: bool = False, ) -> str: """Detect the content/text column name for a collection. @@ -155,11 +154,10 @@ def _detect_content_column( if is_override or cached_fingerprint == current_fingerprint or not current_fingerprint: # Use cached value if: manually overridden, schema unchanged, or no schema to compare return self._content_column_cache[collection_name] - else: - # Schema changed - invalidate cache for this collection - del self._content_column_cache[collection_name] - if collection_name in self._schema_fingerprints: - del self._schema_fingerprints[collection_name] + # Schema changed - invalidate cache for this collection + del self._content_column_cache[collection_name] + if collection_name in self._schema_fingerprints: + del self._schema_fingerprints[collection_name] # Use override if provided if override: @@ -181,8 +179,14 @@ def _detect_content_column( # Fallback: use priority order for common text column names if not detected_column: fallback_priority = [ - "description", "summary", "title", "name", - "message", "comment", "note", "snippet" + "description", + "summary", + "title", + "name", + "message", + "comment", + "note", + "snippet", ] for fallback_name in fallback_priority: @@ -199,6 +203,7 @@ def _detect_content_column( if col_name.lower() not in reserved: if any(t in col_type.lower() for t in text_types): from vector_inspector.core.logging import log_info + log_info( "Content column auto-detected (last resort): '%s' for collection '%s'", col_name, @@ -274,6 +279,7 @@ def _load_persisted_overrides(self) -> None: """Load persisted content column overrides from settings.""" try: from vector_inspector.services.settings_service import SettingsService + settings = SettingsService() overrides_data = settings.get(self._get_override_key(), {}) @@ -290,6 +296,7 @@ def _persist_overrides(self) -> None: """Persist content column overrides to settings.""" try: from vector_inspector.services.settings_service import SettingsService + settings = SettingsService() with self._cache_lock: diff --git a/src/vector_inspector/core/connections/lancedb_connection.py b/src/vector_inspector/core/connections/lancedb_connection.py index 36224608..e5547ba0 100644 --- a/src/vector_inspector/core/connections/lancedb_connection.py +++ b/src/vector_inspector/core/connections/lancedb_connection.py @@ -1,5 +1,6 @@ """LanceDB connection implementation for Vector Inspector.""" +import math import os from typing import Any @@ -33,7 +34,6 @@ def connect(self) -> bool: if isinstance(self._uri, str) and "://" not in self._uri: self._uri = os.path.abspath(os.path.expanduser(self._uri)) self._db = lancedb.connect(self._uri) - print(f"LanceDB connect: uri={self._uri}") self._client = self._db self._connected = True return True @@ -78,61 +78,118 @@ def get_collection_info(self, name: str) -> dict[str, Any] | None: # Row count (prefer num_rows, fallback to pandas length) count = getattr(tbl, "num_rows", None) - # Pull dataframe sample to infer metadata fields and vector dimension + # Extract metadata fields from schema first (more reliable) + metadata_fields: list[str] = [] + vector_dimension: int | str = "Unknown" + reserved_columns = {"id", "vector", "embedding", "_distance"} + + # Detect content column to exclude it from metadata + content_col: str | None = None try: - df = tbl.to_pandas() - # Filter out dummy initialization row - if df is not None and "id" in df.columns: - df = df[df["id"] != "__dummy_init__"] + # Try to get schema to detect content column + schema = tbl.schema + if schema: + # Build a temporary dict for content detection + temp_schema = {field.name: str(field.type) for field in schema} + content_col = self._detect_content_column(name, temp_schema) + reserved_columns.add(content_col) except Exception: - df = None + pass - metadata_fields: list[str] = [] - vector_dimension: int | str = "Unknown" + # Try to get schema from PyArrow table schema + try: + schema = tbl.schema + if schema: + # All non-reserved columns (excluding content) are metadata fields + metadata_fields = [ + field.name + for field in schema + if field.name not in reserved_columns and not field.name.startswith("_") + ] - if df is not None and not df.empty: - # Determine count if not available - if count is None: - try: - count = len(df) - except Exception: - count = 0 + # Get vector dimension from schema if available + for field in schema: + if field.name in ("vector", "embedding"): + try: + # PyArrow FixedSizeListType has list_size attribute + if hasattr(field.type, "list_size"): + vector_dimension = field.type.list_size + except Exception: + pass + except Exception: + # If schema access fails, fall back to pandas sampling + pass - # Infer metadata fields from the first row - first_meta = df.iloc[0].get("metadata") if "metadata" in df.columns else None - if isinstance(first_meta, str): - import ast + # Fallback: Pull dataframe sample if schema extraction didn't work or count is unknown + if not metadata_fields or vector_dimension == "Unknown" or count is None: + try: + df = tbl.to_pandas() + # Filter out dummy initialization row + if df is not None and "id" in df.columns: + df = df[df["id"] != "__dummy_init__"] + except Exception: + df = None - try: - parsed = ast.literal_eval(first_meta) - if isinstance(parsed, dict): - metadata_fields = list(parsed.keys()) - except Exception: - # treat as raw string field - metadata_fields = [] - elif isinstance(first_meta, dict): - metadata_fields = list(first_meta.keys()) - - # Infer vector dimension from the first vector entry - if "vector" in df.columns: - first_vec = df.iloc[0].get("vector") - if first_vec is not None: + if df is not None and not df.empty: + # Determine count if not available + if count is None: try: - vector_dimension = len(first_vec) + count = len(df) except Exception: - vector_dimension = "Unknown" + count = 0 - # Cache vector dimension if known - try: - if isinstance(vector_dimension, int): - self._collection_meta[name] = vector_dimension - except Exception: - pass + # If we didn't get metadata fields from schema, extract from columns + if not metadata_fields: + # First check for flat schema columns + metadata_fields = [ + col for col in df.columns if col not in reserved_columns and not col.startswith("_") + ] - else: - # No dataframe available, try to get count from attribute - if count is None: - count = 0 + # Also check for nested metadata column (legacy format) + if "metadata" in df.columns and "metadata" in metadata_fields: + # Remove "metadata" from the list and try to extract its keys + metadata_fields.remove("metadata") + first_meta = df.iloc[0].get("metadata") + if isinstance(first_meta, str): + import ast + + try: + parsed = ast.literal_eval(first_meta) + if isinstance(parsed, dict): + # Add nested metadata keys as separate fields + metadata_fields.extend(f"metadata.{k}" for k in parsed) + except Exception: + # If parsing fails, keep "metadata" as a column + metadata_fields.append("metadata") + elif isinstance(first_meta, dict): + metadata_fields.extend(f"metadata.{k}" for k in first_meta) + else: + # Not a dict, keep "metadata" as a column + metadata_fields.append("metadata") + + # Infer vector dimension from the first vector entry if still unknown + if vector_dimension == "Unknown" and "vector" in df.columns: + first_vec = df.iloc[0].get("vector") + if first_vec is not None: + try: + vector_dimension = len(first_vec) + except Exception: + vector_dimension = "Unknown" + + # Cache vector dimension if known + try: + if isinstance(vector_dimension, int): + self._collection_meta[name] = vector_dimension + except Exception: + pass + else: + # No dataframe available, try to get count from attribute + if count is None: + count = 0 + + # Final fallback for count + if count is None: + count = 0 distance_metric = "Unknown" @@ -172,17 +229,14 @@ def create_collection(self, name: str, vector_size: int, distance: str = "Cosine "metadata": "{}", } ] - print(f"LanceDB create_collection: Creating table '{name}' with vector_size={vector_size}") self._db.create_table(name, data=dummy_data) - print(f"LanceDB create_collection: Table '{name}' created successfully") # Cache the declared vector size for this collection try: self._collection_meta[name] = int(vector_size) except Exception: pass return True - except Exception as e: - print(f"LanceDB create_collection failed: {e}") + except Exception: return False def add_items( @@ -549,8 +603,7 @@ def query_collection( else: # Fallback: try to use a 'document' key if present in metadata, else empty string documents = [ - m.get(content_col, "") if isinstance(m, dict) and content_col in m else "" - for m in metadatas + m.get(content_col, "") if isinstance(m, dict) and content_col in m else "" for m in metadatas ] # LanceDB returns '_distance' not 'score' @@ -601,8 +654,37 @@ def get_all_items( # Get result count first result_count = len(df) - raw_meta = df["metadata"].tolist() if "metadata" in df.columns else [] - metadatas = self._parse_metadata_list(raw_meta) + # Extract metadata - support both nested "metadata" column and flat schema + # Get content column first to exclude it from metadata + schema = {col: str(dtype) for col, dtype in df.dtypes.items()} + content_col = self._detect_content_column(collection_name, schema) + reserved_columns = {"id", "vector", "embedding", "_distance", content_col} + + if "metadata" in df.columns: + # Legacy format: nested metadata column + raw_meta = df["metadata"].tolist() + metadatas = self._parse_metadata_list(raw_meta) + else: + # Flat schema format: all non-reserved columns are metadata + metadata_columns = [ + col for col in df.columns if col not in reserved_columns and not col.startswith("_") + ] + + if metadata_columns: + # Build metadata dicts from the flat columns + # Use .to_dict('records') for efficient row-wise conversion + records = df[metadata_columns].to_dict("records") + metadatas = [] + for record in records: + # Filter out NaN/None values + meta = { + k: v + for k, v in record.items() + if v is not None and (not isinstance(v, float) or not math.isnan(v)) + } + metadatas.append(meta) + else: + metadatas = [{} for _ in range(result_count)] # Ensure metadatas has same length as result count if len(metadatas) < result_count: @@ -615,17 +697,13 @@ def get_all_items( while len(metadatas) < result_count: metadatas.append({}) - # Get documents from content column (auto-detect) - schema = {col: str(dtype) for col, dtype in df.dtypes.items()} - content_col = self._detect_content_column(collection_name, schema) - + # Get documents from content column (already detected above) if content_col in df.columns: documents = df[content_col].tolist() else: # Fallback: prefer content_col key inside metadata if present, else empty string documents = [ - m.get(content_col, "") if isinstance(m, dict) and content_col in m else "" - for m in metadatas + m.get(content_col, "") if isinstance(m, dict) and content_col in m else "" for m in metadatas ] return { diff --git a/src/vector_inspector/core/connections/milvus_connection.py b/src/vector_inspector/core/connections/milvus_connection.py index d9456a70..e557ec01 100644 --- a/src/vector_inspector/core/connections/milvus_connection.py +++ b/src/vector_inspector/core/connections/milvus_connection.py @@ -526,7 +526,7 @@ def query_collection( query_embeddings: list[list[float]] | None = None, n_results: int = 10, where: dict[str, Any] | None = None, - where_document: dict[str, Any] | None = None, # noqa: ARG002 + where_document: dict[str, Any] | None = None, ) -> dict[str, Any] | None: """ Query a collection for similar vectors. diff --git a/src/vector_inspector/core/connections/pgvector_connection.py b/src/vector_inspector/core/connections/pgvector_connection.py index 48948f1b..800cb4b2 100644 --- a/src/vector_inspector/core/connections/pgvector_connection.py +++ b/src/vector_inspector/core/connections/pgvector_connection.py @@ -117,12 +117,11 @@ def list_collections(self) -> list[str]: with self._client.cursor() as cur: cur.execute(""" SELECT DISTINCT table_name FROM information_schema.columns - WHERE data_type = 'USER-DEFINED' + WHERE data_type = 'USER-DEFINED' AND udt_name = 'vector' AND table_schema = 'public' """) - tables = [row[0] for row in cur.fetchall()] - return tables + return [row[0] for row in cur.fetchall()] except Exception as e: log_tracked_error( "Failed to list collections: %s", @@ -201,7 +200,7 @@ def get_collection_info(self, name: str) -> dict[str, Any] | None: # Get schema to identify metadata columns schema = self._get_table_schema(name) content_col = self._detect_content_column(name, schema) - metadata_fields = [col for col in schema.keys() if col not in ["id", content_col, "embedding"]] + metadata_fields = [col for col in schema if col not in ["id", content_col, "embedding"]] # Try to determine vector dimension and detect stored embedding model from a sample row vector_dimension = "Unknown" @@ -384,9 +383,9 @@ def add_items( # Use JSONB metadata column metadata_json = json.dumps(metadata) if metadata else None cur.execute( - sql.SQL( - "INSERT INTO {} (id, {}, metadata, embedding) VALUES (%s, %s, %s, %s)" - ).format(sql.Identifier(collection_name), sql.Identifier(content_col)), + sql.SQL("INSERT INTO {} (id, {}, metadata, embedding) VALUES (%s, %s, %s, %s)").format( + sql.Identifier(collection_name), sql.Identifier(content_col) + ), (item_id, doc, metadata_json, emb), ) else: @@ -464,7 +463,7 @@ def get_items(self, name: str, ids: list[str]) -> dict[str, Any]: result_embeds = [] for row in rows: - row_dict = dict(zip(colnames, row)) + row_dict = dict(zip(colnames, row, strict=False)) result_ids.append(str(row_dict.get("id", ""))) result_docs.append(row_dict.get(content_col, "")) @@ -554,8 +553,7 @@ def count_collection(self, name: str) -> int: with self._client.cursor() as cur: cur.execute(sql.SQL("SELECT COUNT(*) FROM {}").format(sql.Identifier(name))) result = cur.fetchone() - count = result[0] if result else 0 - return count + return result[0] if result else 0 except Exception as e: log_tracked_error( "Failed to count collection: %s", @@ -678,7 +676,7 @@ def query_collection( dists_q = [] for row in rows: - row_dict = dict(zip(colnames, row)) + row_dict = dict(zip(colnames, row, strict=False)) ids_q.append(str(row_dict.get("id", ""))) docs_q.append(row_dict.get(content_col, "")) @@ -800,7 +798,7 @@ def get_all_items( result_embeds = [] for row in rows: - row_dict = dict(zip(colnames, row)) + row_dict = dict(zip(colnames, row, strict=False)) result_ids.append(str(row_dict.get("id", ""))) result_docs.append(row_dict.get(content_col, "")) @@ -884,7 +882,7 @@ def update_items( docs_to_compute = [documents[i] for i in compute_idxs] computed = self.compute_embeddings_for_documents(collection_name, docs_to_compute) embeddings_local = [None] * len(ids) - for idx, emb in zip(compute_idxs, computed): + for idx, emb in zip(compute_idxs, computed, strict=False): embeddings_local[idx] = emb # Record how many embeddings we generated try: @@ -1071,8 +1069,8 @@ def _get_table_schema(self, table_name: str) -> dict[str, str]: try: with self._client.cursor() as cur: cur.execute( - """SELECT column_name, data_type, udt_name - FROM information_schema.columns + """SELECT column_name, data_type, udt_name + FROM information_schema.columns WHERE table_name = %s AND table_schema = 'public' ORDER BY ordinal_position""", (table_name,), diff --git a/src/vector_inspector/core/connections/qdrant_connection.py b/src/vector_inspector/core/connections/qdrant_connection.py index 016b98a2..61957787 100644 --- a/src/vector_inspector/core/connections/qdrant_connection.py +++ b/src/vector_inspector/core/connections/qdrant_connection.py @@ -223,7 +223,7 @@ def get_collection_info(self, name: str) -> Optional[dict[str, Any]]: point = sample[0][0] if point.payload: # Extract metadata fields, excluding 'document' if present - metadata_fields = [k for k in point.payload.keys() if k != "document"] + metadata_fields = [k for k in point.payload if k != "document"] # Extract vector configuration vector_dimension = "Unknown" @@ -523,7 +523,7 @@ def get_all_items( qdrant_filter = self._build_qdrant_filter(where) # Use scroll to retrieve items - points, next_offset = self._client.scroll( + points, _next_offset = self._client.scroll( collection_name=collection_name, scroll_filter=qdrant_filter, limit=limit, @@ -551,7 +551,7 @@ def get_all_items( # Extract embedding if isinstance(point.vector, dict): # Named vectors - use the first one - embeddings.append(list(point.vector.values())[0] if point.vector else []) + embeddings.append(next(iter(point.vector.values())) if point.vector else []) else: embeddings.append(point.vector if point.vector else []) @@ -655,7 +655,7 @@ def add_items( # Build points points = [] - for i, (doc_id, document, embedding) in enumerate(zip(ids, documents, embeddings)): + for i, (doc_id, document, embedding) in enumerate(zip(ids, documents, embeddings, strict=False)): # Build payload with document and metadata payload = {"document": document} if metadatas and i < len(metadatas): diff --git a/src/vector_inspector/core/connections/qdrant_helpers/qdrant_embedding_resolver.py b/src/vector_inspector/core/connections/qdrant_helpers/qdrant_embedding_resolver.py index a10dd94d..1093f3ae 100644 --- a/src/vector_inspector/core/connections/qdrant_helpers/qdrant_embedding_resolver.py +++ b/src/vector_inspector/core/connections/qdrant_helpers/qdrant_embedding_resolver.py @@ -1,9 +1,7 @@ -from typing import Tuple - from vector_inspector.core.embedding_utils import get_model_for_dimension, load_embedding_model, DEFAULT_MODEL -def resolve_embedding_model(connection, collection_name: str) -> Tuple[object, str, str]: +def resolve_embedding_model(connection, collection_name: str) -> tuple[object, str, str]: """Resolve an embedding model for a collection. Returns (model, model_name, model_type). This encapsulates the previous @@ -17,15 +15,15 @@ def resolve_embedding_model(connection, collection_name: str) -> Tuple[object, s return (model, model_name, model_type) # Priority 1: explicit metadata on collection - if 'embedding_model' in collection_info: - model_name = collection_info['embedding_model'] - model_type = collection_info.get('embedding_model_type', 'sentence-transformer') + if "embedding_model" in collection_info: + model_name = collection_info["embedding_model"] + model_type = collection_info.get("embedding_model_type", "sentence-transformer") model = load_embedding_model(model_name, model_type) return (model, model_name, model_type) # Priority 3: guess by vector dimension - vector_dim = collection_info.get('vector_dimension') - if not vector_dim or vector_dim == 'Unknown': + vector_dim = collection_info.get("vector_dimension") + if not vector_dim or vector_dim == "Unknown": model_name, model_type = DEFAULT_MODEL model = load_embedding_model(model_name, model_type) return (model, model_name, model_type) diff --git a/src/vector_inspector/core/connections/qdrant_helpers/qdrant_filter_builder.py b/src/vector_inspector/core/connections/qdrant_helpers/qdrant_filter_builder.py index 49f3b729..9100b502 100644 --- a/src/vector_inspector/core/connections/qdrant_helpers/qdrant_filter_builder.py +++ b/src/vector_inspector/core/connections/qdrant_helpers/qdrant_filter_builder.py @@ -1,8 +1,8 @@ -from typing import Optional, Dict, Any, List +from typing import Optional, Any from qdrant_client.models import Filter, FieldCondition, MatchValue, MatchText, MatchAny, MatchExcept, Range -def build_filter(where: Optional[Dict[str, Any]] = None) -> Optional[Filter]: +def build_filter(where: Optional[dict[str, Any]] = None) -> Optional[Filter]: """Build a Qdrant `Filter` from a Chroma-style `where` dict. This mirrors the previous inline logic in `QdrantConnection._build_qdrant_filter`. @@ -11,8 +11,8 @@ def build_filter(where: Optional[Dict[str, Any]] = None) -> Optional[Filter]: return None try: - must_conditions: List[FieldCondition] = [] - must_not_conditions: List[FieldCondition] = [] + must_conditions: list[FieldCondition] = [] + must_not_conditions: list[FieldCondition] = [] for key, value in where.items(): if isinstance(value, dict): @@ -44,8 +44,10 @@ def build_filter(where: Optional[Dict[str, Any]] = None) -> Optional[Filter]: must_conditions.append(FieldCondition(key=key, match=MatchValue(value=value))) if must_conditions or must_not_conditions: - return Filter(must=must_conditions if must_conditions else None, - must_not=must_not_conditions if must_not_conditions else None) + return Filter( + must=must_conditions if must_conditions else None, + must_not=must_not_conditions if must_not_conditions else None, + ) return None except Exception: return None diff --git a/src/vector_inspector/core/embedding_providers/__init__.py b/src/vector_inspector/core/embedding_providers/__init__.py index d36733f2..41be6e3c 100644 --- a/src/vector_inspector/core/embedding_providers/__init__.py +++ b/src/vector_inspector/core/embedding_providers/__init__.py @@ -6,9 +6,9 @@ from .provider_factory import ProviderFactory __all__ = [ - 'EmbeddingProvider', - 'EmbeddingMetadata', - 'SentenceTransformerProvider', - 'CLIPProvider', - 'ProviderFactory', + "CLIPProvider", + "EmbeddingMetadata", + "EmbeddingProvider", + "ProviderFactory", + "SentenceTransformerProvider", ] diff --git a/src/vector_inspector/core/embedding_providers/base_provider.py b/src/vector_inspector/core/embedding_providers/base_provider.py index 227196a0..a098e9b0 100644 --- a/src/vector_inspector/core/embedding_providers/base_provider.py +++ b/src/vector_inspector/core/embedding_providers/base_provider.py @@ -2,13 +2,14 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import List, Union, Optional, Any +from typing import Optional, Any from enum import Enum import numpy as np class Modality(Enum): """Embedding modality types.""" + TEXT = "text" IMAGE = "image" MULTIMODAL = "multimodal" @@ -16,6 +17,7 @@ class Modality(Enum): class Normalization(Enum): """Embedding normalization types.""" + NONE = "none" L2 = "l2" @@ -23,6 +25,7 @@ class Normalization(Enum): @dataclass class EmbeddingMetadata: """Metadata about an embedding model.""" + name: str # Model identifier (e.g., "all-MiniLM-L6-v2") dimension: int # Vector dimension modality: Modality # text, image, or multimodal @@ -36,74 +39,69 @@ class EmbeddingMetadata: class EmbeddingProvider(ABC): """Abstract base class for embedding providers. - + Providers handle loading, encoding, and metadata extraction for embedding models. They implement lazy-loading to avoid UI freezes when working with large models. """ - + def __init__(self, model_name: str): """Initialize provider with a model name. - + Args: model_name: Model identifier (HuggingFace ID, path, or API model name) """ self.model_name = model_name self._model = None # Lazy-loaded model instance self._is_loaded = False - + @abstractmethod def get_metadata(self) -> EmbeddingMetadata: """Get metadata about the embedding model. - + This should be fast and not require loading the full model if possible. - + Returns: EmbeddingMetadata with model information """ pass - + @abstractmethod - def encode( - self, - inputs: Union[str, List[str], Any], - normalize: bool = True, - show_progress: bool = False - ) -> np.ndarray: + def encode(self, inputs: str | list[str] | Any, normalize: bool = True, show_progress: bool = False) -> np.ndarray: """Encode inputs into embeddings. - + Args: inputs: Text strings, images, or other inputs depending on modality normalize: Whether to L2-normalize the embeddings show_progress: Whether to show progress bar for batch encoding - + Returns: numpy array of embeddings, shape (n_inputs, dimension) """ pass - + def warmup(self, progress_callback=None): """Load and initialize the model (warm up for faster subsequent calls). - + Args: progress_callback: Optional callback(message: str, progress: float) for UI updates """ if self._is_loaded: return - + if progress_callback: progress_callback(f"Loading {self.model_name}...", 0.0) - + self._load_model() self._is_loaded = True - + if progress_callback: progress_callback(f"Model {self.model_name} loaded", 1.0) - + @abstractmethod def _load_model(self): """Internal method to load the actual model. Override in subclasses.""" pass - + def close(self): """Release model resources and cleanup.""" if self._model is not None: @@ -111,17 +109,17 @@ def close(self): del self._model self._model = None self._is_loaded = False - + @property def is_loaded(self) -> bool: """Check if model is currently loaded in memory.""" return self._is_loaded - + def __enter__(self): """Context manager support.""" self.warmup() return self - + def __exit__(self, exc_type, exc_val, exc_tb): """Context manager cleanup.""" self.close() diff --git a/src/vector_inspector/core/embedding_providers/clip_provider.py b/src/vector_inspector/core/embedding_providers/clip_provider.py index 4df3f285..4f7ebe3b 100644 --- a/src/vector_inspector/core/embedding_providers/clip_provider.py +++ b/src/vector_inspector/core/embedding_providers/clip_provider.py @@ -65,9 +65,7 @@ def get_metadata(self) -> EmbeddingMetadata: ) except ImportError: - raise ImportError( - "transformers library not installed. Install with: pip install transformers" - ) + raise ImportError("transformers library not installed. Install with: pip install transformers") except Exception: # Fallback metadata self._metadata = EmbeddingMetadata( @@ -178,9 +176,7 @@ def encode( with torch.no_grad(): if input_type == "text": # Process text - processed = self._processor( - text=inputs, return_tensors="pt", padding=True, truncation=True - ) + processed = self._processor(text=inputs, return_tensors="pt", padding=True, truncation=True) # Move to same device as model if next(self._model.parameters()).is_cuda: @@ -276,6 +272,4 @@ def similarity( corpus_emb = self.encode(corpus, normalize=True, input_type=corpus_type) # Compute cosine similarity (dot product if normalized) - similarities = np.dot(corpus_emb, query_emb.T).squeeze() - - return similarities + return np.dot(corpus_emb, query_emb.T).squeeze() diff --git a/src/vector_inspector/core/embedding_providers/provider_factory.py b/src/vector_inspector/core/embedding_providers/provider_factory.py index 93defdbc..3d9289b3 100644 --- a/src/vector_inspector/core/embedding_providers/provider_factory.py +++ b/src/vector_inspector/core/embedding_providers/provider_factory.py @@ -1,6 +1,6 @@ """Factory for creating embedding providers.""" -from typing import Optional, Dict, Type +from typing import Optional from .base_provider import EmbeddingProvider from .sentence_transformer_provider import SentenceTransformerProvider from .clip_provider import CLIPProvider @@ -11,7 +11,7 @@ class ProviderFactory: """Factory for creating appropriate embedding providers based on model type.""" # Registry of provider classes by type - _PROVIDER_REGISTRY: Dict[str, Type[EmbeddingProvider]] = { + _PROVIDER_REGISTRY: dict[str, type[EmbeddingProvider]] = { "sentence-transformer": SentenceTransformerProvider, "clip": CLIPProvider, } @@ -37,9 +37,7 @@ class ProviderFactory: } @classmethod - def create( - cls, model_name: str, model_type: Optional[str] = None, **kwargs - ) -> EmbeddingProvider: + def create(cls, model_name: str, model_type: Optional[str] = None, **kwargs) -> EmbeddingProvider: """Create an embedding provider for the given model. Args: @@ -71,11 +69,9 @@ def create( f"Cloud provider '{model_type}' not yet implemented. " f"Currently supported: {', '.join(cls._PROVIDER_REGISTRY.keys())}" ) - else: - raise ValueError( - f"Unknown provider type: {model_type}. " - f"Supported types: {', '.join(cls._PROVIDER_REGISTRY.keys())}" - ) + raise ValueError( + f"Unknown provider type: {model_type}. Supported types: {', '.join(cls._PROVIDER_REGISTRY.keys())}" + ) # Create and return provider instance return provider_class(model_name, **kwargs) @@ -115,12 +111,11 @@ def _detect_provider_type(cls, model_name: str) -> str: return "sentence-transformer" raise ValueError( - f"Cannot auto-detect provider type for model: {model_name}. " - "Please specify model_type explicitly." + f"Cannot auto-detect provider type for model: {model_name}. Please specify model_type explicitly." ) @classmethod - def register_provider(cls, model_type: str, provider_class: Type[EmbeddingProvider]): + def register_provider(cls, model_type: str, provider_class: type[EmbeddingProvider]): """Register a new provider type. Args: @@ -155,9 +150,7 @@ def supports_type(cls, model_type: str) -> bool: # Convenience function for creating providers -def create_provider( - model_name: str, model_type: Optional[str] = None, **kwargs -) -> EmbeddingProvider: +def create_provider(model_name: str, model_type: Optional[str] = None, **kwargs) -> EmbeddingProvider: """Create an embedding provider (convenience wrapper around ProviderFactory). Args: diff --git a/src/vector_inspector/core/embedding_providers/sentence_transformer_provider.py b/src/vector_inspector/core/embedding_providers/sentence_transformer_provider.py index 5235c3ee..1a371f00 100644 --- a/src/vector_inspector/core/embedding_providers/sentence_transformer_provider.py +++ b/src/vector_inspector/core/embedding_providers/sentence_transformer_provider.py @@ -72,8 +72,7 @@ def get_metadata(self) -> EmbeddingMetadata: except ImportError: # sentence-transformers not installed raise ImportError( - "sentence-transformers library not installed. " - "Install with: pip install sentence-transformers" + "sentence-transformers library not installed. Install with: pip install sentence-transformers" ) except Exception: # Fallback metadata if we can't determine dimension @@ -95,8 +94,7 @@ def _load_model(self): from sentence_transformers import SentenceTransformer except ImportError: raise ImportError( - "sentence-transformers library not installed. " - "Install with: pip install sentence-transformers" + "sentence-transformers library not installed. Install with: pip install sentence-transformers" ) # Try to load from cache first @@ -112,9 +110,7 @@ def _load_model(self): if cached_path: try: # Load from cache - self._model = SentenceTransformer( - str(cached_path), trust_remote_code=self.trust_remote_code - ) + self._model = SentenceTransformer(str(cached_path), trust_remote_code=self.trust_remote_code) log_info(f"Loaded sentence-transformer from cache: {self.model_name}") return except Exception as e: @@ -127,9 +123,7 @@ def _load_model(self): if is_cache_enabled(): save_model_to_cache(self._model, self.model_name, "sentence-transformer") - def encode( - self, inputs: str | list[str], normalize: bool = True, show_progress: bool = False - ) -> np.ndarray: + def encode(self, inputs: str | list[str], normalize: bool = True, show_progress: bool = False) -> np.ndarray: """Encode text inputs into embeddings. Args: @@ -149,15 +143,13 @@ def encode( inputs = [inputs] # Encode - embeddings = self._model.encode( + return self._model.encode( inputs, normalize_embeddings=normalize, show_progress_bar=show_progress, convert_to_numpy=True, ) - return embeddings - def encode_batch( self, inputs: list[str], @@ -179,7 +171,7 @@ def encode_batch( if not self._is_loaded: self.warmup() - embeddings = self._model.encode( + return self._model.encode( inputs, batch_size=batch_size, normalize_embeddings=normalize, @@ -187,8 +179,6 @@ def encode_batch( convert_to_numpy=True, ) - return embeddings - def similarity(self, query: str | np.ndarray, corpus: list[str]) -> np.ndarray: """Compute similarity between query and corpus. @@ -211,6 +201,4 @@ def similarity(self, query: str | np.ndarray, corpus: list[str]) -> np.ndarray: corpus_emb = self.encode(corpus, normalize=True) # Compute cosine similarity (dot product if normalized) - similarities = np.dot(corpus_emb, query_emb.T).squeeze() - - return similarities + return np.dot(corpus_emb, query_emb.T).squeeze() diff --git a/src/vector_inspector/core/embedding_utils.py b/src/vector_inspector/core/embedding_utils.py index 4f2cdc4a..ec99b58f 100644 --- a/src/vector_inspector/core/embedding_utils.py +++ b/src/vector_inspector/core/embedding_utils.py @@ -198,6 +198,7 @@ def encode_text(text: str, model: SentenceTransformer | tuple | str, model_type: return embeddings[0] except Exception as e: from vector_inspector.core.logging import log_tracked_error + log_tracked_error( "Ollama embedding failed: %s. Ensure Ollama is running (http://localhost:11434)", e, diff --git a/src/vector_inspector/core/llm_providers/provider_factory.py b/src/vector_inspector/core/llm_providers/provider_factory.py index d879362f..60cf7727 100644 --- a/src/vector_inspector/core/llm_providers/provider_factory.py +++ b/src/vector_inspector/core/llm_providers/provider_factory.py @@ -203,8 +203,7 @@ def generate(self, prompt: str, **opts) -> str: raise RuntimeError("No LLM provider is available.") messages = [{"role": "user", "content": prompt}] model = opts.pop("model", self._provider.get_model_name()) - result = self._provider.generate_messages(messages, model=model, stream=False, **opts) - return result # type: ignore[return-value] + return self._provider.generate_messages(messages, model=model, stream=False, **opts) def is_available(self) -> bool: self._ensure() diff --git a/src/vector_inspector/core/llm_providers/runtime_manager.py b/src/vector_inspector/core/llm_providers/runtime_manager.py index bbea4319..ad19957b 100644 --- a/src/vector_inspector/core/llm_providers/runtime_manager.py +++ b/src/vector_inspector/core/llm_providers/runtime_manager.py @@ -225,7 +225,6 @@ def _select_provider(self) -> LLMProvider: now = datetime.datetime.now(datetime.UTC).isoformat() selected_provider_id: str | None = None selected_model: str | None = None - explicit = False # True when provider was explicitly configured # --- Rank 1: Explicit app config --- cfg_provider = self._settings.get("llm.provider", None) if self._settings else None @@ -241,7 +240,6 @@ def _select_provider(self) -> LLMProvider: } ) selected_provider_id = cfg_provider - explicit = True else: reasons.append( { @@ -269,7 +267,6 @@ def _select_provider(self) -> LLMProvider: } ) selected_provider_id = env_provider - explicit = True else: reasons.append( { diff --git a/src/vector_inspector/core/provider_factory.py b/src/vector_inspector/core/provider_factory.py index 40960901..65c5c4a1 100644 --- a/src/vector_inspector/core/provider_factory.py +++ b/src/vector_inspector/core/provider_factory.py @@ -13,9 +13,7 @@ class ProviderFactory: """Factory for creating database connections from configuration.""" @staticmethod - def create( - provider: str, config: dict[str, Any], credentials: dict[str, Any] = None - ) -> VectorDBConnection: + def create(provider: str, config: dict[str, Any], credentials: dict[str, Any] | None = None) -> VectorDBConnection: """Create a connection object for the specified provider. Args: diff --git a/src/vector_inspector/services/backup_helpers.py b/src/vector_inspector/services/backup_helpers.py index c45f4e15..aa67d6c2 100644 --- a/src/vector_inspector/services/backup_helpers.py +++ b/src/vector_inspector/services/backup_helpers.py @@ -2,40 +2,41 @@ Minimal, well-tested helpers to keep `BackupRestoreService` concise. """ + import json import zipfile -from typing import Tuple, Dict, Any +from typing import Any -def write_backup_zip(path, metadata: Dict[str, Any], data: Dict[str, Any]): +def write_backup_zip(path, metadata: dict[str, Any], data: dict[str, Any]): """Write metadata and data into a zip file at `path`. `path` may be a pathlib.Path or string. """ - with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as zipf: - zipf.writestr('metadata.json', json.dumps(metadata, indent=2)) - zipf.writestr('data.json', json.dumps(data, indent=2)) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zipf: + zipf.writestr("metadata.json", json.dumps(metadata, indent=2)) + zipf.writestr("data.json", json.dumps(data, indent=2)) -def read_backup_zip(path) -> Tuple[Dict[str, Any], Dict[str, Any]]: +def read_backup_zip(path) -> tuple[dict[str, Any], dict[str, Any]]: """Read metadata.json and data.json from a backup zip and return them. Returns (metadata, data). """ - with zipfile.ZipFile(path, 'r') as zipf: - metadata_str = zipf.read('metadata.json').decode('utf-8') + with zipfile.ZipFile(path, "r") as zipf: + metadata_str = zipf.read("metadata.json").decode("utf-8") metadata = json.loads(metadata_str) - data_str = zipf.read('data.json').decode('utf-8') + data_str = zipf.read("data.json").decode("utf-8") data = json.loads(data_str) return metadata, data -def normalize_embeddings(data: Dict[str, Any]) -> Dict[str, Any]: +def normalize_embeddings(data: dict[str, Any]) -> dict[str, Any]: """Ensure embeddings in `data` are plain python lists (no numpy objects). This mutates and returns the same `data` dict for convenience. """ - if 'embeddings' not in data or data['embeddings'] is None: + if "embeddings" not in data or data["embeddings"] is None: return data try: @@ -43,10 +44,10 @@ def normalize_embeddings(data: Dict[str, Any]) -> Dict[str, Any]: except Exception: np = None - emb = data['embeddings'] + emb = data["embeddings"] if np is not None: if isinstance(emb, np.ndarray): - data['embeddings'] = emb.tolist() + data["embeddings"] = emb.tolist() return data if isinstance(emb, list): @@ -56,7 +57,7 @@ def normalize_embeddings(data: Dict[str, Any]) -> Dict[str, Any]: new_list.append(item.tolist()) else: new_list.append(item) - data['embeddings'] = new_list + data["embeddings"] = new_list return data # No numpy available — assume data already serializable diff --git a/src/vector_inspector/services/cluster_runner.py b/src/vector_inspector/services/cluster_runner.py index b379bca9..fbbe5700 100644 --- a/src/vector_inspector/services/cluster_runner.py +++ b/src/vector_inspector/services/cluster_runner.py @@ -77,7 +77,7 @@ def get_cluster_stats(self, labels: np.ndarray) -> dict[str, Any]: if label >= 0: # Exclude noise cluster_sizes[int(label)] = int(np.sum(labels == label)) - stats = { + return { "n_clusters": n_clusters, "n_noise": n_noise, "n_total": len(labels), @@ -85,8 +85,6 @@ def get_cluster_stats(self, labels: np.ndarray) -> dict[str, Any]: "noise_ratio": n_noise / len(labels) if len(labels) > 0 else 0.0, } - return stats - def format_summary(self, labels: np.ndarray, algorithm: str) -> str: """ Format a human-readable summary of clustering results. diff --git a/src/vector_inspector/services/data_loaders.py b/src/vector_inspector/services/data_loaders.py index 1a23c1b0..34ef203d 100644 --- a/src/vector_inspector/services/data_loaders.py +++ b/src/vector_inspector/services/data_loaders.py @@ -59,8 +59,7 @@ def load_all( try: # Use get_all_items if available if hasattr(self.connection, "get_all_items"): - data = self.connection.get_all_items(collection_name=collection, limit=limit, offset=offset) - return data + return self.connection.get_all_items(collection_name=collection, limit=limit, offset=offset) log_tracked_error( "Connection does not support get_all_items", @@ -280,8 +279,7 @@ def load_metadata(self, collection: str, item_ids: Optional[list[str]] = None) - if item_ids: # Load specific items if hasattr(self.connection, "get_by_ids"): - data = self.connection.get_by_ids(collection, item_ids) - return data + return self.connection.get_by_ids(collection, item_ids) else: # Load all metadata if hasattr(self.connection, "get_all_items"): diff --git a/src/vector_inspector/services/filter_service.py b/src/vector_inspector/services/filter_service.py index 46002965..baa3496f 100644 --- a/src/vector_inspector/services/filter_service.py +++ b/src/vector_inspector/services/filter_service.py @@ -1,39 +1,39 @@ """Service for applying client-side filters to data.""" -from typing import Dict, Any, List +from typing import Any -def apply_client_side_filters(data: Dict[str, Any], filters: List[Dict[str, Any]]) -> Dict[str, Any]: +def apply_client_side_filters(data: dict[str, Any], filters: list[dict[str, Any]]) -> dict[str, Any]: """ Apply client-side filters to fetched data. - + Args: data: Data dictionary with ids, documents, metadatas, etc. filters: List of client-side filter dictionaries - + Returns: Filtered data dictionary """ if not filters: return data - + ids = data.get("ids", []) documents = data.get("documents", []) metadatas = data.get("metadatas", []) embeddings = data.get("embeddings", []) - + # Track which indices to keep keep_indices = [] - + for i in range(len(ids)): # Check if this item passes all client-side filters passes = True - + for filt in filters: field = filt.get("field", "") op = filt.get("op", "") value = filt.get("value", "") - + # Special handling for document field if field.lower() == "document": item_value = documents[i] if i < len(documents) else "" @@ -41,32 +41,31 @@ def apply_client_side_filters(data: Dict[str, Any], filters: List[Dict[str, Any] # Get from metadata metadata = metadatas[i] if i < len(metadatas) else {} item_value = metadata.get(field, "") - + # Convert to string for text operations item_value_str = str(item_value).lower() search_value = str(value).lower() - + # Apply operator if op == "contains": if search_value not in item_value_str: passes = False break - elif op == "not_contains": - if search_value in item_value_str: - passes = False - break - + elif op == "not_contains" and search_value in item_value_str: + passes = False + break + if passes: keep_indices.append(i) - + # Filter the data filtered_data = { "ids": [ids[i] for i in keep_indices], "documents": [documents[i] for i in keep_indices if i < len(documents)], "metadatas": [metadatas[i] for i in keep_indices if i < len(metadatas)], } - + if embeddings is not None and len(embeddings) > 0: filtered_data["embeddings"] = [embeddings[i] for i in keep_indices if i < len(embeddings)] - + return filtered_data diff --git a/src/vector_inspector/services/profile_service.py b/src/vector_inspector/services/profile_service.py index 636db5a7..b0ab27b2 100644 --- a/src/vector_inspector/services/profile_service.py +++ b/src/vector_inspector/services/profile_service.py @@ -270,15 +270,13 @@ def duplicate_profile(self, profile_id: str, new_name: str) -> Optional[str]: credentials = self.credential_service.get_credentials(profile_id) # Create new profile - new_id = self.create_profile( + return self.create_profile( name=new_name, provider=source_profile.provider, config=source_profile.config.copy(), credentials=credentials, ) - return new_id - def get_profile_with_credentials(self, profile_id: str) -> Optional[dict[str, Any]]: """ Get a profile along with its credentials. @@ -411,11 +409,9 @@ def migrate_legacy_connection(self, config: dict[str, Any]) -> str: del config["api_key"] # Remove from config # Create profile - profile_id = self.create_profile( + return self.create_profile( name=name, provider=provider, config=config, credentials=credentials if credentials else None, ) - - return profile_id diff --git a/src/vector_inspector/services/update_service.py b/src/vector_inspector/services/update_service.py index 613154eb..c2234bd4 100644 --- a/src/vector_inspector/services/update_service.py +++ b/src/vector_inspector/services/update_service.py @@ -2,7 +2,7 @@ import json import time import requests -from typing import Optional, Dict +from typing import Optional GITHUB_API_URL = "https://api.github.com/repos/anthonypdawson/vector-inspector/releases/latest" CACHE_FILE = os.path.expanduser("~/.vector_inspector_update_cache.json") @@ -11,7 +11,7 @@ class UpdateService: @staticmethod - def get_latest_release(force_refresh: bool = False) -> Optional[Dict]: + def get_latest_release(force_refresh: bool = False) -> Optional[dict]: """ Fetch the latest release info from GitHub, with caching and rate limit handling. Returns None on error or if rate limited. @@ -20,7 +20,7 @@ def get_latest_release(force_refresh: bool = False) -> Optional[Dict]: # Check cache for rate limit state or valid release if os.path.exists(CACHE_FILE): try: - with open(CACHE_FILE, "r", encoding="utf-8") as f: + with open(CACHE_FILE, encoding="utf-8") as f: cache = json.load(f) # If rate limited, respect the reset time if cache.get("rate_limited_until", 0) > now: @@ -36,7 +36,7 @@ def get_latest_release(force_refresh: bool = False) -> Optional[Dict]: with open(CACHE_FILE, "w", encoding="utf-8") as f: json.dump({"timestamp": now, "release": release}, f) return release - elif resp.status_code == 403: + if resp.status_code == 403: # Check for rate limit headers reset = resp.headers.get("X-RateLimit-Reset") if reset: @@ -63,7 +63,7 @@ def parse(v): return parse(latest_version) > parse(current_version) @staticmethod - def get_update_instructions() -> Dict[str, str]: + def get_update_instructions() -> dict[str, str]: """ Returns update instructions for both PyPI and GitHub. """ diff --git a/src/vector_inspector/ui/components/connection_manager_panel.py b/src/vector_inspector/ui/components/connection_manager_panel.py index 2551dc1f..a40f6e74 100644 --- a/src/vector_inspector/ui/components/connection_manager_panel.py +++ b/src/vector_inspector/ui/components/connection_manager_panel.py @@ -106,12 +106,8 @@ def _connect_signals(self): self.connection_manager.connection_opened.connect(self._on_connection_opened) self.connection_manager.connection_closed.connect(self._on_connection_closed) self.connection_manager.connection_state_changed.connect(self._on_connection_state_changed) - self.connection_manager.active_connection_changed.connect( - self._on_active_connection_changed - ) - self.connection_manager.active_collection_changed.connect( - self._on_active_collection_changed - ) + self.connection_manager.active_connection_changed.connect(self._on_active_connection_changed) + self.connection_manager.active_collection_changed.connect(self._on_active_collection_changed) self.connection_manager.collections_updated.connect(self._on_collections_updated) def _on_connection_opened(self, connection_id: str): @@ -127,9 +123,7 @@ def _on_connection_opened(self, connection_id: str): # Create tree item for connection item = QTreeWidgetItem(self.connection_tree) item.setText(0, instance.get_display_name()) - item.setData( - 0, Qt.ItemDataRole.UserRole, {"type": "connection", "connection_id": connection_id} - ) + item.setData(0, Qt.ItemDataRole.UserRole, {"type": "connection", "connection_id": connection_id}) # Set icon/indicator based on state self._update_connection_indicator(item, instance.state) @@ -267,9 +261,7 @@ def _show_context_menu(self, pos): if item_type == "connection": # Connection context menu set_active_action = menu.addAction("Set as Active") - set_active_action.triggered.connect( - lambda: self.connection_manager.set_active_connection(connection_id) - ) + set_active_action.triggered.connect(lambda: self.connection_manager.set_active_connection(connection_id)) menu.addSeparator() @@ -290,24 +282,18 @@ def _show_context_menu(self, pos): select_action = menu.addAction("Select Collection") select_action.triggered.connect( - lambda: self.connection_manager.set_active_collection( - connection_id, collection_name - ) + lambda: self.connection_manager.set_active_collection(connection_id, collection_name) ) menu.addSeparator() info_action = menu.addAction("View Info") - info_action.triggered.connect( - lambda: self._view_collection_info(connection_id, collection_name) - ) + info_action.triggered.connect(lambda: self._view_collection_info(connection_id, collection_name)) menu.addSeparator() delete_action = menu.addAction("Delete Collection...") - delete_action.triggered.connect( - lambda: self._delete_collection(connection_id, collection_name) - ) + delete_action.triggered.connect(lambda: self._delete_collection(connection_id, collection_name)) # Make delete action red/warning style delete_action.setIcon(QIcon()) # Could add warning icon font = delete_action.font() @@ -322,9 +308,7 @@ def _rename_connection(self, connection_id: str): if not instance: return - new_name, ok = QInputDialog.getText( - self, "Rename Connection", "Enter new name:", text=instance.name - ) + new_name, ok = QInputDialog.getText(self, "Rename Connection", "Enter new name:", text=instance.name) if ok and new_name and self.connection_manager.rename_connection(connection_id, new_name): # Update tree item @@ -343,11 +327,7 @@ def _refresh_collections(self, connection_id: str): loading.show_loading("Refreshing collections...") # Cancel any existing refresh thread - if ( - hasattr(self, "_refresh_thread") - and self._refresh_thread - and self._refresh_thread.isRunning() - ): + if hasattr(self, "_refresh_thread") and self._refresh_thread and self._refresh_thread.isRunning(): self._refresh_thread.quit() self._refresh_thread.wait() @@ -359,9 +339,7 @@ def _refresh_collections(self, connection_id: str): self._refresh_thread.error.connect(lambda error: self._on_refresh_error(error, loading)) self._refresh_thread.start() - def _on_refresh_finished( - self, connection_id: str, collections: list, loading: LoadingDialog - ) -> None: + def _on_refresh_finished(self, connection_id: str, collections: list, loading: LoadingDialog) -> None: """Handle successful collections refresh.""" loading.hide_loading() self.connection_manager.update_collections(connection_id, collections) @@ -415,9 +393,7 @@ def _delete_collection(self, connection_id: str, collection_name: str): # Warning header warning_label = QLabel("⚠️ PERMANENT DELETION WARNING ⚠️") - warning_label.setStyleSheet( - "font-size: 16px; font-weight: bold; color: #d32f2f; padding: 10px;" - ) + warning_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #d32f2f; padding: 10px;") warning_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(warning_label) @@ -439,16 +415,13 @@ def _delete_collection(self, connection_id: str, collection_name: str): # Confirmation checkbox confirm_checkbox = QCheckBox( - f"I understand this will PERMANENTLY DELETE '{collection_name}' " - f"and all {item_count:,} items" + f"I understand this will PERMANENTLY DELETE '{collection_name}' and all {item_count:,} items" ) confirm_checkbox.setStyleSheet("font-weight: bold; color: #d32f2f; padding: 10px;") layout.addWidget(confirm_checkbox) # Type collection name to confirm - type_confirm_label = QLabel( - f"Type the collection name to confirm: {collection_name}" - ) + type_confirm_label = QLabel(f"Type the collection name to confirm: {collection_name}") layout.addWidget(type_confirm_label) name_input = QLineEdit() @@ -457,9 +430,7 @@ def _delete_collection(self, connection_id: str, collection_name: str): # Buttons button_box = QDialogButtonBox() - delete_button = button_box.addButton( - "DELETE PERMANENTLY", QDialogButtonBox.ButtonRole.DestructiveRole - ) + delete_button = button_box.addButton("DELETE PERMANENTLY", QDialogButtonBox.ButtonRole.DestructiveRole) delete_button.setEnabled(False) # Disabled until confirmed delete_button.setStyleSheet( "QPushButton { background-color: #d32f2f; color: white; font-weight: bold; " @@ -491,11 +462,7 @@ def check_confirmations(): loading.show_loading(f"Deleting collection '{collection_name}'...") # Cancel any existing delete thread - if ( - hasattr(self, "_delete_thread") - and self._delete_thread - and self._delete_thread.isRunning() - ): + if hasattr(self, "_delete_thread") and self._delete_thread and self._delete_thread.isRunning(): self._delete_thread.quit() self._delete_thread.wait() @@ -507,13 +474,9 @@ def check_confirmations(): parent=self, ) self._delete_thread.finished.connect( - lambda collections: self._on_delete_finished( - connection_id, collection_name, collections, loading, instance - ) - ) - self._delete_thread.error.connect( - lambda error: self._on_delete_error(collection_name, error, loading) + lambda collections: self._on_delete_finished(connection_id, collection_name, collections, loading, instance) ) + self._delete_thread.error.connect(lambda error: self._on_delete_error(collection_name, error, loading)) self._delete_thread.start() def _on_delete_finished( @@ -544,9 +507,7 @@ def _on_delete_finished( f"Collection '{collection_name}' has been permanently deleted.", ) - def _on_delete_error( - self, collection_name: str, error_message: str, loading: LoadingDialog - ) -> None: + def _on_delete_error(self, collection_name: str, error_message: str, loading: LoadingDialog) -> None: """Handle delete error.""" loading.hide_loading() QMessageBox.critical( diff --git a/src/vector_inspector/ui/components/inline_details_pane.py b/src/vector_inspector/ui/components/inline_details_pane.py index 8dbc1c69..a8e0f251 100644 --- a/src/vector_inspector/ui/components/inline_details_pane.py +++ b/src/vector_inspector/ui/components/inline_details_pane.py @@ -562,7 +562,7 @@ def _copy_vector_json(self): if embedding is not None: try: vector_list = embedding.tolist() if hasattr(embedding, "tolist") else list(embedding) - json_str = json.dumps( + json.dumps( { "id": self._current_item.get("id"), "vector": vector_list, diff --git a/src/vector_inspector/ui/components/item_dialog.py b/src/vector_inspector/ui/components/item_dialog.py index 452849b0..8f13ae80 100644 --- a/src/vector_inspector/ui/components/item_dialog.py +++ b/src/vector_inspector/ui/components/item_dialog.py @@ -67,16 +67,13 @@ def _setup_ui(self): # Auto-timestamp checkbox self.auto_timestamp_checkbox = QCheckBox( - "Automatically add timestamp " - + ("(updated_at)" if self.is_edit_mode else "(created_at)") + "Automatically add timestamp " + ("(updated_at)" if self.is_edit_mode else "(created_at)") ) self.auto_timestamp_checkbox.setChecked(False) # Default to disabled layout.addWidget(self.auto_timestamp_checkbox) # Note about embeddings - note_label = QLabel( - "Note: Embeddings will be automatically generated from the document text." - ) + note_label = QLabel("Note: Embeddings will be automatically generated from the document text.") note_label.setStyleSheet("color: gray; font-style: italic;") note_label.setWordWrap(True) layout.addWidget(note_label) diff --git a/src/vector_inspector/ui/components/profile_manager_panel.py b/src/vector_inspector/ui/components/profile_manager_panel.py index fd357280..25ac2516 100644 --- a/src/vector_inspector/ui/components/profile_manager_panel.py +++ b/src/vector_inspector/ui/components/profile_manager_panel.py @@ -697,7 +697,7 @@ def _on_provider_changed(self): dlg = ProviderInstallDialog(provider_info, parent=self) # Disconnect the auto-populate to control it manually - result = dlg.exec() + dlg.exec() # Repopulate providers list after dialog closes self._populate_providers() diff --git a/src/vector_inspector/ui/components/splash_window.py b/src/vector_inspector/ui/components/splash_window.py index bb7410de..6a0768ae 100644 --- a/src/vector_inspector/ui/components/splash_window.py +++ b/src/vector_inspector/ui/components/splash_window.py @@ -31,9 +31,7 @@ def __init__(self, parent=None): about = QTextBrowser() version = get_app_version() - version_html = ( - f"

Vector Inspector {version}

" if version else "

Vector Inspector

" - ) + version_html = f"

Vector Inspector {version}

" if version else "

Vector Inspector

" about_text = ( version_html + "

A comprehensive desktop application for visualizing, " "querying, and managing multiple vector databases simultaneously.

" diff --git a/src/vector_inspector/ui/components/update_details_dialog.py b/src/vector_inspector/ui/components/update_details_dialog.py index b87f4a07..a10935a1 100644 --- a/src/vector_inspector/ui/components/update_details_dialog.py +++ b/src/vector_inspector/ui/components/update_details_dialog.py @@ -9,9 +9,7 @@ class UpdateDetailsDialog(QDialog): pip_command: str github_url: str - def __init__( - self, version: str, release_notes: str, pip_command: str, github_url: str, parent=None - ): + def __init__(self, version: str, release_notes: str, pip_command: str, github_url: str, parent=None): super().__init__(parent) self.version = version self.release_notes = release_notes diff --git a/src/vector_inspector/ui/controllers/connection_controller.py b/src/vector_inspector/ui/controllers/connection_controller.py index dda1d4b3..212a52e7 100644 --- a/src/vector_inspector/ui/controllers/connection_controller.py +++ b/src/vector_inspector/ui/controllers/connection_controller.py @@ -133,7 +133,9 @@ def connect_to_profile(self, profile_id: str) -> bool: # start a duplicate. This prevents repeated db.connection_attempt telemetry # events when the user clicks "Connect" multiple times in quick succession. if profile_id in self._connection_threads and self._connection_threads[profile_id].isRunning(): - log_info("[ConnectionController] Ignoring duplicate connect request for %s — already in progress", profile_id) + log_info( + "[ConnectionController] Ignoring duplicate connect request for %s — already in progress", profile_id + ) return True # Check connection limit @@ -267,7 +269,7 @@ def _on_connection_finished( # Emit signal for UI updates self.connection_completed.emit(connection_id, success, collections, error_message, duration_ms) - def create_collection_with_dialog(self, connection_id: str = None) -> bool: + def create_collection_with_dialog(self, connection_id: str | None = None) -> bool: """Show dialog to create a new collection with optional sample data. Args: diff --git a/src/vector_inspector/ui/dialogs/__init__.py b/src/vector_inspector/ui/dialogs/__init__.py index 008980c2..17b84e4d 100644 --- a/src/vector_inspector/ui/dialogs/__init__.py +++ b/src/vector_inspector/ui/dialogs/__init__.py @@ -4,4 +4,4 @@ from .embedding_config_dialog import EmbeddingConfigDialog from .provider_type_dialog import ProviderTypeDialog -__all__ = ['ContentColumnDialog', 'EmbeddingConfigDialog', 'ProviderTypeDialog'] +__all__ = ["ContentColumnDialog", "EmbeddingConfigDialog", "ProviderTypeDialog"] diff --git a/src/vector_inspector/ui/dialogs/cross_db_migration.py b/src/vector_inspector/ui/dialogs/cross_db_migration.py index 3c8a2205..8961aadc 100644 --- a/src/vector_inspector/ui/dialogs/cross_db_migration.py +++ b/src/vector_inspector/ui/dialogs/cross_db_migration.py @@ -1,20 +1,18 @@ """Cross-database operations for migrating data between vector databases.""" -from typing import Optional, List, Dict, Any +from typing import Optional from pathlib import Path import tempfile from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, - QLabel, QComboBox, QPushButton, QProgressBar, QTextEdit, QGroupBox, QFormLayout, - QSpinBox, QCheckBox, QMessageBox, ) @@ -117,7 +115,7 @@ def run(self): return if success: - self.progress.emit(100, f"Migration complete!") + self.progress.emit(100, "Migration complete!") self.finished.emit( True, f"Successfully migrated {self.source_collection} to {self.target_collection}", @@ -135,9 +133,7 @@ def run(self): except Exception as cleanup_error: log_error("Warning: Failed to clean up target collection: %s", cleanup_error) - self.finished.emit( - False, "Failed to restore to target collection. Target collection cleaned up." - ) + self.finished.emit(False, "Failed to restore to target collection. Target collection cleaned up.") except Exception as e: import traceback @@ -157,7 +153,7 @@ def run(self): except Exception as cleanup_error: log_error("Warning: Failed to clean up target collection: %s", cleanup_error) - self.finished.emit(False, f"Migration error: {str(e)}") + self.finished.emit(False, f"Migration error: {e!s}") finally: # Clean up temporary backup file @@ -305,9 +301,7 @@ def _start_migration(self): target_conn_id = self.target_connection_combo.currentData() if not source_conn_id or not target_conn_id: - QMessageBox.warning( - self, "Invalid Selection", "Please select both source and target connections." - ) + QMessageBox.warning(self, "Invalid Selection", "Please select both source and target connections.") return if source_conn_id == target_conn_id: @@ -332,9 +326,7 @@ def _start_migration(self): target_collection = self.target_collection_combo.currentText().strip() if not source_collection or not target_collection: - QMessageBox.warning( - self, "Invalid Selection", "Please select both source and target collections." - ) + QMessageBox.warning(self, "Invalid Selection", "Please select both source and target collections.") return # Check if target collection exists diff --git a/src/vector_inspector/ui/dialogs/embedding_config_dialog.py b/src/vector_inspector/ui/dialogs/embedding_config_dialog.py index de41323a..4dded556 100644 --- a/src/vector_inspector/ui/dialogs/embedding_config_dialog.py +++ b/src/vector_inspector/ui/dialogs/embedding_config_dialog.py @@ -1,6 +1,6 @@ """Dialog for configuring embedding models for collections (Step 2: Model Selection).""" -from typing import Optional, Tuple +from typing import Optional from PySide6.QtWidgets import ( QDialog, QVBoxLayout, @@ -14,7 +14,7 @@ QLineEdit, QFormLayout, ) -from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtCore import QThread, Signal from vector_inspector.core.embedding_utils import get_available_models_for_dimension from vector_inspector.core.model_registry import get_model_registry @@ -80,9 +80,7 @@ def _setup_ui(self): info_layout.addWidget(QLabel(f"Vector Dimension: {self.vector_dimension}")) if self.current_model: - info_layout.addWidget( - QLabel(f"Current Model: {self.current_model} ({self.current_type})") - ) + info_layout.addWidget(QLabel(f"Current Model: {self.current_model} ({self.current_type})")) else: warning = QLabel("⚠️ No embedding model configured - using automatic detection") warning.setStyleSheet("color: orange;") @@ -110,18 +108,14 @@ def _setup_ui(self): custom_models = settings.get_custom_embedding_models(self.vector_dimension) for model in custom_models: if model["type"] == self.provider_type: - available_models.append( - (model["name"], model["type"], f"{model['description']} (custom)") - ) + available_models.append((model["name"], model["type"], f"{model['description']} (custom)")) except Exception: pass else: available_models = get_available_models_for_dimension(self.vector_dimension) if available_models: - model_layout.addWidget( - QLabel(f"Available models for {self.vector_dimension}-dimensional vectors:") - ) + model_layout.addWidget(QLabel(f"Available models for {self.vector_dimension}-dimensional vectors:")) self.model_combo = QComboBox() for model_name, model_type, description in available_models: @@ -145,9 +139,7 @@ def _setup_ui(self): self.description_text = QTextEdit() self.description_text.setReadOnly(True) self.description_text.setMaximumHeight(100) - self.description_text.setStyleSheet( - "background-color: #f5f5f5; border: 1px solid #ccc; color: #000000;" - ) + self.description_text.setStyleSheet("background-color: #f5f5f5; border: 1px solid #ccc; color: #000000;") model_layout.addWidget(self.description_text) # Update description when selection changes @@ -157,9 +149,7 @@ def _setup_ui(self): else: # No models for this type + dimension type_name = self.provider_type or "any type" - warning = QLabel( - f"⚠️ No models of type '{type_name}' available for {self.vector_dimension} dimensions." - ) + warning = QLabel(f"⚠️ No models of type '{type_name}' available for {self.vector_dimension} dimensions.") warning.setWordWrap(True) model_layout.addWidget(warning) @@ -241,9 +231,7 @@ def _setup_custom_ui(self, layout): self.custom_desc_input.setPlaceholderText("Brief description (optional)") custom_layout.addRow("Description:", self.custom_desc_input) - custom_note = QLabel( - "💡 Custom models will be saved and available for future use with this dimension." - ) + custom_note = QLabel("💡 Custom models will be saved and available for future use with this dimension.") custom_note.setWordWrap(True) custom_note.setStyleSheet("color: #666; font-size: 10px; padding: 4px;") custom_layout.addRow(custom_note) @@ -355,7 +343,7 @@ def _clear_config(self): self.selected_type = None self.done(2) # Custom code for "clear" - def get_selection(self) -> Optional[Tuple[str, str]]: + def get_selection(self) -> Optional[tuple[str, str]]: """Get the selected model and type (from either combo or custom entry).""" if self.selected_model and self.selected_type: return (self.selected_model, self.selected_type) diff --git a/src/vector_inspector/ui/dialogs/provider_type_dialog.py b/src/vector_inspector/ui/dialogs/provider_type_dialog.py index a0340495..883dbffc 100644 --- a/src/vector_inspector/ui/dialogs/provider_type_dialog.py +++ b/src/vector_inspector/ui/dialogs/provider_type_dialog.py @@ -1,10 +1,17 @@ """Dialog for selecting embedding provider type (Step 1 of model selection).""" -from typing import Optional, Tuple +from typing import Optional from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QRadioButton, QButtonGroup, QGroupBox, - QScrollArea, QWidget + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QRadioButton, + QButtonGroup, + QGroupBox, + QScrollArea, + QWidget, ) from PySide6.QtCore import Qt @@ -13,7 +20,7 @@ class ProviderTypeDialog(QDialog): """Dialog for selecting the provider/type category before choosing specific model.""" - + # Provider categories with display info PROVIDER_CATEGORIES = [ { @@ -21,103 +28,102 @@ class ProviderTypeDialog(QDialog): "name": "🤗 Sentence Transformers", "description": "Local models from HuggingFace\nNo API key required, runs on your machine", "filter_type": "sentence-transformer", - "icon": "📦" + "icon": "📦", }, { "id": "clip", "name": "🖼️ CLIP Models", "description": "Multimodal embeddings (text + images)\nLocal models, no API key required", "filter_type": "clip", - "icon": "🎨" + "icon": "🎨", }, { "id": "ollama", "name": "🦙 Ollama", "description": "Local models via Ollama server\nNo API key required, requires Ollama running", "filter_type": "ollama", - "icon": "💻" + "icon": "💻", }, { "id": "openai", "name": "☁️ OpenAI API", "description": "Cloud-based embeddings\nRequires OpenAI API key", "filter_type": "openai", - "icon": "🔑" + "icon": "🔑", }, { "id": "cohere", "name": "☁️ Cohere API", "description": "Cloud-based embeddings\nRequires Cohere API key", "filter_type": "cohere", - "icon": "🔑" + "icon": "🔑", }, { "id": "vertex-ai", "name": "☁️ Google Vertex AI", "description": "Cloud-based embeddings\nRequires Google Cloud credentials", "filter_type": "vertex-ai", - "icon": "🔑" + "icon": "🔑", }, { "id": "voyage", "name": "☁️ Voyage AI", "description": "Cloud-based embeddings\nRequires Voyage API key", "filter_type": "voyage", - "icon": "🔑" + "icon": "🔑", }, { "id": "custom", "name": "✏️ Custom Model", "description": "Enter your own model name\nFor models not in the registry", "filter_type": None, # Special case - "icon": "⚙️" - } + "icon": "⚙️", + }, ] - + def __init__(self, collection_name: str, vector_dimension: int, parent=None): super().__init__(parent) self.collection_name = collection_name self.vector_dimension = vector_dimension self.selected_type = None - + self.setWindowTitle("Select Embedding Provider Type") self.setMinimumWidth(550) self.setMinimumHeight(500) self._setup_ui() - + def _setup_ui(self): """Setup dialog UI.""" layout = QVBoxLayout(self) - + # Header - header = QLabel(f"

Select Provider Type

") + header = QLabel("

Select Provider Type

") layout.addWidget(header) - - info = QLabel(f"Collection: {self.collection_name}
" - f"Vector Dimension: {self.vector_dimension}") + + info = QLabel(f"Collection: {self.collection_name}
Vector Dimension: {self.vector_dimension}") layout.addWidget(info) - + layout.addWidget(QLabel("Choose the type of embedding provider to use:")) - + # Scroll area for provider options scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - + scroll_widget = QWidget() scroll_layout = QVBoxLayout(scroll_widget) scroll_layout.setSpacing(10) - + # Radio button group self.button_group = QButtonGroup(self) - + # Get registry to count models registry = get_model_registry() - + # Create radio buttons for each provider type for i, category in enumerate(self.PROVIDER_CATEGORIES): provider_id = category["id"] - + # Count available models for this type + dimension if provider_id == "custom": count_text = "Enter manually" @@ -134,65 +140,65 @@ def _setup_ui(self): continue # Skip categories with no models for this dimension count_text = f"{count} model{'s' if count != 1 else ''} available" - + # Create group box for this option group = QGroupBox() group_layout = QVBoxLayout() - + # Radio button radio = QRadioButton(category["name"]) radio.setProperty("provider_id", provider_id) self.button_group.addButton(radio, i) group_layout.addWidget(radio) - + # Description desc_label = QLabel(category["description"]) desc_label.setStyleSheet("color: gray; margin-left: 25px;") desc_label.setWordWrap(True) group_layout.addWidget(desc_label) - + # Count count_label = QLabel(f"{count_text}") count_label.setStyleSheet("margin-left: 25px; color: #0066cc;") group_layout.addWidget(count_label) - + group.setLayout(group_layout) scroll_layout.addWidget(group) - + scroll_layout.addStretch() scroll.setWidget(scroll_widget) layout.addWidget(scroll) - + # Buttons button_layout = QHBoxLayout() button_layout.addStretch() - + cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(self.reject) - + self.next_btn = QPushButton("Next →") self.next_btn.clicked.connect(self._on_next) self.next_btn.setEnabled(False) self.next_btn.setDefault(True) - + # Enable Next when selection is made self.button_group.buttonClicked.connect(lambda: self.next_btn.setEnabled(True)) - + button_layout.addWidget(cancel_btn) button_layout.addWidget(self.next_btn) - + layout.addLayout(button_layout) - + def _on_next(self): """Handle Next button click.""" selected_button = self.button_group.checkedButton() if selected_button: self.selected_type = selected_button.property("provider_id") self.accept() - + def get_selected_type(self) -> Optional[str]: """Get the selected provider type ID. - + Returns: Provider type ID or None if cancelled """ diff --git a/src/vector_inspector/ui/loading_screen.py b/src/vector_inspector/ui/loading_screen.py index fbed4c22..6e2031a5 100644 --- a/src/vector_inspector/ui/loading_screen.py +++ b/src/vector_inspector/ui/loading_screen.py @@ -26,9 +26,7 @@ def __init__(self, logo_path, version, app_name, tagline, loading_text): # Logo if os.path.exists(logo_path): - pixmap = QPixmap(logo_path).scaled( - 128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation - ) + pixmap = QPixmap(logo_path).scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation) logo_label = QLabel() logo_label.setPixmap(pixmap) logo_label.setAlignment(Qt.AlignCenter) @@ -105,9 +103,7 @@ def fade_out(self, duration=500): self._fade_animation = animation # Prevent garbage collection -def show_loading_screen( - app_name, version, tagline, loading_text="Initializing providers…", logo_path=None -): +def show_loading_screen(app_name, version, tagline, loading_text="Initializing providers…", logo_path=None): """Show the loading screen if not disabled in settings. This is a convenience function that handles checking settings, finding the logo, diff --git a/src/vector_inspector/ui/main_window.py b/src/vector_inspector/ui/main_window.py index 53e7acf9..1faba874 100644 --- a/src/vector_inspector/ui/main_window.py +++ b/src/vector_inspector/ui/main_window.py @@ -118,8 +118,8 @@ def _maybe_show_splash(self): splash.activateWindow() if splash.exec() == QDialog.DialogCode.Accepted and splash.should_hide(): self.settings_service.set("hide_splash_window", True) - except Exception as e: - print(f"[SplashWindow] Failed to show splash: {e}") + except Exception: + pass def _setup_ui(self): """Setup the main UI layout using InspectorShell.""" @@ -372,8 +372,8 @@ def _show_preferences_dialog(self): dlg = SettingsDialog(self.settings_service, self) if dlg.exec() == QDialog.DialogCode.Accepted: self._apply_settings_to_views() - except Exception as e: - print(f"Failed to open preferences: {e}") + except Exception: + pass def _apply_settings_to_views(self): """Apply relevant settings to existing views.""" diff --git a/src/vector_inspector/ui/views/demo_collection_view.py b/src/vector_inspector/ui/views/demo_collection_view.py index 68c2203a..fb3238e1 100644 --- a/src/vector_inspector/ui/views/demo_collection_view.py +++ b/src/vector_inspector/ui/views/demo_collection_view.py @@ -61,9 +61,7 @@ class DemoCollectionView(QWidget): view = DemoCollectionView(app_state, task_runner) """ - def __init__( - self, app_state: AppState, task_runner: ThreadedTaskRunner, parent: Optional[QWidget] = None - ) -> None: + def __init__(self, app_state: AppState, task_runner: ThreadedTaskRunner, parent: Optional[QWidget] = None) -> None: """ Initialize demo collection view. @@ -278,9 +276,7 @@ def _populate_table(self, data: dict) -> None: # Metadata column metadata = metadatas[row] if row < len(metadatas) else {} metadata_str = str(metadata) if metadata else "" - metadata_item = QTableWidgetItem( - metadata_str[:50] + "..." if len(metadata_str) > 50 else metadata_str - ) + metadata_item = QTableWidgetItem(metadata_str[:50] + "..." if len(metadata_str) > 50 else metadata_str) metadata_item.setFlags(metadata_item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, 1, metadata_item) diff --git a/src/vector_inspector/ui/views/info_panel.py b/src/vector_inspector/ui/views/info_panel.py index 3558b0c2..7d4c137f 100644 --- a/src/vector_inspector/ui/views/info_panel.py +++ b/src/vector_inspector/ui/views/info_panel.py @@ -663,10 +663,16 @@ def _display_collection_info(self, collection_info: dict[str, Any]): try: content_col = backend.get_content_column(self.current_collection) # Check if manually overridden or auto-detected - is_override = backend.is_content_column_overridden(self.current_collection) if hasattr(backend, "is_content_column_overridden") else False + is_override = ( + backend.is_content_column_overridden(self.current_collection) + if hasattr(backend, "is_content_column_overridden") + else False + ) label_suffix = "(manual override)" if is_override else "(auto-detected)" self.content_col_label.setText(f"{content_col} {label_suffix}") - self.content_col_label.setStyleSheet("color: #4CAF50; padding-left: 20px; font-family: " + _MONO_FONT + ";") + self.content_col_label.setStyleSheet( + "color: #4CAF50; padding-left: 20px; font-family: " + _MONO_FONT + ";" + ) self.configure_content_col_btn.setEnabled(True) except Exception: self.content_col_label.setText("N/A") @@ -749,7 +755,9 @@ def _display_collection_info(self, collection_info: dict[str, Any]): if details_list: self.provider_details_label.setText("\n".join(details_list)) - self.provider_details_label.setStyleSheet("color: white; padding-left: 20px; font-family: " + _MONO_FONT + ";") + self.provider_details_label.setStyleSheet( + "color: white; padding-left: 20px; font-family: " + _MONO_FONT + ";" + ) else: self.provider_details_label.setText("No additional details available") self.provider_details_label.setStyleSheet("color: gray; padding-left: 20px;") @@ -803,7 +811,6 @@ def _update_embedding_model_display(self, collection_info: dict[str, Any]): # Ensure we have a valid connection_id for settings lookup # Fallback to connection.id if connection_id not set - effective_connection_id = self.connection_id or (self.connection.id if self.connection else None) # Try to get from connection using the helper method if self.connection and self.current_collection: @@ -991,11 +998,13 @@ def _configure_content_column(self): schema = {col: str(dtype) for col, dtype in df.dtypes.items()} except Exception as e: from PySide6.QtWidgets import QMessageBox + QMessageBox.warning(self, "Error", f"Could not load schema: {e}") return if not schema: from PySide6.QtWidgets import QMessageBox + QMessageBox.information(self, "No Schema", "Could not determine collection schema.") return @@ -1009,11 +1018,7 @@ def _configure_content_column(self): from vector_inspector.ui.dialogs import ContentColumnDialog dialog = ContentColumnDialog( - self.current_collection, - schema, - current_column, - self, - auto_detected_column=auto_detected + self.current_collection, schema, current_column, self, auto_detected_column=auto_detected ) if dialog.exec(): @@ -1024,11 +1029,10 @@ def _configure_content_column(self): # Update display self.content_col_label.setText(f"{selected_column}") - self.content_col_label.setStyleSheet("color: #4CAF50; padding-left: 20px; font-family: " + _MONO_FONT + ";") + self.content_col_label.setStyleSheet( + "color: #4CAF50; padding-left: 20px; font-family: " + _MONO_FONT + ";" + ) from PySide6.QtWidgets import QMessageBox - QMessageBox.information( - self, - "Content Column Updated", - f"Content column set to: {selected_column}" - ) + + QMessageBox.information(self, "Content Column Updated", f"Content column set to: {selected_column}") diff --git a/src/vector_inspector/ui/views/metadata/item_update_helpers.py b/src/vector_inspector/ui/views/metadata/item_update_helpers.py index d829491f..bfaa3119 100644 --- a/src/vector_inspector/ui/views/metadata/item_update_helpers.py +++ b/src/vector_inspector/ui/views/metadata/item_update_helpers.py @@ -29,9 +29,8 @@ def process_item_update_success( ctx.cache_manager.invalidate(ctx.current_database, ctx.current_collection) # Show info about embedding regeneration/preservation when applicable - regen_count = 0 try: - regen_count = int(getattr(ctx.connection, "_last_regenerated_count", 0) or 0) + int(getattr(ctx.connection, "_last_regenerated_count", 0) or 0) if update_row_in_place(view.table, ctx, updated_data): return diff --git a/src/vector_inspector/ui/views/metadata/metadata_io.py b/src/vector_inspector/ui/views/metadata/metadata_io.py index a621e542..c03172a0 100644 --- a/src/vector_inspector/ui/views/metadata/metadata_io.py +++ b/src/vector_inspector/ui/views/metadata/metadata_io.py @@ -75,9 +75,7 @@ def export_data( settings_service = SettingsService() last_dir = settings_service.get("last_import_export_dir", "") default_path = ( - f"{last_dir}/{current_collection}.{format_type}" - if last_dir - else f"{current_collection}.{format_type}" + f"{last_dir}/{current_collection}.{format_type}" if last_dir else f"{current_collection}.{format_type}" ) file_path, _ = QFileDialog.getSaveFileName( @@ -196,9 +194,7 @@ def import_data( settings_service.set("last_import_export_dir", str(Path(file_path).parent)) - QMessageBox.information( - parent, "Import Successful", f"Imported {len(imported_data['ids'])} items." - ) + QMessageBox.information(parent, "Import Successful", f"Imported {len(imported_data['ids'])} items.") return imported_data QMessageBox.warning(parent, "Import Failed", "Failed to import data.") return None diff --git a/src/vector_inspector/ui/views/metadata/metadata_table.py b/src/vector_inspector/ui/views/metadata/metadata_table.py index 44f6bf5f..3e4dc9f1 100644 --- a/src/vector_inspector/ui/views/metadata/metadata_table.py +++ b/src/vector_inspector/ui/views/metadata/metadata_table.py @@ -149,6 +149,14 @@ def populate_table( # Re-pin preview column after resizeColumnsToContents table.setColumnWidth(PREVIEW_COL, 28) + # Apply max width constraints to prevent excessively wide columns + MAX_COLUMN_WIDTH = 600 # Maximum width in pixels + for col in range(table.columnCount()): + if col != PREVIEW_COL: # Skip preview column (already fixed) + current_width = table.columnWidth(col) + if current_width > MAX_COLUMN_WIDTH: + table.setColumnWidth(col, MAX_COLUMN_WIDTH) + def copy_vectors_to_json( table: QTableWidget, diff --git a/src/vector_inspector/ui/views/metadata/metadata_threads.py b/src/vector_inspector/ui/views/metadata/metadata_threads.py index 12993672..ab3e5959 100644 --- a/src/vector_inspector/ui/views/metadata/metadata_threads.py +++ b/src/vector_inspector/ui/views/metadata/metadata_threads.py @@ -81,23 +81,15 @@ def run(self) -> None: success = self.connection.update_items( self.collection, ids=[self.updated_data["id"]], - documents=[self.updated_data["document"]] - if self.updated_data["document"] - else None, - metadatas=[self.updated_data["metadata"]] - if self.updated_data["metadata"] - else None, + documents=[self.updated_data["document"]] if self.updated_data["document"] else None, + metadatas=[self.updated_data["metadata"]] if self.updated_data["metadata"] else None, ) else: success = self.connection.update_items( self.collection, ids=[self.updated_data["id"]], - documents=[self.updated_data["document"]] - if self.updated_data["document"] - else None, - metadatas=[self.updated_data["metadata"]] - if self.updated_data["metadata"] - else None, + documents=[self.updated_data["document"]] if self.updated_data["document"] else None, + metadatas=[self.updated_data["metadata"]] if self.updated_data["metadata"] else None, embeddings=self.embeddings_arg, ) diff --git a/src/vector_inspector/ui/views/visualization/plot_panel.py b/src/vector_inspector/ui/views/visualization/plot_panel.py index 9fffad4d..0a68be68 100644 --- a/src/vector_inspector/ui/views/visualization/plot_panel.py +++ b/src/vector_inspector/ui/views/visualization/plot_panel.py @@ -296,7 +296,7 @@ def create_plot( return; } var pointIndex = point.pointIndex; - + // Toggle: if clicking same point, deselect if (selectedPointIndex === pointIndex) { selectedPointIndex = -1; @@ -305,9 +305,9 @@ def create_plot( } return; } - + selectedPointIndex = pointIndex; - + // Extract ID from hover text var pointId = String(pointIndex); if (point.text) { @@ -326,7 +326,7 @@ def create_plot( } } }); - + // Handle explicit deselection plotDiv.on('plotly_deselect', function() { selectedPointIndex = -1; @@ -337,7 +337,7 @@ def create_plot( plotBridge.onInteraction('select', 0); } }); - + // Handle zoom / pan via relayout event plotDiv.on('plotly_relayout', function(layout) { // Basic heuristic: presence of axis range keys indicates zoom diff --git a/src/vector_inspector/ui/views/visualization_view.py b/src/vector_inspector/ui/views/visualization_view.py index 7786f8b9..1e4674f8 100644 --- a/src/vector_inspector/ui/views/visualization_view.py +++ b/src/vector_inspector/ui/views/visualization_view.py @@ -639,7 +639,7 @@ def _save_cluster_labels_to_metadata(self): # Update metadata with cluster labels updated_metadatas = [] - for i, (item_id, metadata) in enumerate(zip(ids, metadatas)): + for i, (_item_id, metadata) in enumerate(zip(ids, metadatas, strict=False)): if i >= len(self.cluster_labels): break diff --git a/tests/components/test_profile_manager_panel.py b/tests/components/test_profile_manager_panel.py index 5474837c..0c26d2f6 100644 --- a/tests/components/test_profile_manager_panel.py +++ b/tests/components/test_profile_manager_panel.py @@ -436,7 +436,7 @@ def close(self): def test_provider_ui_branches_and_browse_and_load(qtbot, fake_service, mock_providers, monkeypatch): - panel_mod = __import__("vector_inspector.ui.components.profile_manager_panel", fromlist=["*"]) + __import__("vector_inspector.ui.components.profile_manager_panel", fromlist=["*"]) dlg = ProfileEditorDialog(fake_service) qtbot.addWidget(dlg) @@ -744,13 +744,13 @@ def on_connect(pid): def test_save_profile_create_and_update_and_config_kwargs(qtbot, fake_service, mock_providers, monkeypatch): - panel_mod = __import__("vector_inspector.ui.components.profile_manager_panel", fromlist=["*"]) + __import__("vector_inspector.ui.components.profile_manager_panel", fromlist=["*"]) # New profile create (pinecone requires api_key) created = {} def fake_create(name, provider, config, credentials=None): - created["args"] = dict(name=name, provider=provider, config=config, credentials=credentials) + created["args"] = {"name": name, "provider": provider, "config": config, "credentials": credentials} return "id-new" fake_service.create_profile = fake_create @@ -772,7 +772,7 @@ def fake_create(name, provider, config, credentials=None): updated = {} def fake_update(pid, name=None, config=None, credentials=None): - updated["args"] = dict(pid=pid, name=name, config=config, credentials=credentials) + updated["args"] = {"pid": pid, "name": name, "config": config, "credentials": credentials} return True fake_service.update_profile = fake_update diff --git a/tests/core/llm_providers/test_llama_cpp.py b/tests/core/llm_providers/test_llama_cpp.py index d9a8bb04..5eafcd30 100644 --- a/tests/core/llm_providers/test_llama_cpp.py +++ b/tests/core/llm_providers/test_llama_cpp.py @@ -100,7 +100,7 @@ def _make_provider_with_mock_llm(self, tmp_path, response_content: str = " resp return p, mock_llm def test_happy_path_returns_stripped_content(self, tmp_path): - p, mock_llm = self._make_provider_with_mock_llm(tmp_path, " hello world ") + p, _mock_llm = self._make_provider_with_mock_llm(tmp_path, " hello world ") result = p.generate_messages([{"role": "user", "content": "hi"}], model="model.gguf") assert result == "hello world" diff --git a/tests/core/llm_providers/test_runtime_and_selection.py b/tests/core/llm_providers/test_runtime_and_selection.py index fc89176d..5762cd41 100644 --- a/tests/core/llm_providers/test_runtime_and_selection.py +++ b/tests/core/llm_providers/test_runtime_and_selection.py @@ -39,8 +39,8 @@ def test_autodetect_used_when_no_explicit_or_env(self, monkeypatch): monkeypatch.delenv("OPENAI_API_KEY", raising=False) s = _make_settings(**{"llm.provider": "auto"}) mgr = LLMRuntimeManager(settings=s) - with patch("urllib.request.urlopen") as mock_open: - mock_resp = MagicMock = patch # type: ignore + with patch("urllib.request.urlopen"): + pass # type: ignore mgr.get_selection_debug() diff --git a/tests/core/test_clustering.py b/tests/core/test_clustering.py index 36eb637a..1941b278 100644 --- a/tests/core/test_clustering.py +++ b/tests/core/test_clustering.py @@ -103,7 +103,7 @@ def _get(model_name: str): "alpha": 1.0, "cluster_selection_method": "eom", } - labels, algo = run_clustering(embeddings, "HDBSCAN", params) + _labels, algo = run_clustering(embeddings, "HDBSCAN", params) assert algo == "HDBSCAN" assert received_kwargs["cluster_selection_epsilon"] == 0.1 assert received_kwargs["allow_single_cluster"] is True @@ -138,7 +138,7 @@ def fit_predict(self, X): "tol": 1e-4, "algorithm": "lloyd", } - labels, algo = run_clustering(embeddings, "KMeans", params) + _labels, algo = run_clustering(embeddings, "KMeans", params) assert algo == "KMeans" assert received_kwargs["init"] == "random" assert received_kwargs["max_iter"] == 50 @@ -172,7 +172,7 @@ def fit_predict(self, X): "algorithm": "ball_tree", "leaf_size": 20, } - labels, algo = run_clustering(embeddings, "DBSCAN", params) + _labels, algo = run_clustering(embeddings, "DBSCAN", params) assert algo == "DBSCAN" assert received_kwargs["metric"] == "cosine" assert received_kwargs["algorithm"] == "ball_tree" @@ -207,7 +207,7 @@ def fit_predict(self, X): "algorithm": "kd_tree", "leaf_size": 30, } - labels, algo = run_clustering(embeddings, "OPTICS", params) + _labels, algo = run_clustering(embeddings, "OPTICS", params) assert algo == "OPTICS" assert received_kwargs["metric"] == "minkowski" assert received_kwargs["xi"] == 0.05 diff --git a/tests/core/test_provider_factory.py b/tests/core/test_provider_factory.py index 9f6b4e69..fdb9232f 100644 --- a/tests/core/test_provider_factory.py +++ b/tests/core/test_provider_factory.py @@ -25,7 +25,7 @@ def test_ephemeral(self): with patch("vector_inspector.core.provider_factory.get_connection_class") as mock_get_class: MockChroma = MagicMock() mock_get_class.return_value = MockChroma - conn = ProviderFactory.create("chromadb", {}) + ProviderFactory.create("chromadb", {}) mock_get_class.assert_called_once_with("chromadb") MockChroma.assert_called_once_with() @@ -33,7 +33,7 @@ def test_persistent(self): with patch("vector_inspector.core.provider_factory.get_connection_class") as mock_get_class: MockChroma = MagicMock() mock_get_class.return_value = MockChroma - conn = ProviderFactory.create("chromadb", {"type": "persistent", "path": "/data"}) + ProviderFactory.create("chromadb", {"type": "persistent", "path": "/data"}) mock_get_class.assert_called_once_with("chromadb") MockChroma.assert_called_once_with(path="/data") @@ -41,7 +41,7 @@ def test_http(self): with patch("vector_inspector.core.provider_factory.get_connection_class") as mock_get_class: MockChroma = MagicMock() mock_get_class.return_value = MockChroma - conn = ProviderFactory.create("chromadb", {"type": "http", "host": "localhost", "port": 8000}) + ProviderFactory.create("chromadb", {"type": "http", "host": "localhost", "port": 8000}) mock_get_class.assert_called_once_with("chromadb") MockChroma.assert_called_once_with(host="localhost", port=8000) diff --git a/tests/core/test_qdrant_helpers.py b/tests/core/test_qdrant_helpers.py index dbab4263..85a62bef 100644 --- a/tests/core/test_qdrant_helpers.py +++ b/tests/core/test_qdrant_helpers.py @@ -118,7 +118,7 @@ def test_no_collection_info_uses_default(self, monkeypatch): lambda name, mtype: mock_model, ) - model, model_name, model_type = resolve_embedding_model(mock_conn, "my_col") + _model, model_name, model_type = resolve_embedding_model(mock_conn, "my_col") assert model_name == DEFAULT_MODEL[0] assert model_type == DEFAULT_MODEL[1] @@ -139,7 +139,7 @@ def test_uses_explicit_embedding_model_from_collection_info(self, monkeypatch): lambda name, mtype: mock_model, ) - model, name, mtype = resolve_embedding_model(mock_conn, "col") + _model, name, mtype = resolve_embedding_model(mock_conn, "col") assert name == "custom-model" assert mtype == "clip" @@ -161,7 +161,7 @@ def test_falls_back_to_dimension_lookup(self, monkeypatch): lambda dim: ("dim-model", "sentence-transformer"), ) - model, name, mtype = resolve_embedding_model(mock_conn, "col") + _model, name, _mtype = resolve_embedding_model(mock_conn, "col") assert name == "dim-model" def test_unknown_dimension_uses_default(self, monkeypatch): @@ -179,5 +179,5 @@ def test_unknown_dimension_uses_default(self, monkeypatch): lambda name, mtype: mock_model, ) - model, name, mtype = resolve_embedding_model(mock_conn, "col") + _model, name, _mtype = resolve_embedding_model(mock_conn, "col") assert name == DEFAULT_MODEL[0] diff --git a/tests/fakes/fake_provider.py b/tests/fakes/fake_provider.py index 917c1296..5ee0d350 100644 --- a/tests/fakes/fake_provider.py +++ b/tests/fakes/fake_provider.py @@ -25,9 +25,7 @@ def _ensure_collection(self, name: str): "embeddings": [], } - def _matches_where( - self, meta: Optional[dict[str, Any]], where: Optional[dict[str, Any]] - ) -> bool: + def _matches_where(self, meta: Optional[dict[str, Any]], where: Optional[dict[str, Any]]) -> bool: if not where: return True if meta is None: @@ -55,9 +53,7 @@ def get_collection_info(self, name: str) -> Optional[dict[str, Any]]: return { "name": name, "count": len(col.get("ids", [])), - "metadata_fields": list( - {k for m in col.get("metadatas", []) for k in (m or {}).keys()} - ), + "metadata_fields": list({k for m in col.get("metadatas", []) for k in (m or {})}), } # Alias used by some code paths @@ -143,9 +139,7 @@ def query_collection( # If query embeddings provided, compute dot-product similarity for first query q = np.array(query_embeddings[0], dtype=float) - emb_list = [ - embeddings[i] if embeddings[i] is not None else np.zeros_like(q) for i in indices - ] + emb_list = [embeddings[i] if embeddings[i] is not None else np.zeros_like(q) for i in indices] if len(emb_list) == 0: return {"ids": [], "distances": [], "documents": [], "metadatas": [], "embeddings": []} diff --git a/tests/integration/test_clustering_real_embeddings.py b/tests/integration/test_clustering_real_embeddings.py index 4b4c7a6b..8cd12f15 100644 --- a/tests/integration/test_clustering_real_embeddings.py +++ b/tests/integration/test_clustering_real_embeddings.py @@ -141,7 +141,7 @@ def test_single_point_kmeans(self): from vector_inspector.core.clustering import run_clustering embeddings = np.array([[1.0, 2.0, 3.0]]) - labels, algo = run_clustering(embeddings, "KMeans", {"n_clusters": 1}) + labels, _algo = run_clustering(embeddings, "KMeans", {"n_clusters": 1}) assert len(labels) == 1 assert labels[0] == 0 diff --git a/tests/metadata/test_data_operations.py b/tests/metadata/test_data_operations.py index 7feefe6d..1200d247 100644 --- a/tests/metadata/test_data_operations.py +++ b/tests/metadata/test_data_operations.py @@ -16,11 +16,11 @@ def __init__(self, data=None, update_success=True): self.last_update_args = {} def get_all_items(self, collection, limit=None, offset=None, where=None): - self.last_get_all_args = dict(collection=collection, limit=limit, offset=offset, where=where) + self.last_get_all_args = {"collection": collection, "limit": limit, "offset": offset, "where": where} return self._data def update_items(self, collection, ids, documents=None, metadatas=None, embeddings=None): - self.last_update_args = dict(ids=ids, documents=documents, metadatas=metadatas, embeddings=embeddings) + self.last_update_args = {"ids": ids, "documents": documents, "metadatas": metadatas, "embeddings": embeddings} return self._update_success diff --git a/tests/metadata/test_telemetry_settings_panel.py b/tests/metadata/test_telemetry_settings_panel.py index 7999ddbb..a53858e1 100644 --- a/tests/metadata/test_telemetry_settings_panel.py +++ b/tests/metadata/test_telemetry_settings_panel.py @@ -63,7 +63,10 @@ def test_settings_panel_hook_no_duplicate_registration(): hook.clear() calls = [] - handler = lambda l, s, d=None: calls.append(1) + + def handler(l, s, d=None): + return calls.append(1) + hook.register(handler) hook.register(handler) # second registration should be ignored hook.trigger(MagicMock(), MagicMock()) @@ -76,7 +79,10 @@ def test_settings_panel_hook_unregister(): hook.clear() calls = [] - handler = lambda l, s, d=None: calls.append(1) + + def handler(l, s, d=None): + return calls.append(1) + hook.register(handler) hook.unregister(handler) hook.trigger(MagicMock(), MagicMock()) @@ -130,7 +136,10 @@ def test_table_context_menu_hook_no_duplicate(): hook.clear() calls = [] - h = lambda m, t, r, d=None: calls.append(1) + + def h(m, t, r, d=None): + return calls.append(1) + hook.register(h) hook.register(h) hook.trigger(MagicMock(), MagicMock(), 0) diff --git a/tests/providers/lancedb/test_flat_schema_metadata.py b/tests/providers/lancedb/test_flat_schema_metadata.py new file mode 100644 index 00000000..6764858a --- /dev/null +++ b/tests/providers/lancedb/test_flat_schema_metadata.py @@ -0,0 +1,124 @@ +"""Test LanceDB flat schema metadata extraction (e.g., Contextus-style databases).""" + +import pytest + +pytest.importorskip("lancedb") + +import uuid +import lancedb + + +def test_flat_schema_metadata_extraction(tmp_path): + """Test that flat schema columns (non-nested) are correctly extracted as metadata.""" + # Create a LanceDB database with flat schema like Contextus uses + db_path = str(tmp_path) + db = lancedb.connect(db_path) + + # Create a table with flat schema columns (like Contextus) + table_name = f"contextus_test_{uuid.uuid4().hex[:8]}" + data = [ + { + "id": "1", + "vector": [0.1, 0.2, 0.3], + "document": "First document", + "project": "test-project", + "filename": "test.md", + "heading": "Introduction", + "type": "decision", + "chunk_index": 0, + }, + { + "id": "2", + "vector": [0.4, 0.5, 0.6], + "document": "Second document", + "project": "test-project", + "filename": "guide.md", + "heading": "Setup", + "type": "reference", + "chunk_index": 1, + }, + ] + + db.create_table(table_name, data=data, mode="overwrite") + + # Test with Vector Inspector connection + from vector_inspector.core.connections.lancedb_connection import LanceDBConnection + + conn = LanceDBConnection(uri=db_path) + assert conn.connect() + + # Test get_collection_info returns flat schema columns as metadata_fields + info = conn.get_collection_info(table_name) + assert info is not None + assert info["count"] == 2 + + # All non-reserved columns except content column should be in metadata_fields + metadata_fields = info["metadata_fields"] + # "document" is the content column and should be excluded from metadata + expected_fields = {"project", "filename", "heading", "type", "chunk_index"} + assert expected_fields.issubset(set(metadata_fields)), ( + f"Missing fields in metadata_fields. Expected {expected_fields}, got {set(metadata_fields)}" + ) + # Verify document is NOT in metadata_fields (it's the content column) + assert "document" not in metadata_fields, "Content column 'document' should not be in metadata_fields" + + # Test get_all_items extracts flat columns as metadata (excluding content column) + items = conn.get_all_items(table_name, limit=10) + assert items is not None + assert len(items["ids"]) == 2 + assert len(items["metadatas"]) == 2 + + # Check first item's metadata contains the flat schema columns (but not document) + first_meta = items["metadatas"][0] + assert "project" in first_meta + assert "filename" in first_meta + assert "heading" in first_meta + assert "type" in first_meta + assert "chunk_index" in first_meta + # Verify document is NOT duplicated in metadata + assert "document" not in first_meta, "Content column should not appear in metadata" + + assert first_meta["project"] == "test-project" + assert first_meta["filename"] == "test.md" + assert first_meta["heading"] == "Introduction" + assert first_meta["type"] == "decision" + assert first_meta["chunk_index"] == 0 + + # Verify documents are properly extracted to the documents field + assert items["documents"][0] == "First document" + assert items["documents"][1] == "Second document" + + +def test_nested_metadata_column_still_works(tmp_path): + """Test that legacy nested metadata column format still works.""" + from vector_inspector.core.connections.lancedb_connection import LanceDBConnection + + collection_name = f"test_nested_{uuid.uuid4().hex[:8]}" + db_path = str(tmp_path) + + conn = LanceDBConnection(uri=db_path) + assert conn.connect() + + # Create collection with nested metadata (old format) + assert conn.create_collection(collection_name, vector_size=2) + + # Add items with metadata in the traditional nested format + test_docs = ["doc1", "doc2"] + test_metadata = [{"key1": "value1"}, {"key2": "value2"}] + test_ids = ["id1", "id2"] + test_vectors = [[0.1, 0.2], [0.3, 0.4]] + + assert conn.add_items( + collection_name, + documents=test_docs, + metadatas=test_metadata, + ids=test_ids, + embeddings=test_vectors, + ) + + # Verify nested metadata still works + items = conn.get_all_items(collection_name, limit=10) + assert items is not None + assert len(items["metadatas"]) == 2 + assert items["metadatas"][0].get("key1") == "value1" + assert items["metadatas"][1].get("key2") == "value2" diff --git a/tests/providers/pgvector/test_pgvector_connection.py b/tests/providers/pgvector/test_pgvector_connection.py index 373d057f..eb808918 100644 --- a/tests/providers/pgvector/test_pgvector_connection.py +++ b/tests/providers/pgvector/test_pgvector_connection.py @@ -53,7 +53,7 @@ def test_pgvector_add_items(mock_pgvector_conn): def test_pgvector_add_items_missing_embeddings_auto_embed_fails(mock_pgvector_conn): - _, mock_cursor = mock_pgvector_conn + _, _mock_cursor = mock_pgvector_conn conn = PgVectorConnection() conn.connect() # Patch compute_embeddings_for_documents to raise Exception @@ -94,7 +94,7 @@ def test_pgvector_delete_collection(mock_pgvector_conn): def test_list_collections(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn # Simulate two tables returned mock_cursor.fetchall.return_value = [("table1",), ("table2",)] conn = PgVectorConnection() @@ -104,7 +104,7 @@ def test_list_collections(mock_pgvector_conn): def test_list_databases_uses_client_or_tmp(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn # Simulate databases mock_cursor.fetchall.return_value = [("postgres",), ("mydb",)] conn = PgVectorConnection() @@ -114,7 +114,7 @@ def test_list_databases_uses_client_or_tmp(mock_pgvector_conn): def test_get_items_parses_metadata_and_embedding(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn # Prepare a single row with metadata JSON and string vector row = ("id1", "doc1", json.dumps({"a": 1}), "[0.1,0.2]") mock_cursor.fetchall.return_value = [row] @@ -138,7 +138,7 @@ def test_get_items_parses_metadata_and_embedding(mock_pgvector_conn): def test_count_collection_returns_count(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn mock_cursor.fetchone.return_value = (42,) conn = PgVectorConnection() conn.connect() @@ -146,7 +146,7 @@ def test_count_collection_returns_count(mock_pgvector_conn): def test_query_collection_returns_per_query_lists(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn # Simulate one query embedding; rows returned per query # Provide metadata as JSON string and embedding string row = ("id1", "doc1", json.dumps({"k": "v"}), "[0.5,0.6]", 0.123) @@ -178,7 +178,7 @@ def test_query_collection_returns_per_query_lists(mock_pgvector_conn): def test_get_all_items_with_limit_offset_and_where(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn row1 = ("id1", "doc1", json.dumps({"t": "x"}), "[1,2]") row2 = ("id2", "doc2", json.dumps({"t": "y"}), "[3,4]") mock_cursor.fetchall.return_value = [row1, row2] @@ -203,7 +203,7 @@ def test_get_all_items_with_limit_offset_and_where(mock_pgvector_conn): def test_update_items_generates_embeddings_when_needed(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, _mock_cursor = mock_pgvector_conn conn = PgVectorConnection() conn.connect() @@ -227,7 +227,7 @@ def test_update_items_generates_embeddings_when_needed(mock_pgvector_conn): def test_delete_items_commits_and_returns_true(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn conn = PgVectorConnection() conn.connect() assert conn.delete_items("coll", ids=["x"]) is True @@ -235,7 +235,7 @@ def test_delete_items_commits_and_returns_true(mock_pgvector_conn): def test_pgvector_delete_where_uses_metadata_or_columns(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn conn = PgVectorConnection() conn.connect() @@ -254,7 +254,7 @@ def test_pgvector_delete_where_uses_metadata_or_columns(mock_pgvector_conn): def test_get_connection_info_reflects_connection_state(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, _mock_cursor = mock_pgvector_conn conn = PgVectorConnection(host="h", port=1, database="d", user="u") assert conn.get_connection_info()["connected"] is False conn.connect() @@ -287,7 +287,7 @@ def test_parse_vector_with_numpy_array(): def test_create_collection_commits_and_handles_distance_ops(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + mock_conn, _mock_cursor = mock_pgvector_conn conn = PgVectorConnection() conn.connect() # exercise with different distance keywords @@ -299,7 +299,7 @@ def test_create_collection_commits_and_handles_distance_ops(mock_pgvector_conn): def test_add_items_maps_metadata_columns_when_no_metadata_col(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn conn = PgVectorConnection() conn.connect() @@ -317,7 +317,7 @@ def test_add_items_maps_metadata_columns_when_no_metadata_col(mock_pgvector_conn # execute was called multiple times; inspect the last call args *_, last_call = mock_cursor.execute.call_args_list # call_args = (sql_obj, values) - call_args, call_kwargs = last_call + call_args, _call_kwargs = last_call # ensure the values list contains the id and embedding we provided assert ids[0] in call_args[1] assert embeddings[0] in call_args[1] @@ -334,7 +334,7 @@ def test_list_databases_handles_tmp_connect_failure(): def test__get_table_schema_returns_empty_on_error(mock_pgvector_conn): - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn # make fetchall raise mock_cursor.fetchall.side_effect = Exception("boom") conn = PgVectorConnection() @@ -344,7 +344,7 @@ def test__get_table_schema_returns_empty_on_error(mock_pgvector_conn): def test_pgvector_add_items_handles_exception(mock_pgvector_conn): """If DB cursor execute/insert raises, add_items should return False.""" - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn conn = PgVectorConnection() conn.connect() @@ -356,7 +356,7 @@ def test_pgvector_add_items_handles_exception(mock_pgvector_conn): def test_pgvector_get_all_items_handles_exception(mock_pgvector_conn): """If fetching rows raises, get_all_items should return None.""" - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn conn = PgVectorConnection() conn.connect() @@ -368,7 +368,7 @@ def test_pgvector_get_all_items_handles_exception(mock_pgvector_conn): def test_pgvector_delete_items_handles_exception(mock_pgvector_conn): """If delete execution raises, delete_items should return False.""" - mock_conn, mock_cursor = mock_pgvector_conn + _mock_conn, mock_cursor = mock_pgvector_conn conn = PgVectorConnection() conn.connect() diff --git a/tests/providers/pinecone/test_pinecone_connection.py b/tests/providers/pinecone/test_pinecone_connection.py index 07d42257..8489c8df 100644 --- a/tests/providers/pinecone/test_pinecone_connection.py +++ b/tests/providers/pinecone/test_pinecone_connection.py @@ -597,8 +597,7 @@ def test_pinecone_integration(): assert conn.connect() # List indexes - indexes = conn.list_collections() - print(f"Available indexes: {indexes}") + conn.list_collections() conn.disconnect() assert not conn.is_connected diff --git a/tests/providers/weaviate/test_weaviate_connection.py b/tests/providers/weaviate/test_weaviate_connection.py index bea4f1dc..0e595d8c 100644 --- a/tests/providers/weaviate/test_weaviate_connection.py +++ b/tests/providers/weaviate/test_weaviate_connection.py @@ -76,7 +76,7 @@ def test_weaviate_connection_with_api_key(): def test_weaviate_connect_success(mock_weaviate_client): """Test successful connection to Weaviate.""" - _mock_weaviate, mock_client = mock_weaviate_client + _mock_weaviate, _mock_client = mock_weaviate_client conn = WeaviateConnection(host="localhost", port=8080) result = conn.connect() @@ -380,7 +380,7 @@ def test_weaviate_delete_items_by_id(mock_weaviate_client): def test_weaviate_add_items_handles_exception(mock_weaviate_client): """If batch add raises, add_items should return False.""" - mock_weaviate, mock_client = mock_weaviate_client + _mock_weaviate, mock_client = mock_weaviate_client mock_collection = MagicMock() mock_batch = MagicMock() @@ -398,7 +398,7 @@ def test_weaviate_add_items_handles_exception(mock_weaviate_client): def test_weaviate_query_handles_exception(mock_weaviate_client): - mock_weaviate, mock_client = mock_weaviate_client + _mock_weaviate, mock_client = mock_weaviate_client mock_collection = MagicMock() mock_collection.query.near_vector.side_effect = Exception("query fail") mock_client.collections.get.return_value = mock_collection @@ -410,7 +410,7 @@ def test_weaviate_query_handles_exception(mock_weaviate_client): def test_weaviate_delete_handles_exception(mock_weaviate_client): - mock_weaviate, mock_client = mock_weaviate_client + _mock_weaviate, mock_client = mock_weaviate_client mock_collection = MagicMock() mock_collection.data.delete_by_id.side_effect = Exception("delete fail") mock_client.collections.get.return_value = mock_collection @@ -423,7 +423,7 @@ def test_weaviate_delete_handles_exception(mock_weaviate_client): def test_weaviate_get_all_items_handles_exception(mock_weaviate_client): - mock_weaviate, mock_client = mock_weaviate_client + _mock_weaviate, mock_client = mock_weaviate_client mock_collection = MagicMock() mock_collection.query.fetch_objects.side_effect = Exception("fetch fail") mock_client.collections.get.return_value = mock_collection @@ -485,7 +485,7 @@ def test_weaviate_embedded_mode_init(): def test_weaviate_embedded_mode_connection(mock_weaviate_client): """Test connecting to Weaviate in embedded mode.""" - mock_weaviate, mock_client = mock_weaviate_client + mock_weaviate, _mock_client = mock_weaviate_client # Mock embedded options mock_embedded_options = MagicMock() @@ -553,7 +553,7 @@ def test_weaviate_embedded_collections(mock_weaviate_client): def test_weaviate_delete_where_and_ids_precedence(mock_weaviate_client): """Ensure Weaviate delete uses delete_many for filters and delete_by_id for ids.""" - mock_weaviate, mock_client = mock_weaviate_client + _mock_weaviate, mock_client = mock_weaviate_client # Prepare mock collection with data API mock_collection = MagicMock() diff --git a/tests/scripts/conftest.py b/tests/scripts/conftest.py index 1354050c..875b36d9 100644 --- a/tests/scripts/conftest.py +++ b/tests/scripts/conftest.py @@ -1,4 +1,5 @@ """Make scripts/ and project root importable from tests/scripts/.""" + import sys from pathlib import Path diff --git a/tests/scripts/test_bootstrap_installer.py b/tests/scripts/test_bootstrap_installer.py index 47b8fa88..008639ae 100644 --- a/tests/scripts/test_bootstrap_installer.py +++ b/tests/scripts/test_bootstrap_installer.py @@ -10,7 +10,6 @@ import json import subprocess import sys -from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -18,10 +17,12 @@ # Import from vi_installer package modules from vi_installer import platform, python, venv, shortcuts, state, ui + # For compatibility with existing tests, create a simple namespace object class MockBI: pass + bi = MockBI() # Populate with module references @@ -317,9 +318,7 @@ def test_check_existing_install_path_does_not_exist(tmp_path): def test_check_existing_install_replace(tmp_path): install_root = tmp_path / "vi" install_root.mkdir() - (install_root / "bootstrap-state.json").write_text( - json.dumps({"app": "Vector Inspector"}), encoding="utf-8" - ) + (install_root / "bootstrap-state.json").write_text(json.dumps({"app": "Vector Inspector"}), encoding="utf-8") config_dir = tmp_path / ".vector-inspector" config_dir.mkdir() (config_dir / "install_path").write_text(str(install_root), encoding="utf-8") @@ -332,7 +331,7 @@ def test_check_existing_install_replace(tmp_path): patch("sys.stdin") as mock_stdin, ): mock_stdin.isatty.return_value = True - path, mode = bi.check_existing_install() + _path, mode = bi.check_existing_install() assert mode == "replace" assert not install_root.exists() @@ -424,9 +423,7 @@ def test_check_existing_install_strips_whitespace_in_path(tmp_path): def test_run_uninstall_removes_install_root(tmp_path): install_root = tmp_path / "vi" install_root.mkdir() - (install_root / "bootstrap-state.json").write_text( - json.dumps({"app": "Vector Inspector"}), encoding="utf-8" - ) + (install_root / "bootstrap-state.json").write_text(json.dumps({"app": "Vector Inspector"}), encoding="utf-8") config_dir = tmp_path / ".vector-inspector" config_dir.mkdir() install_path_file = config_dir / "install_path" @@ -464,9 +461,7 @@ def test_run_uninstall_cancelled(tmp_path): def test_run_uninstall_removes_desktop_shortcut(tmp_path): install_root = tmp_path / "vi" install_root.mkdir() - (install_root / "bootstrap-state.json").write_text( - json.dumps({"app": "Vector Inspector"}), encoding="utf-8" - ) + (install_root / "bootstrap-state.json").write_text(json.dumps({"app": "Vector Inspector"}), encoding="utf-8") shortcut = tmp_path / "Vector Inspector.command" shortcut.write_text("#!/bin/sh\n", encoding="utf-8") config_dir = tmp_path / ".vector-inspector" diff --git a/tests/services/test_backup_helpers.py b/tests/services/test_backup_helpers.py index 63772665..772ae050 100644 --- a/tests/services/test_backup_helpers.py +++ b/tests/services/test_backup_helpers.py @@ -1,6 +1,3 @@ -import json -from pathlib import Path - from vector_inspector.services.backup_helpers import write_backup_zip, read_backup_zip, normalize_embeddings diff --git a/tests/services/test_collection_service_edgecases.py b/tests/services/test_collection_service_edgecases.py index a1d99a13..98020fe0 100644 --- a/tests/services/test_collection_service_edgecases.py +++ b/tests/services/test_collection_service_edgecases.py @@ -68,7 +68,7 @@ def add_items(self, collection_name, documents, metadatas, ids, embeddings): return True conn = WeaviateConn() - success, msg = svc.populate_with_sample_data(conn, "colW", 2, SampleDataType.TEXT, "m") + success, _msg = svc.populate_with_sample_data(conn, "colW", 2, SampleDataType.TEXT, "m") assert success is True assert "ids" in captured # ensure ids are UUIDs @@ -93,7 +93,7 @@ def add_items(self, collection_name, documents, metadatas, ids, embeddings): return True conn = Conn() - success, msg = svc.populate_with_sample_data(conn, "colN", 3, SampleDataType.TEXT, "m") + success, _msg = svc.populate_with_sample_data(conn, "colN", 3, SampleDataType.TEXT, "m") assert success is True assert captured["embeddings_type"] is list assert captured["inner_len"] == 5 @@ -140,7 +140,7 @@ def save_embedding_model(self, *args, **kwargs): monkeypatch.setattr(settings_mod, "SettingsService", BadSettings) conn = FakeConnection(add_success=True) - success, msg = svc.populate_with_sample_data(conn, "colS", 2, SampleDataType.TEXT, "m") + success, _msg = svc.populate_with_sample_data(conn, "colS", 2, SampleDataType.TEXT, "m") assert success is True @@ -168,7 +168,7 @@ def on_completed(name, success, message): svc.operation_completed.connect(on_completed) conn = FakeConnection(add_success=True) - success, msg = svc.populate_with_sample_data(conn, "colSig", 2, SampleDataType.TEXT, "m") + success, _msg = svc.populate_with_sample_data(conn, "colSig", 2, SampleDataType.TEXT, "m") assert success is True # basic checks for recorded sequences assert any(s[0] == "started" for s in seq) @@ -210,7 +210,7 @@ def save_embedding_model(self, **kwargs): # connection WITH a profile_name conn = FakeConnection(add_success=True, profile_name="my-profile") - success, msg = svc.populate_with_sample_data(conn, "colP", 2, SampleDataType.TEXT, "my-model") + success, _msg = svc.populate_with_sample_data(conn, "colP", 2, SampleDataType.TEXT, "my-model") assert success is True # The settings save should have been called since profile_name was set assert len(saved) == 1 diff --git a/tests/services/test_credential_service.py b/tests/services/test_credential_service.py index 75b4d5ce..2445c944 100644 --- a/tests/services/test_credential_service.py +++ b/tests/services/test_credential_service.py @@ -87,7 +87,7 @@ def test_get_credentials_exception_returns_none(): def test_delete_credentials_with_keyring(): """delete_credentials calls keyring.delete_password.""" - svc, fake_keyring, FakePDE = _make_service_with_keyring() + svc, fake_keyring, _FakePDE = _make_service_with_keyring() result = svc.delete_credentials("profile-1") diff --git a/tests/services/test_filter_service.py b/tests/services/test_filter_service.py index 768efaf4..e7cbac70 100644 --- a/tests/services/test_filter_service.py +++ b/tests/services/test_filter_service.py @@ -1,6 +1,6 @@ -import pytest from vector_inspector.services.filter_service import apply_client_side_filters + def sample_data(): return { "ids": [1, 2, 3], @@ -13,6 +13,7 @@ def sample_data(): "embeddings": [[0.1], [0.2], [0.3]], } + def test_no_filters_returns_all(): data = sample_data() result = apply_client_side_filters(data, []) @@ -27,6 +28,7 @@ def test_no_filters_returns_all(): "embeddings": [[0.1], [0.2], [0.3]], } + def test_contains_document(): data = sample_data() filters = [{"field": "document", "op": "contains", "value": "fox"}] @@ -36,6 +38,7 @@ def test_contains_document(): assert result["metadatas"] == [{"category": "animal", "author": "A"}] assert result["embeddings"] == [[0.1]] + def test_not_contains_metadata(): data = sample_data() filters = [{"field": "category", "op": "not_contains", "value": "animal"}] @@ -45,6 +48,7 @@ def test_not_contains_metadata(): assert result["metadatas"] == [{"category": "greeting", "author": "C"}] assert result["embeddings"] == [[0.3]] + def test_multiple_filters(): data = sample_data() filters = [ @@ -57,10 +61,12 @@ def test_multiple_filters(): assert result["metadatas"] == [{"category": "animal", "author": "B"}] assert result["embeddings"] == [[0.2]] + def test_empty_data(): result = apply_client_side_filters({}, []) assert result == {} + def test_missing_fields(): data = {"ids": [1], "documents": ["foo"]} filters = [{"field": "category", "op": "contains", "value": "animal"}] @@ -69,18 +75,21 @@ def test_missing_fields(): assert result["documents"] == [] assert result["metadatas"] == [] + def test_case_sensitivity(): data = {"ids": [1], "documents": ["Hello World"], "metadatas": [{"author": "Alice"}]} filters = [{"field": "document", "op": "contains", "value": "hello world"}] result = apply_client_side_filters(data, filters) assert result["ids"] == [1] + def test_non_string_metadata_value(): data = {"ids": [1], "documents": ["foo"], "metadatas": [{"num": 123}]} filters = [{"field": "num", "op": "contains", "value": "123"}] result = apply_client_side_filters(data, filters) assert result["ids"] == [1] + def test_unknown_operator(): data = {"ids": [1], "documents": ["foo"], "metadatas": [{"author": "Bob"}]} filters = [{"field": "author", "op": "unknown", "value": "Bob"}] @@ -88,6 +97,7 @@ def test_unknown_operator(): # Unknown op: should not filter out assert result["ids"] == [1] + def test_large_input(): data = { "ids": list(range(1000)), diff --git a/tests/services/test_install_service.py b/tests/services/test_install_service.py index 900a8b11..64e42702 100644 --- a/tests/services/test_install_service.py +++ b/tests/services/test_install_service.py @@ -142,7 +142,7 @@ def test_install_feature_success(): fake_proc = _make_fake_process(0, ["Successfully installed scikit-learn\n"]) with patch("subprocess.Popen", return_value=fake_proc) as mock_popen: with patch(_TELEMETRY_PATH): - returncode, output = install("viz") + returncode, _output = install("viz") assert returncode == 0 cmd_used = mock_popen.call_args[0][0] assert "vector-inspector[viz]" in cmd_used[-1] @@ -207,7 +207,7 @@ def test_uninstall_feature_success(): fake_proc = _make_fake_process(0, ["Successfully uninstalled scikit-learn\n"]) with patch("subprocess.Popen", return_value=fake_proc): with patch(_TELEMETRY_PATH): - returncode, output = uninstall("viz") + returncode, _output = uninstall("viz") assert returncode == 0 diff --git a/tests/services/test_profile_service.py b/tests/services/test_profile_service.py index 9fadc2e6..3d869dfc 100644 --- a/tests/services/test_profile_service.py +++ b/tests/services/test_profile_service.py @@ -277,7 +277,7 @@ def test_export_profiles_no_credentials(svc): def test_export_profiles_with_credentials(svc): - pid = svc.create_profile("DB", "chromadb", {}) + svc.create_profile("DB", "chromadb", {}) with patch.object(svc.credential_service, "get_credentials", return_value={"key": "v"}): exported = svc.export_profiles(include_credentials=True) assert exported[0]["credentials"] == {"key": "v"} diff --git a/tests/services/test_telemetry_service.py b/tests/services/test_telemetry_service.py index cb0bca06..8827a085 100644 --- a/tests/services/test_telemetry_service.py +++ b/tests/services/test_telemetry_service.py @@ -883,4 +883,3 @@ def test_session_start_deduplication_keeps_other_events(): assert event_names.count("session.start") == 1 assert "app_launch" in event_names TelemetryService.reset_for_tests() - diff --git a/tests/services/test_visualization_service.py b/tests/services/test_visualization_service.py index a5f5a1b6..e92a1209 100644 --- a/tests/services/test_visualization_service.py +++ b/tests/services/test_visualization_service.py @@ -118,7 +118,7 @@ def test_prepare_plot_data_basic(): # Simple reduced embeddings reduced = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) - embeddings, labels, colors = svc.prepare_plot_data(reduced) + _embeddings, labels, colors = svc.prepare_plot_data(reduced) assert len(labels) == 3 assert labels[0] == "Point 0" diff --git a/tests/test_content_column_detection.py b/tests/test_content_column_detection.py index 991f67f0..04aa5bc1 100644 --- a/tests/test_content_column_detection.py +++ b/tests/test_content_column_detection.py @@ -1,6 +1,5 @@ """Test content column detection across different schemas.""" -import pytest from vector_inspector.core.connections.pgvector_connection import PgVectorConnection @@ -135,7 +134,7 @@ def test_multiple_text_columns_priority(): "body": "text", "document": "text", "notes": "text", - "embedding": "vector" + "embedding": "vector", } result = conn._detect_content_column("test_col", schema) assert result == "document" diff --git a/tests/test_content_column_persistence.py b/tests/test_content_column_persistence.py index ab2069f4..2fee23d1 100644 --- a/tests/test_content_column_persistence.py +++ b/tests/test_content_column_persistence.py @@ -1,4 +1,5 @@ """Tests for content column persistence across sessions.""" + from vector_inspector.core.connections.lancedb_connection import LanceDBConnection diff --git a/tests/test_lancedb_metadata_padding.py b/tests/test_lancedb_metadata_padding.py index 081fc39d..775f2864 100644 --- a/tests/test_lancedb_metadata_padding.py +++ b/tests/test_lancedb_metadata_padding.py @@ -19,12 +19,14 @@ def test_query_collection_metadata_padding(lancedb_conn): """Test that query_collection pads metadatas to match result count.""" # Mock table with results that have no metadata column mock_table = Mock() - mock_results_df = pd.DataFrame({ - "id": ["id1", "id2", "id3"], - "document": ["doc1", "doc2", "doc3"], - "_distance": [0.1, 0.2, 0.3], - "vector": [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]] - }) + mock_results_df = pd.DataFrame( + { + "id": ["id1", "id2", "id3"], + "document": ["doc1", "doc2", "doc3"], + "_distance": [0.1, 0.2, 0.3], + "vector": [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]], + } + ) mock_search = Mock() mock_search.limit.return_value.to_pandas.return_value = mock_results_df @@ -32,11 +34,7 @@ def test_query_collection_metadata_padding(lancedb_conn): lancedb_conn._db.open_table.return_value = mock_table # Query with embeddings - result = lancedb_conn.query_collection( - "test_collection", - query_embeddings=[[0.1, 0.2]], - n_results=3 - ) + result = lancedb_conn.query_collection("test_collection", query_embeddings=[[0.1, 0.2]], n_results=3) assert result is not None assert len(result["ids"]) == 3 @@ -50,11 +48,9 @@ def test_get_all_items_metadata_padding(lancedb_conn): """Test that get_all_items pads metadatas to match result count.""" # Mock table with no metadata column mock_table = Mock() - mock_df = pd.DataFrame({ - "id": ["id1", "id2"], - "document": ["content1", "content2"], - "vector": [[1.0, 2.0], [3.0, 4.0]] - }) + mock_df = pd.DataFrame( + {"id": ["id1", "id2"], "document": ["content1", "content2"], "vector": [[1.0, 2.0], [3.0, 4.0]]} + ) mock_table.to_pandas.return_value = mock_df lancedb_conn._db.open_table.return_value = mock_table @@ -64,7 +60,9 @@ def test_get_all_items_metadata_padding(lancedb_conn): assert len(result["ids"]) == 2 assert len(result["documents"]) == 2 assert len(result["metadatas"]) == 2 # Should be padded! - assert all(m == {} for m in result["metadatas"]) + # With flat schema support, but "document" is excluded as it's the content column + assert result["metadatas"][0] == {} + assert result["metadatas"][1] == {} def test_query_collection_with_sparse_metadata(lancedb_conn): @@ -72,24 +70,22 @@ def test_query_collection_with_sparse_metadata(lancedb_conn): mock_table = Mock() # Simulate sparse metadata - some rows have no metadata - mock_results_df = pd.DataFrame({ - "id": ["id1", "id2", "id3"], - "document": ["doc1", "doc2", "doc3"], - "metadata": ['{"key": "value1"}', None, None], # Only first has metadata - "_distance": [0.1, 0.2, 0.3], - "vector": [[0.1], [0.2], [0.3]] - }) + mock_results_df = pd.DataFrame( + { + "id": ["id1", "id2", "id3"], + "document": ["doc1", "doc2", "doc3"], + "metadata": ['{"key": "value1"}', None, None], # Only first has metadata + "_distance": [0.1, 0.2, 0.3], + "vector": [[0.1], [0.2], [0.3]], + } + ) mock_search = Mock() mock_search.limit.return_value.to_pandas.return_value = mock_results_df mock_table.search.return_value = mock_search lancedb_conn._db.open_table.return_value = mock_table - result = lancedb_conn.query_collection( - "test_collection", - query_embeddings=[[0.1]], - n_results=3 - ) + result = lancedb_conn.query_collection("test_collection", query_embeddings=[[0.1]], n_results=3) assert len(result["ids"]) == 3 assert len(result["metadatas"]) == 3 @@ -102,11 +98,7 @@ def test_query_collection_with_sparse_metadata(lancedb_conn): def test_get_all_items_empty_collection(lancedb_conn): """Test get_all_items with empty collection.""" mock_table = Mock() - mock_df = pd.DataFrame({ - "id": [], - "document": [], - "vector": [] - }) + mock_df = pd.DataFrame({"id": [], "document": [], "vector": []}) mock_table.to_pandas.return_value = mock_df lancedb_conn._db.open_table.return_value = mock_table @@ -123,24 +115,22 @@ def test_query_collection_metadata_matches_documents(lancedb_conn): mock_table = Mock() # Create results with 5 items but metadata column missing - mock_results_df = pd.DataFrame({ - "id": [f"id{i}" for i in range(5)], - "document": [f"doc{i}" for i in range(5)], - "_distance": [0.1 * i for i in range(5)], - "vector": [[float(i)] for i in range(5)] - # No metadata column at all - }) + mock_results_df = pd.DataFrame( + { + "id": [f"id{i}" for i in range(5)], + "document": [f"doc{i}" for i in range(5)], + "_distance": [0.1 * i for i in range(5)], + "vector": [[float(i)] for i in range(5)], + # No metadata column at all + } + ) mock_search = Mock() mock_search.limit.return_value.to_pandas.return_value = mock_results_df mock_table.search.return_value = mock_search lancedb_conn._db.open_table.return_value = mock_table - result = lancedb_conn.query_collection( - "test_collection", - query_embeddings=[[0.5]], - n_results=5 - ) + result = lancedb_conn.query_collection("test_collection", query_embeddings=[[0.5]], n_results=5) # All arrays must have same length assert len(result["ids"]) == len(result["documents"]) diff --git a/tests/test_ollama_embedding.py b/tests/test_ollama_embedding.py index 11c1e925..dacbbabf 100644 --- a/tests/test_ollama_embedding.py +++ b/tests/test_ollama_embedding.py @@ -15,12 +15,10 @@ def test_load_embedding_model_ollama(): def test_encode_text_ollama_success(): """Test encoding text with Ollama.""" - with patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.urlopen") as mock_urlopen: # Mock HTTP response mock_response = Mock() - mock_response.read.return_value = json.dumps({ - "embeddings": [[0.1, 0.2, 0.3, 0.4]] - }).encode('utf-8') + mock_response.read.return_value = json.dumps({"embeddings": [[0.1, 0.2, 0.3, 0.4]]}).encode("utf-8") mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) mock_urlopen.return_value = mock_response @@ -35,11 +33,9 @@ def test_encode_text_ollama_success(): def test_encode_text_ollama_empty_embeddings(): """Test encoding with empty embeddings response raises error.""" - with patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.urlopen") as mock_urlopen: mock_response = Mock() - mock_response.read.return_value = json.dumps({ - "embeddings": [] - }).encode('utf-8') + mock_response.read.return_value = json.dumps({"embeddings": []}).encode("utf-8") mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) mock_urlopen.return_value = mock_response @@ -50,7 +46,7 @@ def test_encode_text_ollama_empty_embeddings(): def test_encode_text_ollama_connection_error(): """Test encoding handles connection errors.""" - with patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.urlopen") as mock_urlopen: mock_urlopen.side_effect = Exception("Connection refused") with pytest.raises(RuntimeError, match="Failed to get embedding from Ollama"): @@ -59,10 +55,9 @@ def test_encode_text_ollama_connection_error(): def test_encode_text_ollama_request_format(): """Test that Ollama request is formatted correctly.""" - with patch('urllib.request.Request') as mock_request, \ - patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.Request") as mock_request, patch("urllib.request.urlopen") as mock_urlopen: mock_response = Mock() - mock_response.read.return_value = json.dumps({"embeddings": [[0.5]]}).encode('utf-8') + mock_response.read.return_value = json.dumps({"embeddings": [[0.5]]}).encode("utf-8") mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) mock_urlopen.return_value = mock_response @@ -122,7 +117,7 @@ def test_encode_text_with_string_sentence_transformer(): def test_encode_text_ollama_malformed_json(): """Test handling of malformed JSON response from Ollama.""" - with patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.urlopen") as mock_urlopen: mock_response = Mock() mock_response.read.return_value = b"not valid json" mock_response.__enter__ = Mock(return_value=mock_response) @@ -135,9 +130,9 @@ def test_encode_text_ollama_malformed_json(): def test_encode_text_ollama_missing_embeddings_key(): """Test handling of response without 'embeddings' key raises error.""" - with patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.urlopen") as mock_urlopen: mock_response = Mock() - mock_response.read.return_value = json.dumps({"error": "model not found"}).encode('utf-8') + mock_response.read.return_value = json.dumps({"error": "model not found"}).encode("utf-8") mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) mock_urlopen.return_value = mock_response @@ -149,14 +144,11 @@ def test_encode_text_ollama_missing_embeddings_key(): def test_encode_text_ollama_http_error(): """Test handling of HTTP error responses.""" - with patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.urlopen") as mock_urlopen: from urllib.error import HTTPError + mock_urlopen.side_effect = HTTPError( - url="http://localhost:11434/api/embed", - code=404, - msg="Not Found", - hdrs={}, - fp=None + url="http://localhost:11434/api/embed", code=404, msg="Not Found", hdrs={}, fp=None ) with pytest.raises(RuntimeError, match="Failed to get embedding from Ollama"): @@ -165,8 +157,9 @@ def test_encode_text_ollama_http_error(): def test_encode_text_ollama_url_error(): """Test handling of URL/connection errors.""" - with patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.urlopen") as mock_urlopen: from urllib.error import URLError + mock_urlopen.side_effect = URLError("Connection refused") with pytest.raises(RuntimeError, match="Failed to get embedding from Ollama"): @@ -175,11 +168,9 @@ def test_encode_text_ollama_url_error(): def test_encode_text_ollama_multiple_embeddings(): """Test that only first embedding is returned when multiple are present.""" - with patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.urlopen") as mock_urlopen: mock_response = Mock() - mock_response.read.return_value = json.dumps({ - "embeddings": [[0.1, 0.2], [0.3, 0.4]] - }).encode('utf-8') + mock_response.read.return_value = json.dumps({"embeddings": [[0.1, 0.2], [0.3, 0.4]]}).encode("utf-8") mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) mock_urlopen.return_value = mock_response @@ -190,11 +181,9 @@ def test_encode_text_ollama_multiple_embeddings(): def test_encode_text_empty_string(): """Test encoding empty string with Ollama.""" - with patch('urllib.request.urlopen') as mock_urlopen: + with patch("urllib.request.urlopen") as mock_urlopen: mock_response = Mock() - mock_response.read.return_value = json.dumps({ - "embeddings": [[0.0, 0.0, 0.0]] - }).encode('utf-8') + mock_response.read.return_value = json.dumps({"embeddings": [[0.0, 0.0, 0.0]]}).encode("utf-8") mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) mock_urlopen.return_value = mock_response diff --git a/tests/test_pgvector_content_column.py b/tests/test_pgvector_content_column.py index dbd9f0fc..f89aa740 100644 --- a/tests/test_pgvector_content_column.py +++ b/tests/test_pgvector_content_column.py @@ -25,11 +25,7 @@ def test_get_table_schema_basic(): schema = conn._get_table_schema("test_table") # Uses data_type for standard types, udt_name for USER-DEFINED - assert schema == { - "id": "integer", - "content": "text", - "embedding": "vector" - } + assert schema == {"id": "integer", "content": "text", "embedding": "vector"} def test_get_table_schema_varchar_variations(): @@ -115,12 +111,7 @@ def test_content_column_with_vector_dimensions(): """Test that vector columns with dimensions are handled.""" conn = PgVectorConnection() - schema = { - "id": "integer", - "content": "text", - "embedding_768": "vector(768)", - "embedding_384": "vector(384)" - } + schema = {"id": "integer", "content": "text", "embedding_768": "vector(768)", "embedding_384": "vector(384)"} result = conn._detect_content_column("test", schema) assert result == "content" @@ -177,11 +168,7 @@ def test_detect_with_mixed_case_columns(): """Test detection with mixed-case column names.""" conn = PgVectorConnection() - schema = { - "Id": "integer", - "Content": "text", - "Embedding": "vector" - } + schema = {"Id": "integer", "Content": "text", "Embedding": "vector"} # Case-sensitive match should not find 'content' but should find 'Content' result = conn._detect_content_column("test", schema) diff --git a/tests/test_runner.py b/tests/test_runner.py index e1324c69..ec7e566b 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -16,7 +16,7 @@ def run_tests(): if len(args) == 0: args = ["tests"] - returncode = pytest.main( + pytest.main( [ # Turn up verbosity "-vv", @@ -29,8 +29,6 @@ def run_tests(): + args ) - print(f">>>>>>>>>> EXIT {returncode} <<<<<<<<<<") - if __name__ == "__main__": run_tests() diff --git a/tests/ui/test_llm_console.py b/tests/ui/test_llm_console.py index 7a2478e4..7702373e 100644 --- a/tests/ui/test_llm_console.py +++ b/tests/ui/test_llm_console.py @@ -78,7 +78,7 @@ def test_run_emits_single_chunk_and_done(self, qtbot): provider = FakeLLMProvider(mode="echo") # echo mode; streaming=False # Patch capabilities to report non-streaming - caps = provider.get_capabilities() + provider.get_capabilities() caps_no_stream = MagicMock() caps_no_stream.supports_streaming = False with patch.object(provider, "get_capabilities", return_value=caps_no_stream): diff --git a/tests/utils/fake_llm_provider.py b/tests/utils/fake_llm_provider.py index 132944d5..57696f22 100644 --- a/tests/utils/fake_llm_provider.py +++ b/tests/utils/fake_llm_provider.py @@ -136,8 +136,7 @@ def generate_messages( self._maybe_inject_error(model, request_id) if stream or self._mode == _MODE_STREAMING: return self.stream_messages(messages, model, **{**kwargs, "request_id": request_id}) - response = self._build_response(messages) - return response + return self._build_response(messages) def stream_messages( self, diff --git a/tests/utils/test_exception_handler.py b/tests/utils/test_exception_handler.py index c5327663..a710ca8f 100644 --- a/tests/utils/test_exception_handler.py +++ b/tests/utils/test_exception_handler.py @@ -60,7 +60,7 @@ def will_raise(x): will_raise(1) assert "sent" in recorded - msg, event, extra = recorded["sent"] + _msg, event, extra = recorded["sent"] assert event == "TestEvent" assert extra.get("function") == "will_raise" diff --git a/tests/views/test_clustering_panel.py b/tests/views/test_clustering_panel.py index 85e6df0b..8e760cce 100644 --- a/tests/views/test_clustering_panel.py +++ b/tests/views/test_clustering_panel.py @@ -138,7 +138,6 @@ def test_get_clustering_params_hdbscan_basic(panel): def test_get_clustering_params_hdbscan_advanced(qtbot, app_state_with_fake_provider): """HDBSCAN params include advanced params when advanced_features_enabled.""" - app_state = app_state_with_fake_provider # Override advanced_features_enabled to return True class AdvancedAppState: diff --git a/tests/views/test_collection_worker.py b/tests/views/test_collection_worker.py index 37ea6ddf..12589195 100644 --- a/tests/views/test_collection_worker.py +++ b/tests/views/test_collection_worker.py @@ -78,7 +78,7 @@ def test_worker_emits_creation_complete_false_on_service_failure(qtbot, monkeypa with qtbot.waitSignal(worker.creation_complete, timeout=5000) as blocker: worker.start() - success, msg = blocker.args + success, _msg = blocker.args assert success is False diff --git a/tests/views/test_connection_view.py b/tests/views/test_connection_view.py index 12d058a8..aca81c57 100644 --- a/tests/views/test_connection_view.py +++ b/tests/views/test_connection_view.py @@ -634,8 +634,6 @@ def test_show_connection_dialog_accepted(monkeypatch, qtbot): mod = __import__("vector_inspector.ui.views.connection_view", fromlist=["*"]) _make_fake_connection_view_dependencies(monkeypatch, mod) - captured_config = {} - class FakeDialog: def __init__(self, parent=None): pass diff --git a/tests/views/test_metadata_view_inline_details.py b/tests/views/test_metadata_view_inline_details.py index 09f211c3..b2c8d031 100644 --- a/tests/views/test_metadata_view_inline_details.py +++ b/tests/views/test_metadata_view_inline_details.py @@ -135,7 +135,7 @@ def test_open_full_details_from_inline_pane(qtbot, metadata_view): metadata_view.table.selectRow(0) metadata_view._on_selection_changed() - with patch("vector_inspector.ui.views.metadata_view._show_item_details") as mock_show: + with patch("vector_inspector.ui.views.metadata_view._show_item_details"): # Click "Open full details" button in inline pane metadata_view.details_pane.full_details_btn.click() diff --git a/tests/views/test_search_view_ask_ai.py b/tests/views/test_search_view_ask_ai.py index 818ae411..f356619d 100644 --- a/tests/views/test_search_view_ask_ai.py +++ b/tests/views/test_search_view_ask_ai.py @@ -365,7 +365,6 @@ def show(self): ) monkeypatch.setattr(_qw.QMessageBox, "show", lambda self: None) # Override _check_llm_configured to return False and record call - original_check = sv_with_results._check_llm_configured def _fake_check(): shown.append(True) diff --git a/tests/views/test_search_view_inline_details.py b/tests/views/test_search_view_inline_details.py index dbed37c3..0e78a5c2 100644 --- a/tests/views/test_search_view_inline_details.py +++ b/tests/views/test_search_view_inline_details.py @@ -302,7 +302,7 @@ def test_query_section_content_hugs_top(search_view): # Last item should be a stretch last_item = layout.itemAt(layout.count() - 1) # Stretch items have no widget - has_stretch = last_item and last_item.widget() is None + last_item and last_item.widget() is None # This is a proxy check, but indicates stretch was added assert layout.count() > 2 # Should have multiple items