Files
Autoparts-DB/pos/services/meili_search.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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
2026-10-12 00:57:31 +00:00

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