From 0a44fb53046e7668248bccefa3a869d7cf592e18 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Thu, 2 Apr 2026 07:18:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20chatbot=20IA=20con=20OpenRouter=20?= =?UTF-8?q?=E2=80=94=20busqueda=20de=20partes=20por=20lenguaje=20natural?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- pos/blueprints/chat_bp.py | 148 +++++++++++++++++++ pos/config.py | 5 + pos/services/ai_chat.py | 83 +++++++++++ pos/static/css/chat.css | 296 ++++++++++++++++++++++++++++++++++++++ pos/static/js/chat.js | 244 +++++++++++++++++++++++++++++++ 5 files changed, 776 insertions(+) create mode 100644 pos/blueprints/chat_bp.py create mode 100644 pos/services/ai_chat.py create mode 100644 pos/static/css/chat.css create mode 100644 pos/static/js/chat.js diff --git a/pos/blueprints/chat_bp.py b/pos/blueprints/chat_bp.py new file mode 100644 index 0000000..7741836 --- /dev/null +++ b/pos/blueprints/chat_bp.py @@ -0,0 +1,148 @@ +# /home/Autopartes/pos/blueprints/chat_bp.py +"""Chat blueprint: AI-powered parts lookup via natural language. + +Endpoints (all under /pos/api/chat): + POST / — send a message, get AI response + catalog search results +""" + +from flask import Blueprint, request, jsonify, g +from middleware import require_auth +from tenant_db import get_master_conn, get_tenant_conn +from services import catalog_service, ai_chat + +chat_bp = Blueprint("chat", __name__, url_prefix="/pos/api/chat") + + +@chat_bp.route("", methods=["POST"]) +@require_auth("catalog.view") +def chat(): + body = request.get_json(force=True) + user_message = (body.get("message") or "").strip() + if not user_message: + return jsonify({"error": "message required"}), 400 + + history = body.get("history") or [] + + # Call AI + ai_response = ai_chat.chat(user_message, history) + + search_results = [] + vehicle_match = None + + master = None + tenant = None + try: + # If AI suggests a search query, run it against the catalog + search_query = ai_response.get("search_query") + vehicle = ai_response.get("vehicle") + + if search_query or vehicle: + master = get_master_conn() + tenant = get_tenant_conn(g.tenant_id) + branch_id = g.branch_id + + # Try to resolve vehicle to MYE + if vehicle and master: + vehicle_match = _resolve_vehicle(master, vehicle) + + # Run catalog search if we have a search query + if search_query and master and tenant: + try: + results = catalog_service.smart_search( + master, search_query, tenant, branch_id, limit=10 + ) + search_results = results if results else [] + except Exception: + pass # search failure is non-fatal + + except Exception: + pass # DB failure is non-fatal for chat + finally: + if master: + try: + master.close() + except Exception: + pass + if tenant: + try: + tenant.close() + except Exception: + pass + + return jsonify( + { + "response": ai_response.get("message", ""), + "search_results": search_results, + "vehicle": vehicle_match or ai_response.get("vehicle"), + } + ) + + +def _resolve_vehicle(master_conn, vehicle): + """Try to resolve AI-extracted vehicle info to brand_id/model_id in DB.""" + brand_name = (vehicle.get("brand") or "").upper().strip() + model_name = (vehicle.get("model") or "").strip() + year = vehicle.get("year") + + if not brand_name: + return vehicle + + cur = master_conn.cursor() + result = dict(vehicle) + + try: + # Find brand + cur.execute( + "SELECT id_brand, name_brand FROM brands WHERE UPPER(name_brand) = %s", + (brand_name,), + ) + brand_row = cur.fetchone() + if brand_row: + result["brand_id"] = brand_row[0] + result["brand"] = brand_row[1] + + # Find model + if model_name: + cur.execute( + """SELECT m.id_model, m.name_model + FROM models m + WHERE m.brand_id = %s + AND UPPER(m.name_model) LIKE %s + ORDER BY m.name_model + LIMIT 5""", + (brand_row[0], f"%{model_name.upper()}%"), + ) + model_row = cur.fetchone() + if model_row: + result["model_id"] = model_row[0] + result["model"] = model_row[1] + + # Find year -> MYE + if year: + cur.execute( + """SELECT mye.id_mye, y.year_car, e.name_engine, mye.trim_level + FROM model_year_engine mye + JOIN years y ON y.id_year = mye.year_id + JOIN engines e ON e.id_engine = mye.engine_id + WHERE mye.model_id = %s AND y.year_car = %s + ORDER BY e.name_engine + LIMIT 10""", + (model_row[0], int(year)), + ) + mye_rows = cur.fetchall() + if mye_rows: + result["mye_options"] = [ + { + "mye_id": r[0], + "year": r[1], + "engine": r[2], + "trim": r[3], + } + for r in mye_rows + ] + except Exception: + pass + finally: + cur.close() + + return result diff --git a/pos/config.py b/pos/config.py index 35b12bf..845d075 100644 --- a/pos/config.py +++ b/pos/config.py @@ -19,3 +19,8 @@ PIN_LOCKOUT_THRESHOLD = 10 PIN_LOCKOUT_MINUTES = 15 TENANT_TEMPLATE_DB = "tenant_template" + +OPENROUTER_API_KEY = os.environ.get( + "OPENROUTER_API_KEY", + "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" +) diff --git a/pos/services/ai_chat.py b/pos/services/ai_chat.py new file mode 100644 index 0000000..9247940 --- /dev/null +++ b/pos/services/ai_chat.py @@ -0,0 +1,83 @@ +# /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" +MODEL = "anthropic/claude-haiku-4.5" # Fast + cheap for chat + +SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes. + +Cuando el usuario describe lo que necesita, extrae: +1. Marca del vehiculo (si la menciona) +2. Modelo del vehiculo (si lo menciona) +3. Ano del vehiculo (si lo menciona) +4. Tipo de parte que busca + +Responde en espanol, de forma breve y directa. Si puedes identificar el numero de parte OEM, incluyelo. +Si no tienes suficiente informacion, pregunta lo que falte. + +IMPORTANTE: Responde SIEMPRE en formato JSON con esta estructura: +{ + "message": "Tu respuesta al usuario", + "search_query": "texto para buscar en el catalogo" | null, + "vehicle": {"brand": "TOYOTA", "model": "Corolla", "year": 2020} | null +} + +Reglas: +- "message" es tu respuesta conversacional al usuario. +- "search_query" es el texto clave para buscar partes en la base de datos (nombre de parte en ingles, numero OEM, etc). Usa null si no hay busqueda. +- "vehicle" extrae marca, modelo y ano si los menciona. Usa null si no hay vehiculo. +- La marca debe ir en MAYUSCULAS (NISSAN, TOYOTA, CHEVROLET, etc). +- Nombres comunes mexicanos: Tsuru = Sentra/Tsuru, Aveo, Jetta, Pointer, Chevy = Corsa, Vocho = Beetle. +""" + + +def chat(user_message, conversation_history=None): + """Send a message to the AI and get a response with search suggestions.""" + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + if conversation_history: + messages.extend(conversation_history) + messages.append({"role": "user", "content": user_message}) + + 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=15, + ) + resp.raise_for_status() + data = resp.json() + content = data["choices"][0]["message"]["content"] + + # Try to parse JSON response + try: + # Handle markdown-wrapped JSON (```json ... ```) + stripped = content.strip() + if stripped.startswith("```"): + lines = stripped.split("\n") + # Remove first and last lines (``` markers) + 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: + return { + "message": f"Error de conexion: {str(e)}", + "search_query": None, + "vehicle": None, + } diff --git a/pos/static/css/chat.css b/pos/static/css/chat.css new file mode 100644 index 0000000..9981054 --- /dev/null +++ b/pos/static/css/chat.css @@ -0,0 +1,296 @@ +/* ========================================================================== + NEXUS POS — AI Chat Widget + Uses design system tokens from tokens.css + ========================================================================== */ + +/* ─── Floating Button ─── */ + +.chat-fab { + position: fixed; + bottom: 72px; /* above F-keys footer */ + right: var(--space-5); + z-index: 8000; + width: 52px; + height: 52px; + border-radius: var(--radius-full); + border: none; + cursor: pointer; + background: var(--color-accent); + color: #fff; + font-size: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-lg); + transition: transform var(--duration-fast) var(--ease-in-out), + background var(--duration-fast) var(--ease-in-out); +} + +.chat-fab:hover { + transform: scale(1.08); + background: var(--color-accent-hover); +} + +.chat-fab.has-unread::after { + content: ''; + position: absolute; + top: 4px; + right: 4px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-error); +} + +/* ─── Chat Panel ─── */ + +.chat-panel { + position: fixed; + bottom: 72px; + right: var(--space-5); + z-index: 8001; + width: 400px; + height: 520px; + max-height: calc(100vh - 100px); + display: flex; + flex-direction: column; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + overflow: hidden; + transform: translateY(20px) scale(0.95); + opacity: 0; + pointer-events: none; + transition: transform var(--duration-normal) var(--ease-in-out), + opacity var(--duration-normal) var(--ease-in-out); +} + +.chat-panel.open { + transform: translateY(0) scale(1); + opacity: 1; + pointer-events: all; +} + +/* ─── Header ─── */ + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + background: var(--color-accent); + color: #fff; + flex-shrink: 0; +} + +.chat-header h3 { + font-family: var(--font-heading); + font-size: var(--text-body); + font-weight: var(--font-weight-semibold); + margin: 0; +} + +.chat-header-close { + background: none; + border: none; + color: #fff; + font-size: 1.2rem; + cursor: pointer; + padding: var(--space-1); + line-height: 1; + opacity: 0.8; +} + +.chat-header-close:hover { opacity: 1; } + +/* ─── Messages Area ─── */ + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* ─── Message Bubbles ─── */ + +.chat-msg { + max-width: 85%; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-lg); + font-size: var(--text-sm); + line-height: 1.45; + word-wrap: break-word; +} + +.chat-msg.user { + align-self: flex-end; + background: var(--color-accent); + color: #fff; + border-bottom-right-radius: var(--radius-xs); +} + +.chat-msg.ai { + align-self: flex-start; + background: var(--color-bg-muted); + color: var(--color-text-primary); + border-bottom-left-radius: var(--radius-xs); +} + +/* ─── Typing Indicator ─── */ + +.chat-typing { + align-self: flex-start; + display: none; + gap: 4px; + padding: var(--space-2) var(--space-3); + background: var(--color-bg-muted); + border-radius: var(--radius-lg); + border-bottom-left-radius: var(--radius-xs); +} + +.chat-typing.visible { display: flex; } + +.chat-typing span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-text-muted); + animation: chatBounce 1.2s infinite; +} +.chat-typing span:nth-child(2) { animation-delay: 0.2s; } +.chat-typing span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes chatBounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-5px); } +} + +/* ─── Part Result Cards ─── */ + +.chat-parts { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-top: var(--space-2); +} + +.chat-part-card { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + cursor: pointer; + transition: border-color var(--duration-fast) var(--ease-in-out), + background var(--duration-fast) var(--ease-in-out); +} + +.chat-part-card:hover { + border-color: var(--color-accent); + background: var(--color-bg-base); +} + +.chat-part-card .part-number { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--color-accent); + font-weight: var(--font-weight-semibold); +} + +.chat-part-card .part-name { + font-size: var(--text-sm); + color: var(--color-text-primary); + margin-top: 2px; +} + +.chat-part-card .part-stock { + font-size: var(--text-xs); + color: var(--color-text-muted); + margin-top: 2px; +} + +.chat-part-card .part-stock.in-stock { + color: var(--color-success); +} + +/* ─── Input Area ─── */ + +.chat-input-area { + display: flex; + gap: var(--space-2); + padding: var(--space-3); + border-top: 1px solid var(--color-border); + flex-shrink: 0; +} + +.chat-input { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-base); + color: var(--color-text-primary); + font-size: var(--text-sm); + font-family: var(--font-body); + resize: none; + outline: none; + min-height: 38px; + max-height: 80px; +} + +.chat-input:focus { + border-color: var(--color-accent); +} + +.chat-input::placeholder { + color: var(--color-text-muted); +} + +.chat-send-btn { + width: 38px; + height: 38px; + border-radius: var(--radius-md); + border: none; + background: var(--color-accent); + color: #fff; + font-size: 1.1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background var(--duration-fast) var(--ease-in-out); +} + +.chat-send-btn:hover { background: var(--color-accent-hover); } +.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ─── Vehicle Info Banner ─── */ + +.chat-vehicle-banner { + margin-top: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-base); + border: 1px solid var(--color-accent); + border-left: 3px solid var(--color-accent); + border-radius: var(--radius-md); + font-size: var(--text-xs); + color: var(--color-text-secondary); +} + +.chat-vehicle-banner strong { + color: var(--color-text-primary); +} + +/* ─── Responsive ─── */ + +@media (max-width: 480px) { + .chat-panel { + width: calc(100vw - var(--space-4)); + right: var(--space-2); + height: 60vh; + } +} diff --git a/pos/static/js/chat.js b/pos/static/js/chat.js new file mode 100644 index 0000000..0a0eabd --- /dev/null +++ b/pos/static/js/chat.js @@ -0,0 +1,244 @@ +// /home/Autopartes/pos/static/js/chat.js +// AI Chat Widget for Nexus POS — natural language parts lookup + +(function () { + 'use strict'; + + // ─── State ─── + let isOpen = false; + let isSending = false; + const history = []; // conversation history for AI context + + // ─── Build DOM ─── + function init() { + // FAB button + const fab = document.createElement('button'); + fab.className = 'chat-fab'; + fab.id = 'chatFab'; + fab.title = 'Asistente IA'; + fab.innerHTML = '💬'; // speech bubble emoji + fab.setAttribute('aria-label', 'Abrir asistente IA'); + + // Chat panel + const panel = document.createElement('div'); + panel.className = 'chat-panel'; + panel.id = 'chatPanel'; + panel.innerHTML = ` +
+

