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 Z.AI API directly""" import httpx ZAI_API_KEY = "6fef8efda3d24eb9ad3d718daf1ae9a1.RcFc7QPe5uZLr2mS" ZAI_API_URL = "https://api.z.ai/api/anthropic/v1/messages" system_prompt = """Sos el asistente de ventas de Demo Librería en Argentina. Productos: biromes, lápices, cuadernos, colores, reglas, etc. Precios en pesos argentinos. Respondé de forma útil, breve y siempre preguntá si se concretó la venta.""" try: response = httpx.post( ZAI_API_URL, headers={ "Authorization": f"Bearer {ZAI_API_KEY}", "Content-Type": "application/json", }, json={ "model": "glm-4.7", "max_tokens": 200, "system": system_prompt, "messages": [{"role": "user", "content": message}], }, timeout=30.0, ) if response.status_code == 200: data = response.json() content = data.get("content", []) if content and len(content) > 0: return content[0].get("text", "") else: logger.error(f"[Z.AI] API error: {response.status_code} - {response.text}") return None except Exception as e: logger.error(f"[Z.AI] Error: {e}") return None return None # 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(""" 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}") # 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)