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:
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/api/__init__.py
Normal file
0
src/api/__init__.py
Normal file
36
src/api/deps.py
Normal file
36
src/api/deps.py
Normal 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
0
src/api/v1/__init__.py
Normal file
39
src/api/v1/health.py
Normal file
39
src/api/v1/health.py
Normal 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
109
src/api/v1/messages.py
Normal 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
98
src/api/v1/webhooks.py
Normal 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
96
src/config.py
Normal 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
0
src/core/__init__.py
Normal file
100
src/core/constants.py
Normal file
100
src/core/constants.py
Normal 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
66
src/core/exceptions.py
Normal 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
0
src/domain/__init__.py
Normal file
0
src/domain/models/__init__.py
Normal file
0
src/domain/models/__init__.py
Normal file
74
src/domain/models/conversation.py
Normal file
74
src/domain/models/conversation.py
Normal 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(),
|
||||
)
|
||||
0
src/domain/services/__init__.py
Normal file
0
src/domain/services/__init__.py
Normal file
0
src/infrastructure/__init__.py
Normal file
0
src/infrastructure/__init__.py
Normal file
0
src/infrastructure/ai/__init__.py
Normal file
0
src/infrastructure/ai/__init__.py
Normal file
143
src/infrastructure/ai/openai_client.py
Normal file
143
src/infrastructure/ai/openai_client.py
Normal 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
|
||||
202
src/infrastructure/ai/prompts.py
Normal file
202
src/infrastructure/ai/prompts.py
Normal 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."""
|
||||
181
src/infrastructure/ai/rag.py
Normal file
181
src/infrastructure/ai/rag.py
Normal 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
62
src/infrastructure/db.py
Normal 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()
|
||||
0
src/infrastructure/erpnext/__init__.py
Normal file
0
src/infrastructure/erpnext/__init__.py
Normal file
255
src/infrastructure/erpnext/client.py
Normal file
255
src/infrastructure/erpnext/client.py
Normal 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
|
||||
69
src/infrastructure/redis.py
Normal file
69
src/infrastructure/redis.py
Normal 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)
|
||||
0
src/infrastructure/whatsapp/__init__.py
Normal file
0
src/infrastructure/whatsapp/__init__.py
Normal file
254
src/infrastructure/whatsapp/client.py
Normal file
254
src/infrastructure/whatsapp/client.py
Normal 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
|
||||
168
src/infrastructure/whatsapp/webhook.py
Normal file
168
src/infrastructure/whatsapp/webhook.py
Normal 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
170
src/main.py
Normal 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()
|
||||
0
src/use_cases/__init__.py
Normal file
0
src/use_cases/__init__.py
Normal file
372
src/use_cases/handle_incoming_message.py
Normal file
372
src/use_cases/handle_incoming_message.py
Normal 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
0
src/workers/__init__.py
Normal file
61
src/workers/celery_app.py
Normal file
61
src/workers/celery_app.py
Normal 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
67
src/workers/tasks.py
Normal 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())
|
||||
Reference in New Issue
Block a user