# /home/Autopartes/pos/services/ai_chat.py """AI Chat service using OpenRouter for parts lookup assistance.""" import requests import json from config import OPENROUTER_API_KEY OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" # ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago. # El modelo DEBE terminar en ":free" para garantizar costo $0. # Alternativas gratuitas: "meta-llama/llama-4-scout:free", "google/gemma-3-27b-it:free" MODEL = "qwen/qwen3.6-plus-preview:free" def _validate_model(model_id): """Ensure only free models are used. Raises if model is not free.""" if not model_id.endswith(':free'): raise ValueError(f"BLOQUEADO: Solo se permiten modelos gratuitos (:free). Modelo '{model_id}' no es gratuito.") SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes. IMPORTANTE: Responde SIEMPRE en formato JSON valido con esta estructura: { "message": "Tu respuesta al usuario en español", "search_query": "termino de busqueda EN INGLES para el catalogo", "vehicle": {"brand": "TOYOTA", "model": "Corolla", "year": 2020} } Reglas OBLIGATORIAS: 1. "search_query" SIEMPRE debe tener un valor cuando el usuario menciona una parte. NUNCA dejes null si el usuario pide algo. 2. "search_query" debe estar EN INGLES porque el catalogo TecDoc tiene nombres en ingles. Traducciones comunes: - Balatas/Pastillas de freno = "Brake Pad" - Discos de freno = "Brake Disc" - Amortiguador = "Shock Absorber" - Filtro de aceite = "Oil Filter" - Filtro de aire = "Air Filter" - Bujias = "Spark Plug" - Banda serpentina = "V-Belt" o "Serpentine 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/Embrague = "Clutch Kit" - Mofle/Escape = "Exhaust" - Inyector = "Injector" 3. "vehicle" extrae marca, modelo y ano. La marca en MAYUSCULAS. 4. Nombres mexicanos: Tsuru = TSURU, Aveo = AVEO, Jetta = JETTA, Pointer = POINTER, Chevy = CORSA, Vocho = BEETLE. 5. No preguntes mas info si ya puedes buscar. Si el usuario dice "balatas para Tsuru 2015", busca directo. 6. "message" es breve y directo: "Buscando balatas para Nissan Tsuru 2015..." Cuando el usuario describe un SINTOMA del vehiculo (no una parte especifica), diagnostica el problema y sugiere las partes que podrian necesitar reemplazo. Ejemplos de sintomas: - "el carro vibra al frenar" → Discos de freno y/o balatas desgastadas. search_query: "Brake Disc" - "se calienta el motor" → Termostato, bomba de agua, radiador. search_query: "Thermostat" - "hace ruido al dar vuelta" → Juntas homocineticas. search_query: "CV Joint" - "no arranca" → Bateria, alternador, motor de arranque. search_query: "Starter Motor" - "gasta mucha gasolina" → Filtro de aire, bujias, inyectores. search_query: "Air Filter" - "huele a gasolina" → Inyectores, bomba de gasolina, mangueras. search_query: "Fuel Pump" - "se jala a un lado" → Terminales de direccion, rotulas, alineacion. search_query: "Tie Rod End" - "hace ruido al arrancar" → Banda serpentina, tensor, marcha. search_query: "Serpentine Belt" - "pierde aceite" → Junta de tapa de valvulas, empaques. search_query: "Gasket" - "el aire no enfria" → Compresor de AC, gas refrigerante. search_query: "A/C Compressor" Si detectas un sintoma, responde con: 1. Diagnostico probable 2. Lista de partes que podrian necesitar reemplazo (en orden de probabilidad) 3. search_query con la parte mas probable Cuando el usuario pida una COTIZACION o diga "cotizame", "cuanto cuesta", "precio de": 1. Identifica TODAS las partes necesarias para el trabajo completo 2. Devuelve multiples search_queries separadas por | Ejemplo: "cotizame frenos completos para Corolla 2020" search_query: "Brake Pad|Brake Disc|Brake Fluid|Brake Hose" Ejemplo: "servicio completo para Tsuru 2015" search_query: "Oil Filter|Air Filter|Spark Plug|Coolant|Brake Fluid" Ejemplo: "kit de distribucion para Jetta 2018" search_query: "Timing Belt|Tensioner|Idler Pulley|Water Pump" Detecta el idioma del usuario y responde en el mismo idioma. Si escribe en ingles, responde en ingles. Si escribe en espanol, responde en espanol. El search_query SIEMPRE debe ser en ingles (el catalogo TecDoc esta en ingles). """ def get_inventory_context(tenant_conn, branch_id=None): """Build a summary string of the tenant's inventory for AI context. Returns a string like: Este negocio tiene 1234 productos en inventario. Categorias: BOSCH (45), MONROE (32), ACDelco (28), ... Productos con stock bajo (<=3): 15 """ cur = tenant_conn.cursor() try: # Total items where = "i.is_active = true" params = [] if branch_id: where += " AND i.branch_id = %s" params.append(branch_id) cur.execute(f"SELECT COUNT(*) FROM inventory i WHERE {where}", params) total = cur.fetchone()[0] or 0 if total == 0: return "CONTEXTO DEL INVENTARIO:\nEste negocio aun no tiene productos en inventario." # Top brands with counts cur.execute(f""" SELECT i.brand, COUNT(*) as cnt FROM inventory i WHERE {where} AND i.brand IS NOT NULL AND i.brand != '' GROUP BY i.brand ORDER BY cnt DESC LIMIT 15 """, params) brands = cur.fetchall() brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0]) # Products with low stock (<=3) cur.execute(f""" SELECT COUNT(*) FROM inventory i WHERE {where} AND COALESCE((SELECT SUM(quantity) FROM inventory_operations WHERE inventory_id = i.id), 0) <= 3 """, params) low_stock = cur.fetchone()[0] or 0 lines = [ "CONTEXTO DEL INVENTARIO:", f"Este negocio tiene {total} productos en inventario.", ] if brand_list: lines.append(f"Marcas disponibles: {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.") return "\n".join(lines) except Exception: return "" finally: cur.close() def chat(user_message, conversation_history=None, inventory_context=None): """Send a message to the AI and get a response with search suggestions. Args: user_message: The user's chat message. conversation_history: Previous messages in the conversation. inventory_context: Optional inventory summary string to inject into the system prompt. """ _validate_model(MODEL) # Block paid models system_content = SYSTEM_PROMPT if inventory_context: system_content = SYSTEM_PROMPT + "\n\n" + inventory_context messages = [{"role": "system", "content": system_content}] if conversation_history: messages.extend(conversation_history) messages.append({"role": "user", "content": user_message}) import time max_retries = 3 for attempt in range(max_retries): try: resp = requests.post( OPENROUTER_URL, headers={ "Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json", }, json={ "model": MODEL, "messages": messages, "max_tokens": 500, "temperature": 0.3, }, timeout=20, ) if resp.status_code == 429: # Rate limited — wait and retry wait = (attempt + 1) * 5 # 5s, 10s, 15s if attempt < max_retries - 1: time.sleep(wait) continue return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None} resp.raise_for_status() data = resp.json() content = data["choices"][0]["message"]["content"] # Try to parse JSON response try: stripped = content.strip() if stripped.startswith("```"): lines = stripped.split("\n") json_str = "\n".join(lines[1:-1]) parsed = json.loads(json_str) else: parsed = json.loads(stripped) return parsed except (json.JSONDecodeError, IndexError): return {"message": content, "search_query": None, "vehicle": None} except Exception as e: if attempt < max_retries - 1: continue return { "message": f"Error de conexion: {str(e)}", "search_query": None, "vehicle": None, }