From 7e9ce0a98d1dfc1a1f22291d3aef781ac821cda9 Mon Sep 17 00:00:00 2001 From: Andrea Aceto <59835522+andreaceto@users.noreply.github.com> Date: Wed, 23 Jul 2025 12:04:19 +0200 Subject: [PATCH] feat(stack): main app | gradio interface | calendar functionalities | SQLite persistence --- .gitignore | 5 + config.json | 17 ++ run_app.py | 124 +++++++++++++ src/schedulebot/app.py | 75 ++++++++ src/schedulebot/core/calendar_client.py | 175 +++++++++++++++++++ src/schedulebot/core/conversation_manager.py | 47 ----- src/schedulebot/core/tools.json | 74 ++++++++ src/schedulebot/core/tools.py | 98 +++++++++++ 8 files changed, 568 insertions(+), 47 deletions(-) create mode 100644 config.json create mode 100644 run_app.py create mode 100644 src/schedulebot/app.py create mode 100644 src/schedulebot/core/calendar_client.py delete mode 100644 src/schedulebot/core/conversation_manager.py create mode 100644 src/schedulebot/core/tools.json create mode 100644 src/schedulebot/core/tools.py diff --git a/.gitignore b/.gitignore index 568c706..2fec740 100644 --- a/.gitignore +++ b/.gitignore @@ -212,4 +212,9 @@ __marimo__/ # Models models/ + +# Logs logs/ + +# DB +calendar.db diff --git a/config.json b/config.json new file mode 100644 index 0000000..3873fe7 --- /dev/null +++ b/config.json @@ -0,0 +1,17 @@ +{ + "slot_duration_minutes": 30, + "min_gap_minutes": 15, + "max_appointments_per_day": 10, + "working_hours": { + "start": "09:00", + "end": "17:00" + }, + "lunch_break": { + "start": "13:00", + "end": "14:00" + }, + "non_working_days": [ + "Saturday", + "Sunday" + ] +} diff --git a/run_app.py b/run_app.py new file mode 100644 index 0000000..b91cabc --- /dev/null +++ b/run_app.py @@ -0,0 +1,124 @@ +# run_app.py +import gradio as gr +import json +from src.schedulebot.app import ChatbotApp + +# --- Global variable to hold our chatbot instance --- +chatbot_instance = None + + +def save_config( + duration, + gap, + max_daily, + work_start, + work_end, + lunch_start, + lunch_end, + non_working_days, +): + """Saves the user's configuration to config.json and initializes the chatbot.""" + global chatbot_instance + + config = { + "slot_duration_minutes": int(duration), + "min_gap_minutes": int(gap), + "max_appointments_per_day": int(max_daily), + "working_hours": {"start": work_start, "end": work_end}, + "lunch_break": {"start": lunch_start, "end": lunch_end}, + "non_working_days": non_working_days, + } + + with open("config.json", "w") as f: + json.dump(config, f, indent=4) + + print("--- Configuration Saved. Initializing Chatbot... ---") + # Initialize the main app class with the new config + chatbot_instance = ChatbotApp( + nlu_model_repo="andreaceto/schedulebot-nlu-engine", calendar_config=config + ) + print("--- Chatbot Ready ---") + + # Return updates to the Gradio UI + return {setup_box: gr.update(visible=False), chat_box: gr.update(visible=True)} + + +def chat_interface(message, history): + """The function that Gradio will call for each user message.""" + if chatbot_instance: + response = chatbot_instance.process_turn(message) + return response + return "Error: Chatbot not initialized. Please set the configuration first." + + +# --- Build the Gradio UI using Blocks --- +with gr.Blocks(theme=gr.themes.Default(), title="ScheduleBOT+") as demo: + gr.Markdown("# ScheduleBOT+ Configuration") + + # --- State to manage visibility --- + is_configured = gr.State(False) + + # --- Setup Screen (Visible by default) --- + with gr.Group(visible=True) as setup_box: + gr.Markdown("## Calendar Settings") + with gr.Row(): + slot_duration = gr.Number(label="Slot Duration (minutes)", value=30) + min_gap = gr.Number(label="Min Gap Between (minutes)", value=15) + max_appointments = gr.Number(label="Max Appointments per Day", value=10) + gr.Markdown("### Working Hours") + with gr.Row(): + working_start = gr.Textbox(label="Start (HH:MM)", value="09:00") + working_end = gr.Textbox(label="End (HH:MM)", value="17:00") + gr.Markdown("### Lunch Break") + with gr.Row(): + lunch_start = gr.Textbox(label="Start (HH:MM)", value="13:00") + lunch_end = gr.Textbox(label="End (HH:MM)", value="14:00") + + non_working = gr.CheckboxGroup( + label="Non-Working Days", + choices=[ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ], + value=["Saturday", "Sunday"], + ) + + save_button = gr.Button( + "Save Configuration and Start Chatbot", variant="primary" + ) + + # --- Chat Screen (Hidden by default) --- + with gr.Group(visible=False) as chat_box: + gr.ChatInterface( + fn=chat_interface, + title="ScheduleBOT+", + description="An intelligent agent for appointment management.", + examples=[ + ["Is 2 PM tomorrow available?"], + ["I'd like to book a check-up with Dr. Smith."], + ], + ) + + # --- UI Logic --- + save_button.click( + fn=save_config, + inputs=[ + slot_duration, + min_gap, + max_appointments, + working_start, + working_end, + lunch_start, + lunch_end, + non_working, + ], + outputs=[setup_box, chat_box], + ) + +if __name__ == "__main__": + demo.launch() diff --git a/src/schedulebot/app.py b/src/schedulebot/app.py new file mode 100644 index 0000000..3eb9982 --- /dev/null +++ b/src/schedulebot/app.py @@ -0,0 +1,75 @@ +import logging +import json +from src.schedulebot.core.dialogue_manager import DialogueManager +from src.schedulebot.nlu.nlu_processor import NLUProcessor +from src.schedulebot.nlg.rule_based import NLGModule + +# from src.schedulebot.nlg.slm_based import NLGModule +from src.schedulebot.core.tools import ( + initialize_tools, +) # Import the new initializer function + +# --- Setup Logging --- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + filename="chatbot.log", + filemode="a", +) +logger = logging.getLogger(__name__) + + +class ChatbotApp: + """ + Orchestrates the entire NLU -> DM -> NLG pipeline for the chatbot. + """ + + def __init__(self, nlu_model_repo: str, calendar_config: dict): + """ + Initializes all three core modules of the chatbot. + """ + self.nlu_processor = NLUProcessor(multitask_model_repo=nlu_model_repo) + self.dialogue_manager = DialogueManager() + self.nlg_module = NLGModule() + + # --- NEW: Initialize tools with the calendar configuration --- + self.tool_registry = initialize_tools(calendar_config) + + self.conversation_history = [] + logger.info("ChatbotApp initialized successfully with custom configuration.") + + def process_turn(self, user_input: str) -> str: + """ + Processes a single turn of the conversation from user input to bot response. + """ + nlu_output = self.nlu_processor.process(user_input) + logger.info(f"NLU Output: {json.dumps(nlu_output, indent=2)}") + + action = self.dialogue_manager.get_next_action(nlu_output) + logger.info(f"DM Action: {json.dumps(action, indent=2)}") + + # --- NEW: Execute the action using the tool registry --- + tool_name = action.get("action") + + # The NLG module now generates the response based on the action + # For tool execution actions, we first call the tool, then generate a response + if tool_name in self.tool_registry: + try: + tool_function = self.tool_registry[tool_name] + tool_result = tool_function(**action.get("details", {})) + # Create a new action for the NLG module with the result + response_action = { + "action": f"respond_{tool_name}", + "details": {"result": tool_result}, + } + bot_response = self.nlg_module.generate_response(response_action) + except TypeError as e: + logger.error(f"Error calling tool '{tool_name}': {e}") + bot_response = self.nlg_module.generate_response({"action": "fallback"}) + else: + # If it's not a tool, it's a direct NLG action (like greet, confirm, etc.) + bot_response = self.nlg_module.generate_response(action) + + logger.info(f"NLG Response: {bot_response}") + + return bot_response diff --git a/src/schedulebot/core/calendar_client.py b/src/schedulebot/core/calendar_client.py new file mode 100644 index 0000000..db2b813 --- /dev/null +++ b/src/schedulebot/core/calendar_client.py @@ -0,0 +1,175 @@ +import sqlite3 +import datetime + + +class CalendarClient: + """ + A client to interact with a local SQLite database for appointments, + enforcing business rules from a configuration. + """ + + def __init__(self, config, db_path="calendar.db"): + """Initializes the database connection and stores the config.""" + self.config = config + self.db_path = db_path + # Allow the connection to be used across different threads (for Gradio) + self.conn = sqlite3.connect(self.db_path, check_same_thread=False) + self._create_table() + + def _create_table(self): + """Creates the appointments table if it's not already present.""" + cursor = self.conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS appointments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + summary TEXT NOT NULL, + practitioner_name TEXT, + appointment_type TEXT, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL + ); + """ + ) + self.conn.commit() + + def _is_slot_booked(self, start_time, end_time): + """Checks if a specific time range overlaps with any existing appointment.""" + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT COUNT(*) FROM appointments + WHERE (start_time < ? AND end_time > ?) + """, + (end_time.isoformat(), start_time.isoformat()), + ) + return cursor.fetchone()[0] > 0 + + def _respects_minimum_gap(self, start_time, end_time): + """Checks if the slot respects the minimum gap between appointments.""" + gap_minutes = self.config["min_gap_minutes"] + time_before = start_time - datetime.timedelta(minutes=gap_minutes) + time_after = end_time + datetime.timedelta(minutes=gap_minutes) + return not self._is_slot_booked(time_before, time_after) + + def _is_within_working_hours(self, start_time, end_time): + """Checks if the slot is within the defined working hours.""" + working_start = datetime.datetime.strptime( + self.config["working_hours"]["start"], "%H:%M" + ).time() + working_end = datetime.datetime.strptime( + self.config["working_hours"]["end"], "%H:%M" + ).time() + return working_start <= start_time.time() and end_time.time() <= working_end + + def _is_during_lunch_break(self, start_time, end_time): + """Checks if the slot falls within the lunch break.""" + lunch_start = datetime.datetime.strptime( + self.config["lunch_break"]["start"], "%H:%M" + ).time() + lunch_end = datetime.datetime.strptime( + self.config["lunch_break"]["end"], "%H:%M" + ).time() + return not (end_time.time() <= lunch_start or start_time.time() >= lunch_end) + + def _is_on_working_day(self, start_time): + """Checks if the date is a working day.""" + return start_time.strftime("%A") not in self.config["non_working_days"] + + def _is_daily_limit_reached(self, start_time): + """Checks if the maximum number of daily appointments has been reached.""" + cursor = self.conn.cursor() + day_start = start_time.strftime("%Y-%m-%d") + cursor.execute( + "SELECT COUNT(*) FROM appointments WHERE date(start_time) = ?", (day_start,) + ) + return cursor.fetchone()[0] >= self.config["max_appointments_per_day"] + + def check_availability(self, start_time: datetime.datetime): + """ + Checks if a given start time is valid for a new appointment + based on all business rules. + """ + end_time = start_time + datetime.timedelta( + minutes=self.config["slot_duration_minutes"] + ) + + if not self._is_on_working_day(start_time): + return False, "This is a non-working day." + if self._is_daily_limit_reached(start_time): + return ( + False, + "The maximum number of appointments for this day has been reached.", + ) + if not self._is_within_working_hours(start_time, end_time): + return False, "This time is outside of working hours." + if self._is_during_lunch_break(start_time, end_time): + return False, "This time falls within the lunch break." + if self._is_slot_booked(start_time, end_time): + return False, "This time slot is already booked." + if not self._respects_minimum_gap(start_time, end_time): + return False, "This time is too close to another appointment." + + return True, "This time slot is available." + + def book_appointment( + self, + summary: str, + practitioner_name: str, + appointment_type: str, + start_time: datetime.datetime, + ): + """Creates an event in the database after validating availability.""" + end_time = start_time + datetime.timedelta( + minutes=self.config["slot_duration_minutes"] + ) + is_available, reason = self.check_availability(start_time) + if not is_available: + return None, reason + + cursor = self.conn.cursor() + cursor.execute( + """ + INSERT INTO appointments (summary, practitioner_name, appointment_type, start_time, end_time) + VALUES (?, ?, ?, ?, ?) + """, + ( + summary, + practitioner_name, + appointment_type, + start_time.isoformat(), + end_time.isoformat(), + ), + ) + self.conn.commit() + return cursor.lastrowid, "Appointment booked successfully." + + def cancel_appointment(self, appointment_id: int) -> bool: + """Deletes an appointment by its ID. Returns True if successful.""" + cursor = self.conn.cursor() + cursor.execute("DELETE FROM appointments WHERE id = ?", (appointment_id,)) + self.conn.commit() + return cursor.rowcount > 0 + + def reschedule_appointment( + self, appointment_id: int, new_start_time: datetime.datetime + ) -> bool: + """Updates the time for an existing appointment. Returns True if successful.""" + new_end_time = new_start_time + datetime.timedelta( + minutes=self.config["slot_duration_minutes"] + ) + cursor = self.conn.cursor() + cursor.execute( + """ + UPDATE appointments + SET start_time = ?, end_time = ? + WHERE id = ? + """, + (new_start_time.isoformat(), new_end_time.isoformat(), appointment_id), + ) + self.conn.commit() + return cursor.rowcount > 0 + + def __del__(self): + """Ensures the database connection is closed when the object is destroyed.""" + self.conn.close() diff --git a/src/schedulebot/core/conversation_manager.py b/src/schedulebot/core/conversation_manager.py deleted file mode 100644 index dc164c2..0000000 --- a/src/schedulebot/core/conversation_manager.py +++ /dev/null @@ -1,47 +0,0 @@ -from src.schedulebot.nlp.intent_classifier import IntentClassifier -from src.schedulebot.nlp.slot_filler import SlotFiller - - -class ConversationManager: - def __init__(self, nlu_model_repo: str): - """ - Initializes the manager with the NLU models. - """ - self.intent_classifier = IntentClassifier(repo_id=nlu_model_repo) - self.slot_filler = SlotFiller() - - def get_response(self, user_text: str) -> str: - """ - Processes the user's input and returns a text response. - """ - # 1. Classify the intent - intent = self.intent_classifier.predict(user_text) - print(f"[DEBUG: Classified intent: {intent}]") - print(f"[DEBUG: time_slot: {self.slot_filler.parse_time(user_text)}]") - - # 2. Logic based on the intent - if intent == "greet": - return "Hello! How can I help you with your appointments today?" - - if intent == "bye": - return "Goodbye! Have a great day." - - if intent in ["book", "resched"]: - # 3. Extract date and time if required by the intent - time_slot = self.slot_filler.parse_time(user_text) - - action = "book" if intent == "book" else "reschedule" - - if time_slot: - return f"Okay, I see you want to {action} an appointment for {time_slot['value']}. Is that correct?" - else: - return "Sure, but I didn't understand the date and time. Could you please specify when?" - - if intent == "cancel": - return "Okay, I understand you want to cancel an appointment. Can you specify which one?" - - if intent == "avail": - return "I'm checking your availability now. One moment..." - - # Fallback for unhandled intents - return "I'm not sure I understood your request." diff --git a/src/schedulebot/core/tools.json b/src/schedulebot/core/tools.json new file mode 100644 index 0000000..115278c --- /dev/null +++ b/src/schedulebot/core/tools.json @@ -0,0 +1,74 @@ +[ + { + "name": "check_availability", + "description": "Checks the calendar for available appointment slots. Use this when the user asks about availability for a specific time.", + "parameters": { + "type": "object", + "properties": { + "practitioner_name": { + "type": "string", + "description": "The name of the practitioner to check the schedule for." + }, + "date_time": { + "type": "string", + "description": "The specific date or time the user is asking about, in ISO 8601 format." + } + }, + "required": ["date_time"] + } + }, + { + "name": "book_appointment", + "description": "Books a new appointment on the calendar after all required information has been gathered and confirmed.", + "parameters": { + "type": "object", + "properties": { + "practitioner_name": { + "type": "string", + "description": "The name of the practitioner for the appointment." + }, + "appointment_type": { + "type": "string", + "description": "The type of appointment, e.g., 'check-up'." + }, + "date_time": { + "type": "string", + "description": "The specific date and time for the appointment, in ISO 8601 format." + } + }, + "required": ["practitioner_name", "date_time", "appointment_type"] + } + }, + { + "name": "cancel_appointment", + "description": "Cancels an existing appointment using its unique ID.", + "parameters": { + "type": "object", + "properties": { + "appointment_id": { + "type": "string", + "description": "The unique ID of the appointment to cancel, e.g., '#12345'." + } + }, + "required": ["appointment_id"] + } + }, + { + "name": "reschedule_appointment", + "description": "Reschedules an existing appointment to a new time using its unique ID.", + "parameters": { + "type": "object", + "properties": { + "appointment_id": { + "type": "string", + "description": "The unique ID of the appointment to reschedule." + }, + "time": { + "type": "string", + "description": "The new date and time for the appointment, in ISO 8601 format." + } + }, + "required": ["appointment_id", "time"] + } + } +] diff --git a/src/schedulebot/core/tools.py b/src/schedulebot/core/tools.py new file mode 100644 index 0000000..e6686cc --- /dev/null +++ b/src/schedulebot/core/tools.py @@ -0,0 +1,98 @@ +import datetime +from .calendar_client import CalendarClient + + +def check_availability( + calendar: CalendarClient, practitioner_name: str = None, time: str = None +) -> str: + """Checks if a specific time slot is available using the provided calendar client.""" + try: + start_time = datetime.datetime.fromisoformat(time) + is_available, reason = calendar.check_availability(start_time) + return reason + except (TypeError, ValueError): + return "I can check availability, but I'll need a specific date and time." + + +def book_appointment( + calendar: CalendarClient, practitioner_name: str, appointment_type: str, time: str +) -> str: + """Books an appointment in the database using the provided calendar client.""" + try: + start_time = datetime.datetime.fromisoformat(time) + summary = f"{appointment_type} with {practitioner_name}" + + appt_id, reason = calendar.book_appointment( + summary=summary, + practitioner_name=practitioner_name, + appointment_type=appointment_type, + start_time=start_time, + ) + + if appt_id: + return f"Success! Your appointment is booked. The ID is #{appt_id}." + else: + return f"I'm sorry, I couldn't book that. Reason: {reason}" + except Exception as e: + print(f"Error booking appointment: {e}") + return "I'm sorry, I encountered an error while trying to book the appointment." + + +def cancel_appointment(calendar: CalendarClient, appointment_id: str) -> str: + """Cancels an appointment in the database.""" + try: + # Clean up the ID (e.g., remove '#') + clean_id = int(str(appointment_id).strip().replace("#", "")) + if calendar.cancel_appointment(clean_id): + return f"Success! Appointment #{clean_id} has been cancelled." + else: + return f"I'm sorry, I couldn't find an appointment with the ID #{clean_id}." + except (ValueError, TypeError): + return "I can cancel an appointment, but I need a valid appointment ID." + + +def reschedule_appointment( + calendar: CalendarClient, appointment_id: str, time: str +) -> str: + """Reschedules an appointment in the database.""" + try: + clean_id = int(str(appointment_id).strip().replace("#", "")) + new_start_time = datetime.datetime.fromisoformat(time) + + # Check if the new time slot is available before rescheduling + is_available, reason = calendar.check_availability(new_start_time) + if not is_available: + return f"I'm sorry, I can't reschedule to that time. Reason: {reason}" + + if calendar.reschedule_appointment(clean_id, new_start_time): + return f"Success! Appointment #{clean_id} has been rescheduled to {new_start_time.strftime('%I:%M %p on %B %d')}." + else: + return f"I'm sorry, I couldn't find an appointment with the ID #{clean_id} to reschedule." + except Exception as e: + print(f"Error rescheduling: {e}") + return "I'm sorry, I encountered an error. Please provide a valid appointment ID and a full date and time." + + +def initialize_tools(config: dict): + """ + Initializes the CalendarClient with the given config and returns a + registry of tool functions bound to that client. + """ + calendar = CalendarClient(config=config) + + # Use lambda functions to pre-fill the 'calendar' argument for each tool + tool_registry = { + "execute_query_avail": lambda **kwargs: check_availability( + calendar=calendar, **kwargs + ), + "execute_booking": lambda **kwargs: book_appointment( + calendar=calendar, **kwargs + ), + "execute_cancellation": lambda **kwargs: cancel_appointment( + calendar=calendar, **kwargs + ), + "execute_reschedule": lambda **kwargs: reschedule_appointment( + calendar=calendar, **kwargs + ), + } + return tool_registry