← Tech

7 de julio de 2026

Automatización de flujo de pacientes para clínica odontológica

Una clínica odontológica mediana manejaba todo su flujo de pacientes de forma manual: consultas por WhatsApp, disponibilidad revisada a ojo en Dentalink, coordinación de horarios por llamada, confirmaciones que dependían de que alguien se acordara de enviar el mensaje. El resultado era el esperable: citas duplicadas, huecos sin usar, pacientes que se perdían porque nadie les respondió a tiempo.

El objetivo del proyecto fue automatizar ese flujo de punta a punta sin cambiar el software de gestión que ya usaban —Dentalink. Todo corre sobre una VPS de Hetzner Cloud, orquestado con Docker Compose, y la comunicación interna entre servicios se asegura con túneles de WireGuard.

El stack y por qué cada pieza

El backend es una aplicación FastAPI que actúa como orquestador central. Recibe eventos, consulta APIs externas, decide qué responder y mantiene el estado de cada conversación. Elegí FastAPI porque necesitaba async nativo para manejar múltiples webhooks concurrentes sin bloquear el event loop, y porque su integración con Pydantic hace que la validación de datos entrantes desde Chatwoot, WhatsApp y Dentalink sea declarativa y limpia.

PostgreSQL guarda el estado persistente: perfiles de pacientes con su número de WhatsApp, historial de citas, logs de interacciones. La decisión de usar una relacional en vez de algo como MongoDB fue pragmática: los datos tienen relaciones claras (un paciente tiene muchas citas, una cita pertenece a un profesional y a una clínica) y necesitaba integridad referencial para evitar inconsistencias entre lo que el sistema cree y lo que Dentalink tiene.

Redis cumple dos roles. El primero es caché de disponibilidad: cuando un paciente está eligiendo entre varios horarios, el sistema consulta la API de Dentalink una vez y cachea los slots por 30 segundos. Si el paciente pide ver los mismos horarios de nuevo, no se vuelve a pegar a la API. El segundo rol es cola de trabajos diferidos: los recordatorios de cita 24 horas antes se implementan con SETEX y keys con TTL, sin necesidad de un scheduler externo como Celery. Redis ya estaba ahí para el caché, así que usarlo también para esto evita sumar otra dependencia.

El componente más interesante del sistema es la integración con Claude (Anthropic) a través de un router híbrido. El problema de poner un LLM en un flujo productivo como este es que tiende a alucinar cuando no tiene suficiente información —y en un contexto de salud, aunque sea administrativo, un error de horario o una confirmación falsa es inaceptable. La solución fue no dejar que Claude decidiera todo.

El router híbrido: Claude no toca lo determinístico

El concepto está inspirado en este artículo. La idea central es que no todo mensaje necesita pasar por un LLM. Clasificás la intención primero, y según el caso, tomás un camino distinto.

Implementé un endpoint en FastAPI que recibe el mensaje del paciente (ya normalizado desde Chatwoot) y lo pasa por un clasificador ligero. No usé otro modelo para esto: un prompt corto de Claude con temperature=0 y un schema de output JSON estricto alcanza para decidir la ruta. Lo importante es que el schema fuerce a Claude a elegir una categoría sin espacio para creatividad:

from pydantic import BaseModel
from enum import Enum

class IntentType(str, Enum):
    DETERMINISTIC = "deterministic"
    SEMI_STRUCTURED = "semi_structured"
    CONVERSATIONAL = "conversational"

class RouterDecision(BaseModel):
    intent: IntentType
    confidence: float
    reasoning: str

async def classify_message(text: str) -> RouterDecision:
    response = await claude_client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=256,
        temperature=0,
        system=(
            "Clasificá el mensaje del paciente en una de tres categorías. "
            "DETERMINISTIC: consultas de horario, confirmación de cita existente, "
            "cancelación. SEMI_STRUCTURED: reagendar, buscar profesional específico, "
            "consultar cobertura. CONVERSATIONAL: preguntas generales, síntomas, "
            "información de servicios. Respondé solo con el JSON."
        ),
        messages=[{"role": "user", "content": text}],
    )
    return RouterDecision.model_validate_json(response.content[0].text)

