"""Meilisearch integration for sub-100ms catalog search. Provides a thin wrapper over the meilisearch Python client with: - Automatic index creation and settings configuration - Bulk indexing from PostgreSQL - Search with graceful fallback to PostgreSQL tsvector - Incremental add/update/delete for real-time sync Environment: MEILI_URL — Meilisearch server URL (default: http://localhost:7700) MEILI_API_KEY — Master key (default: nexus-master-key-change-me) """ import os import meilisearch from meilisearch.errors import MeilisearchApiError MEILI_URL = os.environ.get('MEILI_URL', 'http://localhost:7700') MEILI_API_KEY = os.environ.get('MEILI_API_KEY', 'nexus-master-key-change-me') INDEX_NAME = 'nexus_parts' # Searchable attributes and ranking INDEX_SETTINGS = { 'searchableAttributes': [ 'name_es', 'name_part', 'oem_part_number', 'description', 'description_es', ], 'rankingRules': [ 'words', 'typo', 'proximity', 'attribute', 'sort', 'exactness', ], 'filterableAttributes': ['group_id'], 'typoTolerance': {'enabled': True, 'minWordSizeForTypos': {'oneTypo': 4, 'twoTypos': 8}}, } _client = None _client_url = None def get_client(): """Get or create Meilisearch client (lazy singleton, URL-aware).""" global _client, _client_url current_url = os.environ.get('MEILI_URL', 'http://localhost:7700') if _client is None or _client_url != current_url: _client = meilisearch.Client(current_url, MEILI_API_KEY) _client_url = current_url return _client def reset_client(): """Force client recreation on next use (useful for tests).""" global _client, _client_url _client = None _client_url = None def health_check(): """Return True if Meilisearch is reachable.""" try: return get_client().health().get('status') == 'available' except Exception: return False def ensure_index(): """Create index if it doesn't exist and configure settings.""" client = get_client() try: client.get_index(INDEX_NAME) except MeilisearchApiError as e: if e.code == 'index_not_found': client.create_index(uid=INDEX_NAME, options={'primaryKey': 'id_part'}) else: raise index = client.index(INDEX_NAME) index.update_settings(INDEX_SETTINGS) return index def index_parts_bulk(parts_iter, batch_size=1000): """Index a large number of parts from an iterable. Args: parts_iter: iterable of dicts with keys: id_part, oem_part_number, name_part, name_es, description, description_es, image_url, group_id batch_size: documents per batch upload """ index = ensure_index() batch = [] total = 0 for part in parts_iter: batch.append(part) if len(batch) >= batch_size: index.add_documents(batch) total += len(batch) batch = [] if batch: index.add_documents(batch) total += len(batch) return total def search_parts(query, limit=50, offset=0): """Search parts via Meilisearch. Returns: dict: Meilisearch response with 'hits', 'offset', 'limit', 'totalHits' or None on error. """ try: index = get_client().index(INDEX_NAME) return index.search(query, {'limit': limit, 'offset': offset}) except Exception as e: print(f"[meili_search] Search error: {e}") return None def add_part(part_doc): """Add or update a single part document.""" try: get_client().index(INDEX_NAME).add_documents([part_doc]) return True except Exception as e: print(f"[meili_search] Add error: {e}") return False def update_part(part_doc): """Update a single part document (same as add).""" return add_part(part_doc) def delete_part(part_id): """Remove a part from the index.""" try: get_client().index(INDEX_NAME).delete_document(part_id) return True except Exception as e: print(f"[meili_search] Delete error: {e}") return False def clear_index(): """Delete all documents from the index.""" try: get_client().index(INDEX_NAME).delete_all_documents() return True except Exception as e: print(f"[meili_search] Clear error: {e}") return False