Главная / Статьи

Чат-бот для бронирования в клинике с использованием LangGraph, SQLite и Streamlit: пошаговая инструкция

Создайте помощника для бронирования приёмов в клинике: таблицы SQLite, функции обслуживания, рабочий процесс LangGraph из девяти узлов с использованием gpt-4o-mini, интерфейс чата в формате Streamlit и режим ноутбука.

Издатель: REACTAPP.TOP17 мин чтения3855 слов

Источники: Agentic AI Project: Build a Customer Service Chatbot for a Clinic

Управление клиникой требует множества утомительных процедур координации: подбора пациентов к врачам, отслеживания свободного времени врачей и избежания двойных записей. Вспомогательный ассистент может взять на себя эту часть работы, связанную с назначением встреч. В рамках этого проекта создается такой ассистент из четырех компонентов: LangGraph для управления многоэтапным диалогом в виде автомата состояний, gpt-4o-mini от OpenAI для понимания того, что пишут люди, SQLite для хранения данных и Streamlit для интерфейса чата.

Проблема и план

Многие клиники по-прежнему полагаются на ручные процессы или смешанный набор инструментов. В результате возникают длительные ожидания, конфликты в расписании, неравномерная нагрузка сотрудников и негативный опыт пациентов, которым бывает трудно найти свободного врача или специалиста нужной специальности. Клиникам необходима автоматизированная система, которая бы осуществляла бронирование через естественный диалог, одновременно ведя точную запись информации о врачах, пациентах и записях.

Чат-бот направляет пользователя по краткому сценарию:

  1. Он приветствует пользователя и спрашивает, нужна ли ему запись.
  2. Он перечисляет доступные медицинские специальности.
  3. Он показывает врача и свободные временные слоты.
  4. Он собирает имя пациента и номер телефона.
  5. Он подтверждает бронирование и сохраняет его в базе данных.

Преимущества заключаются в снижении административной нагрузки, более быстром бронировании, отсутствии конфликтующих записей и более плавном взаимодействии с пациентом. Готовый код разделен на папки для данных, сервисов, агента и интерфейса. Каждый компонент выполняет узкоспециализированную функцию. LangGraph определяет, какой шаг разговора следует выполнить дальше, модель интерпретирует свободный текст, SQLite запоминает кого и когда, а Streamlit отображает всё это пользователю в браузере. Разделение этих функций позволяет изменять одну часть, например интерфейс, не затрагивая остальные.

Настройка

Создайте виртуальную среду. Команда одинакова на Mac, Linux и Windows:

python -m venv .venv

Активируйте его на Mac или Linux:

source .venv/bin/activate

В Windows с PowerShell:

.venv\Scripts\Activate.ps1

В командной строке Windows:

.venv\Scripts\activate

После активации команда терминала начинается с названия среды, например так:

(.venv) your-folder-name %

Далее идут зависимости. В файле requirements.txt указаны Streamlit, python-dotenv, pydantic, pandas и другие библиотеки:

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

Установите их из папки проекта:

cd clinic-agent
pip install -r requirements.txt

Наконец, создайте файл .env в каталоге clinic-agent, в котором будет храниться ваш ключ OpenAI:

OPENAI_API_KEY=your_openai_api_key_here

Часть 1: база данных (data/db.py)

Система бронирования основывается на своем слое данных. Клиника использует SQLite с тремя таблицами для врачей, клиентов и бронирований. Схема является реляционной, что облегчает выполнение запросов во время общения: одно бронирование указывает на одного врача и одного клиента, поэтому один запрос позволяет узнать, кто принимает кого и когда. Номера телефонов служат практическим идентификатором пациента, поскольку у повторно звонящего человека не должно появляться дублирующихся записей.

Врачи. Каждая строка представляет собой врача с его специальностью и рабочими часами, что позволяет чат-боту предложить подходящего специалиста после выбора специальности. Поля — doctor_id (уникальный идентификатор), doctor_name, speciality (например, Дерматолог или Педиатр) и office_timing (часы работы врача). В демо-версии представлены пять специальностей: Дерматолог, Ортопед, Терапевт, Педиатр и ЛОР-врач; для каждой указаны вымышленные имена и часы работы. Добавить новых специалистов очень просто.

Клиенты. В этой таблице хранятся данные, необходимые для бронирования. Каждый раз, когда кто-то делает бронирование, система находит клиента по номеру телефона и создает новую запись, если таковой ещё нет. Поля — customer_id, name и phone.

Бронирования. Центральная таблица. Каждая строка связывает врача и клиента с конкретной датой и временем. Её поля — booking_id, doctor_id и customer_id (ссылки на две другие таблицы), appointment_date, appointment_time и status, такой как Confirmed или Cancelled.

Скрипт определяет эти таблицы и создаёт файл 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.")

Запустите его из папки проекта:

cd clinic-agent
python data/db.py

Это создаёт файл clinic.db с тремя таблицами. Повторная загрузка скрипта — быстрый способ сбросить примерные данные во время экспериментов.

Часть 2: слой сервисов

Сервисы хранят бизнес-логику между агентом и базой данных. Агент LangGraph не выполняет запросы к таблицам напрямую. Он вызывает функции сервисов для получения информации о врачах, генерации временных слотов и подтверждения бронирований. В архитектуре агента эти функции выступают в роли инструментов, позволяя агенту сосредоточиться на ходе разговора. Такое разделение также позволяет тестировать логику отдельно. Вы можете проверять генерацию слотов и процесс бронирования, даже не вызывая языковую модель, именно это и делает следующий небольшой скрипт.

