Files
Autoparts-DB/pos/services/question_ai.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

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,
)