feat(pos): chatbot IA con OpenRouter — busqueda de partes por lenguaje natural

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 07:18:55 +00:00
parent 32581739ad
commit 0a44fb5304
5 changed files with 776 additions and 0 deletions

148
pos/blueprints/chat_bp.py Normal file
View File

@@ -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

View File

@@ -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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"
)

83
pos/services/ai_chat.py Normal file
View File

@@ -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,
}

296
pos/static/css/chat.css Normal file
View File

@@ -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;
}
}

244
pos/static/js/chat.js Normal file
View File

@@ -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 = '&#x1F4AC;'; // 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 = `
<div class="chat-header">
<h3>Asistente IA — Buscar partes</h3>
<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>
</div>
<div class="chat-messages" id="chatMessages">
<div class="chat-msg ai">Hola, soy el asistente de Nexus. Dime que refaccion necesitas y te ayudo a encontrarla.</div>
<div class="chat-typing" id="chatTyping">
<span></span><span></span><span></span>
</div>
</div>
<div class="chat-input-area">
<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>
<button class="chat-send-btn" id="chatSend" aria-label="Enviar">&#9654;</button>
</div>
`;
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 = '<strong>' + esc(vehicle.brand || '') + ' ' + esc(vehicle.model || '') + '</strong>';
if (vehicle.year) html += ' ' + vehicle.year;
if (vehicle.mye_options && vehicle.mye_options.length > 0) {
html += '<br>Motorizaciones encontradas:';
vehicle.mye_options.forEach(function (opt) {
html += '<br>&bull; ' + 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 =
'<div class="part-number">' + esc(partNum) + (priceText ? ' &mdash; ' + priceText : '') + '</div>' +
'<div class="part-name">' + esc(name) + '</div>' +
'<div class="part-stock ' + stockClass + '">' + esc(stockText) + '</div>';
// 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();
}
})();