feat: real ERPNext Healthcare integration + setup tooling

- Replace all mock tools with real ERPNext Healthcare operations
- ERPNextHealthcare class: patients, practitioners, appointments, schedules
- check_availability queries real practitioner schedules from ERPNext
- create_appointment finds/creates patient + validates conflicts + books in ERPNext
- Add /api/v1/config/test endpoint to validate all service connections
- Add scripts/validate_setup.py for CLI validation of Meta/OpenAI/ERPNext/DB
- Add scripts/seed_knowledge.py with full SKEEN catalog (services, products, packages, FAQ)
- Add tests for webhook, health, and WhatsApp client
- Update main.py to include config router
This commit is contained in:
root
2026-04-29 05:37:22 +00:00
parent d30b22b50c
commit 5740d94295
11 changed files with 1274 additions and 33 deletions

View File

@@ -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")