Служба врачей (services/doctor_service.py) обрабатывает специальности, врачей и временные интервалы. Она возвращает из базы данных список специальностей, находит врачей для выбранной специальности, формирует временные интервалы на основе рабочего времени врача и преобразует время отображения, такое как 2:00 PM, в формат 24 часов для хранения.

# 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}"

Служба бронирования (services/booking_service.py) обрабатывает информацию о клиентах и бронированиях. Она находит существующего клиента по номеру телефона или создает нового, определяет свободные временные слоты у врача на указанную дату, исключает уже занятые слоты, генерирует уникальный идентификатор бронирования и сохраняет окончательные данные встречи: врач, пациент, дата и время.

# 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

Проверка сервисов

Небольшой скрипт тестирует оба сервиса:

# 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}")

Запустите его:

cd clinic-agent
python test_service.py

Терминал выводит, среди прочего, список специальностей и свободные слоты для врача:

(.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

Если всё выглядит правильно, сервисы работают, и можно приступать к интересной части. Просмотр реальных специальностей и конкретных временных слотов в результатах подтверждает, что база данных, логика работы врачей и логика бронирования полностью согласованы друг с другом ещё до того, как к ним добавляется логика ведения диалога.

Часть 3: агент

Сердцем чат-бота является агент LangGraph, который прослеживает процесс бронирования на нескольких этапах.

Статус бронирования

Объект статуса представляет собой структурированную память для диалога. Он фиксирует текущий этап, например приветствие или выбор врача, запоминает сделанные выборы, такие как специальность, врач и время приёма, а также хранит данные клиента и бронирования, чтобы агент мог завершить процедуру записи.

# 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": []
    }

Помощник LLM

Одна функция call_llm централизует взаимодействие с моделью OpenAI. Она отправляет системные и пользовательские запросы, возвращает сгенерированный текст и обеспечивает единообразие и возможность повторного использования каждого вызова модели.

# 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 ""

Узлы

Каждый узел представляет собой этап в процессе бронирования. Узлы обрабатывают соответствующую стадию, принимают ввод от пользователя и обновляют состояние, что делает процесс модульным. Их всего девять:

  • greeting_node открывает чат и спрашивает, хочет ли пользователь записаться на прием.
  • select_speciality_node показывает список доступных специальностей.
  • select_doctor_node представляет врачей из выбранной специальности.
  • select_date_node спрашивает, какой день подходит пользователю.
  • select_slot_node показывает свободные временные слоты в этот день.
  • confirm_node просит пользователя подтвердить выбор.
  • collect_details_node запрашивает имя и номер телефона.
  • completed_node завершает успешное бронирование.
  • cancelled_node предназначен для обработки случаев, когда пользователь отказывается от заказа.
  • Создание графа

    Функция build_booking_graph добавляет эти узлы, определяет условные переходы между ними и тем самым задаёт общий путь развития диалога.

    # 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()
    

    Короткий скрипт преобразует график в изображение:

    #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()
    

    Запустите его:

    cd clinic-agent
    python save_langgraph_flow.py
    

    Он сохраняет диаграмму в папку agents, что является удобным способом быстро увидеть этапы и их связи перед тем, как предоставить их реальным пользователям.

    Обработка сообщений

    process_message принимает входные данные с интерфейса, обрабатывает их с помощью графа и возвращает обновленное состояние вместе с ответом ассистента. Он принимает идентификатор потока и имеет значение по умолчанию для имени сессии, так что каждая браузерная сессия или запуск ноутбука сохраняет свой собственный прогресс в процессе бронирования.

    # 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
    

    Тестирование агента

    Скрипт тестирования управляет агентом из терминала:

    # 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"])
    

    Запустите его:

    cd clinic-agent
    python test/test_agent.py
    

    Он должен вывести доступные опции, что свидетельствует о том, что график реагирует. На этом этапе вся работа осуществляется из терминала, не хватает лишь интерфейса отображения результатов.

    Часть 4: интерфейс Streamlit

    Интерфейс предоставляет окно чата в реальном времени, кнопки с опциями для клика и сессии, идентифицируемые уникальными ID. Кнопки крайне важны в таком подходе, поскольку выбор специальности или временного интервала одним кликом происходит быстрее и с меньшим количеством ошибок, чем путем ввода текста.

    # 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()
    

    Часть 5: точка входа

    app.py — это отправная точка, которая запускает всё приложение:

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

    Два способа запуска

    Как веб-приложение Streamlit, что является самым простым вариантом:

    streamlit run app.py
    

    Он открывается по адресу http://localhost:8501.

    В Jupyter Notebook, который подходит для разработки и экспериментов. Сначала импортируйте функции:

    # 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
    )
    

    Создайте граф и отобразите его структуру:

    # 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))
    

    Затем запустите интерактивную сессию чата внутри ноутбука:

    # 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)
    

    Вспомогательный инструмент для ноутбука запускает сессию бронирования на определённом потоке и может быть сброшен для повторного запуска. В ноутбуке поле ввода находится сверху, над ячейками. В репозитории присутствует ноутбук clinic-agent.ipynb, который выполняет весь процесс.

    Что даёт готовый бот

    Результатом стал чат-бот для бронирования, созданный с использованием LangGraph, OpenAI и SQLite с фронтендом на Streamlit. Рабочий процесс, основанный на управлении состоянием, направляет пользователей от выбора специальности к подтверждению времени визита, причем слои (база данных, сервисы, логика агента и интерфейс) остаются разделенными, что делает приложение модульным и удобным для расширения. Благодаря четкому разделению слоев каждое усовершенствование затрагивает только один из них. К следующим логичным шагам относятся интеграция с календарем, уведомления, мультиязычная поддержка и платформы для обмена сообщениями. Это практический пример сочетания современных фреймворков ИИ и простых веб-инструментов в полезное медицинское приложение.