- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
391 lines
13 KiB
Python
391 lines
13 KiB
Python
"""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
|
|
|