Головна / Статті

Чат-бот для бронювання в клініці з 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. Робочий процес, заснований на станах, допомагає користувачам вибрати спеціалізацію та підтвердити час зустрічі, причому шари (база даних, сервіси, логіка агента та інтерфейс) залишаються окремими, що робить додаток модульним та легким для розширення. Оскільки шари чітко розділені, кожне покращення впливає лише на один з них. Серед природних наступних кроків — інтеграція з календарем, сповіщення, підтримка кількох мов та платформи для обміну повідомленнями. Це практичний приклад поєднання сучасних фреймворків штучного інтелекту та простих веб-інструментів у корисний медичний додаток.