Asistente IA — Buscar partes

+ +
+
+
Hola, soy el asistente de Nexus. Dime que refaccion necesitas y te ayudo a encontrarla.
+
+ +
+
+
+ + +
+ `; + + document.body.appendChild(fab); + document.body.appendChild(panel); + + // Events + fab.addEventListener('click', toggleChat); + document.getElementById('chatClose').addEventListener('click', toggleChat); + document.getElementById('chatSend').addEventListener('click', sendMessage); + document.getElementById('chatInput').addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + // Auto-resize textarea + document.getElementById('chatInput').addEventListener('input', function () { + this.style.height = 'auto'; + this.style.height = Math.min(this.scrollHeight, 80) + 'px'; + }); + } + + function toggleChat() { + isOpen = !isOpen; + const panel = document.getElementById('chatPanel'); + const fab = document.getElementById('chatFab'); + if (isOpen) { + panel.classList.add('open'); + fab.style.display = 'none'; + document.getElementById('chatInput').focus(); + } else { + panel.classList.remove('open'); + fab.style.display = 'flex'; + } + } + + function getToken() { + // app-init.js stores token in window.__pos or localStorage + if (window.__pos && window.__pos.token) return window.__pos.token; + return localStorage.getItem('pos_token') || ''; + } + + // ─── Send message ─── + async function sendMessage() { + if (isSending) return; + const input = document.getElementById('chatInput'); + const text = input.value.trim(); + if (!text) return; + + input.value = ''; + input.style.height = 'auto'; + + // Add user bubble + addBubble(text, 'user'); + + // Keep history for context (last 10 exchanges) + history.push({ role: 'user', content: text }); + if (history.length > 20) history.splice(0, 2); + + // Show typing + isSending = true; + document.getElementById('chatSend').disabled = true; + showTyping(true); + + try { + const token = getToken(); + const resp = await fetch('/pos/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }, + body: JSON.stringify({ + message: text, + history: history.slice(-10) + }) + }); + + const data = await resp.json(); + if (!resp.ok) { + addBubble('Error: ' + (data.error || resp.statusText), 'ai'); + return; + } + + // AI response bubble + const aiMsg = data.response || 'Sin respuesta.'; + addBubble(aiMsg, 'ai'); + history.push({ role: 'assistant', content: aiMsg }); + + // Vehicle info + if (data.vehicle && data.vehicle.brand_id) { + addVehicleBanner(data.vehicle); + } + + // Search results + if (data.search_results && data.search_results.length > 0) { + addPartResults(data.search_results); + } + + } catch (err) { + addBubble('Error de conexion: ' + err.message, 'ai'); + } finally { + isSending = false; + document.getElementById('chatSend').disabled = false; + showTyping(false); + } + } + + // ─── DOM helpers ─── + function addBubble(text, role) { + const container = document.getElementById('chatMessages'); + const typing = document.getElementById('chatTyping'); + const div = document.createElement('div'); + div.className = 'chat-msg ' + role; + div.textContent = text; + container.insertBefore(div, typing); + scrollToBottom(); + } + + function addVehicleBanner(vehicle) { + const container = document.getElementById('chatMessages'); + const typing = document.getElementById('chatTyping'); + const div = document.createElement('div'); + div.className = 'chat-vehicle-banner'; + + let html = '' + esc(vehicle.brand || '') + ' ' + esc(vehicle.model || '') + ''; + if (vehicle.year) html += ' ' + vehicle.year; + + if (vehicle.mye_options && vehicle.mye_options.length > 0) { + html += '
Motorizaciones encontradas:'; + vehicle.mye_options.forEach(function (opt) { + html += '
• ' + esc(opt.engine); + if (opt.trim) html += ' (' + esc(opt.trim) + ')'; + }); + } + + div.innerHTML = html; + container.insertBefore(div, typing); + scrollToBottom(); + } + + function addPartResults(parts) { + const container = document.getElementById('chatMessages'); + const typing = document.getElementById('chatTyping'); + + const wrapper = document.createElement('div'); + wrapper.className = 'chat-parts'; + + parts.slice(0, 8).forEach(function (p) { + const card = document.createElement('div'); + card.className = 'chat-part-card'; + + const stockQty = p.local_stock || 0; + const stockClass = stockQty > 0 ? 'in-stock' : ''; + const stockText = stockQty > 0 ? (stockQty + ' en stock') : 'Sin stock local'; + const name = p.name_es || p.name_part || ''; + const partNum = p.oem_part_number || ''; + const priceText = p.price_1 ? ('$' + parseFloat(p.price_1).toFixed(2)) : ''; + + card.innerHTML = + '
' + esc(partNum) + (priceText ? ' — ' + priceText : '') + '
' + + '
' + esc(name) + '
' + + '
' + esc(stockText) + '
'; + + // Click to open detail (if catalog page has a detail function) + card.addEventListener('click', function () { + if (p.id_part && typeof window.openPartDetail === 'function') { + window.openPartDetail(p.id_part); + toggleChat(); + } + }); + + wrapper.appendChild(card); + }); + + container.insertBefore(wrapper, typing); + scrollToBottom(); + } + + function showTyping(show) { + const el = document.getElementById('chatTyping'); + if (el) el.classList.toggle('visible', show); + if (show) scrollToBottom(); + } + + function scrollToBottom() { + const el = document.getElementById('chatMessages'); + if (el) el.scrollTop = el.scrollHeight; + } + + function esc(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } + + // ─── Init when DOM ready ─── + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();