Una vez clasificado, cada ruta tiene su propia lógica. Si la intención es determinística, no vuelvo a llamar a Claude. Simplemente parseo el mensaje con expresiones regulares para extraer la fecha y consulto la API de Dentalink directamente. Para "¿qué horarios tenés mañana?" no necesitás un modelo de lenguaje; necesitás un GET a /citas con un filtro de fecha:

async def get_available_slots(date_str: str, professional_id: int | None = None):
    params = {"fecha": date_str}
    if professional_id:
        params["id_profesional"] = professional_id

    async with httpx.AsyncClient() as client:
        response = await client.get(
            "https://api.dentalink.healthatom.com/api/v1/citas/disponibles",
            params=params,
            headers={"Authorization": f"Token {DENTALINK_API_KEY}"},
        )
        response.raise_for_status()
        return response.json()

Si la intención es semi-estructurada, Claude sí participa pero con herramientas definidas, no con texto libre. Le paso la lista de funciones disponibles (consultar slots, crear cita, modificar cita, cancelar cita) y él decide cuál llamar y con qué parámetros, pero la ejecución real la hace FastAPI, no Claude. Esto elimina las alucinaciones: Claude no inventa un slot que no existe porque ni siquiera tiene acceso a inventarlo.

tools = [
    {
        "name": "get_available_slots",
        "description": "Obtiene los horarios disponibles para una fecha y profesional.",
        "input_schema": {
            "type": "object",
            "properties": {
                "date": {"type": "string", "description": "Fecha en formato YYYY-MM-DD"},
                "professional_id": {"type": "integer"},
            },
            "required": ["date"],
        },
    },
    {
        "name": "create_appointment",
        "description": "Crea una nueva cita en Dentalink.",
        "input_schema": {
            "type": "object",
            "properties": {
                "patient_id": {"type": "integer"},
                "professional_id": {"type": "integer"},
                "datetime": {"type": "string"},
                "reason": {"type": "string"},
            },
            "required": ["patient_id", "professional_id", "datetime"],
        },
    },
]

async def handle_semi_structured(patient_id: int, message: str, history: list):
    response = await claude_client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1024,
        system=(
            "Sos un asistente administrativo de una clínica odontológica. "
            "Usá las herramientas disponibles para ayudar al paciente a agendar, "
            "modificar o cancelar citas. Nunca inventes disponibilidad. "
            "Si no hay slots, decilo claramente y ofrecé alternativas."
        ),
        messages=history + [{"role": "user", "content": message}],
        tools=tools,
    )

    # Procesamos tool_uses sin devolver texto alucinado al paciente
    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            result = await execute_tool(block.name, block.input)
            tool_results.append({
                "tool_use_id": block.id,
                "content": json.dumps(result),
                "type": "tool_result",
            })

    # Segunda llamada: Claude genera la respuesta al paciente
    # basada en datos reales, no en suposiciones
    final_response = await claude_client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=512,
        messages=history + [
            {"role": "user", "content": message},
            {"role": "assistant", "content": response.content},
            {"role": "user", "content": tool_results},
        ],
    )
    return final_response.content[0].text

Si la intención es conversacional, Claude responde con lenguaje natural sobre preguntas frecuentes, recomendaciones generales o información de servicios. Pero siempre con un disclaimer explícito que aclara que no sustituye diagnóstico profesional.

Esta arquitectura de tres rutas hace que el sistema sea rápido cuando puede serlo (las consultas determinísticas no pasan por LLM y responden en milisegundos), seguro cuando tiene que serlo (las operaciones sobre citas pasan por Claude pero con herramientas que limitan lo que puede hacer), y natural cuando aporta valor (las conversaciones abiertas usan toda la capacidad de Claude).

WhatsApp y Chatwoot: la entrada y el control

El paciente nunca sale de WhatsApp. El número de la clínica está conectado a través de la API de WhatsApp Business de Facebook, usando un webhook que reenvía cada mensaje entrante a Chatwoot . La configuración del webhook en Facebook es directa: registrás un endpoint en tu servidor que reciba eventos messages y verificás el webhook con el token que Facebook te da.

