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