Inicio / Artículos

Chatbot para reservas en clínicas con LangGraph, SQLite y Streamlit, paso a paso

Crear un asistente para reservar citas en una clínica: tablas SQLite, funciones de servicio, un flujo de trabajo de nueve nodos en LangGraph con gpt-4o-mini, una interfaz de chat en Streamlit y un modo de cuaderno.

Editor: REACTAPP.TOP17 min de lectura3855 palabras

Fuentes: Agentic AI Project: Build a Customer Service Chatbot for a Clinic

Administrar una clínica implica mucha coordinación tediosa: emparejar a los pacientes con médicos, hacer un seguimiento de quién está disponible en qué momento y evitar reservas duplicadas. Un asistente conversacional puede encargarse de la parte relacionada con las citas. Este proyecto crea uno a partir de cuatro componentes: LangGraph para gestionar una conversación multietapa como máquina de estados, gpt-4o-mini de OpenAI para comprender lo que escriben las personas, SQLite para almacenar los datos y Streamlit para la ventana de chat.

El problema y el plan

Muchas clínicas siguen dependiendo de procesos manuales o de una combinación de herramientas diversas. Las consecuencias son esperas largas, conflictos en la programación, una carga de trabajo desigual entre el personal y una experiencia frustrante para los pacientes, que pueden tener dificultades incluso para encontrar un médico disponible o la especialidad adecuada. Lo que necesita una clínica es un sistema automatizado que haga las reservas a través de conversaciones naturales, manteniendo al mismo tiempo registros precisos de médicos, pacientes y reservas.

El chatbot guía al usuario a través de un breve script:

  1. Saluda al usuario y le pregunta si desea hacer una cita.
  2. Enumera las especialidades médicas disponibles.
  3. Muestra al médico y los horarios libres.
  4. Recopila el nombre y número de teléfono del paciente.
  5. Confirma la reserva y la guarda en la base de datos.

La ventaja es una menor carga administrativa, reservas más rápidas, sin citas conflictivas y un proceso más fluido para el paciente. El código final está organizado en carpetas para los datos, servicios, el agente y la interfaz de usuario. Cada componente tiene una función específica: LangGraph decide qué paso seguir en la conversación, el modelo interpreta el texto libre, SQLite registra quién y cuándo, y Streamlit muestra todo esto al usuario en un navegador. Mantener estas funciones separadas permite modificar una parte, como la interfaz, sin afectar al resto.

Configuración

Cree un entorno virtual. La orden es la misma en Mac, Linux y Windows:

python -m venv .venv

Actívalo en Mac o Linux:

source .venv/bin/activate

En Windows con PowerShell:

.venv\Scripts\Activate.ps1

En el Símbolo del sistema de Windows:

.venv\Scripts\activate

Una vez activo, el símbolo del sistema comienza con el nombre del entorno, de la siguiente manera:

(.venv) your-folder-name %

A continuación vienen las dependencias. El archivo requirements.txt especifica Streamlit, python-dotenv, pydantic, pandas y las demás bibliotecas:

streamlit>=1.50.0
python-dotenv==1.0.0
pydantic==2.12.5
pandas==2.3.3
python-dateutil==2.8.2
langgraph>=1.0.7
openai>=2.16.0
pygraphviz==1.14

Instálelos desde la carpeta del proyecto:

cd clinic-agent
pip install -r requirements.txt

Finalmente, cree un archivo .env en el directorio clinic-agent que contenga su clave de OpenAI:

OPENAI_API_KEY=your_openai_api_key_here

Parte 1: la base de datos (data/db.py)

Un sistema de reservas se sustenta en su capa de datos. La clínica utiliza SQLite con tres tablas para médicos, clientes y reservas. El esquema es relacional y fácil de consultar durante una conversación: una reserva hace referencia a un médico y un cliente, por lo que una sola búsqueda indica quién atiende a quién y cuándo. Los números de teléfono sirven como identificación práctica para un paciente, ya que una llamada recurrente no debería generar un registro duplicado.

