Files
Autoparts-DB/pos/services/ai_chat.py
consultoria-as f9589f4a4e feat(pos): add inventory-aware chat context (#31) and VIN decoder (#17)
Chat now fetches tenant inventory summary (brands, counts, low-stock)
and injects it into the AI system prompt so responses prioritize local
stock. VIN decoder uses free NHTSA vPIC API to decode 17-char VINs and
auto-fills the vehicle selector dropdowns when a catalog match is found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:14:45 +00:00

220 lines
8.9 KiB
Python

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