Files
demo/pymesbot/backend/main.py
Renato 14435cd3b2 Integra AI con tools (Anthropic/Z.ai) y elimina código obsoleto
- Reemplaza respuestas programadas por AI con tools
- Implementa buscar_productos y confirmar_venta como tools
- Configura API Anthropic (Z.ai) con glm-4.7
- Elimina código legacy de búsqueda manual
- Limpia variables obsoletas (PicoClaw, OpenClaw)
- Actualiza docker-compose para nuevo flujo
2026-02-15 22:17:36 +01:00

1018 lines
34 KiB
Python

import json
import os
import sqlite3
import logging
import unicodedata
from datetime import datetime
from pathlib import Path
from typing import Optional, List
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def normalize_text(text: str) -> str:
"""Remove accents and normalize text for searching"""
# Normalize to NFKD and remove combining characters (accents)
normalized = unicodedata.normalize("NFKD", text)
return "".join(c for c in normalized if not unicodedata.combining(c))
from fastapi import (
FastAPI,
WebSocket,
WebSocketDisconnect,
HTTPException,
Request,
)
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
import httpx
# Anthropic API Configuration (Z.ai)
ANTHROPIC_API_KEY = "6fef8efda3d24eb9ad3d718daf1ae9a1.RcFc7QPe5uZLr2mS"
ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic"
# System prompt for the AI sales assistant
SYSTEM_PROMPT = """Sos el asistente de ventas de Demo Librería, una librería escolar en Argentina.
Tu trabajo es ayudar al vendedor a atender clientes de forma rápida y con información exacta del inventario.
## HERRAMIENTAS DISPONIBLES
Tenés acceso a estas herramientas. SIEMPRE usarlas antes de responder sobre stock o precios:
1. `buscar_productos` - Busca productos en el inventario por nombre o descripción
Input: query (string) - nombre o descripción del producto
2. `confirmar_venta` - Registra una venta y descuenta el stock
Input: producto_nombre (string), cantidad (number)
## REGLAS OBLIGATORIAS (nunca violarlas)
1. **NUNCA des precios aproximados.** Siempre usá `buscar_productos` para obtener el precio exacto.
Si la herramienta no devuelve el producto, decí que no tenés ese dato, no inventes.
2. **Si no hay stock de un producto**, ofrecé SIEMPRE la alternativa más cercana que sí haya stock.
Ej: "No tenemos Bic azul, pero sí tenemos Faber roja a $750."
3. **Al final de cada consulta exitosa**, preguntá: "¿Se concretó la venta?"
Si dicen sí, preguntá cuántas unidades y usá `confirmar_venta`.
4. **Respondé SIEMPRE en español argentino coloquial pero profesional.**
Usá "vos", "tenés", "querés". No uses "usted" ni español neutro.
5. **Sé conciso.** Una respuesta de chat, no un ensayo. Máximo 3-4 líneas por respuesta.
6. **Cuando el usuario pida múltiples productos** (ej: "10 cuadernos y 5 lápices"),
usá `buscar_productos` para CADA producto por separado y respondé con la info de todos.
## EJEMPLOS DE RESPUESTAS CORRECTAS
Vendedor: "tenés birome bic azul?"
Bot: [usa buscar_productos con "birome bic azul"]
Bot: "Sí, tenemos Bic Cristal azul a $850. Quedan 23. ¿Se vendió?"
Vendedor: "no hay regla 30cm"
Bot: [usa buscar_productos con "regla 30cm"]
Bot: "No tenemos regla de 30cm por ahora 😕 Pero sí hay de 20cm (Maped, $650, stock 8). ¿Te sirve esa?"
Vendedor: "10 cuadernos y 5 lápices"
Bot: [usa buscar_productos con "cuaderno"]
Bot: [usa buscar_productos con "lápiz"]
Bot: "Encontré:
• Cuaderno Rivadavia 48 Hojas - $2500 (stock: 20)
• Lápiz Faber Castell 2B - $450 (stock: 100)
¿Se concretó la venta? ¿Cuántas unidades de cada uno?"""
def db_buscar_productos(query: str) -> list:
"""Busca productos en la base de datos"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query_normalized = normalize_text(query.lower())
search_term = f"%{query_normalized}%"
cursor.execute(
"""
SELECT id, nombre, marca, categoria, precio, stock
FROM productos
WHERE activo = 1 AND (
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(nombre), 'á', 'a'), 'é', 'e'), 'í', 'i'), 'ó', 'o'), 'ú', 'u') LIKE ? OR
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(marca), 'á', 'a'), 'é', 'e'), 'í', 'i'), 'ó', 'o'), 'ú', 'u') LIKE ? OR
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(categoria), 'á', 'a'), 'é', 'e'), 'í', 'i'), 'ó', 'o'), 'ú', 'u') LIKE ?
)
ORDER BY stock DESC
LIMIT 10
""",
(search_term, search_term, search_term),
)
productos = cursor.fetchall()
conn.close()
return [
{
"id": p["id"],
"nombre": p["nombre"],
"marca": p["marca"] or "",
"categoria": p["categoria"],
"precio": p["precio"],
"stock": p["stock"],
}
for p in productos
]
def db_confirmar_venta(producto_nombre: str, cantidad: int) -> dict:
"""Registra una venta en la base de datos"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query_normalized = normalize_text(producto_nombre.lower())
search_term = f"%{query_normalized}%"
cursor.execute(
"""
SELECT * FROM productos
WHERE activo = 1 AND (
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(nombre), 'á', 'a'), 'é', 'e'), 'í', 'i'), 'ó', 'o'), 'ú', 'u') LIKE ?
)
LIMIT 1
""",
(search_term,),
)
producto = cursor.fetchone()
if not producto:
conn.close()
return {"error": f"Producto no encontrado: {producto_nombre}"}
if producto["stock"] < cantidad:
conn.close()
return {
"error": f"Stock insuficiente. Stock actual: {producto['stock']}, solicitado: {cantidad}",
"producto": producto["nombre"],
"stock_disponible": producto["stock"],
}
cursor.execute(
"""
INSERT INTO ventas (producto_id, cantidad, precio_vendido, vendedor)
VALUES (?, ?, ?, ?)
""",
(producto["id"], cantidad, producto["precio"], "AI Bot"),
)
cursor.execute(
"""
UPDATE productos SET stock = stock - ?, updated_at = datetime('now')
WHERE id = ?
""",
(cantidad, producto["id"]),
)
conn.commit()
cursor.execute("SELECT stock FROM productos WHERE id = ?", (producto["id"],))
nuevo_stock = cursor.fetchone()["stock"]
conn.close()
return {
"success": True,
"producto": producto["nombre"],
"cantidad": cantidad,
"precio_unitario": producto["precio"],
"total": cantidad * producto["precio"],
"stock_nuevo": nuevo_stock,
}
async def chat_with_ai(message: str, session_id: str = "pymesbot") -> Optional[str]:
"""Send message to Anthropic API with tools"""
# Definir las herramientas disponibles
tools = [
{
"name": "buscar_productos",
"description": "Busca productos en el inventario por nombre o descripción. Usar SIEMPRE antes de dar precios.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Nombre o descripción del producto a buscar",
}
},
"required": ["query"],
},
},
{
"name": "confirmar_venta",
"description": "Registra una venta y descuenta el stock del inventario.",
"input_schema": {
"type": "object",
"properties": {
"producto_nombre": {
"type": "string",
"description": "Nombre del producto vendido",
},
"cantidad": {
"type": "integer",
"description": "Cantidad de unidades vendidas",
},
},
"required": ["producto_nombre", "cantidad"],
},
},
]
messages = [{"role": "user", "content": message}]
max_iterations = 5
async with httpx.AsyncClient(timeout=60.0) as client:
for iteration in range(max_iterations):
try:
response = await client.post(
f"{ANTHROPIC_BASE_URL}/v1/messages",
headers={
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json={
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 1024,
"system": SYSTEM_PROMPT,
"messages": messages,
"tools": tools,
},
)
if response.status_code != 200:
logger.error(f"API error: {response.status_code} - {response.text}")
return (
f"Error al procesar la consulta. Por favor, intentá de nuevo."
)
result = response.json()
content = result.get("content", [])
# Verificar si hay tool calls
tool_calls = [
block for block in content if block.get("type") == "tool_use"
]
text_response = [
block for block in content if block.get("type") == "text"
]
if not tool_calls:
# No hay tool calls, devolver la respuesta de texto
if text_response:
return text_response[0].get(
"text", "No pude generar una respuesta."
)
return "No pude generar una respuesta."
# Procesar tool calls
tool_results = []
for tool_call in tool_calls:
tool_name = tool_call.get("name")
tool_input = tool_call.get("input", {})
if tool_name == "buscar_productos":
query = tool_input.get("query", "")
productos = db_buscar_productos(query)
tool_results.append(
{
"type": "tool_result",
"tool_use_id": tool_call.get("id"),
"content": json.dumps({"productos": productos}),
}
)
elif tool_name == "confirmar_venta":
producto_nombre = tool_input.get("producto_nombre", "")
cantidad = tool_input.get("cantidad", 1)
resultado = db_confirmar_venta(producto_nombre, cantidad)
tool_results.append(
{
"type": "tool_result",
"tool_use_id": tool_call.get("id"),
"content": json.dumps(resultado),
}
)
# Agregar el tool use y los resultados a los mensajes
messages.append({"role": "assistant", "content": json.dumps(content)})
messages.append({"role": "user", "content": json.dumps(tool_results)})
except Exception as e:
logger.error(f"Error in chat_with_ai: {e}")
return f"Error al procesar la consulta: {str(e)}"
# Si llegamos aquí, se agotaron las iteraciones
return "Lo siento, no pude completar la consulta en el tiempo permitido."
# 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(),
}
# ============================================================================
# TOOLS PARA PICOCLAW
# ============================================================================
class BuscarProductosTool(BaseModel):
query: str
class ConfirmarVentaTool(BaseModel):
producto_nombre: str
cantidad: int
@app.get("/api/tools/listar_productos")
async def tool_listar_productos():
"""Tool para PicoClaw: Lista todos los productos disponibles"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT id, nombre, marca, categoria, precio, stock
FROM productos
WHERE activo = 1 AND stock > 0
ORDER BY categoria, nombre
""")
productos = cursor.fetchall()
conn.close()
result = []
for p in productos:
result.append(
{
"id": p["id"],
"nombre": p["nombre"],
"marca": p["marca"] or "",
"categoria": p["categoria"],
"precio": p["precio"],
"stock": p["stock"],
}
)
return {"productos": result, "total": len(result)}
@app.post("/api/tools/buscar_productos")
async def tool_buscar_productos(req: BuscarProductosTool):
"""Tool para PicoClaw: Busca productos por nombre o descripción"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Normalizar búsqueda (quitar tildes)
query_normalized = normalize_text(req.query.lower())
search_term = f"%{query_normalized}%"
cursor.execute(
"""
SELECT id, nombre, marca, categoria, precio, stock
FROM productos
WHERE activo = 1 AND (
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(nombre), 'á', 'a'), 'é', 'e'), 'í', 'i'), 'ó', 'o'), 'ú', 'u') LIKE ? OR
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(marca), 'á', 'a'), 'é', 'e'), 'í', 'i'), 'ó', 'o'), 'ú', 'u') LIKE ? OR
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(categoria), 'á', 'a'), 'é', 'e'), 'í', 'i'), 'ó', 'o'), 'ú', 'u') LIKE ?
)
ORDER BY stock DESC
LIMIT 10
""",
(search_term, search_term, search_term),
)
productos = cursor.fetchall()
conn.close()
if not productos:
return {
"productos": [],
"mensaje": f"No se encontraron productos para: {req.query}",
}
result = []
for p in productos:
result.append(
{
"id": p["id"],
"nombre": p["nombre"],
"marca": p["marca"] or "",
"categoria": p["categoria"],
"precio": p["precio"],
"stock": p["stock"],
}
)
return {"productos": result, "query": req.query}
@app.post("/api/tools/confirmar_venta")
async def tool_confirmar_venta(req: ConfirmarVentaTool):
"""Tool para PicoClaw: Registra una venta y descuenta stock"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Buscar producto por nombre
query_normalized = normalize_text(req.producto_nombre.lower())
search_term = f"%{query_normalized}%"
cursor.execute(
"""
SELECT * FROM productos
WHERE activo = 1 AND (
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(nombre), 'á', 'a'), 'é', 'e'), 'í', 'i'), 'ó', 'o'), 'ú', 'u') LIKE ?
)
LIMIT 1
""",
(search_term,),
)
producto = cursor.fetchone()
if not producto:
conn.close()
return {"error": f"Producto no encontrado: {req.producto_nombre}"}
if producto["stock"] < req.cantidad:
conn.close()
return {
"error": f"Stock insuficiente. Stock actual: {producto['stock']}, solicitado: {req.cantidad}",
"producto": producto["nombre"],
"stock_disponible": producto["stock"],
}
# Registrar venta
cursor.execute(
"""
INSERT INTO ventas (producto_id, cantidad, precio_vendido, vendedor)
VALUES (?, ?, ?, ?)
""",
(producto["id"], req.cantidad, producto["precio"], "PicoClaw Bot"),
)
# Actualizar stock
cursor.execute(
"""
UPDATE productos SET stock = stock - ?, updated_at = datetime('now')
WHERE id = ?
""",
(req.cantidad, producto["id"]),
)
conn.commit()
# Obtener stock actualizado
cursor.execute("SELECT stock FROM productos WHERE id = ?", (producto["id"],))
nuevo_stock = cursor.fetchone()["stock"]
conn.close()
return {
"success": True,
"producto": producto["nombre"],
"cantidad": req.cantidad,
"precio_unitario": producto["precio"],
"total": req.cantidad * producto["precio"],
"stock_anterior": producto["stock"],
"stock_nuevo": nuevo_stock,
}
# ============================================================================
# FIN TOOLS PICOCLAW
# ============================================================================
@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(request: Request):
if templates:
return templates.TemplateResponse(
"chat.html", {"request": 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() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${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}")
try:
# Usar AI con tools para respuestas inteligentes
logger.info(f"[WS] Processing: {mensaje}")
respuesta = await chat_with_ai(mensaje, session_id)
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: {e}")
await websocket.send_json({"typing": False})
await websocket.send_json(
{"msg": "Ups, algo salió mal. ¿Podés repetir la consulta?"}
)
except WebSocketDisconnect:
pass
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)