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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user