feat: initial Skeen-CRM AI Agent architecture

- FastAPI + Python 3.12 backend
- Meta WhatsApp Business API client (official)
- OpenAI GPT-4o with function calling
- RAG vector store with pgvector
- ERPNext Frappe REST client
- Celery + Redis async task queue
- PostgreSQL with migrations (Alembic)
- Docker Compose full stack
- Enterprise logging, metrics, health checks
This commit is contained in:
root
2026-04-29 05:30:59 +00:00
commit d30b22b50c
44 changed files with 3603 additions and 0 deletions

0
src/__init__.py Normal file
View File

0
src/api/__init__.py Normal file
View File

36
src/api/deps.py Normal file
View File

@@ -0,0 +1,36 @@
"""FastAPI dependency injection providers."""
from typing import AsyncGenerator
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from src.infrastructure.db import AsyncSessionLocal
from src.infrastructure.redis import RedisCache, get_redis
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""Yield a database session for FastAPI dependency injection."""
session = AsyncSessionLocal()
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_cache() -> AsyncGenerator[RedisCache, None]:
"""Yield a Redis cache instance."""
redis = await get_redis()
yield RedisCache(redis)
def get_client_ip(request: Request) -> str:
"""Extract client IP from request, handling proxies."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"

0
src/api/v1/__init__.py Normal file
View File

39
src/api/v1/health.py Normal file
View File

