708 lines
24 KiB
Python
708 lines
24 KiB
Python
import asyncio
|
|
import json
|
|
import sqlite3
|
|
import os
|
|
import logging
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from fastapi import (
|
|
FastAPI,
|
|
WebSocket,
|
|
WebSocketDisconnect,
|
|
HTTPException,
|
|
UploadFile,
|
|
File,
|
|
Depends,
|
|
)
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
|
|
OPENCLAW_WS_URL = os.getenv("OPENCLAW_WS_URL", "ws://openclaw_gateway:18789")
|
|
OPENCLAW_TOKEN = os.getenv("OPENCLAW_TOKEN", "wlillidan1-demo-token-12345")
|
|
|
|
|
|
async def chat_with_ai(message: str, session_id: str = "pymesbot") -> Optional[str]:
|
|
"""Send message to OpenClaw CLI"""
|
|
import subprocess
|
|
|
|
# Forzar al agente a dar respuesta completa
|
|
message_full = (
|
|
message
|
|
+ " Dame una respuesta completa con TODOS los productos del inventario, no resumas."
|
|
)
|
|
|
|
try:
|
|
# Usar OpenClaw CLI directamente
|
|
result = subprocess.run(
|
|
["openclaw", "agent", "--agent", "main", "-m", message_full],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
cwd="/home/ren/openclaw",
|
|
)
|
|
|
|
# Buscar la respuesta real (ignorar logs de gateway)
|
|
output = result.stdout + result.stderr
|
|
|
|
# Tomar todo después de la última línea de logs
|
|
lines = output.split("\n")
|
|
response_lines = []
|
|
capture = False
|
|
|
|
for line in lines:
|
|
# Empezar a capturar después de estos patrones
|
|
if "Bind:" in line or "Gateway agent" in line:
|
|
capture = True
|
|
continue
|
|
if capture:
|
|
# Ignorar líneas de errores
|
|
if "error:" in line.lower() and "Error:" in line:
|
|
continue
|
|
response_lines.append(line)
|
|
|
|
# Limpiar response
|
|
response = "\n".join(response_lines).strip()
|
|
|
|
# Si está vacío, usar todo el output
|
|
if not response:
|
|
response = output
|
|
|
|
# Limpiar caracteres especiales
|
|
response = response.encode().decode("utf-8", errors="ignore")
|
|
|
|
# Limitar a ~2000 caracteres para mostrar más productos
|
|
if len(response) > 2000:
|
|
response = response[:2000] + "..."
|
|
|
|
return response if response else "Disculpa, no pude obtener una respuesta."
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logger.error("[OpenClaw] Timeout")
|
|
return "La consulta tardó demasiado. ¿Podés esperar un momento e intentarlo de nuevo?"
|
|
except Exception as e:
|
|
logger.error(f"[OpenClaw] Error: {e}")
|
|
return "Disculpa, tuve un problema al procesar tu mensaje."
|
|
|
|
|
|
# Alias para compatibilidad
|
|
chat_with_openclaw = chat_with_ai
|
|
|
|
|
|
DATA_DIR = Path(__file__).parent / "data"
|
|
DB_PATH = DATA_DIR / "stock.db"
|
|
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
|
|
|
|
def get_db():
|
|
if not DB_PATH.exists():
|
|
init_db()
|
|
return DB_PATH
|
|
|
|
|
|
def init_db():
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH)
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS productos (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
nombre TEXT NOT NULL,
|
|
marca TEXT,
|
|
categoria TEXT NOT NULL DEFAULT 'general',
|
|
precio REAL NOT NULL,
|
|
stock INTEGER NOT NULL DEFAULT 0,
|
|
variantes TEXT,
|
|
codigo TEXT,
|
|
activo INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
""")
|
|
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS ventas (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
producto_id INTEGER NOT NULL,
|
|
cantidad INTEGER NOT NULL,
|
|
precio_vendido REAL NOT NULL,
|
|
vendedor TEXT,
|
|
notas TEXT,
|
|
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (producto_id) REFERENCES productos(id)
|
|
)
|
|
""")
|
|
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS config (
|
|
clave TEXT PRIMARY KEY,
|
|
valor TEXT NOT NULL
|
|
)
|
|
""")
|
|
|
|
default_config = [
|
|
("nombre_negocio", "Demo Librería"),
|
|
("moneda", "ARS"),
|
|
("moneda_simbolo", "$"),
|
|
("alerta_stock_minimo", "5"),
|
|
("vendedor_pin", "1234"),
|
|
("admin_password", "admin123"),
|
|
("rubro", "libreria"),
|
|
]
|
|
|
|
for clave, valor in default_config:
|
|
cursor.execute(
|
|
"INSERT OR IGNORE INTO config (clave, valor) VALUES (?, ?)", (clave, valor)
|
|
)
|
|
|
|
sample_products = [
|
|
(
|
|
"Birome Bic Cristal Azul",
|
|
"Bic",
|
|
"escritura",
|
|
850,
|
|
50,
|
|
'{"color": ["azul"]}',
|
|
"7501031311309",
|
|
),
|
|
(
|
|
"Birome Bic Cristal Rojo",
|
|
"Bic",
|
|
"escritura",
|
|
850,
|
|
30,
|
|
'{"color": ["rojo"]}',
|
|
"7501031311316",
|
|
),
|
|
(
|
|
"Birome Bic Cristal Negro",
|
|
"Bic",
|
|
"escritura",
|
|
850,
|
|
45,
|
|
'{"color": ["negro"]}',
|
|
"7501031311323",
|
|
),
|
|
(
|
|
"Cuaderno Rivadavia 48 Hojas",
|
|
"Rivadavia",
|
|
"cuadernos",
|
|
2500,
|
|
20,
|
|
'{"tipo": ["rayado", "blanco"]}',
|
|
None,
|
|
),
|
|
("Lápiz Faber Castell 2B", "Faber Castell", "escritura", 450, 100, None, None),
|
|
("Goma de borrar Staedtler", "Staedtler", "escritura", 320, 25, None, None),
|
|
("Regla 30cm", "Maped", "geometria", 650, 15, None, None),
|
|
("Compás Prisma", "Prisma", "geometria", 2500, 8, None, None),
|
|
("Caja de colores 12", "Maped", "colores", 3200, 18, None, None),
|
|
("Papel glasé x 20", "Laprida", "colores", 850, 40, None, None),
|
|
]
|
|
|
|
for nombre, marca, categoria, precio, stock, variantes, codigo in sample_products:
|
|
v = variantes if variantes else None
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO productos (nombre, marca, categoria, precio, stock, variantes, codigo)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(nombre, marca, categoria, precio, stock, v, codigo),
|
|
)
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
class BuscarStockRequest(BaseModel):
|
|
query: str
|
|
limit: int = 5
|
|
|
|
|
|
class ConfirmarVentaRequest(BaseModel):
|
|
producto_id: int
|
|
cantidad: int
|
|
precio_vendido: float
|
|
vendedor: str = "vendedor"
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
pin: str
|
|
|
|
|
|
app = FastAPI(title="PymesBot Backend")
|
|
|
|
if TEMPLATES_DIR.exists():
|
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
else:
|
|
templates = None
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
init_db()
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT valor FROM config WHERE clave = 'nombre_negocio'")
|
|
nombre = cursor.fetchone()
|
|
conn.close()
|
|
return {"status": "ok", "negocio": nombre[0] if nombre else "Demo"}
|
|
|
|
|
|
@app.get("/stock/search")
|
|
async def buscar_stock(q: str, limit: int = 5):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.cursor()
|
|
|
|
q_lower = q.lower()
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM productos
|
|
WHERE activo = 1 AND (
|
|
LOWER(nombre) LIKE ? OR
|
|
LOWER(marca) LIKE ? OR
|
|
LOWER(categoria) LIKE ?
|
|
)
|
|
ORDER BY
|
|
CASE
|
|
WHEN LOWER(nombre) LIKE ? THEN 1
|
|
ELSE 2
|
|
END,
|
|
stock DESC
|
|
LIMIT ?
|
|
""",
|
|
(f"%{q_lower}%", f"%{q_lower}%", f"%{q_lower}%", f"%{q_lower}%", limit),
|
|
)
|
|
|
|
rows = cursor.fetchall()
|
|
resultados = []
|
|
|
|
for row in rows:
|
|
resultados.append(
|
|
{
|
|
"id": row["id"],
|
|
"nombre": row["nombre"],
|
|
"marca": row["marca"],
|
|
"categoria": row["categoria"],
|
|
"precio": row["precio"],
|
|
"precio_formateado": f"${row['precio']:.0f}",
|
|
"stock": row["stock"],
|
|
"variantes": json.loads(row["variantes"]) if row["variantes"] else {},
|
|
"hay_stock": row["stock"] > 0,
|
|
"promo_activa": None,
|
|
}
|
|
)
|
|
|
|
conn.close()
|
|
|
|
return {"resultados": resultados, "total": len(resultados), "query": q}
|
|
|
|
|
|
@app.get("/stock/producto/{producto_id}")
|
|
async def get_producto(producto_id: int):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("SELECT * FROM productos WHERE id = ?", (producto_id,))
|
|
row = cursor.fetchone()
|
|
conn.close()
|
|
|
|
if not row:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"Producto con id {producto_id} no encontrado"
|
|
)
|
|
|
|
return {
|
|
"id": row["id"],
|
|
"nombre": row["nombre"],
|
|
"marca": row["marca"],
|
|
"categoria": row["categoria"],
|
|
"precio": row["precio"],
|
|
"stock": row["stock"],
|
|
"variantes": json.loads(row["variantes"]) if row["variantes"] else {},
|
|
"codigo": row["codigo"],
|
|
"activo": bool(row["activo"]),
|
|
"created_at": row["created_at"],
|
|
"updated_at": row["updated_at"],
|
|
}
|
|
|
|
|
|
@app.post("/venta/confirmar")
|
|
async def confirmar_venta(req: ConfirmarVentaRequest):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute(
|
|
"SELECT * FROM productos WHERE id = ? AND activo = 1", (req.producto_id,)
|
|
)
|
|
producto = cursor.fetchone()
|
|
|
|
if not producto:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Producto no encontrado")
|
|
|
|
if producto[4] < req.cantidad:
|
|
conn.close()
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Stock insuficiente. Stock actual: {producto[4]}, solicitado: {req.cantidad}",
|
|
)
|
|
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO ventas (producto_id, cantidad, precio_vendido, vendedor)
|
|
VALUES (?, ?, ?, ?)
|
|
""",
|
|
(req.producto_id, req.cantidad, req.precio_vendido, req.vendedor),
|
|
)
|
|
|
|
cursor.execute(
|
|
"""
|
|
UPDATE productos SET stock = stock - ?, updated_at = datetime('now')
|
|
WHERE id = ?
|
|
""",
|
|
(req.cantidad, req.producto_id),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
cursor.execute("SELECT stock FROM productos WHERE id = ?", (req.producto_id,))
|
|
nuevo_stock = cursor.fetchone()[0]
|
|
|
|
venta_id = cursor.lastrowid
|
|
conn.close()
|
|
|
|
return {
|
|
"venta_id": venta_id,
|
|
"producto": producto[1],
|
|
"cantidad": req.cantidad,
|
|
"precio_vendido": req.precio_vendido,
|
|
"total": req.cantidad * req.precio_vendido,
|
|
"stock_anterior": producto[4],
|
|
"stock_nuevo": nuevo_stock,
|
|
"timestamp": datetime.now().isoformat(),
|
|
}
|
|
|
|
|
|
@app.get("/stats/ventas")
|
|
async def ventas_stats(periodo: str = "hoy"):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
cursor = conn.cursor()
|
|
|
|
if periodo == "hoy":
|
|
cursor.execute("""
|
|
SELECT COUNT(*), SUM(cantidad * precio_vendido), AVG(cantidad * precio_vendido)
|
|
FROM ventas
|
|
WHERE date(timestamp) = date('now')
|
|
""")
|
|
elif periodo == "semana":
|
|
cursor.execute("""
|
|
SELECT COUNT(*), SUM(cantidad * precio_vendido), AVG(cantidad * precio_vendido)
|
|
FROM ventas
|
|
WHERE timestamp >= datetime('now', '-7 days')
|
|
""")
|
|
else:
|
|
cursor.execute("""
|
|
SELECT COUNT(*), SUM(cantidad * precio_vendido), AVG(cantidad * precio_vendido)
|
|
FROM ventas
|
|
WHERE timestamp >= datetime('now', '-30 days')
|
|
""")
|
|
|
|
row = cursor.fetchone()
|
|
conn.close()
|
|
|
|
return {
|
|
"periodo": periodo,
|
|
"total_ventas": row[0] or 0,
|
|
"total_pesos": row[1] or 0,
|
|
"ticket_promedio": row[2] or 0,
|
|
}
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
if templates:
|
|
return templates.TemplateResponse(
|
|
"chat.html", {"request": {}, "nombre_negocio": "Demo Librería"}
|
|
)
|
|
return HTMLResponse("""
|
|
<html>
|
|
<head>
|
|
<title>PymesBot - Demo</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
|
|
.container { max-width: 800px; margin: 0 auto; padding: 20px; }
|
|
.chat-box { background: white; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); height: 70vh; display: flex; flex-direction: column; }
|
|
.chat-header { padding: 16px 20px; border-bottom: 1px solid #eee; display: flex; align-items: center; gap: 12px; }
|
|
.chat-header h1 { font-size: 18px; color: #333; }
|
|
.chat-messages { flex: 1; overflow-y: auto; padding: 20px; }
|
|
.message { margin-bottom: 16px; max-width: 80%; }
|
|
.message.bot { margin-right: auto; }
|
|
.message.user { margin-left: auto; text-align: right; }
|
|
.message .bubble { display: inline-block; padding: 12px 16px; border-radius: 18px; }
|
|
.message.bot .bubble { background: #f0f0f0; color: #333; }
|
|
.message.user .bubble { background: #007bff; color: white; }
|
|
.typing { color: #888; font-style: italic; padding: 8px; }
|
|
.chat-input { padding: 16px 20px; border-top: 1px solid #eee; display: flex; gap: 12px; }
|
|
.chat-input input { flex: 1; padding: 12px 16px; border: 1px solid #ddd; border-radius: 24px; outline: none; font-size: 16px; }
|
|
.chat-input button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 24px; cursor: pointer; font-size: 16px; }
|
|
.chat-input button:hover { background: #0056b3; }
|
|
.sidebar { background: white; border-radius: 12px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
.sidebar h2 { font-size: 16px; margin-bottom: 12px; color: #333; }
|
|
.stat { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
|
|
.stat-value { font-weight: 600; color: #007bff; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
|
<div class="chat-box">
|
|
<div class="chat-header">
|
|
<h1>🛒 Demo Librería</h1>
|
|
</div>
|
|
<div class="chat-messages" id="messages">
|
|
<div class="message bot">
|
|
<div class="bubble">¡Hola! Soy el asistente de ventas de Demo Librería. ¿Qué estás buscando?</div>
|
|
</div>
|
|
</div>
|
|
<div class="chat-input">
|
|
<input type="text" id="msgInput" placeholder="Escribí tu consulta..." autocomplete="off">
|
|
<button onclick="sendMessage()">Enviar</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="sidebar">
|
|
<h2>📊 Hoy</h2>
|
|
<div class="stat">
|
|
<span>Ventas</span>
|
|
<span class="stat-value" id="ventas-hoy">0</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span>Total</span>
|
|
<span class="stat-value" id="total-hoy">$0</span>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar">
|
|
<h2>⚠️ Stock bajo</h2>
|
|
<div id="alertas-stock"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
let ws = null;
|
|
const sessionId = Math.random().toString(36).substring(7);
|
|
|
|
function connect() {
|
|
ws = new WebSocket(`wss://${window.location.host}/chat/ws/${sessionId}`);
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
if (data.msg) {
|
|
addMessage(data.msg, 'bot');
|
|
}
|
|
if (data.stats) {
|
|
document.getElementById('ventas-hoy').textContent = data.stats.total_ventas;
|
|
document.getElementById('total-hoy').textContent = '$' + data.stats.total_pesos.toLocaleString();
|
|
}
|
|
};
|
|
}
|
|
|
|
function addMessage(text, type) {
|
|
const div = document.createElement('div');
|
|
div.className = `message ${type}`;
|
|
div.innerHTML = `<div class="bubble">${text}</div>`;
|
|
document.getElementById('messages').appendChild(div);
|
|
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
|
|
}
|
|
|
|
function sendMessage() {
|
|
const input = document.getElementById('msgInput');
|
|
const text = input.value.trim();
|
|
if (!text || !ws) return;
|
|
addMessage(text, 'user');
|
|
ws.send(JSON.stringify({ msg: text, session_id: sessionId }));
|
|
input.value = '';
|
|
}
|
|
|
|
document.getElementById('msgInput').addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') sendMessage();
|
|
});
|
|
|
|
connect();
|
|
|
|
fetch('/stats/ventas?periodo=hoy')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
document.getElementById('ventas-hoy').textContent = data.total_ventas;
|
|
document.getElementById('total-hoy').textContent = '$' + data.total_pesos.toLocaleString();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
""")
|
|
|
|
|
|
chat_sessions = {}
|
|
|
|
|
|
@app.websocket("/chat/ws/{session_id}")
|
|
async def websocket_chat(websocket: WebSocket, session_id: str):
|
|
await websocket.accept()
|
|
logger.info(f"[WS] Accepted connection for session: {session_id}")
|
|
|
|
if session_id not in chat_sessions:
|
|
chat_sessions[session_id] = []
|
|
|
|
try:
|
|
while True:
|
|
data = await websocket.receive_json()
|
|
logger.info(f"[WS] Received: {data}")
|
|
mensaje = data.get("msg", "")
|
|
session_id = data.get("session_id", session_id)
|
|
|
|
await websocket.send_json({"typing": True})
|
|
|
|
logger.info(f"[WS] Processing message: {mensaje}")
|
|
|
|
# Mejorar búsqueda: manejar plurales y buscar en nombre, marca y categoría
|
|
mensaje_lower = mensaje.lower()
|
|
|
|
# Mapeo de plurales a singular
|
|
plurales = {
|
|
"lapices": "lapiz",
|
|
"lápices": "lápiz",
|
|
"lápices": "lápiz",
|
|
"cuadernos": "cuaderno",
|
|
"biromes": "birome",
|
|
"gomas": "goma",
|
|
"marcadores": "marcador",
|
|
"colores": "colores",
|
|
"fibras": "fibras",
|
|
"reglas": "regla",
|
|
"tijeras": "tijera",
|
|
}
|
|
|
|
for plural, singular in plurales.items():
|
|
mensaje_lower = mensaje_lower.replace(plural, singular)
|
|
|
|
# Extraer palabras clave de búsqueda (más de 3 letras)
|
|
palabras = mensaje_lower.split()
|
|
stopwords = {
|
|
"hola",
|
|
"buenos",
|
|
"buenas",
|
|
"que",
|
|
"tiene",
|
|
"tenes",
|
|
"busco",
|
|
"quiero",
|
|
"necesito",
|
|
"para",
|
|
"una",
|
|
"clienta",
|
|
"vino",
|
|
"buscar",
|
|
"necesita",
|
|
"quanto",
|
|
"cuanto",
|
|
"cuántos",
|
|
"cuántas",
|
|
}
|
|
terminos = [p for p in palabras if len(p) > 3 and p not in stopwords]
|
|
|
|
if not terminos:
|
|
# Si no hay términos claros, usar el mensaje completo para buscar
|
|
terminos = [mensaje_lower]
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.cursor()
|
|
|
|
# Buscar todos los términos juntos
|
|
all_results = []
|
|
for term in terminos:
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM productos
|
|
WHERE activo = 1 AND (
|
|
LOWER(nombre) LIKE ? OR
|
|
LOWER(marca) LIKE ? OR
|
|
LOWER(categoria) LIKE ?
|
|
)
|
|
ORDER BY stock DESC
|
|
LIMIT 10
|
|
""",
|
|
(f"%{term}%", f"%{term}%", f"%{term}%"),
|
|
)
|
|
rows = cursor.fetchall()
|
|
for row in rows:
|
|
if row["id"] not in [r["id"] for r in all_results]:
|
|
all_results.append(dict(row))
|
|
|
|
rows = all_results[:5]
|
|
|
|
logger.info(f"[WS] Found {len(rows)} rows")
|
|
|
|
try:
|
|
if rows:
|
|
resultados = []
|
|
for row in rows:
|
|
resultados.append(
|
|
f"• {row['nombre']} - ${row['precio']:.0f} (stock: {row['stock']})"
|
|
)
|
|
|
|
respuesta = f"Encontre estos productos:\n" + "\n".join(resultados)
|
|
respuesta += "\n\n¿Se concretó la venta? ¿Cuántas unidades?"
|
|
logger.info(f"[WS] Respuesta: {respuesta[:50]}")
|
|
else:
|
|
# Usar OpenClaw cuando no hay match en DB
|
|
logger.info(
|
|
f"[WS] No products found, trying OpenClaw for: {mensaje}"
|
|
)
|
|
respuesta = await chat_with_openclaw(mensaje, session_id)
|
|
if not respuesta:
|
|
respuesta = "No encontré productos con esa descripción. ¿Podés ser más específico?"
|
|
logger.info(
|
|
f"[WS] OpenClaw response: {respuesta[:50] if respuesta else 'None'}"
|
|
)
|
|
|
|
chat_sessions[session_id].append({"role": "user", "content": mensaje})
|
|
chat_sessions[session_id].append(
|
|
{"role": "assistant", "content": respuesta}
|
|
)
|
|
|
|
await websocket.send_json({"typing": False})
|
|
await websocket.send_json({"msg": respuesta})
|
|
logger.info("[WS] Response sent")
|
|
except Exception as e:
|
|
logger.error(f"[WS] Error sending response: {e}")
|
|
|
|
conn.close()
|
|
|
|
except WebSocketDisconnect:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|