hermes/sofia: arreglo de herramientas (plugins skeen_odoo y skeen_wacrm por HTTP con la llave del entorno, odoo_api_call reescrito sin XML-RPC, 5 skills v2.0.0 con los parametros reales de la API publica); el agente ya consulta servicios y disponibilidad reales y puede agendar

This commit is contained in:
2026-10-11 21:42:32 +00:00
parent 16ad780d72
commit 5fc0d2a780
22 changed files with 990 additions and 322 deletions

View File

@@ -1,108 +1,97 @@
"""Cliente HTTP (compat) para la API pública de SKEEN/Odoo.
NOTA IMPORTANTE (2026-10):
Hermes **no** carga archivos ``.py`` sueltos de este directorio. La
herramienta real que usa el agente es el plugin ``skeen_odoo`` en
``plugins/skeen_odoo/`` (expuesto a Hermes vía
``~/.hermes/plugins/skeen_odoo`` -> symlink a este repo). Este módulo se
conserva solo por compatibilidad para scripts que importaban el nombre
histórico ``odoo_api_call``.
Este archivo ANTES usaba XML-RPC con ``common.authenticate(ODOO_DB, 1, ...)``
(login ``1`` inválido, ODOO_API_KEY inexistente). Ahora habla HTTP contra
``/skeen/api/v1/*`` con ``Authorization: Bearer $ODOO_API_KEY``.
Endpoints reales (ver skeen_odoo/addons/skeen_whatsapp/controllers/main.py):
GET /skeen/api/v1/health
GET /skeen/api/v1/services
POST /skeen/api/v1/available_slots
POST /skeen/api/v1/create_appointment
POST /skeen/api/v1/cancel_appointment
POST /skeen/api/v1/patient_balance
POST /skeen/api/v1/wallet_redeem
"""
from __future__ import annotations
import os
import xmlrpc.client
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional
ODOO_URL = os.environ.get("ODOO_URL", "http://odoo:8069")
ODOO_DB = os.environ.get("ODOO_DB", "skeen")
ODOO_API_KEY = os.environ.get("ODOO_API_KEY")
import requests
DEFAULT_TIMEOUT = 30
def odoo_api_call(model: str, method: str, args: List = None, kwargs: Dict = None) -> Any:
"""
Plugin de Hermes para comunicarse con Odoo via XML-RPC.
Uso desde skills:
odoo_api_call(model="salon.booking", method="get_available_slots",
args=[service_id, date_from, date_to])
def odoo_api_call(endpoint: str, payload: Optional[Dict[str, Any]] = None,
method: str = "POST", timeout: int = DEFAULT_TIMEOUT) -> Any:
"""Llama un endpoint de la API pública y devuelve el JSON.
Args:
model: Nombre del modelo Odoo (ej: 'res.partner', 'salon.booking')
method: Metodo a ejecutar (ej: 'search_read', 'create', 'write')
args: Lista de argumentos posicionales
kwargs: Diccionario de argumentos nombrados
Returns:
Resultado de la llamada XML-RPC
endpoint: Ruta relativa, p. ej. ``/skeen/api/v1/services``.
payload: Cuerpo JSON (None para GET).
method: Verbo HTTP (GET/POST).
"""
if args is None:
args = []
if kwargs is None:
kwargs = {}
base = (os.environ.get("ODOO_URL") or "http://localhost:8069").rstrip("/")
key = os.environ.get("ODOO_API_KEY")
if not key:
raise RuntimeError("ODOO_API_KEY no está configurada en el entorno.")
if not ODOO_API_KEY:
raise ValueError("ODOO_API_KEY no configurada en variables de entorno")
# Autenticacion
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
uid = common.authenticate(ODOO_DB, 1, ODOO_API_KEY, {})
if not uid:
raise ValueError("Autenticacion con Odoo fallida")
# Ejecutar metodo
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
result = models.execute_kw(
ODOO_DB, uid, ODOO_API_KEY,
model, method, args, kwargs
resp = requests.request(
method,
base + endpoint,
json=payload,
headers={"Authorization": f"Bearer {key}", "Accept": "application/json"},
timeout=timeout,
)
return result
resp.raise_for_status()
return resp.json()
def odoo_search_read(model: str, domain: List = None, fields: List[str] = None,
limit: int = 100) -> List[Dict]:
"""
Atajo para search_read en Odoo.
Uso:
odoo_search_read(model="res.partner",
domain=[["phone", "=", "+526641234567"]],
fields=["name", "email", "phone"])
"""
if domain is None:
domain = []
if fields is None:
fields = []
return odoo_api_call(
model=model,
method="search_read",
args=[domain, fields],
kwargs={"limit": limit}
)
def health() -> Any:
return odoo_api_call("/skeen/api/v1/health", method="GET")
def odoo_create(model: str, values: Dict) -> int:
"""
Atajo para create en Odoo.
Uso:
odoo_create(model="res.partner",
values={"name": "Sofia Garcia", "phone": "+526641234567"})
"""
return odoo_api_call(model=model, method="create", args=[values])
def services() -> Any:
return odoo_api_call("/skeen/api/v1/services", method="GET")
def odoo_write(model: str, ids: List[int], values: Dict) -> bool:
"""
Atajo para write en Odoo.
Uso:
odoo_write(model="salon.booking", ids=[123],
values={"state": "cancelled"})
"""
return odoo_api_call(model=model, method="write", args=[ids, values])
def available_slots(service_id: int, date_from: str, date_to: Optional[str] = None) -> Any:
return odoo_api_call("/skeen/api/v1/available_slots", {
"service_id": int(service_id),
"date_from": date_from,
"date_to": date_to or date_from,
})
# Registro del plugin para Hermes
PLUGIN_NAME = "odoo_api_call"
PLUGIN_VERSION = "1.0.0"
PLUGIN_DESCRIPTION = "Comunicacion con Odoo ERP via XML-RPC"
def create_appointment(phone: str, name: str, service_id: int, date: str, time: str,
**extra: Any) -> Any:
payload = {"phone": phone, "name": name, "service_id": int(service_id),
"date": date, "time": time}
payload.update({k: v for k, v in extra.items() if v is not None})
return odoo_api_call("/skeen/api/v1/create_appointment", payload)
# Herramientas expuestas (para hermes tools)
TOOLS = {
"odoo_api_call": odoo_api_call,
"odoo_search_read": odoo_search_read,
"odoo_create": odoo_create,
"odoo_write": odoo_write,
}
def cancel_appointment(phone: str, booking_id: Optional[int] = None) -> Any:
payload: Dict[str, Any] = {"phone": phone}
if booking_id is not None:
payload["booking_id"] = int(booking_id)
return odoo_api_call("/skeen/api/v1/cancel_appointment", payload)
def patient_balance(phone: str) -> Any:
return odoo_api_call("/skeen/api/v1/patient_balance", {"phone": phone})
def wallet_redeem(phone: str, points: int) -> Any:
return odoo_api_call("/skeen/api/v1/wallet_redeem",
{"phone": phone, "points": int(points)})

View File

@@ -0,0 +1,380 @@
"""Plugin Hermes ``skeen_odoo`` — acceso HTTP a la API pública de Odoo.
Registra el toolset ``skeen_odoo`` en la toolset registry de Hermes. Cada tool
es una llamada HTTP acotada a un endpoint de ``/skeen/api/v1/*``; la llave
(``ODOO_API_KEY``) y la URL (``ODOO_URL``) se leen del entorno de Hermes
(``~/.hermes/.env``). No hay acceso a shell ni a modelos arbitrarios de Odoo.
"""
from __future__ import annotations
from typing import Any, Dict, List
from tools.registry import tool_error, tool_result
from . import client
from .client import SkeenOdooError
TOOLSET = "skeen_odoo"
def _check_configured() -> bool:
"""Los tools solo aparecen cuando hay URL y llave configuradas."""
try:
return client.is_configured()
except Exception:
return False
def _fail(exc: Exception) -> str:
if isinstance(exc, SkeenOdooError):
extra = {}
if exc.status_code is not None:
extra["status_code"] = exc.status_code
return tool_error(str(exc), **extra)
return tool_error(f"Error inesperado del plugin skeen_odoo: {exc}")
def _body(resp: Any) -> Dict[str, Any]:
"""Normaliza la respuesta; si la API reportó ``status: error``, lo señala."""
if isinstance(resp, dict):
return resp
return {"status": "success", "data": resp}
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def handle_health(args: Dict[str, Any], **kw: Any) -> str:
try:
resp = _body(client.call("GET", "/skeen/api/v1/health"))
return tool_result(success=True, **resp)
except Exception as exc:
return _fail(exc)
def handle_services(args: Dict[str, Any], **kw: Any) -> str:
"""Lista el catálogo real de servicios, con filtro y tope de resultados."""
search = str(args.get("search") or "").strip().lower()
category = str(args.get("category") or "").strip().lower()
try:
min_price = float(args.get("min_price") or 0)
except (TypeError, ValueError):
min_price = 0.0
try:
limit = int(args.get("limit") or 20)
except (TypeError, ValueError):
limit = 20
limit = max(1, min(limit, 50))
try:
resp = _body(client.call("GET", "/skeen/api/v1/services"))
except Exception as exc:
return _fail(exc)
if resp.get("status") == "error":
return tool_error(resp.get("message") or "La API devolvió un error")
services: List[Dict[str, Any]] = resp.get("services") or []
total_active = len(services)
sellable = sum(1 for s in services if (s.get("price") or 0) > 0)
def matches(s: Dict[str, Any]) -> bool:
if (s.get("price") or 0) < min_price:
return False
if category and str(s.get("category") or "").lower() != category:
return False
if search:
haystack = " ".join(str(s.get(k) or "") for k in ("name", "code", "category")).lower()
return search in haystack
return True
filtered = [s for s in services if matches(s)]
def compact(s: Dict[str, Any]) -> Dict[str, Any]:
out = {
"id": s.get("id"),
"code": s.get("code"),
"name": s.get("name"),
"category": s.get("category"),
"price": s.get("price"),
"duration_min": s.get("duration_min"),
}
if (s.get("package_sessions") or 0) > 0:
out["package_price"] = s.get("package_price")
out["package_sessions"] = s.get("package_sessions")
desc = str(s.get("description") or "").strip()
if desc:
out["description"] = desc[:140]
return out
return tool_result(
success=True,
total_active=total_active,
sellable_services=sellable,
matching=len(filtered),
returned=len(filtered[:limit]),
note="Usa search/category/min_price para acotar; la lista va recortada.",
services=[compact(s) for s in filtered[:limit]],
)
def handle_available_slots(args: Dict[str, Any], **kw: Any) -> str:
service_id = args.get("service_id")
date_from = args.get("date_from") or args.get("date")
date_to = args.get("date_to") or date_from
if service_id in (None, "", 0, "0"):
return tool_error("service_id es obligatorio (ID numérico del servicio).")
if not date_from:
return tool_error("date_from es obligatorio (formato AAAA-MM-DD).")
payload = {
"service_id": int(service_id),
"date_from": str(date_from),
"date_to": str(date_to),
}
if args.get("doctor_id") not in (None, ""):
payload["doctor_id"] = int(args["doctor_id"])
try:
resp = _body(client.call("POST", "/skeen/api/v1/available_slots", payload))
except Exception as exc:
return _fail(exc)
if resp.get("status") == "error":
return tool_error(resp.get("message") or "La API devolvió un error")
slots = resp.get("slots") or []
return tool_result(success=True, count=len(slots), slots=slots)
def handle_create_appointment(args: Dict[str, Any], **kw: Any) -> str:
phone = str(args.get("phone") or "").strip()
name = str(args.get("name") or "").strip()
service_id = args.get("service_id")
date = str(args.get("date") or "").strip()
time = str(args.get("time") or "").strip()
missing = [k for k, v in (
("phone", phone), ("name", name), ("service_id", service_id),
("date", date), ("time", time),
) if not v]
if missing:
return tool_error("Faltan campos obligatorios: " + ", ".join(missing))
payload = {
"phone": phone,
"name": name,
"service_id": int(service_id),
"date": date,
"time": time,
}
if args.get("doctor_id") not in (None, ""):
payload["doctor_id"] = int(args["doctor_id"])
if args.get("duration") not in (None, ""):
payload["duration"] = int(args["duration"])
try:
resp = _body(client.call("POST", "/skeen/api/v1/create_appointment", payload))
except Exception as exc:
return _fail(exc)
if resp.get("status") == "error":
return tool_error(resp.get("message") or "La API devolvió un error")
return tool_result(
success=True,
booking_id=resp.get("booking_id"),
reference=resp.get("reference"),
patient_id=resp.get("patient_id"),
)
def handle_cancel_appointment(args: Dict[str, Any], **kw: Any) -> str:
phone = str(args.get("phone") or "").strip()
if not phone:
return tool_error("phone es obligatorio (teléfono del paciente).")
payload: Dict[str, Any] = {"phone": phone}
if args.get("booking_id") not in (None, ""):
payload["booking_id"] = int(args["booking_id"])
try:
resp = _body(client.call("POST", "/skeen/api/v1/cancel_appointment", payload))
except Exception as exc:
return _fail(exc)
if resp.get("status") == "error":
return tool_error(resp.get("message") or "La API devolvió un error")
return tool_result(success=True, cancelled_booking=resp.get("cancelled_booking"))
def handle_patient_balance(args: Dict[str, Any], **kw: Any) -> str:
phone = str(args.get("phone") or "").strip()
if not phone:
return tool_error("phone es obligatorio (teléfono del paciente).")
try:
resp = _body(client.call("POST", "/skeen/api/v1/patient_balance", {"phone": phone}))
except Exception as exc:
return _fail(exc)
if resp.get("status") == "error":
return tool_error(resp.get("message") or "La API devolvió un error")
return tool_result(
success=True,
pending_balance=resp.get("pending_balance"),
pending_invoices=resp.get("pending_invoices") or [],
wallet_points=resp.get("wallet_points"),
wallet_mxn=resp.get("wallet_mxn"),
)
def handle_wallet_redeem(args: Dict[str, Any], **kw: Any) -> str:
phone = str(args.get("phone") or "").strip()
points = args.get("points")
if not phone:
return tool_error("phone es obligatorio (teléfono del paciente).")
if points in (None, ""):
return tool_error("points es obligatorio (puntos a redimir).")
try:
resp = _body(client.call(
"POST", "/skeen/api/v1/wallet_redeem",
{"phone": phone, "points": int(points)},
))
except Exception as exc:
return _fail(exc)
if resp.get("status") == "error":
return tool_error(resp.get("message") or "La API devolvió un error")
return tool_result(
success=True,
remaining_points=resp.get("remaining_points"),
remaining_mxn=resp.get("remaining_mxn"),
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
_STR = {"type": "string"}
HEALTH_SCHEMA = {
"name": "skeen_odoo_health",
"description": "Verifica que la API pública de SKEEN/Odoo responde (health check).",
"parameters": {"type": "object", "properties": {}, "required": []},
}
SERVICES_SCHEMA = {
"name": "skeen_odoo_services",
"description": (
"Lista el catálogo REAL de servicios de la clínica desde Odoo, con ids "
"numéricos vivos. Devuelve `total_active` (todos los servicios activos, "
"incl. históricos sin precio), `sellable_services` (con precio > 0) y la "
"lista filtrada en `services`. Úsalo para responder cuántos servicios hay "
"(`total_active`), buscar un tratamiento o resolver el service_id antes de "
"agendar. Para citas usa `min_price=1` y así ignorar entradas históricas."
),
"parameters": {
"type": "object",
"properties": {
"search": {"type": "string", "description": "Texto a buscar en nombre, código o categoría."},
"category": {"type": "string", "description": "Categoría exacta (ej. consulta, tratamiento, procedimiento, paquete)."},
"min_price": {"type": "number", "description": "Precio mínimo (usa 1 para excluir servicios históricos sin precio)."},
"limit": {"type": "integer", "description": "Máximo de servicios a devolver (1-50, por defecto 20). Usa filtros para acotar."},
},
"required": [],
},
}
AVAILABLE_SLOTS_SCHEMA = {
"name": "skeen_odoo_available_slots",
"description": (
"Consulta horarios disponibles reales para un servicio en un rango de "
"fechas. Requiere el ID numérico del servicio (obtenlo con "
"skeen_odoo_services)."
),
"parameters": {
"type": "object",
"properties": {
"service_id": {"type": "integer", "description": "ID numérico del servicio."},
"date_from": {"type": "string", "description": "Fecha inicial AAAA-MM-DD."},
"date_to": {"type": "string", "description": "Fecha final AAAA-MM-DD (por defecto igual a date_from)."},
"doctor_id": {"type": "integer", "description": "Opcional: ID del médico."},
},
"required": ["service_id", "date_from"],
},
}
CREATE_APPOINTMENT_SCHEMA = {
"name": "skeen_odoo_create_appointment",
"description": (
"Crea una cita real en Odoo. Si el teléfono no existe, crea al paciente. "
"Verifica antes la disponibilidad con skeen_odoo_available_slots."
),
"parameters": {
"type": "object",
"properties": {
"phone": {"type": "string", "description": "Teléfono del paciente en formato E.164 (ej. +526641234567)."},
"name": {"type": "string", "description": "Nombre completo del paciente."},
"service_id": {"type": "integer", "description": "ID numérico del servicio."},
"date": {"type": "string", "description": "Fecha de la cita AAAA-MM-DD."},
"time": {"type": "string", "description": "Hora de inicio HH:MM (24h)."},
"doctor_id": {"type": "integer", "description": "Opcional: ID del médico."},
"duration": {"type": "integer", "description": "Opcional: duración en minutos."},
},
"required": ["phone", "name", "service_id", "date", "time"],
},
}
CANCEL_APPOINTMENT_SCHEMA = {
"name": "skeen_odoo_cancel_appointment",
"description": (
"Cancela la cita activa de un paciente (la más próxima). Si se indica "
"booking_id, cancela esa cita en particular."
),
"parameters": {
"type": "object",
"properties": {
"phone": {"type": "string", "description": "Teléfono del paciente."},
"booking_id": {"type": "integer", "description": "Opcional: ID de la cita a cancelar."},
},
"required": ["phone"],
},
}
PATIENT_BALANCE_SCHEMA = {
"name": "skeen_odoo_patient_balance",
"description": "Consulta el saldo pendiente, las citas por pagar y los puntos del monedero de un paciente.",
"parameters": {
"type": "object",
"properties": {"phone": {"type": "string", "description": "Teléfono del paciente."}},
"required": ["phone"],
},
}
WALLET_REDEEM_SCHEMA = {
"name": "skeen_odoo_wallet_redeem",
"description": "Redime puntos del monedero de un paciente. Confirma el saldo antes con skeen_odoo_patient_balance.",
"parameters": {
"type": "object",
"properties": {
"phone": {"type": "string", "description": "Teléfono del paciente."},
"points": {"type": "integer", "description": "Puntos a redimir."},
},
"required": ["phone", "points"],
},
}
_TOOLS = [
("skeen_odoo_health", HEALTH_SCHEMA, handle_health, "🩺"),
("skeen_odoo_services", SERVICES_SCHEMA, handle_services, "📋"),
("skeen_odoo_available_slots", AVAILABLE_SLOTS_SCHEMA, handle_available_slots, "📅"),
("skeen_odoo_create_appointment", CREATE_APPOINTMENT_SCHEMA, handle_create_appointment, "✅"),
("skeen_odoo_cancel_appointment", CANCEL_APPOINTMENT_SCHEMA, handle_cancel_appointment, "🚫"),
("skeen_odoo_patient_balance", PATIENT_BALANCE_SCHEMA, handle_patient_balance, "💳"),
("skeen_odoo_wallet_redeem", WALLET_REDEEM_SCHEMA, handle_wallet_redeem, "🎁"),
]
def register(ctx) -> None:
"""Registra los tools del plugin. Llamado una vez por el cargador de Hermes."""
for name, schema, handler, emoji in _TOOLS:
ctx.register_tool(
name=name,
toolset=TOOLSET,
schema=schema,
handler=handler,
check_fn=_check_configured,
requires_env=["ODOO_URL", "ODOO_API_KEY"],
emoji=emoji,
)

View File

@@ -0,0 +1,88 @@
"""Cliente HTTP para la API pública de SKEEN en Odoo.
Contrato (fuente de verdad: ``skeen_odoo/addons/skeen_whatsapp/controllers/main.py``):
* Base: ``$ODOO_URL/skeen/api/v1``
* Autenticación: ``Authorization: Bearer $ODOO_API_KEY``
* Respuestas JSON planas con ``status`` = ``success`` | ``error``.
Los endpoints de escritura (crear/cancelar cita, monedero) son los únicos que
mutan datos; el resto son de solo lectura.
"""
from __future__ import annotations
import os
from typing import Any, Optional
import requests
DEFAULT_TIMEOUT = 30
class SkeenOdooError(RuntimeError):
"""Error de la API de SKEEN/Odoo, con el código HTTP cuando aplica."""
def __init__(self, message: str, *, status_code: Optional[int] = None,
payload: Any = None) -> None:
super().__init__(message)
self.status_code = status_code
self.payload = payload
def base_url() -> str:
return (os.environ.get("ODOO_URL") or "http://localhost:8069").rstrip("/")
def api_key() -> Optional[str]:
return os.environ.get("ODOO_API_KEY")
def is_configured() -> bool:
"""True cuando hay URL y llave: habilita los tools de este plugin."""
return bool(base_url()) and bool(api_key())
def call(method: str, path: str, payload: Optional[dict] = None,
*, timeout: int = DEFAULT_TIMEOUT) -> Any:
"""Ejecuta una llamada autenticada y devuelve el JSON de la respuesta."""
key = api_key()
if not key:
raise SkeenOdooError(
"ODOO_API_KEY no está configurada en el entorno de Hermes "
"(~/.hermes/.env)."
)
url = base_url() + path
headers = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
if payload is not None:
headers["Content-Type"] = "application/json"
try:
resp = requests.request(method, url, json=payload, headers=headers,
timeout=timeout)
except requests.RequestException as exc:
raise SkeenOdooError(
f"No se pudo contactar la API de Odoo en {url}: {exc}"
) from exc
if not resp.ok:
raise SkeenOdooError(_error_detail(resp), status_code=resp.status_code,
payload=_safe_json(resp))
return _safe_json(resp)
def _safe_json(resp: requests.Response) -> Any:
try:
return resp.json()
except ValueError:
return {"raw": resp.text[:500]}
def _error_detail(resp: requests.Response) -> str:
body = _safe_json(resp)
if isinstance(body, dict):
msg = body.get("message") or body.get("error")
if msg:
return f"HTTP {resp.status_code}: {msg}"
return f"HTTP {resp.status_code} al llamar la API de Odoo"

View File

@@ -0,0 +1,20 @@
name: skeen_odoo
version: 1.0.0
description: >-
SKEEN — herramientas HTTP contra la API pública de Odoo (/skeen/api/v1/*):
salud, catálogo de servicios, disponibilidad, agendar/cancelar cita, saldo de
paciente y redención de monedero. Lee ODOO_URL y ODOO_API_KEY del entorno de
Hermes (Authorization: Bearer). Sin acceso a shell ni a modelos arbitrarios.
author: SKEEN
kind: standalone
requires_env:
- ODOO_URL
- ODOO_API_KEY
provides_tools:
- skeen_odoo_health
- skeen_odoo_services
- skeen_odoo_available_slots
- skeen_odoo_create_appointment
- skeen_odoo_cancel_appointment
- skeen_odoo_patient_balance
- skeen_odoo_wallet_redeem

View File

@@ -0,0 +1,99 @@
"""Plugin Hermes ``skeen_wacrm`` — envío de WhatsApp vía WACRM.
Un solo tool (``skeen_wacrm_send``) que hace ``POST {WACRM_URL}/api/v1/messages``
con ``Authorization: Bearer $WACRM_API_KEY``. Con ``simulate=True`` usa
``/api/v1/messages/simulate``, que NO envía nada real (útil para pruebas).
"""
from __future__ import annotations
import os
from typing import Any, Dict
import requests
from tools.registry import tool_error, tool_result
TOOLSET = "skeen_wacrm"
DEFAULT_TIMEOUT = 30
def _base_url() -> str:
return (os.environ.get("WACRM_URL") or "http://localhost:3000").rstrip("/")
def _api_key() -> str:
return os.environ.get("WACRM_API_KEY") or ""
def _check_configured() -> bool:
return bool(_base_url()) and bool(_api_key())
SEND_SCHEMA = {
"name": "skeen_wacrm_send",
"description": (
"Envía un mensaje de texto de WhatsApp a un número mediante WACRM. "
"Úsalo solo para mensajes proactivos (recordatorios, confirmaciones "
"fuera del turno). Nunca inventes el número: usa el teléfono del "
"paciente que ya conoces."
),
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Teléfono destino en formato E.164 (ej. +526641234567)."},
"text": {"type": "string", "description": "Contenido del mensaje."},
"simulate": {"type": "boolean", "description": "Prueba: no envía nada real (endpoint simulate). Por defecto false."},
},
"required": ["to", "text"],
},
}
def handle_send(args: Dict[str, Any], **kw: Any) -> str:
to = str(args.get("to") or "").strip()
text = str(args.get("text") or "").strip()
if not to or not text:
return tool_error("'to' y 'text' son obligatorios.")
simulate = bool(args.get("simulate"))
endpoint = "/api/v1/messages/simulate" if simulate else "/api/v1/messages"
try:
resp = requests.post(
_base_url() + endpoint,
headers={
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
},
json={"to": to, "type": "text", "text": text},
timeout=DEFAULT_TIMEOUT,
)
except requests.RequestException as exc:
return tool_error(f"No se pudo contactar WACRM: {exc}")
if not resp.ok:
detail = ""
try:
detail = (resp.json().get("error") or {}).get("message") or ""
except ValueError:
detail = resp.text[:200]
return tool_error(f"WACRM respondió HTTP {resp.status_code}: {detail}",
status_code=resp.status_code)
try:
body = resp.json()
except ValueError:
body = {"raw": resp.text[:300]}
return tool_result(success=True, simulated=simulate, response=body)
def register(ctx) -> None:
ctx.register_tool(
name="skeen_wacrm_send",
toolset=TOOLSET,
schema=SEND_SCHEMA,
handler=handle_send,
check_fn=_check_configured,
requires_env=["WACRM_URL", "WACRM_API_KEY"],
emoji="📤",
)

View File

@@ -0,0 +1,14 @@
name: skeen_wacrm
version: 1.0.0
description: >-
SKEEN — envío de mensajes de WhatsApp vía WACRM (§/api/v1/messages). Lee
WACRM_URL y WACRM_API_KEY del entorno de Hermes. Pensado para mensajes
proactivos (recordatorios, confirmaciones fuera del flujo de respuesta);
las respuestas normales del agente ya las entrega el bridge.
author: SKEEN
kind: standalone
requires_env:
- WACRM_URL
- WACRM_API_KEY
provides_tools:
- skeen_wacrm_send

View File

@@ -0,0 +1,13 @@
{
"archive": "skills.tar.gz",
"archive_bytes": 6646,
"created_at": "2026-09-11T09:33:23.968508+00:00",
"cron_jobs": {
"backed_up": false,
"jobs_count": 0,
"reason": "no cron/jobs.json present"
},
"id": "2026-09-11T09-33-23Z",
"reason": "pre-curator-run",
"skill_files": 5
}

View File

@@ -0,0 +1,13 @@
{
"archive": "skills.tar.gz",
"archive_bytes": 6646,
"created_at": "2026-09-18T09:52:18.364653+00:00",
"cron_jobs": {
"backed_up": false,
"jobs_count": 0,
"reason": "no cron/jobs.json present"
},
"id": "2026-09-18T09-52-18Z",
"reason": "pre-curator-run",
"skill_files": 5
}

View File

@@ -0,0 +1,13 @@
{
"archive": "skills.tar.gz",
"archive_bytes": 6646,
"created_at": "2026-09-25T09:57:24.566730+00:00",
"cron_jobs": {
"backed_up": false,
"jobs_count": 0,
"reason": "no cron/jobs.json present"
},
"id": "2026-09-25T09-57-24Z",
"reason": "pre-curator-run",
"skill_files": 5
}

View File

@@ -0,0 +1,13 @@
{
"archive": "skills.tar.gz",
"archive_bytes": 6649,
"created_at": "2026-10-02T10:03:51.398289+00:00",
"cron_jobs": {
"backed_up": false,
"jobs_count": 0,
"reason": "no cron/jobs.json present"
},
"id": "2026-10-02T10-03-51Z",
"reason": "pre-curator-run",
"skill_files": 5
}

View File

@@ -0,0 +1,13 @@
{
"archive": "skills.tar.gz",
"archive_bytes": 6644,
"created_at": "2026-10-09T10:26:38.684078+00:00",
"cron_jobs": {
"backed_up": false,
"jobs_count": 0,
"reason": "no cron/jobs.json present"
},
"id": "2026-10-09T10-26-38Z",
"reason": "pre-curator-run",
"skill_files": 5
}

View File

@@ -0,0 +1,9 @@
{
"last_report_path": "/root/.hermes/logs/curator/20261009-102638",
"last_run_at": "2026-10-09T10:26:38.561235+00:00",
"last_run_duration_seconds": 0.200708,
"last_run_summary": "auto: no changes; llm: skipped (consolidation off)",
"last_run_summary_shown_at": null,
"paused": false,
"run_count": 11
}

View File

@@ -0,0 +1 @@
{"version": 1, "installed": {}}

View File

@@ -0,0 +1 @@
{"taps": []}

View File

@@ -0,0 +1,28 @@
{
"skeen-agendar": {
"archived_at": null,
"created_at": "2026-10-11T21:07:44.555659+00:00",
"created_by": null,
"last_patched_at": "2026-10-11T21:16:36.704807+00:00",
"last_used_at": "2026-10-11T21:41:42.735963+00:00",
"last_viewed_at": "2026-10-11T21:41:42.731127+00:00",
"patch_count": 2,
"pinned": false,
"state": "active",
"use_count": 4,
"view_count": 4
},
"skeen-rag": {
"archived_at": null,
"created_at": "2026-07-05T09:01:39.652729+00:00",
"created_by": null,
"last_patched_at": null,
"last_used_at": "2026-10-11T21:41:24.584008+00:00",
"last_viewed_at": "2026-10-11T21:41:24.579525+00:00",
"patch_count": 0,
"pinned": false,
"state": "active",
"use_count": 16,
"view_count": 6
}
}

View File

View File

@@ -2,102 +2,93 @@
name: skeen-agendar
description: >
Agendamiento de citas para SKEEN Derma Experts.
Consulta disponibilidad, crea citas, reagenda y cancela.
Consulta disponibilidad, crea citas individuales o paquetes de sesiones, reagenda y cancela.
Usar cuando el usuario quiera agendar, reagendar o cancelar una cita.
version: 1.0.0
version: 2.0.0
metadata:
hermes:
requires:
bins: [python]
bins: []
envVars:
- name: ODOO_URL
required: true
- name: ODOO_DB
required: true
- name: ODOO_API_KEY
required: true
platforms: [whatsapp, webchat, cli]
activation: keyword
keywords: ["agendar", "cita", "hora", "reagendar", "cancelar", "disponibilidad"]
keywords: ["agendar", "cita", "hora", "reagendar", "cancelar", "disponibilidad", "paquete", "sesiones"]
---
# SKEEN — Agendamiento de Citas
## Instrucciones de Uso
Herramientas disponibles (toolset `skeen_odoo`, hablan HTTP contra la API real de Odoo):
### PASO 1: Identificar intencion
- `skeen_odoo_services` — catálogo real de servicios con ids numéricos.
- `skeen_odoo_available_slots` — horarios libres reales.
- `skeen_odoo_create_appointment` — crea la cita.
- `skeen_odoo_cancel_appointment` — cancela la cita.
- `skeen_odoo_patient_balance` — saldo/monedero del paciente.
- "Quiero agendar" / "Necesito una cita" → NUEVA CITA
## PASO 1: Identificar intención
- "Quiero agendar" → NUEVA CITA
- "Quiero cambiar mi cita" / "Reagendar" → REAGENDAR
- "Cancelar mi cita" → CANCELAR
- "Tengo una cita para manana?" → CONSULTAR
- "Tengo una cita para mañana?" → CONSULTAR
- "Quiero el paquete de [tratamiento]" → NUEVA CITA CON PAQUETE
### PASO 2: Para NUEVA CITA
## PASO 2: NUEVA CITA
1. Pregunta que tipo de servicio desea (si no lo especifico):
- Consulta dermatologica general ($800 MXN, 30 min)
- Tratamiento facial ($1,200-$3,500 MXN, 45-90 min)
- Depilacion laser (desde $500 MXN por zona, 15-60 min)
- Peel quimico ($1,500 MXN, 30 min)
- O consulta de valoracion gratuita (15 min)
1. Identifica el servicio. Si no es obvio, búscalo con la herramienta:
`skeen_odoo_services(search="<palabra clave>", min_price=1)`.
Usa `min_price=1` para excluir servicios históricos sin precio (el catálogo de
Odoo trae miles de registros antiguos en $0). **`service_id` es el id numérico
que devuelve la herramienta** (NO uses slugs como "cons-derm"). Nunca inventes
el id.
2. Si el servicio tiene `package_sessions` > 0, ofrece precio por sesión y precio
por paquete.
3. Consulta disponibilidad (rango de fechas obligatorio):
`skeen_odoo_available_slots(service_id=<id>, date_from="AAAA-MM-DD", date_to="AAAA-MM-DD")`
Devuelve `slots: [{date, time, datetime}]`. Un rango de un solo día: usa la
misma fecha en `date_from` y `date_to`.
4. Presenta las 3 mejores opciones de horario para la primera sesión.
5. Confirma datos: nombre completo, teléfono (E.164, ej. +52664...),
alergias/condiciones (opcional).
6. Crea la cita:
`skeen_odoo_create_appointment(phone, name, service_id, date="AAAA-MM-DD", time="HH:MM")`
Para paquetes, crea N citas (una por sesión); las siguientes pueden ir cada 7 días.
7. Confirma al paciente con el formato de abajo. **Tu respuesta se envía sola por
WhatsApp** (el bridge la entrega); no necesitas una herramienta de envío.
2. Consulta disponibilidad usando el plugin `odoo_api_call`:
Modelo: salon.booking, metodo: get_available_slots
Argumentos: [service_id, date_from, date_to]
## PASO 3: REAGENDAR
3. Presenta las 3 mejores opciones de horario
1. Consulta el saldo/datos con `skeen_odoo_patient_balance(phone=<telefono>)` para
ver citas pendientes del paciente.
2. Cancela la cita actual: `skeen_odoo_cancel_appointment(phone, booking_id=<id>)`.
3. Consulta disponibilidad y crea la nueva cita con `skeen_odoo_create_appointment`.
4. Confirma el cambio.
4. Cuando el paciente elige, confirma datos:
- Nombre completo
- Confirmar telefono
- Alergias o condiciones especiales (opcional)
## PASO 4: CANCELAR
5. Crea la cita usando `odoo_api_call`:
Modelo: salon.booking, metodo: create
Datos: { name, phone, service_id, date, time, state: 'confirmed' }
1. `skeen_odoo_cancel_appointment(phone=<telefono>)` (cancela la cita activa más
próxima; usa `booking_id` para una específica).
2. Confirma al paciente y ofrece reagendar.
6. Programa recordatorio via cron:
```bash
hermes cron schedule --name "recordatorio-cita-[ID]" \
--at "[fecha_cita] 18:00 - 1 day" \
--command "skill skeen-recordatorios --phone [PHONE] --cita [DATE]"
```
## Formato de confirmación (cita individual)
7. Envia confirmacion via WACRM usando `wacrm_send`
¡Listo! Tu cita está confirmada:
### PASO 3: Para REAGENDAR
Servicio: [Nombre]
Fecha: [día de semana], [DD] de [mes] de [AAAA]
Hora: [HH:MM]
Duración: [X] minutos
Dirección: Blvd. Popotla 123, Playas de Rosarito
1. Busca citas del paciente por telefono via `odoo_api_call`
2. Muestra citas activas
3. Pide nueva fecha/hora
4. Consulta disponibilidad
5. Actualiza cita via `odoo_api_call` (model salon.booking, method write)
6. Confirma cambio
Te recordamos un día antes. Si necesitas cambiar o cancelar, escríbenos.
### PASO 4: Para CANCELAR
## Reglas
1. Busca citas del paciente por telefono
2. Confirma cual cita cancelar
3. Pide motivo (opcional, para estadisticas)
4. Cancela via `odoo_api_call` (state: 'cancelled')
5. Ofrece reagendar
## Formato de Confirmacion
Perfecto! Tu cita esta confirmada:
Servicio: [Nombre servicio]
Fecha: [Dia de semana], [DD] de [mes] de [AAAA]
Hora: [HH:MM] AM/PM
Duracion: [X] minutos
Direccion: Blvd. Popotla 123, Playas de Rosarito
Te enviaremos un recordatorio un dia antes. Si necesitas cambiar o cancelar, solo escribenos!
## Reglas Importantes
- Solo agendar dentro del horario de la clinica
- Validar que el paciente no tenga citas duplicadas
- Consulta de valoracion gratuita tiene duracion de 15 min
- Si no hay disponibilidad, ofrecer lista de espera
- Guardar TODAS las citas en memoria para recordatorios
- Solo agendar dentro del horario de la clínica (lo valida la disponibilidad).
- No agendar citas duplicadas para el mismo paciente.
- Consulta de valoración: 15 min.
- Si no hay disponibilidad, ofrece otra fecha o lista de espera.

View File

@@ -0,0 +1,33 @@
# Disponibilidad y agendado — herramientas reales (SKEEN / Odoo)
La clínica expone una API pública en Odoo. El agente la consume con las
herramientas del plugin `skeen_odoo` (HTTP + `Authorization: Bearer
$ODOO_API_KEY`). Ya **no** se usan `execute_code`, `browser_navigate` ni curl.
## Herramientas
| Herramienta | Endpoint | Parámetros |
|---|---|---|
| `skeen_odoo_health` | GET `/skeen/api/v1/health` | — |
| `skeen_odoo_services` | GET `/skeen/api/v1/services` | `search`, `category`, `limit` |
| `skeen_odoo_available_slots` | POST `/skeen/api/v1/available_slots` | `service_id`, `date_from`, `date_to` |
| `skeen_odoo_create_appointment` | POST `/skeen/api/v1/create_appointment` | `phone`, `name`, `service_id`, `date`, `time`, `doctor_id?`, `duration?` |
| `skeen_odoo_cancel_appointment` | POST `/skeen/api/v1/cancel_appointment` | `phone`, `booking_id?` |
| `skeen_odoo_patient_balance` | POST `/skeen/api/v1/patient_balance` | `phone` |
| `skeen_odoo_wallet_redeem` | POST `/skeen/api/v1/wallet_redeem` | `phone`, `points` |
Contrato completo: `skeen_odoo/addons/skeen_whatsapp/controllers/main.py`.
## Ejemplo verificado
`available_slots(service_id=1, date_from="2026-10-20", date_to="2026-10-20")`
devuelve slots reales (por ejemplo 08:00, 08:15, 08:30, ...). Los horarios se
calculan según la duración del servicio y las citas ya agendadas.
## Pitfalls
- `service_id` es el **id numérico vivo** de Odoo (no slugs como "cons-derm"):
resuélvelo con `skeen_odoo_services`.
- El catálogo real tiene miles de servicios; filtra con `search`/`category`.
- Sin `doctor_id`, la disponibilidad es conservadora (cualquier cita del día
ocupa el hueco), así que puede subestimar huecos reales.

View File

@@ -4,8 +4,8 @@ description: >
Monedero digital de fidelidad de SKEEN Derma Experts.
Consulta saldo, redime puntos, muestra historial.
1 punto = $1 MXN. Se acumula 1 punto por cada $10 gastados.
Usar cuando el usuario pregunte por puntos, saldo, o redencion.
version: 1.0.0
Usar cuando el usuario pregunte por puntos, saldo, o redención.
version: 2.0.0
metadata:
hermes:
requires:
@@ -13,6 +13,8 @@ metadata:
envVars:
- name: ODOO_URL
required: true
- name: ODOO_API_KEY
required: true
platforms: [whatsapp, webchat, cli]
activation: keyword
keywords: ["puntos", "monedero", "saldo", "redimir", "fidelidad"]
@@ -22,34 +24,29 @@ metadata:
## Consultar saldo
Usa `odoo_api_call`: modelo skeen.monedero, metodo get_balance, args: [phone_number]
Usa `skeen_odoo_patient_balance(phone="<telefono>")`. Devuelve:
`pending_balance`, `pending_invoices`, `wallet_points` (puntos) y
`wallet_mxn` (= $ MXN disponibles).
Respuesta esperada: { points: 450, equivalent_mxn: 450, history: [...] }
Enviar via WACRM con formato:
Formato de respuesta (tu mensaje se envía solo por WhatsApp):
Tu monedero SKEEN:
Saldo actual: 450 puntos (= $450 MXN)
Historial reciente:
- 03/07/2026: +120 pts (Pago Tratamiento Acne — $1,200)
- 28/06/2026: +80 pts (Pago Consulta + peel — $800)
- 15/06/2026: +250 pts (Pago Depilacion laser — $2,500)
Saldo actual: [wallet_points] puntos (= $[wallet_mxn] MXN)
Saldo pendiente por pagar: $[pending_balance] MXN
Puedes redimir tus puntos en tu proxima visita! Cada 10 puntos = $10 MXN de descuento.
¡Puedes redimir tus puntos en tu próxima visita!
## Redimir puntos
1. Verificar que tenga suficientes puntos
2. Consultar monto a pagar de la cita/servicio
3. Calcular descuento (maximo 50% del total)
4. Confirmar redencion
5. Aplicar via `odoo_api_call`: modelo skeen.monedero, metodo redeem
6. Confirmar redencion via WACRM
1. Consulta el saldo con `skeen_odoo_patient_balance`.
2. Verifica que tenga suficientes puntos y que el descuento no exceda 50% del total.
3. Confirma con el paciente.
4. `skeen_odoo_wallet_redeem(phone="<telefono>", points=<puntos>)`.
Devuelve `remaining_points` y `remaining_mxn`.
## Reglas
- Maximo redimible: 50% del total de la compra
- Puntos vencen a los 12 meses de inactividad
- No transferibles entre pacientes
- Se acumulan en pagos confirmados unicamente
- En devoluciones, se restan los puntos correspondientes
- Máximo redimible: 50% del total de la compra.
- Puntos vencen a los 12 meses de inactividad.
- No transferibles entre pacientes.
- Se acumulan en pagos confirmados únicamente.

View File

@@ -1,79 +1,61 @@
---
name: skeen-pagos
description: >
Gestion de pagos para SKEEN Derma Experts.
Genera links de pago (Stripe/MercadoPago), consulta estados,
y gestiona facturacion. Usar cuando el usuario quiera pagar,
consultar saldo, o ver sus pagos pendientes.
version: 1.0.0
Gestión de pagos para SKEEN Derma Experts.
Consulta saldo y citas pendientes de pago por paciente.
Usar cuando el usuario quiera consultar saldo, deuda o pagos pendientes.
version: 2.0.0
metadata:
hermes:
requires:
bins: [python]
env: [STRIPE_SECRET_KEY, MP_ACCESS_TOKEN]
bins: []
env: []
envVars:
- name: STRIPE_SECRET_KEY
required: true
- name: MP_ACCESS_TOKEN
required: true
- name: ODOO_URL
required: true
- name: ODOO_API_KEY
required: true
- name: STRIPE_SECRET_KEY
required: false
- name: MP_ACCESS_TOKEN
required: false
platforms: [whatsapp, webchat, cli]
activation: keyword
keywords: ["pagar", "saldo", "deuda", "factura", "pago", "stripe", "mercadopago"]
---
# SKEEN — Gestion de Pagos
# SKEEN — Gestión de Pagos
## Instrucciones de Uso
## PASO 1: Identificar la intención
### PASO 1: Identificar intencion de pago
- "Cuánto debo?" / "Saldo pendiente" → consultar deuda.
- "Quiero pagar" → dar los métodos de pago de la clínica.
- "Necesito factura" → tomar datos fiscales y avisar a recepción.
- "Quiero pagar" → Generar link de pago
- "Cuanto debo?" / "Saldo pendiente" → Consultar deuda
- "Ya pague" → Verificar pago
- "Necesito factura" → Generar factura (CFDI)
## PASO 2: Consultar saldo y citas pendientes
### PASO 2: Consultar servicios pendientes
Usa `skeen_odoo_patient_balance(phone="<telefono>")`. Devuelve `pending_balance`
(total pendiente en MXN) y `pending_invoices` (lista de citas por pagar, cada una
con `reference`, `service`, `amount`, `date`). Además `wallet_points`/`wallet_mxn`
del monedero.
Usa `odoo_api_call` para consultar modelo account.move:
Dominio: [['partner_id.phone', '=', PACIENTE_PHONE], ['state', '=', 'posted'], ['payment_state', '!=', 'paid']]
Campos: ['name', 'amount_residual', 'invoice_date_due']
## PASO 3: Métodos de pago
### PASO 3: Generar link de pago
La generación de links de pago en línea (Stripe / MercadoPago) **aún no está
configurada** en esta instalación (faltan `STRIPE_SECRET_KEY` / `MP_ACCESS_TOKEN`).
No prometas un link que no puedas generar. Ofrece en su lugar:
Para pagos en Mexico (MXN) — usa MercadoPago:
Crear preference con items del servicio
notification_url: https://skeen.mx/webhooks/mercadopago
back_urls: { success: 'https://app.skeen.mx/pagos/exitoso' }
- Pago en recepción (efectivo / tarjeta) el día de la cita.
- Transferencia SPEI (pide los datos a recepción).
- Si el paciente pide factura, toma RFC y régimen fiscal y avisa que recepción la emite.
Para pagos internacionales — usa Stripe Checkout:
Crear session con line_items
mode: 'payment'
success_url: 'https://app.skeen.mx/pagos/exitoso'
metadata: { patient_phone: PACIENTE_PHONE }
## PASO 4: Confirmación
### PASO 4: Enviar link de pago
Usa `wacrm_send` para enviar mensaje con resumen y link.
Incluir:
- Listado de servicios con precios
- Total a pagar
- Link de pago
- Metodos aceptados (tarjeta, OXXO, SPEI)
- Puntos de monedero que acumulara
### PASO 5: Confirmacion de pago
Cuando el webhook confirme el pago:
1. Registra pago en Odoo via `odoo_api_call`
2. Calcula puntos de monedero (10% del monto)
3. Envia confirmacion + puntos acumulados
Resume: servicios pendientes, total a pagar y puntos de monedero que acumulará
(1 punto por cada $10 pagados). Tu respuesta se envía sola por WhatsApp.
## Reglas
- Siempre confirmar el monto antes de generar el link
- Para pagos >$5,000 MXN, ofrecer opcion de meses sin intereses
- Verificar que el telefono coincida con el paciente
- Guardar referencia de pago en memoria
- Confirma el monto antes de dar instrucciones de pago.
- Verifica que el teléfono coincida con el paciente.
- Nunca inventes links ni montos: usa siempre lo que devuelve la herramienta.

View File

@@ -2,58 +2,50 @@
name: skeen-rag
description: >
Catalogo de servicios dermatologicos de SKEEN Derma Experts.
Proporciona informacion sobre tratamientos, precios, duracion y contraindicaciones.
Proporciona informacion sobre tratamientos, precios, duracion, paquetes y contraindicaciones.
SOLO usar cuando el usuario pregunte por servicios, tratamientos, precios o el catalogo.
version: 1.0.0
version: 2.0.0
metadata:
hermes:
requires:
bins: []
envVars:
- name: SKEEN_SERVICES_JSON
required: false
description: "Path al archivo JSON con catalogo de servicios"
- name: ODOO_URL
required: true
- name: ODOO_API_KEY
required: true
platforms: [whatsapp, webchat, cli]
activation: keyword
keywords: ["servicios", "tratamientos", "precios", "catalogo", "acne", "peel", "laser", "dermatologia"]
keywords: ["servicios", "tratamientos", "precios", "catalogo", "acne", "peel", "laser", "dermatologia", "paquete", "sesiones"]
---
# SKEEN Derma Experts — Catalogo de Servicios
# SKEEN Derma Experts — Catálogo de Servicios
## Instrucciones de Uso
El catálogo es **vivo**: se lee de Odoo con la herramienta `skeen_odoo_services`.
No uses listas fijas ni slugs.
Cuando un paciente pregunte por tratamientos, servicios, precios o recomendaciones dermatologicas:
## Instrucciones
1. Lee el archivo de catalogo: `${env:SKEEN_SERVICES_JSON:-/skills/data/skeen-servicios.json}`
2. Filtra los servicios relevantes segun la pregunta
3. Presenta la informacion de forma clara y amigable
4. SIEMPRE incluye: nombre del tratamiento, precio aproximado, duracion, y una breve descripcion
5. NUNCA diagnostiques condiciones medicas — invita al paciente a agendar una consulta
6. Si no encuentras el servicio, ofrece agendar una consulta de valoracion
1. Para saber cuántos hay: `skeen_odoo_services(limit=1)`. El campo `total_active`
es el número real de servicios activos de la clínica; `sellable_services` es
cuántos tienen precio (>0). OJO: el catálogo de Odoo incluye miles de registros
históricos en $0, por eso al buscar usa siempre `min_price=1`.
2. Para buscar un tratamiento concreto: `skeen_odoo_services(search="<palabra>", min_price=1)`.
3. Filtra por categoría con `skeen_odoo_services(category="consulta"|"tratamiento"|"procedimiento"|"paquete", min_price=1)`.
4. Presenta: nombre, precio por sesión, duración y una descripción breve.
Si `package_sessions` > 0 y `package_price` > 0, menciona el paquete y el ahorro.
5. El `id` numérico que devuelve la herramienta es el `service_id` que necesitas
para `skeen_odoo_available_slots` y `skeen_odoo_create_appointment`.
## Formato de Respuesta
## Límites
Hola! En SKEEN contamos con estos tratamientos para [condicion]:
- NO diagnostiques condiciones médicas — invita a agendar una consulta.
- NO recomiendes medicamentos específicos.
- Precios pueden variar según valoración.
- Si no encuentras el servicio, ofrece una consulta de valoración.
1. **[Nombre tratamiento]** — $[precio] MXN
Duracion: [X] minutos
[Descripcion breve]
2. **[Nombre tratamiento]** — $[precio] MXN
...
Te gustaria agendar una consulta de valoracion? Podemos evaluar tu caso especifico y recomendarte el mejor tratamiento.
## Limites
- NO diagnosticar — solo informacion general
- NO recomendar medicamentos especificos
- SIEMPRE sugerir consulta presencial para evaluacion
- Precios son aproximados — pueden variar segun evaluacion
## Datos de la Clinica
## Datos de la clínica
- Nombre: SKEEN Derma Experts
- Direccion: Blvd. Popotla 123, Playas de Rosarito, BC, Mexico
- Dirección: Blvd. Popotla 123, Playas de Rosarito, BC, Mexico
- Horario: Lun-Vie 9:00-18:00, Sab 9:00-14:00
- Telefono: +52 (664) 123-4567

View File

@@ -4,7 +4,7 @@ description: >
Recordatorios de citas, follow-ups y re-engagement para SKEEN.
Usar PRINCIPALMENTE via cron (hermes cron).
Tambien puede responder a consultas sobre recordatorios.
version: 1.0.0
version: 2.0.0
metadata:
hermes:
always: true
@@ -13,10 +13,12 @@ metadata:
envVars:
- name: ODOO_URL
required: true
- name: ODOO_API_KEY
required: true
- name: WACRM_URL
required: true
required: false
- name: WACRM_API_KEY
required: true
required: false
platforms: [whatsapp, webchat, cli]
activation: cron
cron:
@@ -30,60 +32,37 @@ metadata:
# SKEEN — Recordatorios y Follow-ups
## Ejecucion via Cron
Herramientas: `skeen_odoo_patient_balance` (datos del paciente y citas) y
`skeen_wacrm_send` (envío proactivo por WhatsApp). Las respuestas normales a un
mensaje entrante las entrega el bridge automáticamente; `skeen_wacrm_send` es
solo para mensajes que el paciente NO provocó (recordatorios, seguimientos).
Esta skill se ejecuta automaticamente via `hermes cron`.
## PASO 1: Recordatorios de citas (24 h antes)
### PASO 1: Generar recordatorios (cada 6 horas)
1. Identifica las citas de mañana (el backend de agenda es `skeen.cita` en Odoo).
2. Envía el recordatorio al paciente:
`skeen_wacrm_send(to="+52...", text="Hola [Nombre]! Te recordamos tu cita
mañana [fecha] a las [hora] en SKEEN Derma Experts.")`
3. Registra el envío. Si falla, reintenta en la siguiente ejecución (máx. 3).
Recordatorios de citas (24h antes):
Consulta Odoo: salon.booking con date=tomorrow, state=confirmed
Para cada cita, programa cron:
```bash
hermes cron schedule --name "recordatorio-[booking_id]" \
--at "[fecha_cita] 18:00 - 1 day" \
--command "skill skeen-recordatorios --action send --phone [PHONE] --cita [DATE]"
```
## PASO 2: Follow-ups post-tratamiento
Follow-ups post-tratamiento (7 dias despues):
Consulta Odoo: salon.booking con date=7_days_ago, state=done
Programa cron para cada paciente:
```bash
hermes cron schedule --name "followup-[booking_id]" \
--at "[fecha_cita + 7 days] 10:00" \
--command "skill skeen-recordatorios --action followup --phone [PHONE]"
```
A los 7 días de una cita completada, envía con `skeen_wacrm_send`:
"Hola [Nombre]! Han pasado 7 días desde tu [servicio]. ¿Cómo te sientes?"
Re-engagement (pacientes inactivos 90 dias):
Consulta Odoo: res.partner con last_visit <= 90_days_ago
Programa cron semanal:
```bash
hermes cron schedule --name "reengage-[partner_id]" \
--at "lunes 9:00" \
--command "skill skeen-recordatorios --action reengage --phone [PHONE]"
```
## PASO 3: Re-engagement (pacientes inactivos ~90 días)
### PASO 2: Enviar recordatorios (6 PM diario)
Envía con `skeen_wacrm_send`:
"Hola [Nombre]! Te extrañamos en SKEEN. Agenda tu próxima visita y recibe 50 puntos."
Para cada recordatorio programado para hoy:
1. Enviar via WACRM usando `wacrm_send` con template aprobado
2. Si es exitoso, marcar como enviado en Odoo
3. Si falla, reintentar en siguiente ejecucion (max 3 intentos)
## Pruebas / simulación
### PASO 3: Limpieza
`skeen_wacrm_send(..., simulate=true)` usa el endpoint de simulación de WACRM:
NO envía un mensaje real. Úsalo para validar plantillas.
Eliminar cron jobs completados con mas de 7 dias:
```bash
hermes cron list --completed | awk '$4 > 7 {print $1}' | xargs hermes cron delete
```
## Notas de operación
## Templates de WhatsApp Aprobados
recordatorio_cita_skeen:
"Hola {{1}}! Te recordamos tu cita manana {{2}} a las {{3}} en SKEEN Derma Experts."
followup_tratamiento_skeen:
"Hola {{1}}! Han pasado {{2}} dias desde tu {{3}}. Como te sientes?"
reengage_skeen:
"Hola {{1}}! Te extranamos en SKEEN. Agenda tu proxima visita y recibe 50 puntos."
- El envío de recordatorios por *template* de Meta requiere plantillas aprobadas
y opt-in del paciente; hasta que existan, envía texto simple dentro de la
ventana de 24 h.
- El heartbeat de recordatorios del bridge está controlado por `REMINDERS_ENABLED`.