Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -40,6 +40,139 @@ INDEX_SETTINGS = {
'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
@@ -124,6 +257,51 @@ def search_parts(query, limit=50, offset=0):
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:
@@ -157,3 +335,56 @@ def clear_index():
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