@@ -0,0 +1,39 @@
"""Health check endpoints."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.deps import get_db_session
from src.config import settings
router = APIRouter(tags=["health"])
@router.get("/health", status_code=status.HTTP_200_OK)
async def health_check() -> dict:
"""Liveness probe."""
return {
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
"version": "0.1.0",
"environment": settings.APP_ENV,
}
@router.get("/ready", status_code=status.HTTP_200_OK)
async def readiness_check(db: AsyncSession = Depends(get_db_session)) -> dict:
"""Readiness probe including database connectivity."""
try:
await db.execute(text("SELECT 1"))
db_status = "connected"
except Exception as exc:
db_status = f"error: {exc}"
return {
"status": "ready" if db_status == "connected" else "not_ready",
"database": db_status,
"timestamp": datetime.now(timezone.utc).isoformat(),
}

109
src/api/v1/messages.py Normal file
View File

@@ -0,0 +1,109 @@
"""API endpoints for sending WhatsApp messages manually."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.deps import get_db_session
from src.infrastructure.whatsapp.client import get_whatsapp_client
router = APIRouter(prefix="/messages", tags=["messages"])
class SendTextMessageRequest(BaseModel):
to: str = Field(..., description="Phone number in international format (e.g., 5216641234567)")
text: str = Field(..., max_length=4096, description="Message text")
preview_url: bool = Field(False, description="Show URL preview")
class SendTemplateMessageRequest(BaseModel):
to: str = Field(..., description="Phone number in international format")
template_name: str = Field(..., description="Registered template name")
language_code: str = Field("es_MX", description="Template language code")
class SendButtonsRequest(BaseModel):
to: str
body: str = Field(..., max_length=1024)
buttons: list[dict[str, str]] = Field(..., min_length=1, max_length=3)
class MessageResponse(BaseModel):
status: str
message_id: str | None = None
details: dict | None = None
@router.post("/text", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
async def send_text_message(
request: SendTextMessageRequest,
db: AsyncSession = Depends(get_db_session),
) -> MessageResponse:
"""Send a text message to a WhatsApp user."""
client = await get_whatsapp_client()
try:
result = await client.send_text_message(
to=request.to,
text=request.text,
preview_url=request.preview_url,
)
return MessageResponse(
status="sent",
message_id=result.get("messages", [{}])[0].get("id"),
details=result,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
@router.post("/template", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
async def send_template_message(
request: SendTemplateMessageRequest,
db: AsyncSession = Depends(get_db_session),
) -> MessageResponse:
"""Send a template message (for 24h+ window)."""
client = await get_whatsapp_client()
try:
result = await client.send_template_message(
to=request.to,
template_name=request.template_name,
language_code=request.language_code,
)
return MessageResponse(
status="sent",
message_id=result.get("messages", [{}])[0].get("id"),
details=result,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
@router.post("/buttons", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
async def send_buttons(
request: SendButtonsRequest,
db: AsyncSession = Depends(get_db_session),
) -> MessageResponse:
"""Send an interactive button message."""
client = await get_whatsapp_client()
try:
result = await client.send_interactive_buttons(
to=request.to,
body=request.body,
buttons=request.buttons,
)
return MessageResponse(
status="sent",
message_id=result.get("messages", [{}])[0].get("id"),
details=result,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc

98
src/api/v1/webhooks.py Normal file
View File

@@ -0,0 +1,98 @@
"""Meta WhatsApp webhook endpoints."""
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.deps import get_client_ip, get_db_session
from src.config import settings
from src.core.exceptions import ValidationError
from src.infrastructure.whatsapp.webhook import (
WebhookVerifier,
parse_webhook_payload,
)
from src.use_cases.handle_incoming_message import process_incoming_message
from src.workers.celery_app import process_whatsapp_message_task
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
class WebhookVerificationResponse(BaseModel):
challenge: str
@router.get("/whatsapp")
async def verify_whatsapp_webhook(
hub_mode: str | None = None,
hub_verify_token: str | None = None,
hub_challenge: str | None = None,
) -> str:
"""Verify webhook subscription with Meta.
Meta sends a GET request to verify the endpoint during webhook setup.
"""
is_valid = WebhookVerifier.verify_subscription(hub_mode, hub_verify_token)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid verification token",
)
return hub_challenge or ""
@router.post("/whatsapp", status_code=status.HTTP_200_OK)
async def receive_whatsapp_webhook(
request: Request,
db: AsyncSession = Depends(get_db_session),
x_hub_signature_256: str | None = Header(None),
x_forwarded_for: str | None = Header(None),
) -> dict:
"""Receive incoming WhatsApp messages and status updates.
In production, this endpoint should return 200 immediately and
process the message asynchronously via Celery to avoid timeouts.
"""
body = await request.body()
# Verify signature in production
if settings.is_production:
is_valid = WebhookVerifier.verify_signature(body, x_hub_signature_256)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid signature",
)
try:
payload = await request.json()
webhook = parse_webhook_payload(payload)
except ValidationError as exc:
# Return 200 for non-message events to prevent Meta retries
return {"status": "ignored", "reason": exc.message}
except Exception:
return {"status": "ignored", "reason": "invalid_payload"}
client_ip = x_forwarded_for or get_client_ip(request)
# Handle message statuses (delivered, read, etc.)
if webhook.has_statuses:
# TODO: Update message status in database
return {"status": "acknowledged", "type": "status_update"}
# Handle incoming messages
if webhook.has_messages:
if settings.is_production:
# Async processing via Celery for production
process_whatsapp_message_task.delay(
webhook.raw,
client_ip=client_ip,
)
else:
# Sync processing for development/testing
await process_incoming_message(
db=db,
webhook_data=webhook.raw,
client_ip=client_ip,
)
return {"status": "processed"}

96
src/config.py Normal file
View File

@@ -0,0 +1,96 @@
"""Application configuration using Pydantic Settings."""
from functools import lru_cache
from pydantic import Field, PostgresDsn, RedisDsn, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# App
APP_NAME: str = "Skeen-CRM-Agent"
APP_ENV: str = "development"
DEBUG: bool = False
LOG_LEVEL: str = "INFO"
SECRET_KEY: SecretStr = SecretStr("change-me")
# Server
HOST: str = "0.0.0.0"
PORT: int = 8000
# Database
DATABASE_URL: PostgresDsn = PostgresDsn(
"postgresql+asyncpg://skeen:skeen123@localhost:5432/skeen_crm"
)
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
# Redis
REDIS_URL: RedisDsn = RedisDsn("redis://localhost:6379/0")
# Meta / WhatsApp Business API
META_API_VERSION: str = "v18.0"
META_ACCESS_TOKEN: SecretStr = SecretStr("")
META_PHONE_NUMBER_ID: str = ""
META_BUSINESS_ACCOUNT_ID: str = ""
META_WEBHOOK_VERIFY_TOKEN: SecretStr = SecretStr("")
META_APP_SECRET: SecretStr = SecretStr("")
# OpenAI
OPENAI_API_KEY: SecretStr = SecretStr("")
OPENAI_MODEL: str = "gpt-4o"
OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small"
OPENAI_TEMPERATURE: float = 0.3
OPENAI_MAX_TOKENS: int = 1500
# RAG
VECTOR_DIMENSION: int = 1536
RAG_TOP_K: int = 5
RAG_SIMILARITY_THRESHOLD: float = 0.75
# ERPNext
ERPNEXT_BASE_URL: str = ""
ERPNEXT_API_KEY: SecretStr = SecretStr("")
ERPNEXT_API_SECRET: SecretStr = SecretStr("")
ERPNEXT_VERIFY_SSL: bool = True
# Celery
CELERY_BROKER_URL: RedisDsn = RedisDsn("redis://localhost:6379/1")
CELERY_RESULT_BACKEND: RedisDsn = RedisDsn("redis://localhost:6379/2")
CELERY_WORKER_CONCURRENCY: int = 4
# Monitoring
ENABLE_METRICS: bool = True
SENTRY_DSN: str = ""
@property
def database_url_str(self) -> str:
"""Return database URL as plain string for SQLAlchemy."""
return str(self.DATABASE_URL)
@property
def meta_api_base_url(self) -> str:
"""Return Meta Graph API base URL."""
return f"https://graph.facebook.com/{self.META_API_VERSION}"
@property
def is_production(self) -> bool:
"""Check if running in production environment."""
return self.APP_ENV.lower() == "production"
@lru_cache
def get_settings() -> Settings:
"""Return cached settings instance."""
return Settings()
settings = get_settings()

0
src/core/__init__.py Normal file
View File

100
src/core/constants.py Normal file
View File

@@ -0,0 +1,100 @@
"""Application-wide constants."""
from enum import Enum
class MessageRole(str, Enum):
"""Conversation message roles."""
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"
TOOL = "tool"
class ConversationStatus(str, Enum):
"""Status of a patient conversation."""
ACTIVE = "active"
PAUSED = "paused"
RESOLVED = "resolved"
ESCALATED = "escalated"
APPOINTMENT_CONFIRMED = "appointment_confirmed"
class AppointmentType(str, Enum):
"""Types of medical appointments."""
PRIMERA_VEZ = "primera_vez"
SUBSECUENTE = "subsecuente"
PAQUETE = "paquete"
VALORACION = "valoracion"
class PaymentMethod(str, Enum):
"""Supported payment methods."""
EFECTIVO_MN = "efectivo_mn"
EFECTIVO_USD = "efectivo_usd"
TARJETA = "tarjeta"
TRANSFERENCIA = "transferencia"
MONEDERO = "monedero"
class WhatsAppMessageType(str, Enum):
"""WhatsApp message types from Meta webhook."""
TEXT = "text"
IMAGE = "image"
AUDIO = "audio"
VIDEO = "video"
DOCUMENT = "document"
LOCATION = "location"
CONTACTS = "contacts"
INTERACTIVE = "interactive"
BUTTON_REPLY = "button_reply"
LIST_REPLY = "list_reply"
UNKNOWN = "unknown"
class WhatsAppMessageStatus(str, Enum):
"""WhatsApp message delivery statuses."""
SENT = "sent"
DELIVERED = "delivered"
READ = "read"
FAILED = "failed"
# System prompts
SKEEN_SYSTEM_PROMPT = """Eres SKEEN Assistant, el agente de inteligencia artificial oficial de SKEEN Clínica de Belleza y Dermatología Estética.
INFORMACIÓN DEL NEGOCIO:
- SKEEN es una clínica dermatológica y estética con sedes en Rosarito y Tijuana, B.C., México.
- Ofrecemos tratamientos faciales, corporales, depilación láser, toxina botulínica, ácido hialurónico, y consultas dermatológicas.
- Horario de atención: Lunes a Sábado 9:00 - 18:00, Domingos 10:00 - 14:00.
- Teléfono: (664) 123-4567
TU ROL:
1. Atiende a pacientes y prospectos por WhatsApp de manera cálida, profesional y eficiente.
2. Agenda citas verificando disponibilidad con el CRM.
3. Responde preguntas sobre servicios, precios, paquetes y promociones usando el catálogo (RAG).
4. Procesa pagos y consulta saldo de monedero electrónico.
5. Escal a un humano cuando el paciente lo solicite o el caso sea complejo.
REGLAS CRÍTICAS:
- SIEMPRE saluda por el nombre del paciente si lo conoces.
- NUNCA inventes precios ni disponibilidad. Consulta el RAG o el CRM.
- SIEMPRE confirma los detalles de la cita (fecha, hora, sucursal, servicio, doctor) antes de agendar.
- NUNCA compartas información médica de otros pacientes.
- Usa emojis con moderación para mantener un tono cálido pero profesional.
- El idioma principal es español (México).
FORMATO DE RESPUESTA:
- Sé conciso pero completo (máximo 3 párrafos cortos para WhatsApp).
- Usa viñetas cuando listes opciones.
- Incluye llamados a la acción claros.
"""
# Webhook
WHATSAPP_WEBHOOK_SUBSCRIBE_MODE = "subscribe"

66
src/core/exceptions.py Normal file
View File

@@ -0,0 +1,66 @@
"""Custom application exceptions."""
class SkeenException(Exception):
"""Base exception for SKEEN application."""
def __init__(self, message: str, status_code: int = 500, details: dict | None = None) -> None:
self.message = message
self.status_code = status_code
self.details = details or {}
super().__init__(self.message)
class WhatsAppAPIError(SkeenException):
"""Error communicating with Meta WhatsApp Business API."""
def __init__(self, message: str, status_code: int = 502, details: dict | None = None) -> None:
super().__init__(message, status_code, details)
class OpenAIError(SkeenException):
"""Error communicating with OpenAI API."""
def __init__(self, message: str, status_code: int = 502, details: dict | None = None) -> None:
super().__init__(message, status_code, details)
class ERPNextError(SkeenException):
"""Error communicating with ERPNext API."""
def __init__(self, message: str, status_code: int = 502, details: dict | None = None) -> None:
super().__init__(message, status_code, details)
class ConversationNotFoundError(SkeenException):
"""Requested conversation does not exist."""
def __init__(self, conversation_id: str) -> None:
super().__init__(
f"Conversation {conversation_id} not found",
status_code=404,
)
class PatientNotFoundError(SkeenException):
"""Requested patient does not exist."""
def __init__(self, patient_id: str) -> None:
super().__init__(
f"Patient {patient_id} not found",
status_code=404,
)
class ValidationError(SkeenException):
"""Input validation error."""
def __init__(self, message: str, details: dict | None = None) -> None:
super().__init__(message, status_code=422, details=details)
class RateLimitError(SkeenException):
"""Rate limit exceeded."""
def __init__(self, message: str = "Rate limit exceeded") -> None:
super().__init__(message, status_code=429)

0
src/domain/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,74 @@
"""Conversation and message domain models."""
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import JSON, Column, DateTime, Enum, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from src.core.constants import ConversationStatus
from src.infrastructure.db import Base
class Conversation(Base):
"""A WhatsApp conversation with a patient."""
__tablename__ = "conversations"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
phone_number: Mapped[str] = mapped_column(String(20), index=True, nullable=False)
patient_id: Mapped[str | None] = mapped_column(String(100), index=True, nullable=True)
patient_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[ConversationStatus] = mapped_column(
Enum(ConversationStatus),
default=ConversationStatus.ACTIVE,
nullable=False,
)
context: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
last_message_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
class Message(Base):
"""Individual message within a conversation."""
__tablename__ = "messages"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
conversation_id: Mapped[str] = mapped_column(
String(36),
index=True,
nullable=False,
)
direction: Mapped[str] = mapped_column(
String(10),
nullable=False,
) # 'inbound' or 'outbound'
role: Mapped[str] = mapped_column(String(20), nullable=False)
message_type: Mapped[str] = mapped_column(String(50), default="text")
content: Mapped[str] = mapped_column(Text, nullable=False)
whatsapp_message_id: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
)
tool_calls: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True)
tool_results: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True)
tokens_used: Mapped[int | None] = mapped_column(default=0)
metadata: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)

