"""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}}, } # ─── Synonyms (A6) ────────────────────────────────────────────────────────── # Spanish/English aftermarket equivalents so that "brake pad" finds "BALATA" # and vice-versa. Built from a curated core plus the existing bilingual # taxonomy (services.translations.PART_TRANSLATIONS). Multi-word synonyms are # supported by Meilisearch. CURATED_SYNONYMS = [ ('balata', 'pastilla de freno', 'brake pad', 'brake pads'), ('disco de freno', 'disco', 'brake disc', 'brake rotor'), ('bujia', 'bujía', 'spark plug', 'spark plugs'), ('clutch', 'embrague', 'cloch', 'clochs'), ('faro', 'faro delantero', 'headlight', 'headlamp'), ('mofle', 'mofle', 'silenciador', 'muffler'), ('amortiguador', 'shock absorber', 'strut'), ('rotula', 'rótula', 'ball joint'), ('terminal', 'terminal de direccion', 'tie rod end'), ('banda', 'banda serpentina', 'belt', 'serpentine belt'), ('banda de distribucion', 'timing belt'), ('bomba de agua', 'water pump'), ('radiador', 'radiator'), ('alternador', 'alternator'), ('marcha', 'arrancador', 'starter', 'starter motor'), ('bobina', 'bobina de encendido', 'ignition coil'), ('bateria', 'batería', 'battery'), ('filtro de aire', 'air filter'), ('filtro de aceite', 'oil filter'), ('filtro de gasolina', 'fuel filter'), ('sensor', 'sensor'), ('collarín', 'collarín de clutch', 'throw out bearing', 'release bearing'), ('bomba de gasolina', 'fuel pump'), ('foco', 'foco', 'bulb', 'lamp'), ('cristal', 'cristal', 'glass', 'windshield'), ('parabrisas', 'parabrisas', 'windshield'), ('espejo', 'espejo lateral', 'mirror', 'side mirror'), ('abanico', 'ventilador', 'cooling fan', 'fan'), ('compresor', 'compresor de aire', 'a/c compressor', 'ac compressor'), ('valvula', 'válvula', 'valve'), ('junta', 'empaque', 'gasket'), ('manguera', 'hose'), ] def _strip_accents(text): if not text: return '' out = text for a, b in (('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'), ('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'), ('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), ('Ñ', 'N')): out = out.replace(a, b) return out def _symmetrize(groups): """{term: [other equivalent terms]} from groups of equivalent terms.""" synonyms = {} for group in groups: terms = [] for term in group: t = (term or '').strip().lower() if t and t not in terms: terms.append(t) for term in terms: others = [t for t in terms if t != term] synonyms.setdefault(term, []) for o in others: if o not in synonyms[term]: synonyms[term].append(o) # Accent-insensitive variant so "bujia" matches "bujía". plain = _strip_accents(term) if plain and plain != term: synonyms.setdefault(plain, []) for o in others: if o not in synonyms[plain]: synonyms[plain].append(o) return synonyms def build_synonyms(include_taxonomy=True): """Build the synonym map for the search indexes. Curated colloquial Mexican terms first, then the curated bilingual taxonomy in ``services.translations.PART_TRANSLATIONS`` (English → Spanish). """ groups = list(CURATED_SYNONYMS) if include_taxonomy: try: from services.translations import PART_TRANSLATIONS for en, es in PART_TRANSLATIONS.items(): en, es = (en or '').strip(), (es or '').strip() if not en or not es or '/' in es: continue if len(en) > 40 or len(es) > 40: continue if en.lower() == es.lower(): continue groups.append((en.lower(), es.lower())) except Exception: pass return _symmetrize(groups) _SYNONYMS_CACHE = None def get_synonyms(): """Cached synonym map (built once per process).""" global _SYNONYMS_CACHE if _SYNONYMS_CACHE is None: _SYNONYMS_CACHE = build_synonyms() return _SYNONYMS_CACHE # Per-index settings used by scripts/index_meilisearch.py. ``aliases`` holds # inventory_sku_aliases / interchange numbers for cross-reference search. INVENTORY_SEARCHABLE_ATTRIBUTES = ['part_number', 'name', 'brand', 'aliases'] CATALOG_SEARCHABLE_ATTRIBUTES = ['sku', 'name', 'category', 'supplier_name', 'aliases'] def inventory_index_settings(): return { 'searchableAttributes': list(INVENTORY_SEARCHABLE_ATTRIBUTES), 'filterableAttributes': ['brand'], 'synonyms': get_synonyms(), } def catalog_index_settings(): return { 'searchableAttributes': list(CATALOG_SEARCHABLE_ATTRIBUTES), 'filterableAttributes': ['category', 'supplier_name'], 'synonyms': get_synonyms(), } _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 is_enabled(): """True si MEILI_ENABLED está activo (se lee en cada llamada).""" return os.environ.get('MEILI_ENABLED', 'true').lower() == 'true' def search_index(index_uid, query, options=None): """Búsqueda genérica en cualquier índice. Returns: dict con la respuesta de Meilisearch, o None si Meilisearch está apagado, sin API key, o falla — el caller debe usar su fallback SQL. """ if not is_enabled(): return None try: return get_client().index(index_uid).search(query, options or {}) except MeilisearchApiError as e: # Índice inexistente (tenant sin reindexar) es esperado: silencio. if e.code != 'index_not_found': print(f"[meili_search] Search error on {index_uid}: {e}") return None except Exception as e: print(f"[meili_search] Search error on {index_uid}: {e}") return None def inventory_index_name(db_name): """Índice de inventario de un tenant (ver scripts/index_meilisearch.py).""" return f"nexus_inventory_{db_name}" def search_inventory(db_name, query, limit=50, offset=0): """Busca en el índice de inventario de un tenant. Returns: list de hits (posiblemente vacía) o None si Meilisearch no está disponible o el índice no existe → el caller debe usar SQL. """ res = search_index(inventory_index_name(db_name), query, {'limit': limit, 'offset': offset}) if res is None: return None return res.get('hits', []) 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 # ─── A6: settings / synonym refresh ───────────────────────────────────────── def _index_uids(prefix=None): """UIDs of existing indexes (optionally filtered by prefix).""" client = get_client() try: result = client.get_indexes() except TypeError: # older meilisearch-python returns a dict result = client.get_indexes() if isinstance(result, dict): results = result.get('results', []) else: results = getattr(result, 'results', result) uids = [] for item in results: uid = item.get('uid') if isinstance(item, dict) else getattr(item, 'uid', None) if uid and (prefix is None or uid.startswith(prefix)): uids.append(uid) return uids def apply_index_settings(index_uid, settings=None): """Apply searchable attributes + synonyms to one index (no reindex).""" if settings is None: if index_uid == 'nexus_supplier_catalog': settings = catalog_index_settings() elif index_uid.startswith('nexus_inventory_'): settings = inventory_index_settings() else: settings = {'synonyms': get_synonyms()} task = get_client().index(index_uid).update_settings(settings) return task def apply_synonyms_to_all(prefixes=('nexus_inventory_', 'nexus_supplier_catalog')): """Push the synonym map to every inventory/catalog index. Updating ``synonyms`` does not require reindexing documents, so this is safe to run against a live Meilisearch. Returns {index_uid: ok}. """ results = {} for prefix in prefixes: uids = _index_uids(prefix) for uid in uids: try: apply_index_settings(uid) results[uid] = True except Exception as e: results[uid] = f"error: {e}" return results