- 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
326 lines
12 KiB
Python
326 lines
12 KiB
Python
# /home/Autopartes/pos/services/question_ai.py
|
|
"""A7 — Intent classification + suggested answers for MercadoLibre questions.
|
|
|
|
Given a buyer question (optionally tied to a listing/inventory item) this
|
|
service:
|
|
1. Classifies the intent: compatibility | price | shipping | availability | other.
|
|
2. Builds a suggested answer from real data — ``inventory`` (name/brand/price),
|
|
``inventory_vehicle_compat`` (fitment) and stock — and falls back to the
|
|
LLM (``services.ai_chat``) when the question needs free text.
|
|
|
|
Public API:
|
|
classify_intent(text) -> (intent, confidence)
|
|
suggest_answer(tenant_conn, question_text, inventory_id=None, ...) -> dict
|
|
get_question_suggestion(tenant_conn, question_id, ...) -> dict
|
|
|
|
Returned dict:
|
|
{
|
|
"intent": str,
|
|
"suggested_answer": str,
|
|
"confidence": float, # 0.0 - 1.0 (intent + data certainty)
|
|
"source": str, # rules | inventory | compat_db | template | llm | none
|
|
"auto_ok": bool, # safe to auto-send (high confidence + template)
|
|
"evidence": dict # data used to build the answer
|
|
}
|
|
"""
|
|
|
|
import re
|
|
|
|
INTENTS = ('compatibility', 'price', 'shipping', 'availability', 'other')
|
|
|
|
# Ordered by priority: fitment questions often also contain "tienen"/"hay",
|
|
# so compatibility is matched first.
|
|
_INTENT_PATTERNS = [
|
|
('compatibility', [
|
|
r'compatib', r'\bsirve\b', r'\bsirven\b', r'le queda', r'queda (a|en|para)',
|
|
r'le queda a', r'es para (un|una|mi)', r'funciona (para|en)', r'aplica para',
|
|
r'\bfits?\b', r'quedar[áa]', r'le sirve', r'modelo \d{4}',
|
|
]),
|
|
('price', [
|
|
r'\bprecio\b', r'cu[áa]nto (cuesta|vale|es|sale)', r'\bcosto\b', r'\bvale\b',
|
|
r'\bdescuento\b', r'\bfactura\b', r'\bmeses\b', r'\bmsi\b', r'\bbarato\b',
|
|
r'\bpago\b', r'\bcuotas?\b',
|
|
]),
|
|
('shipping', [
|
|
r'env[íi]o', r'env[íi]an', r'\benvias?\b', r'\bmandan\b', r'\bllega\b',
|
|
r'\bentrega\b', r'paqueter[íi]a', r'\bgu[íi]a\b', r'mensajer[íi]a',
|
|
r'\benvio\b',
|
|
]),
|
|
('availability', [
|
|
r'\btienes\b', r'\btiene[n]?\b', r'\bhay\b', r'disponib', r'\bstock\b',
|
|
r'existencia', r'inventario', r'cu[áa]ntas? (piezas|unidades|tienes)',
|
|
r'tienen en',
|
|
]),
|
|
]
|
|
|
|
_YEAR_RE = re.compile(r'\b(19[89]\d|20[0-3]\d)\b')
|
|
|
|
|
|
def classify_intent(text):
|
|
"""Rule-based intent classifier. Returns (intent, confidence)."""
|
|
if not text:
|
|
return 'other', 0.0
|
|
low = str(text).lower()
|
|
|
|
for intent, patterns in _INTENT_PATTERNS:
|
|
for pat in patterns:
|
|
if re.search(pat, low):
|
|
return intent, 0.85
|
|
|
|
# A bare model-year usually means "does it fit?".
|
|
if _YEAR_RE.search(low):
|
|
return 'compatibility', 0.6
|
|
|
|
return 'other', 0.35
|
|
|
|
|
|
# ─── data helpers ───────────────────────────────────────────────────────────
|
|
|
|
def _inventory_row(conn, inventory_id):
|
|
"""Return a small dict for the inventory item backing a listing."""
|
|
if not inventory_id:
|
|
return None
|
|
cur = conn.cursor()
|
|
try:
|
|
cur.execute("""
|
|
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.condition, c.name
|
|
FROM inventory i
|
|
LEFT JOIN part_categories c ON c.id = i.category_id
|
|
WHERE i.id = %s
|
|
""", (inventory_id,))
|
|
row = cur.fetchone()
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
cur.close()
|
|
if not row:
|
|
return None
|
|
return {
|
|
'id': row[0], 'part_number': row[1] or '', 'name': row[2] or '',
|
|
'brand': row[3] or '', 'price': float(row[4] or 0), 'condition': row[5] or '',
|
|
'category': row[6] or '',
|
|
}
|
|
|
|
|
|
def _compat_summary(conn, inventory_id, limit=6):
|
|
"""Human-readable fitment list from inventory_vehicle_compat (text rows)."""
|
|
cur = conn.cursor()
|
|
try:
|
|
cur.execute("""
|
|
SELECT make, model, year
|
|
FROM inventory_vehicle_compat
|
|
WHERE inventory_id = %s AND (make IS NOT NULL OR model IS NOT NULL)
|
|
GROUP BY make, model, year
|
|
ORDER BY make, model, year
|
|
LIMIT %s
|
|
""", (inventory_id, limit))
|
|
rows = cur.fetchall()
|
|
except Exception:
|
|
return [], 0
|
|
finally:
|
|
cur.close()
|
|
|
|
lines = []
|
|
for make, model, year in rows:
|
|
parts = [str(p).strip() for p in (make, model) if p]
|
|
label = ' '.join(parts)
|
|
if year:
|
|
label = f"{label} {year}".strip()
|
|
if label:
|
|
lines.append(label)
|
|
return lines, len(lines)
|
|
|
|
|
|
def _trailing_confirm():
|
|
return ("Si tu vehículo no aparece, compárteme marca, modelo y año "
|
|
"(o los últimos 8 dígitos de tu VIN) y te confirmo la compatibilidad.")
|
|
|
|
|
|
def _config_value(conn, key, default=None):
|
|
cur = conn.cursor()
|
|
try:
|
|
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
|
|
row = cur.fetchone()
|
|
return row[0] if row and row[0] else default
|
|
except Exception:
|
|
return default
|
|
finally:
|
|
cur.close()
|
|
|
|
|
|
def _stock(conn, inventory_id):
|
|
try:
|
|
from services.inventory_engine import get_stock
|
|
return get_stock(conn, inventory_id)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ai_chat returns these generic strings when every backend fails. They are not
|
|
# real answers, so a question must fall back to the template instead.
|
|
_LLM_ERROR_PREFIXES = (
|
|
'El asistente está ocupado',
|
|
'El asistente esta ocupado',
|
|
'El asistente tardó mucho',
|
|
'El asistente tardo mucho',
|
|
'El asistente no está disponible',
|
|
'El asistente no esta disponible',
|
|
)
|
|
|
|
|
|
def _llm_answer(tenant_conn, question_text, item):
|
|
"""Free-text answer via ai_chat. Returns (text, error)."""
|
|
try:
|
|
from services import ai_chat
|
|
except Exception as e:
|
|
return None, f'ai_chat import: {e}'
|
|
|
|
context = None
|
|
if item:
|
|
context = (f"Producto publicado: {item['name']} (marca {item['brand'] or 'N/D'}, "
|
|
f"código {item['part_number'] or 'N/D'}). Precio ${item['price']:.2f} MXN.")
|
|
prompt = ("Eres un vendedor de autopartes que responde preguntas de compradores "
|
|
f"en MercadoLibre. Responde breve (máximo 3 líneas), en español y con tono "
|
|
f"amable. Nunca inventes compatibilidad que no puedas confirmar.\n\nPregunta: {question_text}")
|
|
try:
|
|
res = ai_chat.chat(prompt, inventory_context=context)
|
|
except Exception as e:
|
|
return None, str(e)
|
|
if isinstance(res, dict):
|
|
msg = (res.get('message') or '').strip()
|
|
if msg and not msg.startswith(_LLM_ERROR_PREFIXES):
|
|
return msg, None
|
|
if msg:
|
|
return None, f'backend sin respuesta: {msg}'
|
|
return None, 'sin respuesta del modelo'
|
|
|
|
|
|
# ─── answer builder ─────────────────────────────────────────────────────────
|
|
|
|
def suggest_answer(tenant_conn, question_text, inventory_id=None, listing_id=None,
|
|
master_conn=None, use_llm=True):
|
|
"""Classify a question and build a suggested answer.
|
|
|
|
``tenant_conn`` is an open tenant connection (not committed). The function
|
|
never raises for backend/data failures: it degrades to a template answer.
|
|
"""
|
|
intent, conf = classify_intent(question_text)
|
|
item = _inventory_row(tenant_conn, inventory_id) if inventory_id else None
|
|
|
|
out = {
|
|
'intent': intent,
|
|
'suggested_answer': '',
|
|
'confidence': conf,
|
|
'source': 'rules',
|
|
'auto_ok': False,
|
|
'evidence': {},
|
|
'listing_id': listing_id,
|
|
'inventory_id': inventory_id,
|
|
}
|
|
if item:
|
|
out['evidence']['item'] = {
|
|
'part_number': item['part_number'], 'name': item['name'],
|
|
'brand': item['brand'], 'price': item['price'],
|
|
}
|
|
|
|
if intent == 'compatibility' and inventory_id:
|
|
lines, n = _compat_summary(tenant_conn, inventory_id)
|
|
out['evidence']['compat_count'] = n
|
|
if lines:
|
|
out['suggested_answer'] = (
|
|
"¡Sí! Esta pieza es compatible con: " + ", ".join(lines) + ". " + _trailing_confirm()
|
|
)
|
|
out['confidence'] = 0.9
|
|
out['source'] = 'compat_db'
|
|
# Fitment can still be model/trim specific -> human review, no auto-send.
|
|
out['auto_ok'] = False
|
|
return out
|
|
|
|
elif intent == 'price' and item and item['price'] > 0:
|
|
out['suggested_answer'] = (
|
|
f"El precio es ${item['price']:,.2f} MXN. "
|
|
"El envío se calcula al finalizar la compra según tu código postal. ¿Te ayudo con algo más?"
|
|
)
|
|
out['confidence'] = 0.9
|
|
out['source'] = 'inventory'
|
|
out['auto_ok'] = True
|
|
return out
|
|
|
|
elif intent == 'availability' and inventory_id:
|
|
stock = _stock(tenant_conn, inventory_id)
|
|
out['evidence']['stock'] = stock
|
|
if stock is not None:
|
|
if stock > 0:
|
|
out['suggested_answer'] = (
|
|
f"Sí, tenemos {stock} {'pieza' if stock == 1 else 'piezas'} disponibles. "
|
|
"Puedes comprar directo desde la publicación."
|
|
)
|
|
out['confidence'] = 0.9
|
|
else:
|
|
out['suggested_answer'] = (
|
|
"Por el momento no tenemos existencia de esta pieza. "
|
|
"Déjame tu pregunta y te aviso en cuanto llegue."
|
|
)
|
|
out['confidence'] = 0.8
|
|
out['source'] = 'inventory'
|
|
out['auto_ok'] = True
|
|
return out
|
|
|
|
elif intent == 'shipping':
|
|
mode = _config_value(tenant_conn, 'meli_shipping_mode', '')
|
|
extra = f" Modalidad: {mode}." if mode else ''
|
|
out['suggested_answer'] = (
|
|
"El costo y tiempo de envío se muestran al ingresar tu código postal en MercadoLibre." + extra +
|
|
" Si estás en la ciudad, también puedes pasar a recogerlo a la tienda."
|
|
)
|
|
out['confidence'] = 0.75
|
|
out['source'] = 'template'
|
|
out['auto_ok'] = out['confidence'] >= 0.8
|
|
return out
|
|
|
|
# Fallback: free text for "other", or when the data needed was missing.
|
|
if use_llm:
|
|
text, err = _llm_answer(tenant_conn, question_text, item)
|
|
if text:
|
|
out['suggested_answer'] = text
|
|
out['source'] = 'llm'
|
|
out['confidence'] = max(out['confidence'], 0.6)
|
|
out['auto_ok'] = False
|
|
return out
|
|
out['evidence']['llm_error'] = err
|
|
|
|
if not out['suggested_answer']:
|
|
if intent == 'compatibility':
|
|
out['suggested_answer'] = ("Con gusto te confirmo la compatibilidad. " + _trailing_confirm())
|
|
else:
|
|
out['suggested_answer'] = (
|
|
"¡Hola! Gracias por tu pregunta; en un momento te atendemos con el detalle."
|
|
)
|
|
out['source'] = 'template'
|
|
out['confidence'] = min(out['confidence'], 0.5)
|
|
out['auto_ok'] = False
|
|
return out
|
|
|
|
|
|
def get_question_suggestion(tenant_conn, question_id, master_conn=None, use_llm=True):
|
|
"""Resolve a local marketplace question then build its suggested answer."""
|
|
cur = tenant_conn.cursor()
|
|
try:
|
|
cur.execute("""
|
|
SELECT q.question_text, q.listing_id, l.inventory_id
|
|
FROM marketplace_questions q
|
|
LEFT JOIN marketplace_listings l ON l.id = q.listing_id
|
|
WHERE q.id = %s
|
|
""", (question_id,))
|
|
row = cur.fetchone()
|
|
finally:
|
|
cur.close()
|
|
if not row:
|
|
raise ValueError('Question not found')
|
|
|
|
q_text, listing_id, inventory_id = row
|
|
return suggest_answer(
|
|
tenant_conn, q_text or '', inventory_id=inventory_id,
|
|
listing_id=listing_id, master_conn=master_conn, use_llm=use_llm,
|
|
)
|