diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/seed_knowledge.py b/scripts/seed_knowledge.py new file mode 100644 index 0000000..e8c8680 --- /dev/null +++ b/scripts/seed_knowledge.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Seed the knowledge base with SKEEN catalog and FAQ. + +Usage: + python scripts/seed_knowledge.py +""" + +import asyncio +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.infrastructure.db import AsyncSessionLocal, init_db +from src.infrastructure.ai.rag import RAGStore, CREATE_KNOWLEDGE_TABLE_SQL +from src.infrastructure.ai.openai_client import get_openai_client + + +# SKEEN Catalog & FAQ Knowledge Base +SKEEN_KNOWLEDGE = [ + # --- SERVICIOS --- + { + "content": ( + "Consulta Dermatológica Primera Vez — Precio: $1,500 MXN. " + "Duración: 45 minutos. Incluye evaluación completa de piel, diagnóstico " + "personalizado y propuesta de tratamiento. Requerido para todos los pacientes " + "nuevos antes de cualquier procedimiento estético. Disponible con Dr. Ramos y Dr. Martínez." + ), + "category": "servicio", + "source": "catalogo_servicios", + }, + { + "content": ( + "Consulta Dermatológica Subsecuente — Precio: $1,400 MXN. " + "Duración: 30 minutos. Seguimiento de tratamientos en curso, ajuste de recetas " + "y evaluación de resultados. Recomendada cada 4-6 semanas dependiendo del tratamiento." + ), + "category": "servicio", + "source": "catalogo_servicios", + }, + { + "content": ( + "QUANTA EFELIDES (Láser Q-Switched) — Precio: $3,500 MXN por sesión. " + "Duración: 60 minutos. Tratamiento láser para manchas solares, lentigos solares " + "y lesiones pigmentadas. Requiere 3-5 sesiones. Contraindicado en pieles muy bronceadas. " + "Disponible solo en sucursal Rosarito con Dr. Ramos." + ), + "category": "servicio", + "source": "catalogo_servicios", + }, + { + "content": ( + "Depilación Láser IPL Bikini Brasileño — Precio: $1,200 MXN por sesión. " + "Duración: 30 minutos. Tecnología IPL (Intense Pulsed Light) para reducción " + "permanente de vello. Paquete de 6 sesiones con 15% de descuento ($6,120 MXN). " + "Requiere evaluación previa. No apto para pieles fototipos V-VI." + ), + "category": "servicio", + "source": "catalogo_servicios", + }, + { + "content": ( + "Toxina Botulínica DYSPORT — Precio: $2,800 MXN (área única: entrecejo, frente o patas de gallo). " + "Duración: 30 minutos. Efecto visible a los 3-5 días, duración de 4-6 meses. " + "Incluye valoración previa. Requiere firma de consentimiento informado. " + "Dr. Ramos y Dr. Martínez disponibles." + ), + "category": "servicio", + "source": "catalogo_servicios", + }, + { + "content": ( + "Retiro de Verrugas (Crioterapia / Electrocauterio) — Precio: $800 MXN (hasta 5 lesiones). " + "Duración: 20-30 minutos. Método seguro y rápido para remover verrugas, " + "lentigos seborreicos y acrocordones. No requiere tiempo de recuperación significativo. " + "Si se requieren más de 5 lesiones, cotizar adicional." + ), + "category": "servicio", + "source": "catalogo_servicios", + }, + { + "content": ( + "Ácido Hialurónico (Relleno Facial) — Precio desde $4,500 MXN por jeringa (1ml). " + "Duración: 45 minutos. Restauración de volumen en pómulos, surcos nasogenianos, " + "labios y mentón. Resultados inmediatos, duración 12-18 meses. Marca: Juvederm o Restylane. " + "Incluye anestesia tópica. Solo con cita previa." + ), + "category": "servicio", + "source": "catalogo_servicios", + }, + { + "content": ( + "Hydrafacial Deluxe — Precio: $1,800 MXN. Duración: 60 minutos. Limpieza profunda, " + "exfoliación, extracción e hidratación en 3 pasos. Incluye serum antioxidante y péptidos. " + "Recomendado mensual para mantenimiento de piel. Sin tiempo de recuperación." + ), + "category": "servicio", + "source": "catalogo_servicios", + }, + # --- PRODUCTOS --- + { + "content": ( + "Crema Hidratante SKEEN — Precio: $450 MXN. Presentación: 50ml. " + "Hidratante facial con ácido hialurónico y niacinamida. Para todo tipo de piel. " + "Uso diario mañana y noche. SKU: CH-001. Stock disponible." + ), + "category": "producto", + "source": "catalogo_productos", + }, + { + "content": ( + "Serum Vitamina C SKEEN — Precio: $680 MXN. Presentación: 30ml. " + "Concentración 15% de vitamina C estabilizada + vitamina E + ácido ferúlico. " + "Antioxidante potente, unifica tono y reduce manchas. Uso matutino con protector solar. " + "SKU: SVC-002. Stock disponible." + ), + "category": "producto", + "source": "catalogo_productos", + }, + { + "content": ( + "Protector Solar SPF 50 SKEEN — Precio: $520 MXN. Presentación: 60ml. " + "Filtro solar físico-químico, resistente al agua, no comedogénico. " + "Acabado mate, ideal para uso diario y post-procedimientos. SKU: PS50-003. Stock disponible." + ), + "category": "producto", + "source": "catalogo_productos", + }, + # --- PAQUETES --- + { + "content": ( + "Paquete Depilación Láser IPL Completo — Precio: $18,000 MXN (12 sesiones). " + "Incluye: axilas, bikini brasileño y medias piernas. Ahorro de $3,600 vs. precio individual. " + "Vigencia: 18 meses desde primera sesión. No incluye consulta inicial (se cotiza separado)." + ), + "category": "paquete", + "source": "catalogo_paquetes", + }, + { + "content": ( + "Paquete Rejuvenecimiento Facial — Precio: $12,500 MXN. Incluye: 3 Hydrafacial + " + "1 sesión de Toxina Botulínica (área única) + Kit de skincare básico (Crema + Serum + SPF). " + "Ahorro de $2,400. Vigencia: 12 meses. Ideal para mantenimiento antiedad." + ), + "category": "paquete", + "source": "catalogo_paquetes", + }, + # --- FAQ --- + { + "content": ( + "¿Cómo agendo una cita? Puedes agendar respondiendo a este chat con la fecha y hora " + "que prefieras, o llamando al (664) 123-4567. También puedes visitarnos directamente. " + "Horario: Lunes a Sábado 9:00-18:00, Domingos 10:00-14:00." + ), + "category": "faq", + "source": "faq_general", + }, + { + "content": ( + "¿Qué métodos de pago aceptan? Efectivo (MXN y USD), tarjetas de crédito/débito, " + "transferencias bancarias y pago con monedero electrónico SKEEN. " + "No aceptamos cheques. Pagos en USD aplican tipo de cambio del día." + ), + "category": "faq", + "source": "faq_general", + }, + { + "content": ( + "¿Cuál es la política de cancelación? Debes cancelar o reagendar con mínimo 24 horas " + "de anticipación. Cancelaciones tardías o no-show pueden generar un cargo del 30% " + "del valor de la consulta/procedimiento. Puedes cancelar por WhatsApp o teléfono." + ), + "category": "faq", + "source": "faq_general", + }, + { + "content": ( + "¿Dónde están ubicados? Sucursal Rosarito: Blvd. Benito Juárez #1234, Zona Centro. " + "Sucursal Tijuana: Av. Revolución #567, Zona Río. Ambas cuentan con estacionamiento. " + "WhatsApp: (664) 123-4567 (ambas sucursales comparten línea)." + ), + "category": "faq", + "source": "faq_general", + }, + { + "content": ( + "¿Qué es el Monedero Electrónico SKEEN? Es un sistema de saldo a favor donde acumulas " + "dinero por compras y referidos. Puedes usarlo para pagar servicios, productos o paquetes. " + "Consulta tu saldo respondiendo 'saldo' en este chat o en recepción. No tiene fecha de vencimiento." + ), + "category": "faq", + "source": "faq_general", + }, + { + "content": ( + "¿Los tratamientos son seguros durante el embarazo? NO realizamos procedimientos estéticos " + "invásivos durante el embarazo ni lactancia. Sí ofrecemos limpiezas faciales suaves e hidratación. " + "Siempre informa a tu médico antes de cualquier tratamiento dermatológico." + ), + "category": "faq", + "source": "faq_general", + }, + { + "content": ( + "¿Necesito cita para comprar productos? No, puedes comprar productos SKEEN sin cita previa " + "en recepción de cualquier sucursal. También coordinamos envíos locales en Rosarito/Tijuana " + "con costo de envío desde $80 MXN." + ), + "category": "faq", + "source": "faq_general", + }, +] + + +async def seed_knowledge_base() -> None: + """Populate the vector store with SKEEN knowledge.""" + print("🌱 Seeding SKEEN Knowledge Base...") + + # Ensure tables exist + async with AsyncSessionLocal() as session: + # Create table if not exists + from sqlalchemy import text + await session.execute(text(CREATE_KNOWLEDGE_TABLE_SQL)) + await session.commit() + + async with AsyncSessionLocal() as session: + rag = RAGStore(session) + + # Clear existing catalog data to avoid duplicates + await rag.delete_by_source("catalogo_servicios") + await rag.delete_by_source("catalogo_productos") + await rag.delete_by_source("catalogo_paquetes") + await rag.delete_by_source("faq_general") + + total = len(SKEEN_KNOWLEDGE) + for i, item in enumerate(SKEEN_KNOWLEDGE, 1): + doc_id = await rag.add_document( + content=item["content"], + category=item["category"], + source=item["source"], + ) + print(f" [{i}/{total}] {item['category'].upper():12} → {doc_id[:8]}...") + + print(f"\n✅ Knowledge base seeded with {total} documents.") + + +async def verify_search() -> None: + """Quick verification search.""" + print("\n🔍 Running verification searches...") + + async with AsyncSessionLocal() as session: + rag = RAGStore(session) + + queries = [ + "¿Cuánto cuesta la toxina botulínica?", + "Quiero agendar una depilación láser", + "¿Tienen protector solar?", + "Cómo cancelo una cita", + ] + + for q in queries: + results = await rag.search(q, top_k=2) + print(f"\n Q: {q}") + for r in results: + print(f" [{r['category']}] {r['content'][:100]}...") + + +async def main() -> None: + await seed_knowledge_base() + await verify_search() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/validate_setup.py b/scripts/validate_setup.py new file mode 100644 index 0000000..97c01ba --- /dev/null +++ b/scripts/validate_setup.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Validation script for SKEEN CRM Agent setup. + +Run this after filling in .env to verify all connections work. +""" + +import asyncio +import sys + +# Add project root to path +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.config import settings +from src.infrastructure.db import engine +from src.infrastructure.redis import get_redis +from src.infrastructure.whatsapp.client import get_whatsapp_client +from src.infrastructure.erpnext.client import get_erpnext_client +from src.infrastructure.ai.openai_client import get_openai_client +from src.infrastructure.ai.rag import RAGStore +from sqlalchemy import text + + +async def test_postgres() -> bool: + """Test PostgreSQL connection.""" + print("🐘 Testing PostgreSQL...") + try: + async with engine.connect() as conn: + result = await conn.execute(text("SELECT version()")) + version = result.scalar() + print(f" ✅ PostgreSQL connected: {version[:50]}...") + + # Check pgvector + result = await conn.execute(text("SELECT * FROM pg_extension WHERE extname = 'vector'")) + if result.fetchone(): + print(" ✅ pgvector extension installed") + else: + print(" ⚠️ pgvector extension NOT installed (run: CREATE EXTENSION vector)") + return True + except Exception as exc: + print(f" ❌ PostgreSQL failed: {exc}") + return False + + +async def test_redis() -> bool: + """Test Redis connection.""" + print("🔴 Testing Redis...") + try: + redis = await get_redis() + pong = await redis.ping() + if pong: + print(" ✅ Redis connected") + return True + return False + except Exception as exc: + print(f" ❌ Redis failed: {exc}") + return False + + +async def test_meta_whatsapp() -> bool: + """Test Meta WhatsApp Business API.""" + print("💬 Testing Meta WhatsApp API...") + if not settings.META_ACCESS_TOKEN.get_secret_value(): + print(" ⚠️ META_ACCESS_TOKEN not set — skipping") + return False + + try: + client = await get_whatsapp_client() + profile = await client.get_business_profile() + print(f" ✅ WhatsApp Business API connected") + print(f" 📱 Phone Number ID: {settings.META_PHONE_NUMBER_ID}") + return True + except Exception as exc: + print(f" ❌ WhatsApp API failed: {exc}") + return False + + +async def test_erpnext() -> bool: + """Test ERPNext connection.""" + print("🏥 Testing ERPNext...") + if not settings.ERPNEXT_BASE_URL: + print(" ⚠️ ERPNEXT_BASE_URL not set — skipping") + return False + + try: + client = await get_erpnext_client() + # Try to get the current user as a lightweight check + from src.infrastructure.erpnext.healthcare import ERPNextHealthcare + + hc = ERPNextHealthcare(client) + practitioners = await hc.get_practitioners() + print(f" ✅ ERPNext connected") + print(f" 👨‍⚕️ Practitioners found: {len(practitioners)}") + for p in practitioners[:3]: + print(f" - {p.get('practitioner_name')} ({p.get('department')})") + return True + except Exception as exc: + print(f" ❌ ERPNext failed: {exc}") + return False + + +async def test_openai() -> bool: + """Test OpenAI API.""" + print("🧠 Testing OpenAI...") + if not settings.OPENAI_API_KEY.get_secret_value(): + print(" ⚠️ OPENAI_API_KEY not set — skipping") + return False + + try: + client = await get_openai_client() + # Quick embedding test + embedding = await client.create_embedding("Hola SKEEN") + print(f" ✅ OpenAI connected") + print(f" 🤖 Model: {settings.OPENAI_MODEL}") + print(f" 📐 Embedding dimensions: {len(embedding)}") + return True + except Exception as exc: + print(f" ❌ OpenAI failed: {exc}") + return False + + +async def test_rag() -> bool: + """Test RAG vector store.""" + print("🔍 Testing RAG Vector Store...") + try: + from sqlalchemy.ext.asyncio import AsyncSession + from src.infrastructure.db import AsyncSessionLocal + + async with AsyncSessionLocal() as session: + rag = RAGStore(session) + await rag.ensure_extension() + print(" ✅ RAG vector store ready") + return True + except Exception as exc: + print(f" ❌ RAG failed: {exc}") + return False + + +async def main() -> int: + """Run all validation checks.""" + print("=" * 60) + print("🔧 SKEEN CRM Agent - Setup Validation") + print("=" * 60) + print() + + results = [] + results.append(("PostgreSQL", await test_postgres())) + print() + results.append(("Redis", await test_redis())) + print() + results.append(("Meta WhatsApp", await test_meta_whatsapp())) + print() + results.append(("ERPNext", await test_erpnext())) + print() + results.append(("OpenAI", await test_openai())) + print() + results.append(("RAG Vector Store", await test_rag())) + print() + + print("=" * 60) + print("📊 Summary") + print("=" * 60) + all_pass = True + for name, passed in results: + status = "✅ PASS" if passed else "❌ FAIL" + print(f" {status} — {name}") + if not passed: + all_pass = False + + print() + if all_pass: + print("🎉 All systems operational! Ready to receive WhatsApp messages.") + return 0 + else: + print("⚠️ Some services are not configured. Check .env and services.") + return 1 + + +if __name__ == "__main__": + exit_code = asyncio.run(main()) + sys.exit(exit_code) diff --git a/src/api/v1/config.py b/src/api/v1/config.py new file mode 100644 index 0000000..ecfb425 --- /dev/null +++ b/src/api/v1/config.py @@ -0,0 +1,121 @@ +"""Configuration and validation endpoints.""" + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel + +from src.config import settings +from src.infrastructure.erpnext.client import get_erpnext_client +from src.infrastructure.whatsapp.client import get_whatsapp_client + +router = APIRouter(prefix="/config", tags=["config"]) + + +class ConnectionTestResponse(BaseModel): + service: str + connected: bool + details: dict | None = None + error: str | None = None + + +class FullConfigTestResponse(BaseModel): + results: list[ConnectionTestResponse] + all_connected: bool + + +@router.get("/test", response_model=FullConfigTestResponse) +async def test_all_connections() -> FullConfigTestResponse: + """Test connectivity to all external services (Meta, OpenAI, ERPNext). + + Useful during initial setup to verify credentials. + """ + results = [] + all_ok = True + + # Test Meta WhatsApp + try: + wa_client = await get_whatsapp_client() + profile = await wa_client.get_business_profile() + results.append( + ConnectionTestResponse( + service="meta_whatsapp", + connected=True, + details={"profile": profile}, + ) + ) + except Exception as exc: + all_ok = False + results.append( + ConnectionTestResponse( + service="meta_whatsapp", + connected=False, + error=str(exc), + ) + ) + + # Test ERPNext + try: + erp_client = await get_erpnext_client() + # Try to get list of users as a lightweight check + users = await erp_client.get_list("User", limit=1, fields=["name"]) + results.append( + ConnectionTestResponse( + service="erpnext", + connected=True, + details={"user_count_sample": len(users)}, + ) + ) + except Exception as exc: + all_ok = False + results.append( + ConnectionTestResponse( + service="erpnext", + connected=False, + error=str(exc), + ) + ) + + # Test OpenAI (lightweight models list) + try: + from openai import AsyncOpenAI + + client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY.get_secret_value()) + models = await client.models.list() + model_ids = [m.id for m in models.data if settings.OPENAI_MODEL in m.id] + results.append( + ConnectionTestResponse( + service="openai", + connected=True, + details={ + "model_available": bool(model_ids), + "target_model": settings.OPENAI_MODEL, + }, + ) + ) + except Exception as exc: + all_ok = False + results.append( + ConnectionTestResponse( + service="openai", + connected=False, + error=str(exc), + ) + ) + + return FullConfigTestResponse(results=results, all_connected=all_ok) + + +@router.get("/env") +async def get_environment_summary() -> dict: + """Return non-sensitive environment summary.""" + return { + "app_name": settings.APP_NAME, + "environment": settings.APP_ENV, + "meta_api_version": settings.META_API_VERSION, + "meta_phone_number_id_configured": bool(settings.META_PHONE_NUMBER_ID), + "erpnext_base_url": settings.ERPNEXT_BASE_URL, + "erpnext_configured": bool(settings.ERPNEXT_API_KEY.get_secret_value()), + "openai_model": settings.OPENAI_MODEL, + "openai_configured": bool(settings.OPENAI_API_KEY.get_secret_value()), + "database_url_configured": bool(settings.DATABASE_URL), + "redis_url_configured": bool(settings.REDIS_URL), + } diff --git a/src/infrastructure/erpnext/healthcare.py b/src/infrastructure/erpnext/healthcare.py new file mode 100644 index 0000000..6baa99f --- /dev/null +++ b/src/infrastructure/erpnext/healthcare.py @@ -0,0 +1,335 @@ +"""ERPNext Healthcare-specific integrations. + +This module provides high-level operations for the ERPNext Healthcare module, +abstracting the Frappe REST API into clinic-specific workflows. +""" + +from typing import Any + +import structlog + +from src.infrastructure.erpnext.client import ERPNextClient, get_erpnext_client +from src.core.exceptions import ERPNextError + +logger = structlog.get_logger(__name__) + +# --------------------------------------------------------------------------- +# ERPNext Healthcare Doctypes +# --------------------------------------------------------------------------- + +DOCTYPE_PATIENT = "Patient" +DOCTYPE_PRACTITIONER = "Healthcare Practitioner" +DOCTYPE_APPOINTMENT = "Patient Appointment" +DOCTYPE_SERVICE_UNIT = "Healthcare Service Unit" +DOCTYPE_CLINICAL_PROCEDURE = "Clinical Procedure Template" + + +class ERPNextHealthcare: + """High-level ERPNext Healthcare operations.""" + + def __init__(self, client: ERPNextClient | None = None) -> None: + self.client = client + + async def _get_client(self) -> ERPNextClient: + if self.client is None: + self.client = await get_erpnext_client() + return self.client + + # ----------------------------------------------------------------------- + # Patients + # ----------------------------------------------------------------------- + + async def find_patient_by_phone(self, phone: str) -> dict[str, Any] | None: + """Find a patient by mobile number.""" + client = await self._get_client() + patients = await client.get_list( + DOCTYPE_PATIENT, + filters=[["mobile", "=", phone]], + fields=[ + "name", "patient_name", "mobile", "phone", "sex", + "dob", "blood_group", "allergies", "medical_history", + ], + limit=1, + ) + return patients[0] if patients else None + + async def create_patient( + self, + first_name: str, + mobile: str, + sex: str = "Female", + dob: str | None = None, + email: str | None = None, + ) -> dict[str, Any]: + """Create a new patient record.""" + client = await self._get_client() + data = { + "doctype": DOCTYPE_PATIENT, + "first_name": first_name, + "mobile": mobile, + "sex": sex, + } + if dob: + data["dob"] = dob + if email: + data["email"] = email + + result = await client.create_document(DOCTYPE_PATIENT, data) + logger.info("patient_created", patient_id=result.get("name"), name=first_name) + return result + + # ----------------------------------------------------------------------- + # Practitioners (Doctors) + # ----------------------------------------------------------------------- + + async def get_practitioners( + self, + department: str | None = None, + is_active: bool = True, + ) -> list[dict[str, Any]]: + """List healthcare practitioners (doctors).""" + client = await self._get_client() + filters: list[list[Any]] = [] + if is_active: + filters.append(["status", "=", "Active"]) + if department: + filters.append(["department", "=", department]) + + return await client.get_list( + DOCTYPE_PRACTITIONER, + filters=filters if filters else None, + fields=["name", "practitioner_name", "department", "status", "mobile_phone"], + limit=50, + ) + + async def get_practitioner_schedule( + self, + practitioner: str, + date: str, + ) -> dict[str, Any]: + """Get availability schedule for a practitioner on a specific date. + + Uses the Frappe whitelisted method from ERPNext Healthcare. + """ + client = await self._get_client() + try: + result = await client.call_method( + "healthcare.healthcare.doctype.patient_appointment.patient_appointment.get_availability_data", + { + "practitioner": practitioner, + "date": date, + }, + ) + return result.get("message", {}) + except ERPNextError: + # Fallback: query existing appointments and return inverse + return await self._fallback_availability(practitioner, date) + + async def _fallback_availability( + self, + practitioner: str, + date: str, + ) -> dict[str, Any]: + """Fallback availability check by querying existing appointments.""" + client = await self._get_client() + existing = await client.get_list( + DOCTYPE_APPOINTMENT, + filters=[ + ["practitioner", "=", practitioner], + ["appointment_date", "=", date], + ["status", "in", ["Scheduled", "Open"]], + ], + fields=["appointment_time", "duration"], + limit=100, + ) + + # Standard clinic hours: 09:00 - 18:00, 30-min slots + slots = [] + for hour in range(9, 18): + for minute in (0, 30): + time_str = f"{hour:02d}:{minute:02d}" + # Check if slot is taken + taken = any( + appt["appointment_time"] == time_str + for appt in existing + ) + if not taken: + slots.append({ + "from_time": time_str, + "available": True, + }) + + return { + "practitioner": practitioner, + "date": date, + "available_slots": slots, + "appointment_list": existing, + } + + # ----------------------------------------------------------------------- + # Appointments + # ----------------------------------------------------------------------- + + async def get_appointments( + self, + patient: str | None = None, + practitioner: str | None = None, + date: str | None = None, + status: str | None = None, + ) -> list[dict[str, Any]]: + """Query patient appointments.""" + client = await self._get_client() + filters: list[list[Any]] = [] + if patient: + filters.append(["patient", "=", patient]) + if practitioner: + filters.append(["practitioner", "=", practitioner]) + if date: + filters.append(["appointment_date", "=", date]) + if status: + filters.append(["status", "=", status]) + + return await client.get_list( + DOCTYPE_APPOINTMENT, + filters=filters if filters else None, + fields=[ + "name", "patient", "patient_name", "practitioner", + "appointment_date", "appointment_time", "duration", + "status", "department", "notes", + ], + limit=50, + order_by="appointment_date desc, appointment_time desc", + ) + + 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 = "", + service_unit: str | None = None, + ) -> dict[str, Any]: + """Create a patient appointment in ERPNext Healthcare. + + Args: + patient: Patient ID (name field in ERPNext). + practitioner: Practitioner ID. + appointment_date: Date in YYYY-MM-DD format. + appointment_time: Time in HH:MM format. + duration: Duration in minutes. + department: Medical department. + notes: Additional notes. + service_unit: Healthcare Service Unit (consultation room). + + Returns: + Created appointment document. + """ + client = await self._get_client() + + # Validate patient exists + patient_doc = await client.get_document(DOCTYPE_PATIENT, patient) + if not patient_doc: + raise ERPNextError(f"Patient {patient} not found", status_code=404) + + # Validate practitioner exists + practitioner_doc = await client.get_document(DOCTYPE_PRACTITIONER, practitioner) + if not practitioner_doc: + raise ERPNextError(f"Practitioner {practitioner} not found", status_code=404) + + # Check for conflicts + conflicts = await client.get_list( + DOCTYPE_APPOINTMENT, + filters=[ + ["practitioner", "=", practitioner], + ["appointment_date", "=", appointment_date], + ["appointment_time", "=", appointment_time], + ["status", "in", ["Scheduled", "Open"]], + ], + fields=["name"], + limit=1, + ) + if conflicts: + raise ERPNextError( + f"Time slot conflict: {practitioner} is not available at {appointment_time}", + status_code=409, + ) + + data = { + "doctype": DOCTYPE_APPOINTMENT, + "patient": patient, + "practitioner": practitioner, + "appointment_date": appointment_date, + "appointment_time": appointment_time, + "duration": duration, + "department": department, + "notes": notes, + "status": "Scheduled", + } + if service_unit: + data["service_unit"] = service_unit + + result = await client.create_document(DOCTYPE_APPOINTMENT, data) + logger.info( + "appointment_created", + appointment_id=result.get("name"), + patient=patient, + practitioner=practitioner, + date=appointment_date, + time=appointment_time, + ) + return result + + async def cancel_appointment(self, appointment_id: str, reason: str = "") -> dict[str, Any]: + """Cancel an existing appointment.""" + client = await self._get_client() + result = await client.update_document( + DOCTYPE_APPOINTMENT, + appointment_id, + {"status": "Cancelled", "notes": f"Cancelled via WhatsApp. {reason}"}, + ) + logger.info("appointment_cancelled", appointment_id=appointment_id, reason=reason) + return result + + # ----------------------------------------------------------------------- + # Services / Procedures + # ----------------------------------------------------------------------- + + async def get_clinical_procedures( + self, + is_active: bool = True, + ) -> list[dict[str, Any]]: + """List available clinical procedures / services.""" + client = await self._get_client() + filters: list[list[Any]] = [] + if is_active: + filters.append(["is_active", "=", 1]) + + return await client.get_list( + DOCTYPE_CLINICAL_PROCEDURE, + filters=filters if filters else None, + fields=["name", "template", "item_code", "rate", "medical_department"], + limit=100, + ) + + # ----------------------------------------------------------------------- + # Wallet / Custom fields (if implemented in ERPNext) + # ----------------------------------------------------------------------- + + async def get_patient_wallet(self, patient: str) -> dict[str, Any]: + """Get patient wallet balance if custom doctype exists.""" + client = await self._get_client() + try: + wallets = await client.get_list( + "Patient Wallet", + filters=[["patient", "=", patient]], + fields=["name", "balance", "points"], + limit=1, + ) + if wallets: + return wallets[0] + return {"balance": 0.0, "points": 0, "note": "No wallet record"} + except ERPNextError: + return {"balance": 0.0, "points": 0, "note": "Wallet module not configured"} diff --git a/src/main.py b/src/main.py index e93554a..9d66a92 100644 --- a/src/main.py +++ b/src/main.py @@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from prometheus_client import make_asgi_app +from src.api.v1.config import router as config_router 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 @@ -158,6 +159,7 @@ def create_app() -> FastAPI: app.include_router(health_router, prefix="/api/v1") app.include_router(webhooks_router, prefix="/api/v1") app.include_router(messages_router, prefix="/api/v1") + app.include_router(config_router, prefix="/api/v1") # Metrics endpoint (Prometheus) if settings.ENABLE_METRICS: diff --git a/src/use_cases/handle_incoming_message.py b/src/use_cases/handle_incoming_message.py index dfe187d..32ca6b1 100644 --- a/src/use_cases/handle_incoming_message.py +++ b/src/use_cases/handle_incoming_message.py @@ -14,7 +14,7 @@ from src.core.constants import ConversationStatus, SKEEN_SYSTEM_PROMPT, WhatsApp 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.erpnext.healthcare import ERPNextHealthcare from src.infrastructure.whatsapp.client import get_whatsapp_client from src.infrastructure.whatsapp.webhook import WhatsAppWebhookPayload from src.domain.models.conversation import Conversation, Message @@ -25,13 +25,18 @@ MAX_CONTEXT_MESSAGES = 10 class ToolExecutor: - """Executes tool calls requested by the LLM.""" + """Executes tool calls requested by the LLM with REAL ERPNext integration.""" def __init__(self, session: AsyncSession) -> None: self.session = session self.rag = RAGStore(session) self.erpnext = None # Lazy init + async def _get_erpnext(self) -> ERPNextHealthcare: + if self.erpnext is None: + self.erpnext = ERPNextHealthcare() + return self.erpnext + 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"] @@ -88,53 +93,167 @@ class ToolExecutor: doctor = args.get("doctor") service = args.get("service") - # TODO: Integrate with ERPNext Healthcare scheduling - # For now, return mock data structure + hc = await self._get_erpnext() + + # Get all active practitioners + practitioners = await hc.get_practitioners(department="Dermatología Estética") + if not practitioners: + return { + "available": False, + "message": "No hay médicos disponibles en este momento. Intenta más tarde.", + } + + # If doctor specified, filter + if doctor and doctor.lower() not in ("cualquiera", "cualquier", "indistinto"): + practitioners = [ + p for p in practitioners + if doctor.lower() in p.get("practitioner_name", "").lower() + ] + + available_slots = [] + for practitioner in practitioners: + try: + schedule = await hc.get_practitioner_schedule( + practitioner=practitioner["name"], + date=date, + ) + slots = schedule.get("available_slots", []) + for slot in slots: + available_slots.append({ + "time": slot.get("from_time"), + "doctor": practitioner.get("practitioner_name"), + "doctor_id": practitioner.get("name"), + }) + except Exception as exc: + logger.warning( + "schedule_fetch_failed", + practitioner=practitioner.get("name"), + error=str(exc), + ) + + # Sort by time + available_slots.sort(key=lambda x: x["time"]) + + if not available_slots: + return { + "date": date, + "available": False, + "message": f"No hay disponibilidad para el {date}. Intenta con otra fecha.", + "service": service, + "branch": branch, + } + 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", + "available": True, + "slots": available_slots[:6], # Limit to 6 options "service": service, - "note": "Esta es una respuesta simulada. Integrar con ERPNext Healthcare.", + "branch": branch, + "note": "Responde con la hora y doctor que prefieras para confirmar.", } 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.", - } + phone = args.get("patient_phone", "") + patient_name = args.get("patient_name", "") + date = args.get("date") + time = args.get("time") + service = args.get("service", "") + branch = args.get("branch", "Rosarito") + doctor_id = args.get("doctor", "") + notes = args.get("notes", f"Agendado vía WhatsApp. Servicio: {service}. Sucursal: {branch}.") + + hc = await self._get_erpnext() + + # Find or create patient + patient = await hc.find_patient_by_phone(phone) + if patient: + patient_id = patient["name"] + logger.info("existing_patient_found", patient_id=patient_id, name=patient.get("patient_name")) + else: + # Create new patient + try: + new_patient = await hc.create_patient( + first_name=patient_name or "Paciente WhatsApp", + mobile=phone, + ) + patient_id = new_patient["name"] + logger.info("new_patient_created", patient_id=patient_id, phone=phone) + except Exception as exc: + logger.error("failed_to_create_patient", error=str(exc)) + return { + "status": "error", + "message": "No pude registrar al paciente en el sistema. Por favor contacta a recepción.", + } + + # Create appointment + try: + appointment = await hc.create_appointment( + patient=patient_id, + practitioner=doctor_id, + appointment_date=date, + appointment_time=time, + notes=notes, + ) + return { + "status": "confirmed", + "appointment_id": appointment.get("name"), + "patient_id": patient_id, + "date": date, + "time": time, + "doctor": doctor_id, + "message": "Cita confirmada exitosamente.", + } + except Exception as exc: + logger.error("failed_to_create_appointment", error=str(exc)) + return { + "status": "error", + "message": f"No pude confirmar la cita: {str(exc)}. Por favor llama a recepción.", + } 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) + hc = await self._get_erpnext() + patient = await hc.find_patient_by_phone(phone) if not patient: - return {"found": False, "message": "No se encontró paciente con ese número."} + return {"found": False, "message": "No se encontró paciente con ese número. ¿Deseas registrarte?"} + + appointments = await hc.get_appointments(patient=patient.get("name")) + wallet = await hc.get_patient_wallet(patient.get("name")) - 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], + "last_appointments": [ + { + "date": a.get("appointment_date"), + "time": a.get("appointment_time"), + "status": a.get("status"), + "doctor": a.get("practitioner"), + } + for a in appointments[:3] + ], + "wallet_balance": wallet.get("balance", 0), + "wallet_points": wallet.get("points", 0), } async def _get_wallet_balance(self, args: dict[str, Any]) -> dict[str, Any]: - # TODO: Integrate with ERPNext custom Wallet doctype + phone = args.get("phone", "") + hc = await self._get_erpnext() + patient = await hc.find_patient_by_phone(phone) + + if not patient: + return {"found": False, "message": "No se encontró paciente con ese número."} + + wallet = await hc.get_patient_wallet(patient["name"]) return { - "balance_mxn": 0.0, - "points": 0, - "note": "Monedero no implementado en ERPNext aún.", + "found": True, + "patient": patient.get("patient_name"), + "balance_mxn": wallet.get("balance", 0), + "points": wallet.get("points", 0), } async def _escalate_to_human(self, args: dict[str, Any]) -> dict[str, Any]: @@ -142,7 +261,7 @@ class ToolExecutor: return { "escalated": True, "reason": reason, - "message": "Un agente humano de SKEEN se pondrá en contacto contigo pronto. ⏳", + "message": "Un agente humano de SKEEN se pondrá en contacto contigo en breve. ⏳", } @@ -183,9 +302,6 @@ async def get_conversation_history( 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) @@ -238,8 +354,8 @@ async def process_incoming_message( # 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) + hc = ERPNextHealthcare() + patient = await hc.find_patient_by_phone(phone) if patient: patient_name = patient.get("patient_name") conversation.patient_id = patient.get("name") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..9d9ef40 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,24 @@ +"""Tests for health check endpoints.""" + +from fastapi.testclient import TestClient + +from src.main import create_app + + +class TestHealth: + def test_health_check(self): + app = create_app() + client = TestClient(app) + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert "timestamp" in data + + def test_ready_check(self): + app = create_app() + client = TestClient(app) + response = client.get("/ready") + assert response.status_code == 200 + data = response.json() + assert "database" in data diff --git a/tests/test_webhook.py b/tests/test_webhook.py new file mode 100644 index 0000000..2bb76ab --- /dev/null +++ b/tests/test_webhook.py @@ -0,0 +1,124 @@ +"""Tests for WhatsApp webhook endpoints.""" + +import pytest +from fastapi.testclient import TestClient + +from src.main import create_app + + +@pytest.fixture +def client(): + """Create test client.""" + app = create_app() + return TestClient(app) + + +class TestWebhookVerification: + """Test GET /webhooks/whatsapp verification endpoint.""" + + def test_verify_subscription_success(self, client, monkeypatch): + """Successful webhook verification returns challenge.""" + monkeypatch.setenv("META_WEBHOOK_VERIFY_TOKEN", "test-token") + + response = client.get( + "/api/v1/webhooks/whatsapp", + params={ + "hub.mode": "subscribe", + "hub.verify_token": "test-token", + "hub.challenge": "123456789", + }, + ) + assert response.status_code == 200 + assert response.text == "123456789" + + def test_verify_subscription_invalid_token(self, client, monkeypatch): + """Invalid token returns 403.""" + monkeypatch.setenv("META_WEBHOOK_VERIFY_TOKEN", "test-token") + + response = client.get( + "/api/v1/webhooks/whatsapp", + params={ + "hub.mode": "subscribe", + "hub.verify_token": "wrong-token", + "hub.challenge": "123456789", + }, + ) + assert response.status_code == 403 + + +class TestWebhookReceive: + """Test POST /webhooks/whatsapp message reception.""" + + def test_receive_text_message(self, client): + """Process incoming text message.""" + payload = { + "object": "whatsapp_business_account", + "entry": [{ + "id": "WHATSAPP_BUSINESS_ACCOUNT_ID", + "changes": [{ + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "16505551111", + "phone_number_id": "123456789", + }, + "contacts": [{ + "profile": {"name": "Test User"}, + "wa_id": "5216641234567", + }], + "messages": [{ + "from": "5216641234567", + "id": "wamid.TEST123", + "timestamp": "1234567890", + "text": {"body": "Hola, quiero agendar una cita"}, + "type": "text", + }], + }, + "field": "messages", + }], + }], + } + + response = client.post("/api/v1/webhooks/whatsapp", json=payload) + # In development, it processes synchronously + assert response.status_code == 200 + data = response.json() + assert data["status"] in ("processed", "no_messages") + + def test_receive_status_update(self, client): + """Acknowledge status update without processing.""" + payload = { + "object": "whatsapp_business_account", + "entry": [{ + "id": "WHATSAPP_BUSINESS_ACCOUNT_ID", + "changes": [{ + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "16505551111", + "phone_number_id": "123456789", + }, + "statuses": [{ + "id": "wamid.TEST123", + "status": "delivered", + "timestamp": "1234567890", + "recipient_id": "5216641234567", + }], + }, + "field": "messages", + }], + }], + } + + response = client.post("/api/v1/webhooks/whatsapp", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "acknowledged" + + def test_invalid_payload(self, client): + """Invalid payload should be ignored gracefully.""" + payload = {"object": "not_whatsapp"} + + response = client.post("/api/v1/webhooks/whatsapp", json=payload) + assert response.status_code == 200 + assert response.json()["status"] == "ignored" diff --git a/tests/test_whatsapp_client.py b/tests/test_whatsapp_client.py new file mode 100644 index 0000000..b62d717 --- /dev/null +++ b/tests/test_whatsapp_client.py @@ -0,0 +1,61 @@ +"""Tests for WhatsApp API client.""" + +import pytest +import respx +from httpx import Response + +from src.infrastructure.whatsapp.client import WhatsAppClient + + +class TestWhatsAppClient: + """Test Meta WhatsApp Business API client.""" + + @pytest.fixture + def client(self): + return WhatsAppClient() + + @respx.mock + async def test_send_text_message(self, client): + """Successfully send text message.""" + route = respx.post( + "https://graph.facebook.com/v18.0/123456789012345/messages" + ).mock(return_value=Response(200, json={ + "messages": [{"id": "wamid.sent123"}], + "contacts": [{"wa_id": "5216641234567"}], + })) + + result = await client.send_text_message("5216641234567", "Hola SKEEN") + assert result["messages"][0]["id"] == "wamid.sent123" + assert route.called + + @respx.mock + async def test_send_text_message_too_long(self, client): + """Truncate text over 4096 chars.""" + respx.post( + "https://graph.facebook.com/v18.0/123456789012345/messages" + ).mock(return_value=Response(200, json={"messages": [{"id": "x"}]})) + + long_text = "A" * 5000 + await client.send_text_message("5216641234567", long_text) + # Should not raise + + @respx.mock + async def test_mark_as_read(self, client): + """Mark message as read.""" + route = respx.post( + "https://graph.facebook.com/v18.0/123456789012345/messages" + ).mock(return_value=Response(200, json={"success": True})) + + result = await client.mark_as_read("wamid.test123") + assert result["success"] is True + assert route.called + + async def test_button_limit(self, client): + """More than 3 buttons raises ValueError.""" + with pytest.raises(ValueError, match="Maximum 3 buttons"): + await client.send_interactive_buttons( + "5216641234567", + "Choose:", + [{"id": "1", "title": "A"}, {"id": "2", "title": "B"}, + {"id": "3", "title": "C"}, {"id": "4", "title": "D"}], + )