View File

View File

View File

View File

@@ -0,0 +1,143 @@
"""Async OpenAI client with structured logging and retry logic."""
import json
from typing import Any
import openai
import structlog
from openai import AsyncOpenAI
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from src.config import settings
from src.core.exceptions import OpenAIError
logger = structlog.get_logger(__name__)
class OpenAIClient:
"""Enterprise-grade async OpenAI client."""
def __init__(self) -> None:
self.client = AsyncOpenAI(
api_key=settings.OPENAI_API_KEY.get_secret_value(),
max_retries=0, # We handle retries manually with tenacity
)
self.model = settings.OPENAI_MODEL
self.embedding_model = settings.OPENAI_EMBEDDING_MODEL
self.temperature = settings.OPENAI_TEMPERATURE
self.max_tokens = settings.OPENAI_MAX_TOKENS
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((openai.RateLimitError, openai.APITimeoutError)),
reraise=True,
)
async def chat_completion(
self,
messages: list[dict[str, str]],
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] = "auto",
temperature: float | None = None,
max_tokens: int | None = None,
) -> dict[str, Any]:
"""Create a chat completion with optional function calling."""
try:
response = await self.client.chat.completions.create(
model=self.model,
messages=messages, # type: ignore[arg-type]
tools=tools, # type: ignore[arg-type]
tool_choice=tool_choice if tools else None, # type: ignore[arg-type]
temperature=temperature or self.temperature,
max_tokens=max_tokens or self.max_tokens,
)
message = response.choices[0].message
result = {
"content": message.content,
"role": message.role,
"tool_calls": None,
"finish_reason": response.choices[0].finish_reason,
"usage": {
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
"total_tokens": response.usage.total_tokens if response.usage else 0,
},
}
if message.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"type": tc.type,
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in message.tool_calls
]
logger.info(
"openai_chat_completion",
model=self.model,
finish_reason=result["finish_reason"],
tokens=result["usage"]["total_tokens"],
)
return result
except openai.RateLimitError as exc:
logger.error("openai_rate_limited", error=str(exc))
raise OpenAIError("Rate limited by OpenAI", status_code=429) from exc
except openai.AuthenticationError as exc:
logger.error("openai_auth_error", error=str(exc))
raise OpenAIError("OpenAI authentication failed", status_code=401) from exc
except openai.APIError as exc:
logger.error("openai_api_error", error=str(exc), code=exc.code)
raise OpenAIError(f"OpenAI API error: {exc}", status_code=502) from exc
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=5),
retry=retry_if_exception_type((openai.RateLimitError,)),
reraise=True,
)
async def create_embedding(self, text: str) -> list[float]:
"""Create embedding vector for text."""
try:
response = await self.client.embeddings.create(
model=self.embedding_model,
input=text,
encoding_format="float",
)
embedding = response.data[0].embedding
logger.info(
"openai_embedding_created",
model=self.embedding_model,
dimensions=len(embedding),
)
return embedding
except openai.APIError as exc:
logger.error("openai_embedding_error", error=str(exc))
raise OpenAIError(f"Embedding failed: {exc}", status_code=502) from exc
async def create_embeddings_batch(self, texts: list[str]) -> list[list[float]]:
"""Create embeddings for multiple texts."""
response = await self.client.embeddings.create(
model=self.embedding_model,
input=texts,
encoding_format="float",
)
return [d.embedding for d in response.data]
# Global singleton
_openai_client: OpenAIClient | None = None
async def get_openai_client() -> OpenAIClient:
"""Return OpenAI client singleton."""
global _openai_client
if _openai_client is None:
_openai_client = OpenAIClient()
return _openai_client

View File

@@ -0,0 +1,202 @@
"""Prompt templates and tool definitions for the SKEEN AI Agent."""
from src.core.constants import SKEEN_SYSTEM_PROMPT
# =============================================================================
# TOOL DEFINITIONS (OpenAI Function Calling)
# =============================================================================
TOOLS = [
{
"type": "function",
"function": {
"name": "search_catalog",
"description": "Busca servicios, productos o paquetes en el catálogo de SKEEN. Usa esta herramienta cuando el paciente pregunte por tratamientos, precios, disponibilidad o promociones.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Términos de búsqueda del paciente (ej: 'depilación láser bikini', 'toxina botulínica precio')",
},
"category": {
"type": "string",
"enum": ["servicio", "producto", "paquete", "general"],
"description": "Categoría a filtrar, si se menciona explícitamente",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "check_availability",
"description": "Consulta la disponibilidad de citas en una fecha, sucursal o con un doctor específico.",
"parameters": {
"type": "object",
"properties": {
"date": {
"type": "string",
"description": "Fecha en formato ISO 8601 (YYYY-MM-DD). Si no se especifica, usa la fecha de mañana.",
},
"branch": {
"type": "string",
"description": "Nombre de la sucursal: 'Rosarito' o 'Tijuana'. Si no se especifica, busca en ambas.",
},
"doctor": {
"type": "string",
"description": "Nombre del doctor o 'cualquiera'.",
},
"service": {
"type": "string",
"description": "Nombre del servicio que desea agendar.",
},
},
"required": ["date"],
},
},
},
{
"type": "function",
"function": {
"name": "create_appointment",
"description": "Crea una cita médica en el sistema. SOLO usar después de confirmar fecha, hora, sucursal, servicio y doctor con el paciente.",
"parameters": {
"type": "object",
"properties": {
"patient_phone": {
"type": "string",
"description": "Número de WhatsApp del paciente (con lada, ej: 5216641234567).",
},
"patient_name": {
"type": "string",
"description": "Nombre completo del paciente.",
},
"date": {
"type": "string",
"description": "Fecha de la cita (YYYY-MM-DD).",
},
"time": {
"type": "string",
"description": "Hora de la cita en formato 24h (HH:MM).",
},
"service": {
"type": "string",
"description": "Nombre del servicio a agendar.",
},
"branch": {
"type": "string",
"description": "Sucursal: 'Rosarito' o 'Tijuana'.",
},
"doctor": {
"type": "string",
"description": "Nombre del doctor asignado.",
},
"notes": {
"type": "string",
"description": "Notas adicionales para la cita.",
},
},
"required": ["patient_phone", "patient_name", "date", "time", "service", "branch"],
},
},
},
{
"type": "function",
"function": {
"name": "get_patient_info",
"description": "Consulta la información de un paciente existente: historial de citas, saldo de monedero, adeudos, paquetes activos.",
"parameters": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"description": "Número de WhatsApp del paciente.",
},
},
"required": ["phone"],
},
},
},
{
"type": "function",
"function": {
"name": "get_wallet_balance",
"description": "Consulta el saldo del monedero electrónico de un paciente.",
"parameters": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"description": "Número de WhatsApp del paciente.",
},
},
"required": ["phone"],
},
},
},
{
"type": "function",
"function": {
"name": "escalate_to_human",
"description": "Escalar la conversación a un agente humano cuando el paciente lo solicite o el caso sea muy complejo/emocional.",
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Motivo de la escalación.",
},
},
"required": ["reason"],
},
},
},
]
# =============================================================================
# PROMPT TEMPLATES
# =============================================================================
APPOINTMENT_CONFIRMATION_PROMPT = """El paciente ha confirmado los siguientes datos para su cita:
- Nombre: {patient_name}
- Fecha: {date}
- Hora: {time}
- Servicio: {service}
- Sucursal: {branch}
- Doctor: {doctor}
Genera un mensaje de confirmación cálido y profesional para WhatsApp que:
1. Agradezca la preferencia.
2. Confirme todos los detalles en formato claro.
3. Indique política de cancelación (24h de anticipación).
4. Incluya dirección de la sucursal.
5. Sea corto (máximo 4 párrafos)."""
NO_AVAILABILITY_PROMPT = """No hay disponibilidad para la fecha/hora/sucursal solicitada.
Sugerencias actuales:
{alternatives}
Genera un mensaje amable ofreciendo las alternativas disponibles. Mantén tono profesional y empático."""
WALLET_BALANCE_PROMPT = """Saldo de monedero del paciente:
- Saldo actual: ${balance_mxn} MXN
- Puntos acumulados: {points}
Genera un resumen amigable del saldo y sugiere cómo puede usarlo (aplicar a su próxima cita o paquete)."""
NEW_PATIENT_ONBOARDING_PROMPT = """Este es un nuevo prospecto. Bienvenida/da a SKEEN.
Información capturada:
- Nombre: {name}
- Interés: {interest}
Genera un mensaje de bienvenida cálido que:
1. Presente brevemente a SKEEN.
2. Mencione que pueden agendar valoración gratuita.
3. Pregunte si desea información sobre algún tratamiento específico.
4. Incluya link a valoración express si es relevante."""

