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(""" PymesBot - Demo

🛒 Demo Librería

¡Hola! Soy el asistente de ventas de Demo Librería. ¿Qué estás buscando?
""") 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)