¿Por qué Chatwoot en el medio y no conectar FastAPI directamente al webhook de WhatsApp? Por tres razones prácticas. La primera es que Chatwoot ya resuelve todo el manejo de sesiones de WhatsApp: reconexiones, rate limits, templates de mensajes, marcado de mensajes como leídos. Implementar eso desde cero es semanas de trabajo para un problema que ya está resuelto. La segunda es que Chatwoot permite intervención humana sin fricción: si el sistema no puede resolver algo, un agente humano toma la conversación desde el dashboard de Chatwoot y el paciente no nota el traspaso. La tercera es que Chatwoot expone webhooks bidireccionales limpios: cuando entra un mensaje nuevo, Chatwoot dispara un POST a FastAPI con el contenido y los metadatos del contacto; cuando FastAPI genera una respuesta, se la envía de vuelta a Chatwoot, que la despacha por WhatsApp.

El webhook de FastAPI que recibe los mensajes desde Chatwoot se ve así:

from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

@app.post("/webhook/chatwoot")
async def chatwoot_webhook(request: Request):
    body = await request.json()

    # Chatwoot envía el evento como message_created o message_updated
    event = body.get("event")
    if event != "message_created":
        return {"status": "ignored"}

    message_type = body.get("message_type")
    if message_type != "incoming":
        return {"status": "ignored"}

    conversation_id = body.get("conversation", {}).get("id")
    contact_phone = body.get("sender", {}).get("phone_number")
    content = body.get("content")

    if not all([conversation_id, contact_phone, content]):
        raise HTTPException(status_code=400, detail="Missing fields")

    # Resolvemos o creamos el paciente en PostgreSQL
    patient = await get_or_create_patient(contact_phone)

    # Obtenemos el historial reciente de la conversación
    history = await get_conversation_history(conversation_id)

    # Clasificamos y respondemos
    response_text = await process_message(
        patient_id=patient.id,
        message=content,
        history=history,
    )

    # Enviamos la respuesta de vuelta a Chatwoot
    await send_chatwoot_message(conversation_id, response_text)

    return {"status": "ok"}

Enviar la respuesta a Chatwoot es un POST simple a su API, autenticado con el token de agente que configuraste en el dashboard:

async def send_chatwoot_message(conversation_id: int, text: str):
    async with httpx.AsyncClient() as client:
        await client.post(
            f"https://{CHATWOOT_HOST}/api/v1/accounts/{ACCOUNT_ID}/"
            f"conversations/{conversation_id}/messages",
            headers={"api_access_token": CHATWOOT_AGENT_TOKEN},
            json={"content": text, "message_type": "outgoing"},
        )

Del lado de WhatsApp también aprovechamos WhatsApp Flow, que permite incrustar formularios interactivos directamente en el chat: selección de fecha y hora, confirmación de datos personales. El paciente toca botones en vez de escribir texto, lo que elimina ambigüedad en la entrada y hace que el router híbrido casi siempre vaya por la ruta determinística —la más rápida y barata.

Dentalink como fuente única de verdad

Un principio importante del diseño fue que Dentalink sigue siendo el sistema de registro canónico. El proyecto no reemplaza Dentalink, se conecta a él. Las citas se crean, modifican y cancelan exclusivamente a través de su API REST. PostgreSQL guarda una copia del historial para consultas rápidas y para no depender de la disponibilidad de la API de Dentalink en cada request, pero la escritura siempre va contra Dentalink primero y solo si esa escritura es exitosa se persiste localmente.

Esto significa que si el sistema automatizado se cae, la clínica puede seguir operando manualmente con Dentalink sin perder nada. La automatización es una capa encima, no un reemplazo. La integración con la API de Dentalink usa autenticación por token y todas las llamadas se hacen con reintento exponencial para tolerar caídas temporales del servicio:

import asyncio

async def create_dentalink_appointment(
    patient_id: int,
    professional_id: int,
    start_time: str,
    reason: str,
    max_retries: int = 3,
):
    payload = {
        "id_paciente": patient_id,
        "id_profesional": professional_id,
        "fecha_hora_inicio": start_time,
        "motivo": reason,
    }

    for attempt in range(max_retries):
        async with httpx.AsyncClient() as client:
            response = await client.post(
                "https://api.dentalink.healthatom.com/api/v1/citas",
                json=payload,
                headers={"Authorization": f"Token {DENTALINK_API_KEY}"},
                timeout=15.0,
            )
            if response.status_code == 201:
                return response.json()
            if response.status_code >= 500:
                # Error del servidor, reintentamos con backoff
                await asyncio.sleep(2 ** attempt)
                continue
            # Error del cliente (400, 409, etc.), no reintentamos
            response.raise_for_status()

    raise Exception(f"Failed to create appointment after {max_retries} attempts")