View File

@@ -0,0 +1,181 @@
"""Retrieval-Augmented Generation with pgvector."""
from typing import Any
import structlog
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.infrastructure.ai.openai_client import get_openai_client
logger = structlog.get_logger(__name__)
class RAGStore:
"""Vector store for SKEEN catalog and knowledge base using pgvector."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def ensure_extension(self) -> None:
"""Ensure pgvector extension is installed."""
await self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
await self.session.commit()
async def search(
self,
query: str,
top_k: int | None = None,
similarity_threshold: float | None = None,
category: str | None = None,
) -> list[dict[str, Any]]:
"""Search knowledge base using semantic similarity.
Args:
query: User query text.
top_k: Number of results (default from settings).
similarity_threshold: Minimum similarity score.
category: Filter by category (e.g., 'servicio', 'producto', 'faq').
Returns:
List of matching chunks with content, metadata, and similarity score.
"""
top_k = top_k or settings.RAG_TOP_K
threshold = similarity_threshold or settings.RAG_SIMILARITY_THRESHOLD
# Generate embedding for query
openai_client = await get_openai_client()
embedding = await openai_client.create_embedding(query)
embedding_str = f"[{','.join(str(x) for x in embedding)}]"
# Build query
category_filter = "AND category = :category" if category else ""
sql = f"""
SELECT
id,
content,
metadata,
category,
source,
1 - (embedding <=> :embedding_vec::vector) AS similarity
FROM knowledge_chunks
WHERE 1 - (embedding <=> :embedding_vec::vector) >= :threshold
{category_filter}
ORDER BY embedding <=> :embedding_vec::vector
LIMIT :top_k
"""
params = {
"embedding_vec": embedding_str,
"threshold": threshold,
"top_k": top_k,
}
if category:
params["category"] = category
result = await self.session.execute(text(sql), params)
rows = result.mappings().all()
documents = []
for row in rows:
documents.append({
"id": row["id"],
"content": row["content"],
"metadata": row["metadata"],
"category": row["category"],
"source": row["source"],
"similarity": float(row["similarity"]),
})
logger.info(
"rag_search_completed",
query=query[:50],
results=len(documents),
category=category,
)
return documents
async def add_document(
self,
content: str,
metadata: dict[str, Any] | None = None,
category: str = "general",
source: str = "",
doc_id: str | None = None,
) -> str:
"""Add a document chunk to the knowledge base."""
openai_client = await get_openai_client()
embedding = await openai_client.create_embedding(content)
embedding_str = f"[{','.join(str(x) for x in embedding)}]"
sql = """
INSERT INTO knowledge_chunks (id, content, metadata, category, source, embedding)
VALUES (
COALESCE(:doc_id, gen_random_uuid()::text),
:content,
:metadata,
:category,
:source,
:embedding_vec::vector
)
RETURNING id
"""
result = await self.session.execute(
text(sql),
{
"doc_id": doc_id,
"content": content,
"metadata": json.dumps(metadata or {}),
"category": category,
"source": source,
"embedding_vec": embedding_str,
},
)
await self.session.commit()
row = result.mappings().first()
inserted_id = row["id"] if row else ""
logger.info(
"rag_document_added",
doc_id=inserted_id,
category=category,
source=source,
)
return inserted_id
async def delete_by_source(self, source: str) -> int:
"""Delete all chunks from a specific source."""
result = await self.session.execute(
text("DELETE FROM knowledge_chunks WHERE source = :source"),
{"source": source},
)
await self.session.commit()
deleted = result.rowcount or 0
logger.info("rag_documents_deleted", source=source, count=deleted)
return deleted
# SQL to create table (run via Alembic or init script)
CREATE_KNOWLEDGE_TABLE_SQL = """
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS knowledge_chunks (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
category TEXT DEFAULT 'general',
source TEXT DEFAULT '',
embedding VECTOR(1536),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_knowledge_embedding
ON knowledge_chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_knowledge_category ON knowledge_chunks(category);
CREATE INDEX IF NOT EXISTS idx_knowledge_source ON knowledge_chunks(source);
"""

62
src/infrastructure/db.py Normal file
View File

@@ -0,0 +1,62 @@
"""Database configuration with async SQLAlchemy + pgvector."""
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
from sqlalchemy.pool import NullPool
from src.config import settings
# Base class for SQLModel/SQLAlchemy models
Base = declarative_base()
# Engine configuration
engine_kwargs = {
"pool_size": settings.DATABASE_POOL_SIZE,
"max_overflow": settings.DATABASE_MAX_OVERFLOW,
"pool_pre_ping": True,
"pool_recycle": 300,
"echo": settings.DEBUG,
}
if settings.APP_ENV == "testing":
engine_kwargs["poolclass"] = NullPool
engine = create_async_engine(
settings.database_url_str,
**engine_kwargs,
)
# Session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Yield an async database session for dependency injection."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""Initialize database tables (for dev/testing only)."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def close_db() -> None:
"""Dispose database engine."""
await engine.dispose()

View File

View File

@@ -0,0 +1,255 @@
"""ERPNext Frappe REST API client."""
from typing import Any
import httpx
import structlog
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from src.config import settings
from src.core.exceptions import ERPNextError
logger = structlog.get_logger(__name__)
class ERPNextClient:
"""Async client for ERPNext Frappe REST API."""
def __init__(self) -> None:
self.base_url = settings.ERPNEXT_BASE_URL.rstrip("/")
self.api_key = settings.ERPNEXT_API_KEY.get_secret_value()
self.api_secret = settings.ERPNEXT_API_SECRET.get_secret_value()
self.verify_ssl = settings.ERPNEXT_VERIFY_SSL
self.headers = {
"Authorization": f"token {self.api_key}:{self.api_secret}",
"Content-Type": "application/json",
"Accept": "application/json",
}
self.client = httpx.AsyncClient(
base_url=self.base_url,
headers=self.headers,
timeout=httpx.Timeout(30.0, connect=10.0),
verify=self.verify_ssl,
)
async def close(self) -> None:
"""Close HTTP client."""
await self.client.aclose()
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.NetworkError)),
reraise=True,
)
async def get_document(
self,
doctype: str,
name: str,
fields: list[str] | None = None,
) -> dict[str, Any]:
"""Get a single document from ERPNext."""
params: dict[str, Any] = {}
if fields:
params["fields"] = str(fields)
response = await self.client.get(
f"/api/resource/{doctype}/{name}",
params=params,
)
response.raise_for_status()
data = response.json()
return data.get("data", {})
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.NetworkError)),
reraise=True,
)
async def get_list(
self,
doctype: str,
filters: list[list[Any]] | None = None,
fields: list[str] | None = None,
or_filters: list[list[Any]] | None = None,
limit: int = 20,
limit_start: int = 0,
order_by: str = "modified desc",
) -> list[dict[str, Any]]:
"""Query a list of documents from ERPNext."""
params: dict[str, Any] = {
"limit_page_length": limit,
"limit_start": limit_start,
"order_by": order_by,
}
if filters:
params["filters"] = str(filters)
if or_filters:
params["or_filters"] = str(or_filters)
if fields:
params["fields"] = str(fields)
response = await self.client.get(
f"/api/resource/{doctype}",
params=params,
)
response.raise_for_status()
data = response.json()
return data.get("data", [])
async def create_document(
self,
doctype: str,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new document in ERPNext."""
try:
response = await self.client.post(
f"/api/resource/{doctype}",
json=data,
)
response.raise_for_status()
result = response.json()
logger.info(
"erpnext_document_created",
doctype=doctype,
name=result.get("data", {}).get("name"),
)
return result.get("data", {})
except httpx.HTTPStatusError as exc:
logger.error(
"erpnext_create_failed",
doctype=doctype,
status=exc.response.status_code,
response=exc.response.text,
)
raise ERPNextError(
f"Failed to create {doctype}: {exc.response.text}",
status_code=exc.response.status_code,
) from exc
async def update_document(
self,
doctype: str,
name: str,
data: dict[str, Any],
) -> dict[str, Any]:
"""Update an existing document."""
try:
response = await self.client.put(
f"/api/resource/{doctype}/{name}",
json=data,
)
response.raise_for_status()
result = response.json()
logger.info("erpnext_document_updated", doctype=doctype, name=name)
return result.get("data", {})
except httpx.HTTPStatusError as exc:
raise ERPNextError(
f"Failed to update {doctype}: {exc.response.text}",
status_code=exc.response.status_code,
) from exc
async def call_method(
self,
method: str,
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Call a Frappe method (whitelisted)."""
try:
response = await self.client.post(
f"/api/method/{method}",
json=data or {},
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
raise ERPNextError(
f"Method {method} failed: {exc.response.text}",
status_code=exc.response.status_code,
) from exc
# -------------------------------------------------------------------------
# Convenience methods for Healthcare
# -------------------------------------------------------------------------
async def find_patient_by_phone(self, phone: str) -> dict[str, Any] | None:
"""Find a patient by mobile number."""
patients = await self.get_list(
"Patient",
filters=[["mobile", "=", phone]],
fields=["name", "patient_name", "mobile", "sex", "dob", "blood_group"],
limit=1,
)
return patients[0] if patients else None
async def get_appointments(
self,
patient: str | None = None,
date: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
"""Get patient appointments."""
filters: list[list[Any]] = []
if patient:
filters.append(["patient", "=", patient])
if date:
filters.append(["appointment_date", "=", date])
if status:
filters.append(["status", "=", status])
return await self.get_list(
"Patient Appointment",
filters=filters if filters else None,
fields=[
"name", "patient", "practitioner", "appointment_date",
"appointment_time", "duration", "status", "department",
],
limit=50,
)
async def create_appointment(
self,
patient: str,
practitioner: str,
appointment_date: str,
appointment_time: str,
duration: int = 30,
department: str = "Dermatología Estética",
notes: str = "",
) -> dict[str, Any]:
"""Create a patient appointment."""
data = {
"doctype": "Patient Appointment",
"patient": patient,
"practitioner": practitioner,
"appointment_date": appointment_date,
"appointment_time": appointment_time,
"duration": duration,
"department": department,
"notes": notes,
"status": "Scheduled",
}
return await self.create_document("Patient Appointment", data)
# Global singleton
_erpnext_client: ERPNextClient | None = None
async def get_erpnext_client() -> ERPNextClient:
"""Return ERPNext client singleton."""
global _erpnext_client
if _erpnext_client is None:
_erpnext_client = ERPNextClient()
return _erpnext_client
async def close_erpnext_client() -> None:
"""Close ERPNext client."""
global _erpnext_client
if _erpnext_client:
await _erpnext_client.close()
_erpnext_client = None

View File

@@ -0,0 +1,69 @@
"""Redis client configuration."""
import orjson
import redis.asyncio as aioredis
from redis.asyncio import Redis
from src.config import settings
# Global Redis client instance
_redis_client: Redis | None = None
async def get_redis() -> Redis:
"""Return async Redis client singleton."""
global _redis_client
if _redis_client is None:
_redis_client = aioredis.from_url(
str(settings.REDIS_URL),
decode_responses=True,
encoding="utf-8",
)
return _redis_client
async def close_redis() -> None:
"""Close Redis connection."""
global _redis_client
if _redis_client:
await _redis_client.close()
_redis_client = None
class RedisCache:
"""Helper class for common Redis operations."""
def __init__(self, redis: Redis) -> None:
self.redis = redis
async def get(self, key: str) -> dict | None:
"""Get and deserialize JSON value."""
value = await self.redis.get(key)
if value is None:
return None
return orjson.loads(value)
async def set(
self,
key: str,
value: dict,
ttl: int = 3600,
) -> None:
"""Serialize and set JSON value with TTL."""
await self.redis.setex(key, ttl, orjson.dumps(value))
async def delete(self, key: str) -> None:
"""Delete key."""
await self.redis.delete(key)
async def exists(self, key: str) -> bool:
"""Check if key exists."""
return await self.redis.exists(key) > 0
async def increment(self, key: str, amount: int = 1) -> int:
"""Increment counter."""
return await self.redis.incrby(key, amount)
async def expire(self, key: str, ttl: int) -> None:
"""Set expiration on key."""
await self.redis.expire(key, ttl)

View File

View File

@@ -0,0 +1,254 @@
"""Official Meta WhatsApp Business API client."""
import asyncio
from typing import Any
import httpx
import structlog
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from src.config import settings
from src.core.exceptions import WhatsAppAPIError
logger = structlog.get_logger(__name__)
class WhatsAppClient:
"""Async client for Meta WhatsApp Business API."""
def __init__(self) -> None:
self.base_url = settings.meta_api_base_url
self.phone_number_id = settings.META_PHONE_NUMBER_ID
self.access_token = settings.META_ACCESS_TOKEN.get_secret_value()
self.headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
self.client = httpx.AsyncClient(
base_url=self.base_url,
headers=self.headers,
timeout=httpx.Timeout(30.0, connect=10.0),
http2=True,
)
async def close(self) -> None:
"""Close HTTP client."""
await self.client.aclose()
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.NetworkError)),
reraise=True,
)
async def _post(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Make POST request with retries."""
response = await self.client.post(endpoint, json=payload)
response.raise_for_status()
return response.json()
async def send_text_message(
self,
to: str,
text: str,
preview_url: bool = False,
) -> dict[str, Any]:
"""Send a text message to a WhatsApp user.
Args:
to: Recipient phone number in international format (e.g., 5216641234567).
text: Message body (max 4096 chars).
preview_url: Whether to show URL preview.
Returns:
API response with message ID.
"""
if len(text) > 4096:
text = text[:4093] + "..."
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "text",
"text": {"preview_url": preview_url, "body": text},
}
endpoint = f"/{self.phone_number_id}/messages"
try:
result = await self._post(endpoint, payload)
logger.info(
"whatsapp_text_sent",
to=to,
message_id=result.get("messages", [{}])[0].get("id"),
)
return result
except httpx.HTTPStatusError as exc:
logger.error(
"whatsapp_text_failed",
to=to,
status_code=exc.response.status_code,
response=exc.response.text,
)
raise WhatsAppAPIError(
f"Failed to send WhatsApp message: {exc.response.text}",
status_code=exc.response.status_code,
) from exc
async def send_template_message(
self,
to: str,
template_name: str,
language_code: str = "es_MX",
components: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Send a template message (required for 24h+ inactive conversations)."""
payload: dict[str, Any] = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "template",
"template": {
"name": template_name,
"language": {"code": language_code},
},
}
if components:
payload["template"]["components"] = components
endpoint = f"/{self.phone_number_id}/messages"
try:
result = await self._post(endpoint, payload)
logger.info(
"whatsapp_template_sent",
to=to,
template=template_name,
message_id=result.get("messages", [{}])[0].get("id"),
)
return result
except httpx.HTTPStatusError as exc:
raise WhatsAppAPIError(
f"Failed to send template: {exc.response.text}",
status_code=exc.response.status_code,
) from exc
async def send_interactive_buttons(
self,
to: str,
body: str,
buttons: list[dict[str, str]],
) -> dict[str, Any]:
"""Send interactive button message.
Args:
buttons: List of {"id": "btn_1", "title": "Option 1"} (max 3).
"""
if len(buttons) > 3:
raise ValueError("Maximum 3 buttons allowed")
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "interactive",
"interactive": {
"type": "button",
"body": {"text": body},
"action": {
"buttons": [
{"type": "reply", "reply": {"id": b["id"], "title": b["title"]}}
for b in buttons
]
},
},
}
endpoint = f"/{self.phone_number_id}/messages"
try:
result = await self._post(endpoint, payload)
logger.info("whatsapp_buttons_sent", to=to)
return result
except httpx.HTTPStatusError as exc:
raise WhatsAppAPIError(
f"Failed to send buttons: {exc.response.text}",
status_code=exc.response.status_code,
) from exc
async def send_interactive_list(
self,
to: str,
body: str,
button_text: str,
sections: list[dict[str, Any]],
) -> dict[str, Any]:
"""Send interactive list message (e.g., service selection)."""
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "interactive",
"interactive": {
"type": "list",
"body": {"text": body},
"action": {
"button": button_text,
"sections": sections,
},
},
}
endpoint = f"/{self.phone_number_id}/messages"
try:
result = await self._post(endpoint, payload)
logger.info("whatsapp_list_sent", to=to)
return result
except httpx.HTTPStatusError as exc:
raise WhatsAppAPIError(
f"Failed to send list: {exc.response.text}",
status_code=exc.response.status_code,
) from exc
async def mark_as_read(self, message_id: str) -> dict[str, Any]:
"""Mark a message as read."""
payload = {
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id,
}
endpoint = f"/{self.phone_number_id}/messages"
try:
result = await self._post(endpoint, payload)
logger.info("whatsapp_marked_read", message_id=message_id)
return result
except httpx.HTTPStatusError as exc:
raise WhatsAppAPIError(
f"Failed to mark as read: {exc.response.text}",
status_code=exc.response.status_code,
) from exc
async def get_business_profile(self) -> dict[str, Any]:
"""Get business profile information."""
endpoint = f"/{self.phone_number_id}/whatsapp_business_profile"
response = await self.client.get(endpoint, params={"fields": "about,description,email"})
response.raise_for_status()
return response.json()
# Global client instance
_whatsapp_client: WhatsAppClient | None = None
async def get_whatsapp_client() -> WhatsAppClient:
"""Return WhatsApp client singleton."""
global _whatsapp_client
if _whatsapp_client is None:
_whatsapp_client = WhatsAppClient()
return _whatsapp_client
async def close_whatsapp_client() -> None:
"""Close WhatsApp client."""
global _whatsapp_client
if _whatsapp_client:
await _whatsapp_client.close()
_whatsapp_client = None

View File

@@ -0,0 +1,168 @@
"""Meta WhatsApp webhook parser and validator."""
import hmac
import hashlib
from typing import Any
import structlog
from src.config import settings
from src.core.constants import WhatsAppMessageType
from src.core.exceptions import ValidationError
logger = structlog.get_logger(__name__)
class WhatsAppWebhookPayload:
"""Parsed WhatsApp webhook payload."""
def __init__(self, raw: dict[str, Any]) -> None:
self.raw = raw
self._entry = raw.get("entry", [{}])[0]
self._change = self._entry.get("changes", [{}])[0]
self._value = self._change.get("value", {})
self._message = self._value.get("messages", [{}])[0] if "messages" in self._value else {}
@property
def object_type(self) -> str:
return self.raw.get("object", "")
@property
def business_phone_number_id(self) -> str:
return self._value.get("metadata", {}).get("phone_number_id", "")
@property
def display_phone_number(self) -> str:
return self._value.get("metadata", {}).get("display_phone_number", "")
@property
def has_messages(self) -> bool:
return "messages" in self._value and len(self._value["messages"]) > 0
@property
def has_statuses(self) -> bool:
return "statuses" in self._value and len(self._value["statuses"]) > 0
# Message properties
@property
def message_id(self) -> str:
return self._message.get("id", "")
@property
def from_number(self) -> str:
return self._message.get("from", "")
@property
def timestamp(self) -> str:
return self._message.get("timestamp", "")
@property
def message_type(self) -> WhatsAppMessageType:
msg_type = self._message.get("type", "")
try:
return WhatsAppMessageType(msg_type)
except ValueError:
return WhatsAppMessageType.UNKNOWN
@property
def text_body(self) -> str:
if self.message_type == WhatsAppMessageType.TEXT:
return self._message.get("text", {}).get("body", "")
return ""
@property
def button_payload(self) -> str:
if self.message_type == WhatsAppMessageType.INTERACTIVE:
interactive = self._message.get("interactive", {})
if "button_reply" in interactive:
return interactive["button_reply"].get("id", "")
if "list_reply" in interactive:
return interactive["list_reply"].get("id", "")
return ""
@property
def button_title(self) -> str:
if self.message_type == WhatsAppMessageType.INTERACTIVE:
interactive = self._message.get("interactive", {})
if "button_reply" in interactive:
return interactive["button_reply"].get("title", "")
if "list_reply" in interactive:
return interactive["list_reply"].get("title", "")
return ""
@property
def image_data(self) -> dict[str, Any]:
if self.message_type == WhatsAppMessageType.IMAGE:
return self._message.get("image", {})
return {}
@property
def audio_data(self) -> dict[str, Any]:
if self.message_type == WhatsAppMessageType.AUDIO:
return self._message.get("audio", {})
return {}
@property
def context(self) -> dict[str, Any]:
"""Context of a reply (original message ID)."""
return self._message.get("context", {})
@property
def is_reply(self) -> bool:
return bool(self.context.get("id"))
# Status properties
@property
def statuses(self) -> list[dict[str, Any]]:
return self._value.get("statuses", [])
def __repr__(self) -> str:
return (
f"<WhatsAppWebhookPayload from={self.from_number} "
f"type={self.message_type.value} id={self.message_id}>"
)
class WebhookVerifier:
"""Verify Meta webhook signatures."""
@staticmethod
def verify_subscription(mode: str | None, token: str | None) -> bool:
"""Verify webhook subscription challenge."""
if mode != "subscribe":
return False
verify_token = settings.META_WEBHOOK_VERIFY_TOKEN.get_secret_value()
return token == verify_token
@staticmethod
def verify_signature(body: bytes, signature: str | None) -> bool:
"""Verify X-Hub-Signature-256 header."""
if not signature:
logger.warning("missing_webhook_signature")
return False
app_secret = settings.META_APP_SECRET.get_secret_value()
expected = hmac.new(
app_secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(f"sha256={expected}", signature):
logger.warning("invalid_webhook_signature")
return False
return True
def parse_webhook_payload(payload: dict[str, Any]) -> WhatsAppWebhookPayload:
"""Parse and validate incoming webhook payload."""
if payload.get("object") != "whatsapp_business_account":
raise ValidationError("Invalid webhook object type")
parsed = WhatsAppWebhookPayload(payload)
if not parsed.has_messages and not parsed.has_statuses:
raise ValidationError("Webhook contains no messages or statuses")
return parsed

170
src/main.py Normal file
View File

@@ -0,0 +1,170 @@
"""SKEEN CRM AI Agent - FastAPI Application Entry Point."""
import time
import uuid
from contextlib import asynccontextmanager
import structlog
from asgi_correlation_id import CorrelationIdMiddleware
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from prometheus_client import make_asgi_app
from src.api.v1.health import router as health_router
from src.api.v1.messages import router as messages_router
from src.api.v1.webhooks import router as webhooks_router
from src.config import settings
from src.core.exceptions import SkeenException
from src.infrastructure.db import close_db, init_db
from src.infrastructure.erpnext.client import close_erpnext_client
from src.infrastructure.redis import close_redis
from src.infrastructure.whatsapp.client import close_whatsapp_client
logger = structlog.get_logger(__name__)
def setup_logging() -> None:
"""Configure structured logging."""
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.processors.JSONRenderer(),
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events."""
setup_logging()
logger.info(
"application_starting",
app_name=settings.APP_NAME,
environment=settings.APP_ENV,
version="0.1.0",
)
# Initialize database in development
if settings.APP_ENV == "development":
try:
await init_db()
logger.info("database_initialized")
except Exception as exc:
logger.warning("database_init_skipped", error=str(exc))
yield
# Cleanup
logger.info("application_shutting_down")
await close_redis()
await close_whatsapp_client()
await close_erpnext_client()
await close_db()
logger.info("application_shutdown_complete")
def create_app() -> FastAPI:
"""Application factory."""
app = FastAPI(
title=settings.APP_NAME,
description="SKEEN CRM AI Agent for WhatsApp Business API + ERPNext",
version="0.1.0",
docs_url="/docs" if not settings.is_production else None,
redoc_url="/redoc" if not settings.is_production else None,
openapi_url="/openapi.json" if not settings.is_production else None,
lifespan=lifespan,
)
# Middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: Restrict in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(CorrelationIdMiddleware)
# Request timing middleware
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.time()
request_id = str(uuid.uuid4())
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
request_id=request_id,
path=request.url.path,
method=request.method,
)
try:
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
response.headers["X-Request-ID"] = request_id
logger.info(
"request_completed",
status_code=response.status_code,
duration_ms=round(process_time * 1000, 2),
)
return response
except Exception as exc:
logger.error("request_failed", error=str(exc))
raise
# Exception handlers
@app.exception_handler(SkeenException)
async def skeen_exception_handler(request: Request, exc: SkeenException):
logger.error(
"application_exception",
error=exc.message,
status_code=exc.status_code,
details=exc.details,
)
return JSONResponse(
status_code=exc.status_code,
content={
"error": exc.message,
"details": exc.details,
"request_id": str(uuid.uuid4()),
},
)
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
logger.exception("unhandled_exception", error=str(exc))
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": "Internal server error",
"request_id": str(uuid.uuid4()),
},
)
# Routes
app.include_router(health_router, prefix="/api/v1")
app.include_router(webhooks_router, prefix="/api/v1")
app.include_router(messages_router, prefix="/api/v1")
# Metrics endpoint (Prometheus)
if settings.ENABLE_METRICS:
metrics_app = make_asgi_app()
app.mount("/metrics", metrics_app)
return app
app = create_app()

