feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes

- Add MercadoLibre OAuth, listings, orders, webhooks and category search
- New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py
- New marketplace_external.html/js with ML management UI
- Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors
- Inventory: new .btn--meli styles, select/label CSS fixes
- WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog
- DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue
- Add Celery tasks for ML sync and webhook processing
- Sidebar: MercadoLibre navigation link
This commit is contained in:
2026-05-26 04:24:07 +00:00
parent 50c0dbe7d4
commit a236187f3a
66 changed files with 7335 additions and 498 deletions

View File

@@ -86,11 +86,42 @@ def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temp
return None
SYSTEM_PROMPT_SHORT = """Eres un asistente de refaccionaria automotriz mexicana. Ayuda a encontrar autopartes.
SYSTEM_PROMPT_SHORT = """Eres Juan, vendedor estrella de Autopartes Estrada. Llevas 10 años ayudando a mecanicos y dueños de taller. Tu estilo: directo, calido, sin rollos tecnicos. Hablas como un compa que sabe de carros.
IMPORTANTE: NO prometas stock hasta verificar. Usa "Reviso...", "Busco...", "Déjame checar..." en vez de "Tengo..." a menos que estes 100% seguro.
Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}}
search_query va EN INGLES cuando el usuario pide una parte. Traducciones: Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector.
No preguntes mas si ya puedes buscar. Si el usuario describe un sintoma, diagnostica y sugiere partes.
Cuando pida cotizacion o multiples partes, search_query DEBE usar | para separar cada parte: "Brake Pad|Air Filter|Oil Filter|Spark Plug".
REGLAS DE VENTA AVANZADAS:
1. PRECIO AL FRENTE: Si hay stock, di precio y marca sin rodeos.
2. KIT INTELIGENTE: Siempre sugiere 1-2 productos relacionados que se necesitan para el mismo trabajo.
- Balatas → "Ya que vas a cambiar balatas, checa si los discos tambien estan gastados. Te armo paquete con descuento."
- Alternador → "Mientras cambias alternador, conviene cambiar la banda serpentina para que no se te rompa despues."
- Filtro de aceite → "¿Ya tienes filtro de aire y bujias? Para servicio completo conviene cambiar todo junto."
3. MANEJO DE OBJECIONES:
- "Esta caro""Te entiendo. Esta es marca original. Tambien manejo opcion economica. ¿Te mando las dos para comparar?"
- "Voy a checar en otro lado""Dale, te espero. Guardame este precio. Si encuentras mas barato, mandame foto de la cotizacion y veo si te la mejoro."
- "Lo necesito para hoy" / "Urgente""Perfecto. Tenemos entrega express en 2-4 horas o puedes pasar directo a la tienda. ¿Te lo armo ya?"
- "No se si sea esa""No hay problema. Dame los ultimos 4 digitos de tu VIN y te confirmo compatibilidad exacta."
- "Solo estoy cotizando""Claro, sin compromiso. Te armo la cotizacion y si decides despues, aqui queda guardada."
4. CIERRE SUAVE (termina SIEMPRE con pregunta):
- "¿Te lo aparto?"
- "¿Lo mando a tu taller o lo pasas a recoger?"
- "¿Con esto quedas o necesitas algo mas?"
- "¿Te armo el paquete completo? Sale mejor que por separado."
5. RECONOCIMIENTO DE CLIENTE: Si el contexto dice que compro antes, mencionalo. "Veo que compraste balatas hace 6 meses. ¿Ya es hora de cambiar las del otro eje?"
6. DIAGNOSTICO RAPIDO: Si describe sintoma, diagnostica en 1-2 frases y sugiere 2-3 partes mas probables.
TRADUCCIONES search_query (EN INGLES):
Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector, Banda de distribucion=Timing Belt, Tensor=Belt Tensioner, Junta homocinetica=CV Joint, Marcha=Starter Motor, Bateria=Battery, Aceite=Engine Oil, Refrigerante=Coolant.
FORMATO:
- search_query EN INGLES. NUNCA null si pide algo.
- vehicle: {"brand":"NISSAN","model":"Frontier","year":2019} marca en MAYUSCULAS.
- Multiples partes: "Brake Pad|Brake Disc|Brake Fluid"
- Mensaje maximo 4 lineas cortas. Lenguaje natural, nada robotico.
- Si ya detectaste vehiculo en conversacion anterior, NO vuelvas a pedirlo.
- Termina SIEMPRE con una pregunta de cierre.
"""
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
@@ -195,11 +226,24 @@ def get_inventory_context(tenant_conn, branch_id=None):
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
GROUP BY i.brand
ORDER BY cnt DESC
LIMIT 15
LIMIT 10
""", params)
brands = cur.fetchall()
brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0])
# Top categories with counts
cur.execute(f"""
SELECT c.name, COUNT(*) as cnt
FROM inventory i
JOIN part_categories c ON c.id = i.category_id
WHERE {where} AND c.name IS NOT NULL AND c.name != ''
GROUP BY c.name
ORDER BY cnt DESC
LIMIT 10
""", params)
categories = cur.fetchall()
category_list = ", ".join(f"{row[0]} ({row[1]})" for row in categories if row[0])
# Products with low stock (<=3)
cur.execute(f"""
SELECT COUNT(*) FROM inventory i
@@ -212,10 +256,12 @@ def get_inventory_context(tenant_conn, branch_id=None):
"CONTEXTO DEL INVENTARIO:",
f"Este negocio tiene {total} productos en inventario.",
]
if category_list:
lines.append(f"Categorias principales: {category_list}")
if brand_list:
lines.append(f"Marcas disponibles: {brand_list}")
lines.append(f"Marcas top: {brand_list}")
lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}")
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local.")
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local. Si no hay stock exacto, sugiere alternativa similar.")
return "\n".join(lines)
except Exception:
@@ -284,10 +330,10 @@ def chat_with_image(user_message, image_base64, conversation_history=None, inven
]
messages.append({"role": "user", "content": user_content})
# Try Hermes first for vision (if enabled), fallback to OpenRouter
# Vision backends: QWEN only, fallback to OpenRouter if key present
backends = []
if HERMES_ENABLED:
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_VISION_MODEL))
if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
if OPENROUTER_API_KEY:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
@@ -339,10 +385,10 @@ def classify_part(part_number):
{"role": "user", "content": prompt}
]
# Try Hermes first (if enabled), fallback to OpenRouter
# Backends: QWEN only, fallback to OpenRouter if key present
backends = []
if HERMES_ENABLED:
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL))
if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
if OPENROUTER_API_KEY:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL))
@@ -528,12 +574,10 @@ def chat(user_message, conversation_history=None, inventory_context=None):
last_error = None
# Build backend list: QWEN first (fast, ~1s), then Hermes (specialized, ~30s), then OpenRouter
# Build backend list: QWEN first, then OpenRouter fallback
backends = []
if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 35, SYSTEM_PROMPT_SHORT, 4000))
if HERMES_ENABLED:
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL, 45, SYSTEM_PROMPT, 800))
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 18, SYSTEM_PROMPT_SHORT, 1200))
if OPENROUTER_API_KEY:
for m in FALLBACK_MODELS:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
@@ -548,14 +592,22 @@ def chat(user_message, conversation_history=None, inventory_context=None):
if conversation_history:
msgs.extend(conversation_history)
msgs.append({"role": "user", "content": user_message})
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
# Retry logic: QWEN gets 3 attempts with 2s delay because the API is flaky
max_retries = 3 if url == QWEN_CHAT_URL else 1
result = None
for attempt in range(1, max_retries + 1):
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
if result is not None:
break
if attempt < max_retries:
print(f"[AI] QWEN attempt {attempt} failed, retrying in 2s...")
_time_chat.sleep(2)
if result is None:
if url == QWEN_CHAT_URL:
print(f"[AI] QWEN failed, trying Hermes fallback...")
print(f"[AI] QWEN failed after {max_retries} attempts, trying fallback...")
last_error = "qwen_failed"
elif url == HERMES_CHAT_URL:
print(f"[AI] Hermes failed, trying OpenRouter fallback...")
last_error = "hermes_timeout"
else:
print(f"[AI] Rate limited on {model_id}, trying next model...")
last_error = "rate_limit"
@@ -589,7 +641,7 @@ def chat(user_message, conversation_history=None, inventory_context=None):
# All models exhausted — DON'T cache errors, we want retries next time
if last_error == "rate_limit":
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
if last_error == "hermes_timeout":
if last_error == "qwen_failed":
return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None}
return {
"message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.",