Recordatorios con Redis

Los recordatorios automáticos 24 horas antes de la cita se implementan con Redis, aprovechando que ya está en el stack para el caché de disponibilidad. Cuando se crea una cita exitosamente en Dentalink, se calcula el timestamp del recordatorio y se guarda en un sorted set de Redis donde el score es el timestamp Unix:

import time
from datetime import datetime, timedelta

async def schedule_reminder(appointment_id: int, appointment_time: str):
    appointment_dt = datetime.fromisoformat(appointment_time)
    reminder_dt = appointment_dt - timedelta(hours=24)
    reminder_ts = int(reminder_dt.timestamp())

    redis_client.zadd(
        "reminders:pending",
        {str(appointment_id): reminder_ts},
    )

Un proceso liviano, que corre como un loop infinito dentro del mismo contenedor de FastAPI, consulta cada 30 segundos si hay recordatorios cuyo timestamp ya pasó. Si encuentra alguno, lo procesa y lo remueve del sorted set. Esto es deliberadamente simple: para el volumen de una clínica (decenas de citas por día, no millones), este enfoque es más que suficiente y evita la complejidad de un scheduler distribuido:

async def process_reminders():
    while True:
        now = int(time.time())
        # ZRANGEBYSCORE con limit 10 para procesar en lotes chicos
        due = redis_client.zrangebyscore(
            "reminders:pending", 0, now, start=0, num=10
        )
        for appointment_id in due:
            appointment = await get_appointment(int(appointment_id))
            patient = await get_patient(appointment["patient_id"])
            text = (
                f"Hola {patient['first_name']}, te recordamos que mañana "
                f"tenés cita a las {appointment['time']}. "
                f"Respondé CONFIRMAR para confirmar asistencia."
            )
            await send_chatwoot_message(
                patient["conversation_id"], text
            )
            redis_client.zrem("reminders:pending", appointment_id)

        await asyncio.sleep(30)

Despliegue en Hetzner con Docker y WireGuard

Todo el sistema se despliega con Docker Compose sobre una VPS CX22 de Hetzner Cloud (2 vCPU, 4 GB RAM). El docker-compose.yml levanta cinco servicios: FastAPI, Chatwoot, PostgreSQL, Redis, y un contenedor de WireGuard que crea la red privada virtual.

WireGuard se usa para dos cosas. La primera es conectar el entorno de desarrollo local con el servidor de producción sin exponer PostgreSQL ni Redis a internet: el túnel hace que 10.0.0.1:5432 en mi máquina local sea postgres:5432 en el servidor. La segunda es que, si en el futuro se suman más clínicas al sistema, cada una puede tener su propio túnel aislado sin necesidad de una VPN compleja. La configuración de WireGuard es mínima: un archivo de configuración con la clave privada del servidor, la clave pública del peer, y el rango de IPs del túnel.

El frontend administrativo está construido con TypeScript y React (Next.js). No es el foco del proyecto, pero permite que el personal de la clínica vea en tiempo real las conversaciones activas, revise estadísticas de conversión y tome control manual de una conversación desde Chatwoot si el sistema escala a un humano.

Lo que el sistema no hace (y no debería hacer)

Hay una línea clara que el sistema no cruza: diagnóstico. Si un paciente escribe "me duele la muela", Claude responde con orientación general y la recomendación de agendar una consulta, pero nunca sugiere un diagnóstico ni un tratamiento. El disclaimer es explícito en cada respuesta conversacional, y el router híbrido está diseñado para que las rutas determinística y semi-estructurada —que tocan datos reales de la clínica— nunca generen texto libre sin supervisión.

Tampoco reemplaza al personal administrativo. Lo que hace es absorber la tarea repetitiva de coordinar horarios para que las personas puedan dedicarse a lo que requiere presencia humana: recibir al paciente, manejar casos atípicos y cuidar la experiencia dentro del consultorio. El dashboard de Chatwoot siempre está ahí para intervenir cuando el sistema no puede resolver algo, y esa intervención es transparente para el paciente.

Comentarios (0)