# /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() VISION_MODEL = "google/gemma-3-27b-it:free" VISION_SYSTEM_PROMPT = """Eres un experto en identificación de autopartes. El usuario te envía una foto de una parte automotriz. Tu trabajo es: 1. Identificar que parte es (nombre en español e inglés) 2. Describir características visibles (material, desgaste, marca si es visible) 3. Sugerir términos de búsqueda para encontrarla en un catálogo IMPORTANTE: Responde SIEMPRE en formato JSON válido con esta estructura: { "message": "Descripción de la parte identificada en español", "search_query": "término de búsqueda EN INGLÉS para el catálogo", "vehicle": null } Ejemplos de partes comunes: - Pastillas/balatas de freno = "Brake Pad" - Disco de freno = "Brake Disc" - Filtro de aceite = "Oil Filter" - Bujía = "Spark Plug" - Amortiguador = "Shock Absorber" - Bomba de agua = "Water Pump" - Sensor de oxígeno = "Oxygen Sensor" """ def chat_with_image(user_message, image_base64, conversation_history=None, inventory_context=None): """Send a message with an image to a vision-capable AI model. Args: user_message: The user's chat message. image_base64: Base64-encoded image (with or without data URL prefix). conversation_history: Previous messages in the conversation. inventory_context: Optional inventory summary string. """ _validate_model(VISION_MODEL) system_content = VISION_SYSTEM_PROMPT if inventory_context: system_content = VISION_SYSTEM_PROMPT + "\n\n" + inventory_context # Ensure proper data URL format if image_base64 and not image_base64.startswith('data:'): image_base64 = 'data:image/jpeg;base64,' + image_base64 messages = [{"role": "system", "content": system_content}] if conversation_history: # Only add text-only history messages for h in conversation_history: if isinstance(h.get('content'), str): messages.append(h) # Build multimodal user message user_content = [ {"type": "image_url", "image_url": {"url": image_base64}}, {"type": "text", "text": user_message or "Identifica esta parte automotriz y sugiere términos de búsqueda."} ] messages.append({"role": "user", "content": user_content}) 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": VISION_MODEL, "messages": messages, "max_tokens": 500, "temperature": 0.3, }, timeout=30, ) if resp.status_code == 429: wait = (attempt + 1) * 5 if attempt < max_retries - 1: time.sleep(wait) continue return {"message": "El asistente esta 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: 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 al analizar imagen: {str(e)}", "search_query": None, "vehicle": None, } def classify_part(part_number): """Ask AI to identify a part by its OEM number.""" _validate_model(MODEL) prompt = ( f"Given auto part number '{part_number}', identify:\n" f"1) What part it is (name in Spanish)\n" f"2) Which brand makes it\n" f"3) What vehicle it fits\n" f"4) What category it belongs to (e.g. Frenos, Motor, Suspensión, Eléctrico, Filtros, Transmisión)\n" f"Respond ONLY in valid JSON: {{\"name\": \"...\", \"brand\": \"...\", \"vehicle\": \"...\", \"category\": \"...\"}}" ) messages = [ {"role": "system", "content": "Eres un experto en autopartes. Responde SOLO en JSON válido, sin texto adicional."}, {"role": "user", "content": prompt} ] 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": 300, "temperature": 0.2, }, timeout=15, ) if resp.status_code == 429: wait = (attempt + 1) * 5 if attempt < max_retries - 1: time.sleep(wait) continue return {"name": None, "brand": None, "vehicle": None, "category": None} resp.raise_for_status() data = resp.json() content = data["choices"][0]["message"]["content"] 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 Exception: if attempt < max_retries - 1: continue return {"name": None, "brand": None, "vehicle": None, "category": None} 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, }