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
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ __pycache__/
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
picoclaw/
|
||||
pymesbot/picoclaw/workspace/
|
||||
|
||||
@@ -14,10 +14,39 @@ services:
|
||||
environment:
|
||||
- OPENCLAW_TOKEN=wlillidan1-demo-token-12345
|
||||
- OPENCLAW_WS_URL=ws://openclaw_gateway:18789
|
||||
- PICOLAW_URL=http://picoclaw:8080
|
||||
- USE_PICOLAW=true
|
||||
networks:
|
||||
- default
|
||||
- caddy
|
||||
- openclaw_net
|
||||
depends_on:
|
||||
- picoclaw
|
||||
|
||||
picoclaw:
|
||||
build:
|
||||
context: ./picoclaw
|
||||
dockerfile: Dockerfile
|
||||
container_name: pymesbot_picoclaw
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./pymesbot/picoclaw/config.json:/root/.picoclaw/config.json:ro
|
||||
- ./pymesbot/picoclaw/workspace:/root/.picoclaw/workspace
|
||||
environment:
|
||||
# Configuración Anthropic (Z.ai)
|
||||
- ANTHROPIC_API_KEY=6fef8efda3d24eb9ad3d718daf1ae9a1.RcFc7QPe5uZLr2mS
|
||||
- ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
|
||||
- ANTHROPIC_MODEL=glm-4.7
|
||||
# Variables PicoClaw
|
||||
- PICOCLAW_LOG_LEVEL=info
|
||||
- PICOCLAW_AGENTS_DEFAULTS_MODEL=claude-3-5-sonnet-20241022
|
||||
- PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS=4096
|
||||
- PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE=0.3
|
||||
networks:
|
||||
- default
|
||||
- caddy
|
||||
ports:
|
||||
- "8202:8080"
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
||||
@@ -1,97 +1,324 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import os
|
||||
import sqlite3
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
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,
|
||||
UploadFile,
|
||||
File,
|
||||
Depends,
|
||||
Request,
|
||||
)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
|
||||
|
||||
OPENCLAW_WS_URL = os.getenv("OPENCLAW_WS_URL", "ws://openclaw_gateway:18789")
|
||||
OPENCLAW_TOKEN = os.getenv("OPENCLAW_TOKEN", "wlillidan1-demo-token-12345")
|
||||
# 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 OpenClaw CLI"""
|
||||
import subprocess
|
||||
"""Send message to Anthropic API with tools"""
|
||||
|
||||
# Forzar al agente a dar respuesta completa
|
||||
message_full = (
|
||||
message
|
||||
+ " Dame una respuesta completa con TODOS los productos del inventario, no resumas."
|
||||
)
|
||||
# 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"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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",
|
||||
)
|
||||
messages = [{"role": "user", "content": message}]
|
||||
max_iterations = 5
|
||||
|
||||
# Buscar la respuesta real (ignorar logs de gateway)
|
||||
output = result.stdout + result.stderr
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
# Tomar todo después de la última línea de logs
|
||||
lines = output.split("\n")
|
||||
response_lines = []
|
||||
capture = False
|
||||
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."
|
||||
)
|
||||
|
||||
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)
|
||||
result = response.json()
|
||||
content = result.get("content", [])
|
||||
|
||||
# Limpiar response
|
||||
response = "\n".join(response_lines).strip()
|
||||
# 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"
|
||||
]
|
||||
|
||||
# Si está vacío, usar todo el output
|
||||
if not response:
|
||||
response = output
|
||||
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."
|
||||
|
||||
# Limpiar caracteres especiales
|
||||
response = response.encode().decode("utf-8", errors="ignore")
|
||||
# Procesar tool calls
|
||||
tool_results = []
|
||||
for tool_call in tool_calls:
|
||||
tool_name = tool_call.get("name")
|
||||
tool_input = tool_call.get("input", {})
|
||||
|
||||
# Limitar a ~2000 caracteres para mostrar más productos
|
||||
if len(response) > 2000:
|
||||
response = response[:2000] + "..."
|
||||
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),
|
||||
}
|
||||
)
|
||||
|
||||
return response if response else "Disculpa, no pude obtener una respuesta."
|
||||
# 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 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."
|
||||
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
|
||||
@@ -400,6 +627,182 @@ async def confirmar_venta(req: ConfirmarVentaRequest):
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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)
|
||||
@@ -436,10 +839,10 @@ async def ventas_stats(periodo: str = "hoy"):
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
async def root(request: Request):
|
||||
if templates:
|
||||
return templates.TemplateResponse(
|
||||
"chat.html", {"request": {}, "nombre_negocio": "Demo Librería"}
|
||||
"chat.html", {"request": request, "nombre_negocio": "Demo Librería"}
|
||||
)
|
||||
return HTMLResponse("""
|
||||
<html>
|
||||
@@ -512,7 +915,8 @@ async def root():
|
||||
const sessionId = Math.random().toString(36).substring(7);
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(`wss://${window.location.host}/chat/ws/${sessionId}`);
|
||||
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) {
|
||||
@@ -582,107 +986,10 @@ async def websocket_chat(websocket: WebSocket, session_id: str):
|
||||
|
||||
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'}"
|
||||
)
|
||||
# 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(
|
||||
@@ -692,10 +999,13 @@ async def websocket_chat(websocket: WebSocket, session_id: str):
|
||||
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 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
|
||||
|
||||
@@ -2,17 +2,37 @@
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/root/.picoclaw/workspace",
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20,
|
||||
"system": "Sos el asistente de ventas de Demo PymesBot, una librería en Argentina.\n\nREGLAS:\n1. Respondé en español argentino.\n2. Sé conciso (máximo 3 líneas).\n3. Siempre preguntá si se concretó la venta."
|
||||
"model": "claude-3-5-sonnet-20241022",
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.3,
|
||||
"max_tool_iterations": 10,
|
||||
"system": "Sos el asistente de ventas de Demo Librería, una librería escolar en Argentina.\n\nTu trabajo es ayudar al vendedor a atender clientes de forma rápida y con información exacta del inventario.\n\n## HERRAMIENTAS DISPONIBLES\nTenés acceso a estas herramientas. SIEMPRE usarlas antes de responder sobre stock o precios:\n- `buscar_productos`: busca productos en el inventario por nombre o descripción\n- `confirmar_venta`: registra una venta y descuenta el stock\n- `listar_productos`: muestra todos los productos disponibles\n\n## REGLAS OBLIGATORIAS (nunca violarlas)\n\n1. **NUNCA des precios aproximados.** Siempre usá `buscar_productos` para obtener el precio exacto. \n Si la herramienta no devuelve el producto, decí que no tenés ese dato, no inventes.\n\n2. **Si no hay stock de un producto**, ofrecé SIEMPRE la alternativa más cercana que sí haya stock.\n Ej: \"No tenemos Bic azul, pero sí tenemos Faber roja a $750.\"\n\n3. **Al final de cada consulta exitosa**, preguntá: \"¿Se concretó la venta?\"\n Si dicen sí, preguntá cuántas unidades y llamá a `confirmar_venta`.\n\n4. **Respondé SIEMPRE en español argentino coloquial pero profesional.**\n Usá \"vos\", \"tenés\", \"querés\". No uses \"usted\" ni español neutro.\n\n5. **Sé conciso.** Una respuesta de chat, no un ensayo. Máximo 3-4 líneas por respuesta.\n\n6. **Cuando el usuario pida múltiples productos** (ej: \"10 cuadernos y 5 lápices\"),\n buscá CADA producto por separado y respondé con la info de todos.\n\n## EJEMPLOS DE RESPUESTAS CORRECTAS\n\nVendedor: \"tenés birome bic azul?\"\nBot: [llama buscar_productos con \"birome bic azul\"]\nBot: \"Sí, tenemos Bic Cristal azul a $850. Quedan 23. ¿Se vendió?\"\n\nVendedor: \"no hay regla 30cm\"\nBot: [llama buscar_productos con \"regla 30cm\"]\nBot: \"No tenemos regla de 30cm por ahora 😕 Pero sí hay de 20cm (Maped, $650, stock 8). ¿Te sirve esa?\"\n\nVendedor: \"10 cuadernos y 5 lápices\"\nBot: [llama buscar_productos con \"cuaderno\"]\nBot: [llama buscar_productos con \"lápiz\"]\nBot: \"Encontré:\n• Cuaderno Rivadavia 48 Hojas - $2500 (stock: 20)\n• Lápiz Faber Castell 2B - $450 (stock: 100)\n\n¿Se concretó la venta? ¿Cuántas unidades de cada uno?\""
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"anthropic": {
|
||||
"api_key": "6fef8efda3d24eb9ad3d718daf1ae9a1.RcFc7QPe5uZLr2mS",
|
||||
"api_base": "https://api.z.ai/v1"
|
||||
"api_base": "https://api.z.ai/api/anthropic"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"custom": {
|
||||
"buscar_productos": {
|
||||
"command": "curl -s -X POST -H 'Content-Type: application/json' -d '{\"query\": \"{{query}}\"}' http://pymesbot_backend:8000/api/tools/buscar_productos",
|
||||
"description": "Busca productos en el inventario por nombre o descripción. Usar SIEMPRE antes de dar precios. Input: query (string) - nombre o descripción del producto a buscar. Devuelve lista de productos con nombre, marca, precio y stock."
|
||||
},
|
||||
"confirmar_venta": {
|
||||
"command": "curl -s -X POST -H 'Content-Type: application/json' -d '{\"producto_nombre\": \"{{producto_nombre}}\", \"cantidad\": {{cantidad}}}' http://pymesbot_backend:8000/api/tools/confirmar_venta",
|
||||
"description": "Registra una venta y descuenta el stock del inventario. Input: producto_nombre (string), cantidad (number). Devuelve confirmación con detalles de la venta."
|
||||
},
|
||||
"listar_productos": {
|
||||
"command": "curl -s http://pymesbot_backend:8000/api/tools/listar_productos",
|
||||
"description": "Muestra todos los productos disponibles en el inventario. No requiere input. Devuelve lista completa de productos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user