Médicos. Cada fila corresponde a un médico con su especialidad y horario de trabajo, lo que permite al chatbot sugerir a la persona adecuada una vez seleccionada la especialidad. Los campos son doctor_id (identificador único), doctor_name, speciality (como Dermatólogo o Pediatra) y office_timing (los horarios en los que trabaja el médico). La demostración incluye cinco especialidades: Dermatólogo, Ortopedista, Médico General, Pediatra y Especialista en Otorrinolaringología, con nombres e horarios ficticios para cada una. Añadir más es sencillo.

Clientes. Esta tabla contiene la información necesaria para realizar reservas. Cada vez que alguien hace una reserva, el sistema busca al cliente por número de teléfono y crea un registro nuevo si no existe uno. Los campos son customer_id, name y phone.

Reservas. La tabla central. Cada fila relaciona a un médico y a un cliente con una fecha y hora específicas. Sus campos son booking_id, doctor_id y customer_id (referencias a las otras dos tablas), appointment_date, appointment_time y status, como Confirmado o Cancelado.

El script define estas tablas y crea el archivo clinic.db:

# data/db.py - Database initialization and operations

import sqlite3
import os
from datetime import datetime, timedelta

DB_PATH = os.path.join(os.path.dirname(__file__), "clinic.db")

def get_connection():
    """Get a database connection."""
    return sqlite3.connect(DB_PATH)

