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/
|
data/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
picoclaw/
|
||||||
|
pymesbot/picoclaw/workspace/
|
||||||
|
|||||||
@@ -14,10 +14,39 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- OPENCLAW_TOKEN=wlillidan1-demo-token-12345
|
- OPENCLAW_TOKEN=wlillidan1-demo-token-12345
|
||||||
- OPENCLAW_WS_URL=ws://openclaw_gateway:18789
|
- OPENCLAW_WS_URL=ws://openclaw_gateway:18789
|
||||||
|
- PICOLAW_URL=http://picoclaw:8080
|
||||||
|
- USE_PICOLAW=true
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- caddy
|
- caddy
|
||||||
- openclaw_net
|
- 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:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,97 +1,324 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import unicodedata
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
logger = logging.getLogger(__name__)
|
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 (
|
from fastapi import (
|
||||||
FastAPI,
|
FastAPI,
|
||||||
WebSocket,
|
WebSocket,
|
||||||
WebSocketDisconnect,
|
WebSocketDisconnect,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
UploadFile,
|
Request,
|
||||||
File,
|
|
||||||
Depends,
|
|
||||||
)
|
)
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
OPENCLAW_WS_URL = os.getenv("OPENCLAW_WS_URL", "ws://openclaw_gateway:18789")
|
# Anthropic API Configuration (Z.ai)
|
||||||
OPENCLAW_TOKEN = os.getenv("OPENCLAW_TOKEN", "wlillidan1-demo-token-12345")
|
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]:
|
async def chat_with_ai(message: str, session_id: str = "pymesbot") -> Optional[str]:
|
||||||
"""Send message to OpenClaw CLI"""
|
"""Send message to Anthropic API with tools"""
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# Forzar al agente a dar respuesta completa
|
# Definir las herramientas disponibles
|
||||||
message_full = (
|
tools = [
|
||||||
message
|
{
|
||||||
+ " Dame una respuesta completa con TODOS los productos del inventario, no resumas."
|
"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:
|
try:
|
||||||
# Usar OpenClaw CLI directamente
|
response = await client.post(
|
||||||
result = subprocess.run(
|
f"{ANTHROPIC_BASE_URL}/v1/messages",
|
||||||
["openclaw", "agent", "--agent", "main", "-m", message_full],
|
headers={
|
||||||
capture_output=True,
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
text=True,
|
"anthropic-version": "2023-06-01",
|
||||||
timeout=60,
|
"Content-Type": "application/json",
|
||||||
cwd="/home/ren/openclaw",
|
},
|
||||||
|
json={
|
||||||
|
"model": "claude-3-5-sonnet-20241022",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"system": SYSTEM_PROMPT,
|
||||||
|
"messages": messages,
|
||||||
|
"tools": tools,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Buscar la respuesta real (ignorar logs de gateway)
|
if response.status_code != 200:
|
||||||
output = result.stdout + result.stderr
|
logger.error(f"API error: {response.status_code} - {response.text}")
|
||||||
|
return (
|
||||||
|
f"Error al procesar la consulta. Por favor, intentá de nuevo."
|
||||||
|
)
|
||||||
|
|
||||||
# Tomar todo después de la última línea de logs
|
result = response.json()
|
||||||
lines = output.split("\n")
|
content = result.get("content", [])
|
||||||
response_lines = []
|
|
||||||
capture = False
|
|
||||||
|
|
||||||
for line in lines:
|
# Verificar si hay tool calls
|
||||||
# Empezar a capturar después de estos patrones
|
tool_calls = [
|
||||||
if "Bind:" in line or "Gateway agent" in line:
|
block for block in content if block.get("type") == "tool_use"
|
||||||
capture = True
|
]
|
||||||
continue
|
text_response = [
|
||||||
if capture:
|
block for block in content if block.get("type") == "text"
|
||||||
# Ignorar líneas de errores
|
]
|
||||||
if "error:" in line.lower() and "Error:" in line:
|
|
||||||
continue
|
|
||||||
response_lines.append(line)
|
|
||||||
|
|
||||||
# Limpiar response
|
if not tool_calls:
|
||||||
response = "\n".join(response_lines).strip()
|
# 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."
|
||||||
|
|
||||||
# Si está vacío, usar todo el output
|
# Procesar tool calls
|
||||||
if not response:
|
tool_results = []
|
||||||
response = output
|
for tool_call in tool_calls:
|
||||||
|
tool_name = tool_call.get("name")
|
||||||
|
tool_input = tool_call.get("input", {})
|
||||||
|
|
||||||
# Limpiar caracteres especiales
|
if tool_name == "buscar_productos":
|
||||||
response = response.encode().decode("utf-8", errors="ignore")
|
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),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Limitar a ~2000 caracteres para mostrar más productos
|
# Agregar el tool use y los resultados a los mensajes
|
||||||
if len(response) > 2000:
|
messages.append({"role": "assistant", "content": json.dumps(content)})
|
||||||
response = response[:2000] + "..."
|
messages.append({"role": "user", "content": json.dumps(tool_results)})
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"[OpenClaw] Error: {e}")
|
logger.error(f"Error in chat_with_ai: {e}")
|
||||||
return "Disculpa, tuve un problema al procesar tu mensaje."
|
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
|
# 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")
|
@app.get("/stats/ventas")
|
||||||
async def ventas_stats(periodo: str = "hoy"):
|
async def ventas_stats(periodo: str = "hoy"):
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
@@ -436,10 +839,10 @@ async def ventas_stats(periodo: str = "hoy"):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root(request: Request):
|
||||||
if templates:
|
if templates:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"chat.html", {"request": {}, "nombre_negocio": "Demo Librería"}
|
"chat.html", {"request": request, "nombre_negocio": "Demo Librería"}
|
||||||
)
|
)
|
||||||
return HTMLResponse("""
|
return HTMLResponse("""
|
||||||
<html>
|
<html>
|
||||||
@@ -512,7 +915,8 @@ async def root():
|
|||||||
const sessionId = Math.random().toString(36).substring(7);
|
const sessionId = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
function connect() {
|
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) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.msg) {
|
if (data.msg) {
|
||||||
@@ -582,107 +986,10 @@ async def websocket_chat(websocket: WebSocket, session_id: str):
|
|||||||
|
|
||||||
logger.info(f"[WS] Processing message: {mensaje}")
|
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:
|
try:
|
||||||
if rows:
|
# Usar AI con tools para respuestas inteligentes
|
||||||
resultados = []
|
logger.info(f"[WS] Processing: {mensaje}")
|
||||||
for row in rows:
|
respuesta = await chat_with_ai(mensaje, session_id)
|
||||||
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": "user", "content": mensaje})
|
||||||
chat_sessions[session_id].append(
|
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({"typing": False})
|
||||||
await websocket.send_json({"msg": respuesta})
|
await websocket.send_json({"msg": respuesta})
|
||||||
logger.info("[WS] Response sent")
|
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:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,17 +2,37 @@
|
|||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "/root/.picoclaw/workspace",
|
"workspace": "/root/.picoclaw/workspace",
|
||||||
"model": "gpt-4o",
|
"model": "claude-3-5-sonnet-20241022",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 4096,
|
||||||
"temperature": 0.7,
|
"temperature": 0.3,
|
||||||
"max_tool_iterations": 20,
|
"max_tool_iterations": 10,
|
||||||
"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."
|
"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": {
|
"providers": {
|
||||||
"openai": {
|
"anthropic": {
|
||||||
"api_key": "6fef8efda3d24eb9ad3d718daf1ae9a1.RcFc7QPe5uZLr2mS",
|
"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