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:
121
src/api/v1/config.py
Normal file
121
src/api/v1/config.py
Normal file
@@ -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),
|
||||
}
|
||||
335
src/infrastructure/erpnext/healthcare.py
Normal file
335
src/infrastructure/erpnext/healthcare.py
Normal file
@@ -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"}
|
||||
@@ -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:
|
||||
|
||||
@@ -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