def init_db():
    """Initialize the database with tables and sample data."""
    conn = get_connection()
    cursor = conn.cursor()

    # Doctors table
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS doctors (
            doctor_id TEXT PRIMARY KEY,
            doctor_name TEXT NOT NULL,
            speciality TEXT NOT NULL,
            office_timing TEXT NOT NULL
        )
    """)

    # Customers table
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS customers (
            customer_id TEXT PRIMARY KEY,
            name TEXT NOT NULL,
            phone TEXT NOT NULL
        )
    """)

    # Bookings table
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS bookings (
            booking_id TEXT PRIMARY KEY,
            doctor_id TEXT NOT NULL,
            customer_id TEXT NOT NULL,
            appointment_date TEXT NOT NULL,
            appointment_time TEXT NOT NULL,
            status TEXT NOT NULL,
            FOREIGN KEY (doctor_id) REFERENCES doctors (doctor_id),
            FOREIGN KEY (customer_id) REFERENCES customers (customer_id)
        )
    """)

    # Insert sample doctors
    doctors = [
        ("D1", "Dr. Anil Sharma", "General Physician", "10:00-14:00"),
        ("D2", "Dr. Neha Verma", "Dermatologist", "11:00-16:00"),
        ("D3", "Dr. Rohit Mehta", "Orthopedic", "09:00-13:00"),
        ("D4", "Dr. Kavita Rao", "Pediatrician", "10:00-15:00"),
        ("D5", "Dr. Sanjay Iyer", "ENT Specialist", "12:00-17:00"),
    ]

    for doctor in doctors:
        cursor.execute(
            "INSERT OR IGNORE INTO doctors (doctor_id, doctor_name, speciality, office_timing) VALUES (?, ?, ?, ?)",
            doctor
        )

    conn.commit()
    conn.close()

if __name__ == "__main__":
    print("Initializing database...")
    init_db()
    print("Database initialized successfully.")

Ejecútelo desde la carpeta del proyecto:

cd clinic-agent
python data/db.py

Eso genera clinic.db con las tres tablas. Ejecutar el script nuevamente es una forma rápida de restablecer los datos de ejemplo mientras se realizan pruebas.

Parte 2: la capa de servicios

Los servicios albergan la lógica empresarial entre el agente y la base de datos. El agente LangGraph no consulta las tablas directamente; en su lugar, llama a funciones de servicio para obtener información de médicos, generar horarios y confirmar reservas. En el diseño de un agente, estas funciones actúan como herramientas, permitiendo que el agente se concentre en el flujo de la conversación. Esta separación también hace que la lógica pueda probarse por separado. Se puede verificar la generación de horarios y las reservas sin tener que llamar al modelo de lenguaje, y eso es exactamente lo que hace el siguiente script sencillo.

El servicio de médicos (services/doctor_service.py) se ocupa de las especialidades, los médicos y los horarios disponibles. Devuelve la lista de especialidades del banco de datos, obtiene los médicos correspondientes a una especialidad elegida, crea los horarios a partir de los horarios de atención del médico y convierte las horas de visualización, como 2:00 PM, al formato de 24 horas para su almacenamiento.

# services/doctor_service.py - Doctor operations

from data.db import get_all_doctors, get_doctor_by_speciality, get_doctor_by_id

def get_specialities_list():
    """Get list of all specialities."""
    doctors = get_all_doctors()
    # Return unique specialities
    return list(dict.fromkeys([doc[2] for doc in doctors]))

def get_doctor_info(speciality):
    """Get doctor information by speciality."""
    doctor = get_doctor_by_speciality(speciality)
    if doctor:
        return {
            "doctor_id": doctor[0],
            "doctor_name": doctor[1],
            "speciality": doctor[2],
            "office_timing": doctor[3]
        }
    return None

def generate_time_slots(office_timing):
    """Generate hourly time slots from office timing string.

    Args:
        office_timing: String like "11:00-16:00"

    Returns:
        List of time slots like ["11:00 AM", "12:00 PM", ...]
    """
    start_time, end_time = office_timing.split("-")
    start_hour = int(start_time.split(":")[0])
    end_hour = int(end_time.split(":")[0])

    slots = []
    for hour in range(start_hour, end_hour):
        if hour < 12:
            suffix = "AM"
            display_hour = hour if hour > 0 else 12
        elif hour == 12:
            suffix = "PM"
            display_hour = 12
        else:
            suffix = "PM"
            display_hour = hour - 12
        slots.append(f"{display_hour}:00 {suffix}")

    return slots

def parse_time_slot(slot_str):
    """Parse time slot string to 24-hour format.

    Args:
        slot_str: String like "1:00 PM"

    Returns:
        String like "13:00"
    """
    time_part, suffix = slot_str.split(" ")
    hour, minute = time_part.split(":")
    hour = int(hour)

    if suffix == "PM" and hour != 12:
        hour += 12
    elif suffix == "AM" and hour == 12:
        hour = 0

    return f"{hour:02d}:{minute}"

El servicio de reservas (services/booking_service.py) se encarga de los clientes y las reservas. Encuentra a un cliente existente por número de teléfono o crea uno nuevo, busca los horarios ya ocupados para un médico en una fecha, los elimina para dejar solo los horarios libres, genera un ID de reserva único y guarda los detalles finales de la cita: médico, paciente, fecha y hora.

# services/booking_service.py - Booking operations

import uuid
from datetime import datetime
from data.db import (
    create_customer,
    create_booking,
    get_customer_by_phone,
    get_bookings_by_doctor_and_date,
    get_booking_by_id
)
from services.doctor_service import parse_time_slot

def get_or_create_customer(name, phone):
    """Get existing customer or create new one."""
    customer = get_customer_by_phone(phone)
    if customer:
        return customer[0]  # Return customer_id

    customer_id = f"CUST-{uuid.uuid4().hex[:6].upper()}"
    create_customer(customer_id, name, phone)
    return customer_id

def get_available_slots(doctor_id, office_timing):
    """Get available time slots for a doctor for today.

    Args:
        doctor_id: Doctor ID
        office_timing: Office timing string like "11:00-16:00"

    Returns:
        List of available time slots
    """
    from services.doctor_service import generate_time_slots

    today = datetime.now().strftime("%Y-%m-%d")
    all_slots = generate_time_slots(office_timing)

    # Get booked slots
    booked_times = get_bookings_by_doctor_and_date(doctor_id, today)

    # Filter out booked slots
    available = []
    for slot in all_slots:
        slot_24h = parse_time_slot(slot)
        if slot_24h not in booked_times:
            available.append(slot)

    return available

def confirm_booking(doctor_id, customer_name, customer_phone, time_slot, appointment_date=None):
    """Confirm a booking.

    Args:
        doctor_id: Doctor ID
        customer_name: Customer name
        customer_phone: Customer phone
        time_slot: Time slot like "1:00 PM"
        appointment_date: Optional date in YYYY-MM-DD format. Defaults to today.

    Returns:
        Booking ID
    """
    # Get or create customer
    customer_id = get_or_create_customer(customer_name, customer_phone)

    # Generate booking ID
    booking_id = f"BKG-{uuid.uuid4().hex[:6].upper()}"

    # Format appointment time
    if not appointment_date:
        appointment_date = datetime.now().strftime("%Y-%m-%d")
    appointment_time = parse_time_slot(time_slot)

    # Create booking
    create_booking(booking_id, doctor_id, customer_id, appointment_date, appointment_time)

    return booking_id

Verificación de los servicios

Un pequeño script prueba ambos servicios:

# test/test_service.py - to test the services created

from pathlib import Path
import sys

# Allow running this file directly: `python test/test_service.py`.
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from services.doctor_service import get_specialities_list, generate_time_slots
from services.booking_service import confirm_booking

# Get all specialities
specialities = get_specialities_list()
print("Available specialities:", specialities)

# Generate time slots for a doctor (11:00 AM - 4:00 PM)
slots = generate_time_slots("11:00-16:00")
print("Available slots:", slots)

# Confirm a booking
booking_id = confirm_booking(
    doctor_id="D1",
    customer_name="John Doe",
    customer_phone="9876543210",
    time_slot="2:00 PM"
)
print(f"Booking confirmed: {booking_id}")

Ejecútalo:

cd clinic-agent
python test_service.py

La terminal muestra, entre otras cosas, la lista de especialidades y los horarios disponibles para un médico:

(.venv) (base) my-mac clinic-agent % python test_service.py
Available specialities: ['General Physician', 'Dermatologist', 'Orthopedic', 'Pediatrician', 'ENT Specialist']
Available slots: ['11:00 AM', '12:00 PM', '1:00 PM', '2:00 PM', '3:00 PM']
Booking confirmed: BKG-C4F60A

Si todo parece correcto, los servicios funcionan y puede comenzar la parte interesante. Ver especialidades reales y horarios disponibles en el resultado confirma que la base de datos, la lógica del médico y la lógica de reserva están en concordancia entre sí antes de que se aplique cualquier lógica de conversación.

Parte 3: el agente

El núcleo del chatbot es un agente LangGraph que lleva a cabo la reserva a través de varias etapas.

Estado de la reserva

El objeto de estado es una memoria estructurada para la conversación. Registra la etapa actual, como el saludo o la selección de un médico, recuerda las elecciones realizadas como especialidad, médico y horario, y mantiene los detalles del cliente y de la reserva para que el agente pueda finalizar la cita.

# agents/booking_agent.py - LangGraph agent implementation

from typing import TypedDict, Annotated, List, Optional
from langgraph.graph import StateGraph, END
from openai import OpenAI
import os
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

class BookingState(TypedDict):
    """State for the booking conversation."""
    messages: List[dict]                    # Chat history
    stage: str                               # greeting, select_speciality, select_doctor, etc.
    selected_speciality: Optional[str]      # Chosen medical specialty
    selected_doctor: Optional[dict]         # Selected doctor details
    selected_date: Optional[str]            # Appointment date
    selected_slot: Optional[str]            # Time slot
    customer_name: Optional[str]            # Customer name
    customer_phone: Optional[str]           # Customer phone
    booking_id: Optional[str]               # Confirmation ID
    available_options: List[str]            # UI options

def create_initial_state():
    """Create initial state for the conversation."""
    return {
        "messages": [],
        "stage": "greeting",
        "selected_speciality": None,
        "selected_doctor": None,
        "selected_date": None,
        "selected_slot": None,
        "customer_name": None,
        "customer_phone": None,
        "booking_id": None,
        "available_options": []
    }

El asistente LLM

Una única función call_llm centraliza la comunicación con el modelo de OpenAI. Envía instrucciones del sistema y del usuario, devuelve el texto generado y garantiza que cada llamada al modelo sea consistente y reutilizable.

# agents/booking_agent.py - LangGraph agent implementation
def call_llm(
    system_prompt: str,
    user_prompt: str,
    *,
    model: str = "gpt-4o-mini",
    temperature: float = 0,
    max_tokens: int = 50,
) -> str:
    """
    Centralized helper for all LLM calls.
    Returns the assistant's response
    """
    try:
        response = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
            temperature=temperature,
            max_tokens=max_tokens,
        )
        return response
    except Exception as e:
        print(f"LLM call error: {e}")
        return ""

Nodos

Cada nodo representa un paso en el flujo de trabajo de reserva. Los nodos se encargan de la etapa que representan, leen las entradas del usuario y actualizan el estado, lo que mantiene el flujo de trabajo modular. Hay nueve en total:

  • greeting_node abre el chat y pregunta si el usuario desea hacer una cita.
  • select_speciality_node muestra las especialidades disponibles.
  • select_doctor_node presenta a los médicos de la especialidad elegida.
  • select_date_node pregunta qué día le conviene al usuario.
  • select_slot_node ofrece los horarios libres de ese día.
  • confirm_node pide al usuario que apruebe la elección.
  • collect_details_node solicita el nombre y un número de teléfono.
  • completed_node finaliza una reserva exitosa.
  • cancelled_node se refiere a un usuario que cancela su participación.
  • Construcción del grafo

    La función build_booking_graph agrega esos nodos, define las transiciones condicionales entre ellos y, de este modo, establece el camino general de la conversación.

    # agents/booking_agent.py - LangGraph agent implementation
    from langgraph.checkpoint.memory import MemorySaver
    
    def build_booking_graph():
        """Build the LangGraph workflow."""
        workflow = StateGraph(BookingState)
    
        # Add all nodes
        workflow.add_node("greeting", greeting_node)
        workflow.add_node("select_speciality", select_speciality_node)
        workflow.add_node("select_doctor", select_doctor_node)
        workflow.add_node("select_date", select_date_node)
        workflow.add_node("select_slot", select_slot_node)
        workflow.add_node("confirm", confirm_node)
        workflow.add_node("collect_details", collect_details_node)
        workflow.add_node("completed", completed_node)
        workflow.add_node("cancelled", cancelled_node)
    
        # Set entry point
        workflow.set_entry_point("greeting")
    
        # Add conditional edges based on routing
        workflow.add_conditional_edges(
            "greeting",
            llm_router,
            {
                "greeting": "greeting",
                "select_speciality": "select_speciality",
                "cancelled": "cancelled"
            }
        )
    
        # Similar conditional edges for other nodes...
    
        # Final edges to END
        workflow.add_edge("completed", END)
        workflow.add_edge("cancelled", END)
    
        # Compile with checkpointer for session management
        return workflow.compile(checkpointer=MemorySaver())
    
    # Create the compiled graph
    booking_graph = build_booking_graph()
    

    Un pequeño script convierte el gráfico en una imagen:

    #agents/save_langgraph_flow.py
    """Save the clinic booking LangGraph flow to png format in this folder."""
    
    
    from pathlib import Path
    import sys
    
    
    # Ensure imports work whether the script is run from project root or this folder.
    AGENTS_DIR = Path(__file__).resolve().parent
    PROJECT_ROOT = AGENTS_DIR.parent
    if str(PROJECT_ROOT) not in sys.path:
        sys.path.insert(0, str(PROJECT_ROOT))
    
    from agents.booking_agent import booking_graph  # noqa: E402
    
    
    def save_graph_files() -> None:
        """Export graph as PNG."""
        graph = booking_graph.get_graph()
    
        png_path = AGENTS_DIR / "langgraph_flow.png"
        png_data = graph.draw_mermaid_png()
        png_path.write_bytes(png_data)
        print(f"Saved PNG flow to: {png_path}")
    
    
    if __name__ == "__main__":
        save_graph_files()
    

    Ejecútalo:

    cd clinic-agent
    python save_langgraph_flow.py
    

    Guarda el diagrama en la carpeta agents, lo que es una forma práctica de ver las etapas y sus conexiones de un vistazo antes de confiarles usuarios reales.

    Procesamiento de mensajes

    process_message recibe la entrada desde la interfaz, la procesa a través del grafo y devuelve el estado actualizado junto con la respuesta del asistente. Acepta un ID de hilo y tiene un nombre de sesión por defecto, de modo que cada sesión del navegador o ejecución de cuaderno tenga su propio progreso guardado en el flujo de reserva.

    # agents/booking_agent.py - LangGraph agent implementation
    def process_message(state: BookingState, user_message: str, thread_id: str = "default_session") -> BookingState:
        """Process a user message through the booking graph."""
        config = {"configurable": {"thread_id": thread_id}}
    
        # Check if the graph is currently interrupted
        current_state = booking_graph.get_state(config)
    
        if current_state.tasks and current_state.tasks[0].interrupts:
            # Resume the graph with the user's message
            result = booking_graph.invoke(Command(resume=user_message), config=config)
        else:
            # No interrupt, so start/continue normally
            # Add user message to state (unless it's an initial trigger)
            if user_message.lower() != "hi" or state["messages"]:
                # Avoid duplicate user messages if already added
                if not state["messages"] or state["messages"][-1].get("content") != user_message:
                    state["messages"].append({
                        "role": "user",
                        "content": user_message
                    })
            # Run the graph
            result = booking_graph.invoke(state, config=config)
    
        # Update available_options and ensure message is in history
        snapshot = booking_graph.get_state(config)
        if snapshot.tasks and snapshot.tasks[0].interrupts:
            interrupt_value = snapshot.tasks[0].interrupts[0].value
    
            # Handle both dict and string interrupt values
            msg_content = ""
            options = []
            if isinstance(interrupt_value, dict):
                msg_content = interrupt_value.get("content", "")
                options = interrupt_value.get("available_options", [])
            else:
                msg_content = str(interrupt_value)
    
            # Ensure the interrupt message is in the chat history
            if msg_content:
                # Check if it was already added by the node
                last_msg_content = result["messages"][-1].get("content", "") if result["messages"] else ""
                if last_msg_content != msg_content:
                    result["messages"].append({
                        "role": "assistant",
                        "content": msg_content,
                        "options": options
                    })
                else:
                    # If already added, just update it with options if missing
                    result["messages"][-1]["options"] = options
    
            result["available_options"] = options
        else:
            # If not interrupted, use whatever set in state, or default to empty
            if "available_options" not in result:
                result["available_options"] = []
    
        return result
    

    Prueba del agente

    Un script de prueba controla al agente desde la terminal:

    # test/test_agent.py - test agent
    # Initialize state
    
    from pathlib import Path
    import sys
    
    # Allow running this file directly: `python test/test_agent.py`.
    PROJECT_ROOT = Path(__file__).resolve().parents[1]
    if str(PROJECT_ROOT) not in sys.path:
     sys.path.insert(0, str(PROJECT_ROOT))
    
    from agents.booking_agent import create_initial_state, process_message
    
    
    state = create_initial_state()
    
    # Process messages
    state = process_message(state, "Hi", thread_id="session_1")
    print(state["messages"][-1]["content"])
    
    state = process_message(state, "I want to book", thread_id="session_1")
    print(state["available_options"])
    

    Ejecútalo:

    cd clinic-agent
    python test/test_agent.py
    

    Debería imprimir las opciones disponibles, lo que indica que el gráfico está respondiendo. En este punto toda la interacción funciona desde la terminal, y solo falta la presentación.

    Parte 4: la interfaz de Streamlit

    La interfaz de usuario ofrece una ventana de chat en tiempo real, botones de opciones clickeables y sesiones identificadas por IDs únicos. Los botones son importantes en un flujo como este, ya que elegir una especialidad o un horario con un solo clic es más rápido y menos propenso a errores que escribirlo manualmente.

    # ui/chat_ui.py - Streamlit chatbot interface
    
    """Streamlit UI for the clinic booking chatbot."""
    
    import streamlit as st
    from agents.booking_agent import create_initial_state, process_message
    from data.db import init_db
    
    
    def initialize_session():
        """Initialize session state."""
        if "state" not in st.session_state:
            st.session_state.state = create_initial_state()
        if "initialized" not in st.session_state:
            st.session_state.initialized = False
        if "session_id" not in st.session_state:
            import uuid
            st.session_state.session_id = str(uuid.uuid4())
    
    
    def display_chat_history():
        """Display the chat history with persistent options and styling."""
        messages = st.session_state.state.get("messages", [])
        for i, message in enumerate(messages):
            if message["role"] == "assistant":
                with st.chat_message("assistant"):
                    st.markdown(message["content"])
    
                    # Show options if they exist
                    options = message.get("options", [])
                    if options:
                        # If this is the last message in history, show as clickable buttons
                        if i == len(messages) - 1 and st.session_state.state["stage"] not in ["completed", "cancelled"]:
                            st.markdown("---")
                            # Create columns for buttons
                            cols = st.columns(min(len(options), 3))
                            for idx, option in enumerate(options):
                                col_idx = idx % 3
                                with cols[col_idx]:
                                    if st.button(option, key=f"btn_{i}_{idx}", use_container_width=True):
                                        handle_user_input(option)
                        else:
                            # For older messages, show options as pills/text to keep history
                            options_str = "  ".join([f"`{opt}`" for opt in options])
                            st.markdown(f"**Available options:** {options_str}")
            else:
                with st.chat_message("user"):
                    st.markdown(message["content"])
    
    
    def handle_user_input(user_input: str):
        """Handle user input and process through agent."""
        # Process the message
        st.session_state.state = process_message(
            st.session_state.state,
            user_input,
            thread_id=st.session_state.session_id
        )
    
        # Rerun to update UI
        st.rerun()
    
    
    def run_chat_ui():
        """Run the chat UI."""
        # Page config
        st.set_page_config(
            page_title="CarePlus Clinic - Book Appointment",
            page_icon="🏥",
            layout="centered"
        )
    
        # Custom CSS for distinction between messages
        st.markdown("""
            <style>
            [data-testid="stChatMessageUser"] {
                flex-direction: row-reverse;
                text-align: right;
                background-color: #e0f2f1;
                border-radius: 15px 15px 0px 15px;
            }
            [data-testid="stChatMessageAssistant"] {
                background-color: #f5f5f5;
                border-radius: 15px 15px 15px 0px;
            }
            </style>
        """, unsafe_allow_html=True)
    
        # Initialize database
        init_db()
    
        # Initialize session
        initialize_session()
    
        # Header
        st.title("🏥 CarePlus Clinic")
        st.markdown("*Book your doctor appointment easily*")
        st.markdown("---")
    
        # Send initial greeting if not initialized
        if not st.session_state.initialized:
            st.session_state.state = process_message(
                st.session_state.state,
                "Hi",
                thread_id=st.session_state.session_id
            )
            st.session_state.initialized = True
            st.rerun()
    
        # Display chat history
        display_chat_history()
    
        # Chat input (only show if not completed)
        if st.session_state.state["stage"] not in ["completed", "cancelled"]:
            if prompt := st.chat_input("Type your message here..."):
                handle_user_input(prompt)
        else:
            # Show restart button after completion
            st.markdown("---")
            if st.button("🔄 Start New Booking", use_container_width=True):
                st.session_state.state = create_initial_state()
                st.session_state.initialized = False
                st.rerun()
    

    Parte 5: el punto de entrada

    app.py es el punto de partida que inicia toda la aplicación:

    # app.py - Main entry point for the application
    
    from ui.chat_ui import run_chat_ui
    
    if __name__ == "__main__":
        run_chat_ui()
    

    Dos maneras de ejecutarlo

    Como una aplicación web de Streamlit, que es el método más sencillo:

    streamlit run app.py
    

    Se abre en http://localhost:8501.

    En un cuaderno de Jupyter, adecuado para el desarrollo y las pruebas. Primero importe las funciones:

    # clinic-agent.ipynb
    from services.doctor_service import get_specialities_list, get_doctor_info, generate_time_slots
    from services.booking_service import confirm_booking
    from agents.booking_agent import (
        BookingState,
        create_initial_state,
        build_booking_graph,
        process_message
    )
    

    Construir el grafo y mostrar su estructura:

    # clinic-agent.ipynb
    # Initialize the booking graph
    booking_graph = build_booking_graph()
    
    ## Visualize the booking graph structure
    from IPython.display import Image, display
    
    png_bytes = booking_graph.get_graph().draw_mermaid_png()
    display(Image(png_bytes))
    

    Luego, ejecute una sesión de chat interactiva dentro del cuaderno de notas:

    # clinic-agent.ipynb
    from langgraph.types import Command
    
    def run_booking_session(graph, thread_id="notebook_session", reset=False):
        config = {"configurable": {"thread_id": thread_id}}
    
        # 1. Start or Reset logic
        current_state = graph.get_state(config)
        if reset or not current_state.values:
            print(f"--- {'🔄 Resetting' if reset else '🆕 Initializing'} Session ---")
            # Using invoke() here kicks off the 'greeting' node immediately
            graph.invoke(create_initial_state(), config=config)
    
        print("---⚕⚕ Starting CarePlus Booking Session ---")
    
        last_displayed_message_idx = -1  # Track which messages have been displayed
    
        while True:
            state = graph.get_state(config)
    
            # Display any new assistant messages that haven't been shown yet
            # (This handles guardrail/off-topic responses)
            if state.values and state.values.get('messages'):
                messages = state.values['messages']
                for idx in range(last_displayed_message_idx + 1, len(messages)):
                    msg = messages[idx]
                    if msg.get("role") == "assistant":
                        print(f"\n[AI]: {msg['content']}")
                last_displayed_message_idx = len(messages) - 1
    
            # 2. Check for Interrupts
            if state.tasks and state.tasks[0].interrupts:
                interrupt_info = state.tasks[0].interrupts[0].value
    
                # --- FIX: Safely handle both String and Dict interrupts ---
                if isinstance(interrupt_info, dict):
                    ai_message = interrupt_info.get('content', 'No message content')
                    options = interrupt_info.get('available_options', [])
                else:
                    ai_message = interrupt_info
                    options = []
    
                print(f"\n[AI]: {ai_message}")
                if options:
                    print(f"Options: {', '.join(options)}")
                # -------------------------------------------------------
    
                user_input = input("\n[YOU]: ")
                print(f"[YOU]: {user_input}")
    
                # Resume the graph with the user's input
                graph.invoke(Command(resume=user_input), config=config)
    
            # 3. Check if the graph has finished
            elif not state.next:
                # Before ending, check if there's a final assistant message to print
                if state.values and state.values.get('messages') and state.values['messages'][-1]["role"] == "assistant":
                    if last_displayed_message_idx < len(state.values['messages']) - 1:
                        print(f"\n[AI]: {state.values['messages'][-1]['content']}")
                print("\n--- ⚑⚑ Session Ended ---")
                break
    
            # 4. If nodes are pending but no interrupt, let them run (the gas pedal)
            else:
                graph.invoke(None, config=config)
    
    # IMPORTANT: Set reset=True only when you want to wipe the history.
    # Set it to False to actually continue the conversation!
    run_booking_session(booking_graph, reset=True)
    

    El asistente de cuaderno ejecuta una sesión de reserva en un hilo designado y puede reiniciarse para comenzar de nuevo. En el cuaderno, el cuadro de entrada aparece en la parte superior, por encima de las celdas. El repositorio incluye un cuaderno clinic-agent.ipynb que ejecuta todo el flujo.

    Qué te ofrece el bot finalizado

    El resultado es un chatbot de reservas creado con LangGraph, OpenAI y SQLite, junto con una interfaz de usuario basada en Streamlit. Un flujo de trabajo impulsado por el estado guía a los usuarios desde la selección de una especialidad hasta la confirmación de la cita, y las capas (base de datos, servicios, lógica del agente e interfaz de usuario) permanecen separadas, lo que hace que la aplicación sea modular y fácil de ampliar. Dado que las capas están claramente separadas, cada mejora afecta solo a una de ellas. Entre los siguientes pasos lógicos se encuentran la integración con calendarios, notificaciones, soporte multilingüe y plataformas de mensajería. Es un ejemplo práctico de cómo combinar marcos de trabajo de IA modernos y herramientas web sencillas para crear una aplicación útil en el sector sanitario.

    Leer más

  • Agentes Auto-Mejorables: Curación, Aprendizaje y Cambio Controlado — Una arquitectura para agentes que se recuperan en tiempo de ejecución, aprenden a lo largo de múltiples ejecuciones y solo cambian mediante versiones evaluadas y probadas, utilizando LangGraph, MongoDB y Phoenix.
  • Frameworks de Agentes: Reason, Temporal Survives: Ejecución Duradera para Agentes — LangGraph, CrewAI y AutoGen orquestan las llamadas a los LLM pero evitan caídas o esperas de semanas. Cómo la ejecución duradera de Temporal llena esa brecha, y cuándo resulta excesiva.