View File

View File

@@ -0,0 +1,372 @@
"""Core use case: process incoming WhatsApp message with AI agent."""
import json
import uuid
from datetime import datetime, timezone
from typing import Any
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.core.constants import ConversationStatus, SKEEN_SYSTEM_PROMPT, WhatsAppMessageType
from src.infrastructure.ai.openai_client import get_openai_client
from src.infrastructure.ai.prompts import TOOLS
from src.infrastructure.ai.rag import RAGStore
from src.infrastructure.erpnext.client import get_erpnext_client
from src.infrastructure.whatsapp.client import get_whatsapp_client
from src.infrastructure.whatsapp.webhook import WhatsAppWebhookPayload
from src.domain.models.conversation import Conversation, Message
logger = structlog.get_logger(__name__)
MAX_CONTEXT_MESSAGES = 10
class ToolExecutor:
"""Executes tool calls requested by the LLM."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.rag = RAGStore(session)
self.erpnext = None # Lazy init
async def execute(self, tool_call: dict[str, Any]) -> dict[str, Any]:
"""Execute a single tool call and return result."""
name = tool_call["function"]["name"]
arguments = json.loads(tool_call["function"]["arguments"])
tool_call_id = tool_call["id"]
logger.info("executing_tool", tool=name, args=arguments)
try:
if name == "search_catalog":
result = await self._search_catalog(arguments)
elif name == "check_availability":
result = await self._check_availability(arguments)
elif name == "create_appointment":
result = await self._create_appointment(arguments)
elif name == "get_patient_info":
result = await self._get_patient_info(arguments)
elif name == "get_wallet_balance":
result = await self._get_wallet_balance(arguments)
elif name == "escalate_to_human":
result = await self._escalate_to_human(arguments)
else:
result = {"error": f"Unknown tool: {name}"}
except Exception as exc:
logger.error("tool_execution_failed", tool=name, error=str(exc))
result = {"error": f"Failed to execute {name}: {str(exc)}"}
return {
"tool_call_id": tool_call_id,
"role": "tool",
"name": name,
"content": json.dumps(result, ensure_ascii=False),
}
async def _search_catalog(self, args: dict[str, Any]) -> dict[str, Any]:
query = args.get("query", "")
category = args.get("category")
results = await self.rag.search(query, category=category, top_k=5)
return {
"results": [
{
"content": r["content"],
"category": r["category"],
"source": r["source"],
"similarity": round(r["similarity"], 3),
}
for r in results
]
}
async def _check_availability(self, args: dict[str, Any]) -> dict[str, Any]:
date = args.get("date")
branch = args.get("branch")
doctor = args.get("doctor")
service = args.get("service")
# TODO: Integrate with ERPNext Healthcare scheduling
# For now, return mock data structure
return {
"date": date,
"available_slots": [
{"time": "10:00", "doctor": "Dr. Ramos"},
{"time": "11:30", "doctor": "Dr. Martínez"},
{"time": "15:00", "doctor": "Dr. Ramos"},
],
"branch": branch or "Rosarito",
"service": service,
"note": "Esta es una respuesta simulada. Integrar con ERPNext Healthcare.",
}
async def _create_appointment(self, args: dict[str, Any]) -> dict[str, Any]:
# TODO: Integrate with ERPNext to create real appointments
return {
"status": "simulated",
"appointment_id": f"APT-{uuid.uuid4().hex[:8].upper()}",
"details": args,
"note": "Cita simulada. Integrar con ERPNext Patient Appointment.",
}
async def _get_patient_info(self, args: dict[str, Any]) -> dict[str, Any]:
phone = args.get("phone", "")
erpnext = await get_erpnext_client()
patient = await erpnext.find_patient_by_phone(phone)
if not patient:
return {"found": False, "message": "No se encontró paciente con ese número."}
appointments = await erpnext.get_appointments(patient=patient.get("name"))
return {
"found": True,
"name": patient.get("patient_name"),
"sex": patient.get("sex"),
"blood_group": patient.get("blood_group"),
"total_appointments": len(appointments),
"last_appointments": appointments[:3],
}
async def _get_wallet_balance(self, args: dict[str, Any]) -> dict[str, Any]:
# TODO: Integrate with ERPNext custom Wallet doctype
return {
"balance_mxn": 0.0,
"points": 0,
"note": "Monedero no implementado en ERPNext aún.",
}
async def _escalate_to_human(self, args: dict[str, Any]) -> dict[str, Any]:
reason = args.get("reason", "Solicitud del paciente")
return {
"escalated": True,
"reason": reason,
"message": "Un agente humano de SKEEN se pondrá en contacto contigo pronto. ⏳",
}
async def get_or_create_conversation(
db: AsyncSession,
phone_number: str,
) -> Conversation:
"""Get existing conversation or create new one."""
result = await db.execute(
select(Conversation)
.where(Conversation.phone_number == phone_number)
.order_by(Conversation.created_at.desc())
)
conversation = result.scalars().first()
if conversation is None:
conversation = Conversation(
id=str(uuid.uuid4()),
phone_number=phone_number,
status=ConversationStatus.ACTIVE,
context={},
)
db.add(conversation)
await db.flush()
logger.info("new_conversation_created", phone=phone_number, conversation_id=conversation.id)
else:
# Reactivate if resolved
if conversation.status == ConversationStatus.RESOLVED:
conversation.status = ConversationStatus.ACTIVE
conversation.last_message_at = datetime.now(timezone.utc)
return conversation
async def get_conversation_history(
db: AsyncSession,
conversation_id: str,
limit: int = MAX_CONTEXT_MESSAGES,
) -> list[dict[str, str]]:
"""Get recent messages formatted for OpenAI context."""
from sqlalchemy import select
from src.domain.models.conversation import Message
result = await db.execute(
select(Message)
.where(Message.conversation_id == conversation_id)
.order_by(Message.created_at.desc())
.limit(limit)
)
messages = result.scalars().all()
# Reverse to chronological order
history = []
for msg in reversed(messages):
history.append({"role": msg.role, "content": msg.content})
return history
async def process_incoming_message(
db: AsyncSession,
webhook_data: dict[str, Any],
client_ip: str = "unknown",
) -> dict[str, Any]:
"""Process an incoming WhatsApp message end-to-end.
1. Parse webhook
2. Load/create conversation
3. Build LLM context
4. Call OpenAI with tools
5. Execute tools if needed
6. Send response
7. Persist everything
"""
webhook = WhatsAppWebhookPayload(webhook_data)
if not webhook.has_messages:
return {"status": "no_messages"}
phone = webhook.from_number
message_text = webhook.text_body or "[Mensaje no textual]"
message_type = webhook.message_type.value
logger.info(
"incoming_message_received",
from_number=phone[-4:], # Log last 4 digits only for privacy
message_type=message_type,
ip=client_ip,
)
# Get or create conversation
conversation = await get_or_create_conversation(db, phone)
# Try to find patient in ERPNext for personalization
patient_name = None
try:
erpnext = await get_erpnext_client()
patient = await erpnext.find_patient_by_phone(phone)
if patient:
patient_name = patient.get("patient_name")
conversation.patient_id = patient.get("name")
conversation.patient_name = patient_name
except Exception as exc:
logger.warning("patient_lookup_failed", error=str(exc))
# Save inbound message
inbound_msg = Message(
id=str(uuid.uuid4()),
conversation_id=conversation.id,
direction="inbound",
role="user",
message_type=message_type,
content=message_text,
whatsapp_message_id=webhook.message_id,
metadata={"ip": client_ip, "timestamp": webhook.timestamp},
)
db.add(inbound_msg)
# Build context for OpenAI
system_prompt = SKEEN_SYSTEM_PROMPT
if patient_name:
system_prompt += f"\n\nPACIENTE ACTUAL: {patient_name}. Salúdalo/a por su nombre cuando sea apropiado."
messages: list[dict[str, str]] = [
{"role": "system", "content": system_prompt},
]
# Add conversation history
history = await get_conversation_history(db, conversation.id)
messages.extend(history)
# Add current message
messages.append({"role": "user", "content": message_text})
# Call OpenAI
openai_client = await get_openai_client()
llm_response = await openai_client.chat_completion(
messages=messages,
tools=TOOLS,
)
assistant_message = llm_response["content"] or ""
tool_calls = llm_response.get("tool_calls")
tool_results = []
# Execute tools if requested
if tool_calls:
executor = ToolExecutor(db)
for tc in tool_calls:
result = await executor.execute(tc)
tool_results.append(result)
# Second LLM call with tool results
messages.append({
"role": "assistant",
"content": assistant_message,
"tool_calls": tool_calls,
})
for tr in tool_results:
messages.append({
"role": "tool",
"tool_call_id": tr["tool_call_id"],
"name": tr["name"],
"content": tr["content"],
})
final_response = await openai_client.chat_completion(
messages=messages,
tools=TOOLS,
)
assistant_message = final_response["content"] or assistant_message
# Mark as read (best effort)
try:
wa_client = await get_whatsapp_client()
await wa_client.mark_as_read(webhook.message_id)
except Exception:
pass
# Send response
if assistant_message:
try:
wa_client = await get_whatsapp_client()
send_result = await wa_client.send_text_message(phone, assistant_message)
response_message_id = send_result.get("messages", [{}])[0].get("id")
except Exception as exc:
logger.error("failed_to_send_response", error=str(exc))
response_message_id = None
else:
response_message_id = None
# Save outbound message
outbound_msg = Message(
id=str(uuid.uuid4()),
conversation_id=conversation.id,
direction="outbound",
role="assistant",
message_type="text",
content=assistant_message,
whatsapp_message_id=response_message_id,
tool_calls=tool_calls,
tool_results=tool_results,
tokens_used=llm_response.get("usage", {}).get("total_tokens", 0),
metadata={"model": settings.OPENAI_MODEL},
)
db.add(outbound_msg)
# Update conversation
conversation.last_message_at = datetime.now(timezone.utc)
if any(tr.get("name") == "escalate_to_human" for tr in tool_results):
conversation.status = ConversationStatus.ESCALATED
await db.commit()
logger.info(
"message_processed",
conversation_id=conversation.id,
patient=patient_name,
tools_used=len(tool_calls) if tool_calls else 0,
response_length=len(assistant_message),
)
return {
"status": "processed",
"conversation_id": conversation.id,
"response": assistant_message,
"tools_executed": [tr["name"] for tr in tool_results],
}

0
src/workers/__init__.py Normal file
View File

61
src/workers/celery_app.py Normal file
View File

@@ -0,0 +1,61 @@
"""Celery configuration for background task processing."""
import os
from celery import Celery
from celery.signals import setup_logging
from src.config import settings
os.environ.setdefault("CELERY_CONFIG_MODULE", "src.workers.celery_app")
celery_app = Celery(
"skeen_crm",
broker=str(settings.CELERY_BROKER_URL),
backend=str(settings.CELERY_RESULT_BACKEND),
include=["src.workers.tasks"],
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="America/Tijuana",
enable_utc=True,
task_track_started=True,
task_time_limit=300,
worker_prefetch_multiplier=1,
worker_concurrency=settings.CELERY_WORKER_CONCURRENCY,
task_routes={
"src.workers.tasks.process_whatsapp_message_task": {"queue": "whatsapp"},
"src.workers.tasks.sync_erpnext_task": {"queue": "erpnext"},
"src.workers.tasks.generate_embedding_task": {"queue": "ai"},
},
task_default_queue="default",
)
@setup_logging.connect
def config_loggers(*args, **kwargs) -> None:
"""Configure structlog for Celery workers."""
import logging
import structlog
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.processors.JSONRenderer(),
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
logging.basicConfig(format="%(message)s", level=logging.INFO)

67
src/workers/tasks.py Normal file
View File

@@ -0,0 +1,67 @@
"""Celery background tasks."""
import structlog
from src.infrastructure.db import AsyncSessionLocal
from src.use_cases.handle_incoming_message import process_incoming_message
from src.workers.celery_app import celery_app
logger = structlog.get_logger(__name__)
@celery_app.task(bind=True, max_retries=3, default_retry_delay=10)
def process_whatsapp_message_task(self, webhook_data: dict, client_ip: str = "unknown") -> dict:
"""Process WhatsApp message asynchronously.
This task runs in a Celery worker and handles the full AI pipeline
so the webhook endpoint can return 200 immediately to Meta.
"""
import asyncio
async def _process() -> dict:
async with AsyncSessionLocal() as db:
try:
result = await process_incoming_message(
db=db,
webhook_data=webhook_data,
client_ip=client_ip,
)
return result
except Exception as exc:
logger.error("whatsapp_task_failed", error=str(exc), attempt=self.request.retries)
raise self.retry(exc=exc)
return asyncio.run(_process())
@celery_app.task
def sync_erpnext_patient_task(patient_data: dict) -> dict:
"""Sync patient data to ERPNext in background."""
logger.info("syncing_patient_to_erpnext", patient=patient_data.get("name"))
# TODO: Implement ERPNext sync logic
return {"status": "synced", "patient": patient_data.get("name")}
@celery_app.task
def generate_embedding_task(document_id: str, content: str, category: str) -> dict:
"""Generate embedding for a document chunk asynchronously."""
import asyncio
async def _generate() -> dict:
from src.infrastructure.ai.openai_client import get_openai_client
from src.infrastructure.db import AsyncSessionLocal
from src.infrastructure.ai.rag import RAGStore
async with AsyncSessionLocal() as db:
client = await get_openai_client()
embedding = await client.create_embedding(content)
store = RAGStore(db)
await store.add_document(
content=content,
category=category,
doc_id=document_id,
)
return {"document_id": document_id, "embedding_dims": len(embedding)}
return asyncio.run(_generate())