diff --git a/Makefile b/Makefile index f6c73a9..561fdab 100644 --- a/Makefile +++ b/Makefile @@ -6,40 +6,45 @@ SHELL := /bin/bash # Default command is 'help' .DEFAULT_GOAL := help +## -------------------------------------- +## Application Commands +## -------------------------------------- + +.PHONY: run +run: ## Run the Gradio application + @echo ">> Starting the ScheduleBOT+ application..." + @python run_app.py + ## -------------------------------------- ## Docker Commands ## -------------------------------------- .PHONY: build -build: ## ๐Ÿ› ๏ธ Build or rebuild the Docker services +build: ## Build or rebuild the Docker services @echo ">> Building services..." @docker-compose build .PHONY: up -up: ## ๐Ÿš€ Start all services in detached mode +up: ## Start backend services (like Duckling) in detached mode @echo ">> Starting services in the background..." @docker-compose up -d .PHONY: down -down: ## ๐Ÿ›‘ Stop and remove all services +down: ## Stop and remove all Docker services @echo ">> Stopping and removing containers..." @docker-compose down -.PHONY: logs -logs: ## ๐Ÿ“œ View real-time logs for all services - @echo ">> Tailing logs (press Ctrl+C to stop)..." - @docker-compose logs -f - -.PHONY: test -test: ## ๐Ÿงช Run pytest inside the app container - @echo ">> Running tests..." - @docker-compose run --rm app pytest ## -------------------------------------- ## Help ## -------------------------------------- .PHONY: help -help: ## ๐Ÿ™‹ Show this help message +help: ## Show this help message @echo "Available commands:" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_-LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + @echo "" + @echo " build Build or rebuild the Docker services" + @echo " up Start backend services (like Duckling) in detached mode" + @echo " run Run the Gradio application" + @echo " down Stop and remove all Docker services" + @echo " help Show this help message" diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index 7431ab1..b0b6eeb 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -27,4 +27,4 @@ COPY .env /app/.env # Command to run the application when the container launches # This will be the main entry point for your Gradio app in the next milestone. # For now, we can use the command-line chat. -CMD ["python", "-m", "src.schedulebot.main"] +CMD ["python", "-m", "run_app.py"] diff --git a/requirements.txt b/requirements.txt index af5ed10..cdb5105 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,4 @@ seqeval accelerate bitsandbytes gradio +pytest diff --git a/run_app.py b/run_app.py index b91cabc..2108958 100644 --- a/run_app.py +++ b/run_app.py @@ -1,4 +1,3 @@ -# run_app.py import gradio as gr import json from src.schedulebot.app import ChatbotApp @@ -39,8 +38,13 @@ def save_config( ) print("--- Chatbot Ready ---") - # Return updates to the Gradio UI - return {setup_box: gr.update(visible=False), chat_box: gr.update(visible=True)} + # Return updates to the Gradio UI to hide the setup and show the chat + return { + # --- NEW: Hide the main title --- + main_title: gr.update(visible=False), + setup_group: gr.update(visible=False), + chat_group: gr.update(visible=True), + } def chat_interface(message, history): @@ -53,13 +57,11 @@ def chat_interface(message, history): # --- 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) + # --- NEW: Create a separate component for the title --- + main_title = gr.Markdown("# ScheduleBOT+ Configuration") # --- Setup Screen (Visible by default) --- - with gr.Group(visible=True) as setup_box: + with gr.Group(visible=True) as setup_group: gr.Markdown("## Calendar Settings") with gr.Row(): slot_duration = gr.Number(label="Slot Duration (minutes)", value=30) @@ -93,13 +95,14 @@ def chat_interface(message, history): ) # --- Chat Screen (Hidden by default) --- - with gr.Group(visible=False) as chat_box: + with gr.Group(visible=False) as chat_group: gr.ChatInterface( fn=chat_interface, title="ScheduleBOT+", description="An intelligent agent for appointment management.", examples=[ - ["Is 2 PM tomorrow available?"], + ["Hi there!"], + ["Is 2 PM tomorrow available for a dental cleaning?"], ["I'd like to book a check-up with Dr. Smith."], ], ) @@ -117,8 +120,10 @@ def chat_interface(message, history): lunch_end, non_working, ], - outputs=[setup_box, chat_box], + # --- NEW: Add the title to the outputs --- + outputs=[main_title, setup_group, chat_group], ) + if __name__ == "__main__": demo.launch() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/schedulebot/app.py b/src/schedulebot/app.py index 3eb9982..edb54ea 100644 --- a/src/schedulebot/app.py +++ b/src/schedulebot/app.py @@ -3,8 +3,6 @@ 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 @@ -32,7 +30,6 @@ def __init__(self, nlu_model_repo: str, calendar_config: dict): 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 = [] @@ -48,22 +45,41 @@ def process_turn(self, user_input: str) -> str: 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}, - } + + if tool_result.get("success"): + # For successful tool calls, use a specific response action + response_action = { + "action": f"respond_{tool_name}", + "details": {"result": tool_result.get("message")}, + } + else: + # Handle failures and suggestions + if tool_result.get("suggestions"): + suggestions_str = ", ".join( + [s.strftime("%I:%M %p") for s in tool_result["suggestions"]] + ) + response_action = { + "action": "suggest_slots", + "details": { + "reason": tool_result.get("message"), + "suggestions": suggestions_str, + }, + } + else: + response_action = { + "action": "inform_failure", + "details": tool_result, + } + bot_response = self.nlg_module.generate_response(response_action) - except TypeError as e: + + except Exception as e: logger.error(f"Error calling tool '{tool_name}': {e}") bot_response = self.nlg_module.generate_response({"action": "fallback"}) else: @@ -71,5 +87,4 @@ def process_turn(self, user_input: str) -> str: 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 index db2b813..4f8cdfa 100644 --- a/src/schedulebot/core/calendar_client.py +++ b/src/schedulebot/core/calendar_client.py @@ -1,5 +1,10 @@ import sqlite3 import datetime +import logging + +# Get the logger instance that is configured in your main app.py +# This ensures all log messages go to the same place (chatbot.log) +logger = logging.getLogger(__name__) class CalendarClient: @@ -12,26 +17,34 @@ 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() + try: + # 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() + logger.info(f"Successfully connected to database at {self.db_path}") + except sqlite3.Error as e: + logger.error(f"Error connecting to database: {e}") + self.conn = None def _create_table(self): """Creates the appointments table if it's not already present.""" - cursor = self.conn.cursor() - cursor.execute( + try: + 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 + ); """ - 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() + ) + self.conn.commit() + except sqlite3.Error as e: + logger.error(f"Error creating appointments table: {e}") def _is_slot_booked(self, start_time, end_time): """Checks if a specific time range overlaps with any existing appointment.""" @@ -125,31 +138,72 @@ def book_appointment( ) is_available, reason = self.check_availability(start_time) if not is_available: + logger.warning(f"Booking failed for '{summary}' at {start_time}: {reason}") 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." + try: + 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() + appt_id = cursor.lastrowid + logger.info(f"Successfully booked appointment ID {appt_id}: {summary}") + return appt_id, "Appointment booked successfully." + except sqlite3.Error as e: + logger.error(f"Database error during booking: {e}") + return None, "A database error occurred." + + def find_available_slots(self, start_date: datetime.date): + """ + Finds the first few available slots for a given day. + """ + available_slots = [] + working_start_time = datetime.datetime.strptime( + self.config["working_hours"]["start"], "%H:%M" + ).time() + working_end_time = datetime.datetime.strptime( + self.config["working_hours"]["end"], "%H:%M" + ).time() + current_slot_start = datetime.datetime.combine(start_date, working_start_time) + day_end = datetime.datetime.combine(start_date, working_end_time) + while current_slot_start < day_end: + is_available, reason = self.check_availability(current_slot_start) + if is_available: + available_slots.append(current_slot_start) + if len(available_slots) >= 3: + break + current_slot_start += datetime.timedelta( + minutes=self.config["slot_duration_minutes"] + ) + return available_slots 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 + try: + cursor = self.conn.cursor() + cursor.execute("DELETE FROM appointments WHERE id = ?", (appointment_id,)) + self.conn.commit() + success = cursor.rowcount > 0 + if success: + logger.info(f"Successfully cancelled appointment ID {appointment_id}") + else: + logger.warning( + f"Attempted to cancel non-existent appointment ID {appointment_id}" + ) + return success + except sqlite3.Error as e: + logger.error( + f"Database error during cancellation for ID {appointment_id}: {e}" + ) + return False def reschedule_appointment( self, appointment_id: int, new_start_time: datetime.datetime @@ -158,18 +212,31 @@ def reschedule_appointment( 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 + try: + 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() + success = cursor.rowcount > 0 + if success: + logger.info( + f"Successfully rescheduled appointment ID {appointment_id} to {new_start_time}" + ) + else: + logger.warning( + f"Attempted to reschedule non-existent appointment ID {appointment_id}" + ) + return success + except sqlite3.Error as e: + logger.error( + f"Database error during reschedule for ID {appointment_id}: {e}" + ) + return False def __del__(self): """Ensures the database connection is closed when the object is destroyed.""" - self.conn.close() + if self.conn: + self.conn.close() + logger.info("Database connection closed.") diff --git a/src/schedulebot/core/tools.py b/src/schedulebot/core/tools.py index e6686cc..c52ecf9 100644 --- a/src/schedulebot/core/tools.py +++ b/src/schedulebot/core/tools.py @@ -1,23 +1,48 @@ import datetime +import logging from .calendar_client import CalendarClient +# Get the logger instance that is configured in your main app.py +logger = logging.getLogger(__name__) + 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.""" +) -> dict: + """Checks availability and returns a structured result.""" + logger.info( + f"Executing tool: check_availability with params: practitioner='{practitioner_name}', time='{time}'" + ) + if not time: + logger.warning("check_availability called without a time parameter.") + return { + "success": False, + "message": "A specific date and time are required to check availability.", + } 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." + + if is_available: + return {"success": True, "message": reason} + else: + suggestions = calendar.find_available_slots(start_time.date()) + return {"success": False, "message": reason, "suggestions": suggestions} + except (TypeError, ValueError) as e: + logger.error(f"Invalid time format in check_availability: '{time}'. Error: {e}") + return { + "success": False, + "message": "That doesn't seem to be a valid date and time format.", + } 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.""" +) -> dict: + """Books an appointment and returns a structured result.""" + logger.info( + f"Executing tool: book_appointment with params: practitioner='{practitioner_name}', type='{appointment_type}', time='{time}'" + ) try: start_time = datetime.datetime.fromisoformat(time) summary = f"{appointment_type} with {practitioner_name}" @@ -30,57 +55,85 @@ def book_appointment( ) if appt_id: - return f"Success! Your appointment is booked. The ID is #{appt_id}." + return {"success": True, "message": reason, "appointment_id": appt_id} else: - return f"I'm sorry, I couldn't book that. Reason: {reason}" + suggestions = calendar.find_available_slots(start_time.date()) + return {"success": False, "message": reason, "suggestions": suggestions} except Exception as e: - print(f"Error booking appointment: {e}") - return "I'm sorry, I encountered an error while trying to book the appointment." + logger.error(f"Unhandled error in book_appointment: {e}") + return { + "success": False, + "message": "I encountered an internal error while booking.", + } -def cancel_appointment(calendar: CalendarClient, appointment_id: str) -> str: - """Cancels an appointment in the database.""" +def cancel_appointment(calendar: CalendarClient, appointment_id: str) -> dict: + """Cancels an appointment and returns a structured result.""" + logger.info(f"Executing tool: cancel_appointment with ID: {appointment_id}") 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." + return { + "success": True, + "message": f"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." + return { + "success": False, + "message": f"I couldn't find an appointment with the ID #{clean_id}.", + } + except (ValueError, TypeError) as e: + logger.error( + f"Invalid appointment_id format in cancel_appointment: '{appointment_id}'. Error: {e}" + ) + return { + "success": False, + "message": "That doesn't seem to be a valid appointment ID.", + } def reschedule_appointment( calendar: CalendarClient, appointment_id: str, time: str -) -> str: - """Reschedules an appointment in the database.""" +) -> dict: + """Reschedules an appointment and returns a structured result.""" + logger.info( + f"Executing tool: reschedule_appointment with ID: {appointment_id}, new time: {time}" + ) 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}" + return { + "success": False, + "message": f"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')}." + return { + "success": True, + "message": f"Appointment #{clean_id} has been rescheduled.", + } else: - return f"I'm sorry, I couldn't find an appointment with the ID #{clean_id} to reschedule." + return { + "success": False, + "message": f"I couldn't find appointment #{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." + logger.error(f"Unhandled error in reschedule_appointment: {e}") + return { + "success": False, + "message": "I encountered an error. Please provide a valid 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. + Initializes the CalendarClient and returns a registry of tool functions. """ 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 diff --git a/src/schedulebot/main.py b/src/schedulebot/main.py deleted file mode 100644 index 46ee214..0000000 --- a/src/schedulebot/main.py +++ /dev/null @@ -1,30 +0,0 @@ -from src.schedulebot.core.conversation_manager import ConversationManager -import os -from dotenv import load_dotenv - - -def main(): - """ - Main loop to interact with the chatbot from the command line. - """ - # Load environment variables - load_dotenv() - repo_id = os.getenv("HUB_MODEL_ID") - - print("Initializing ConversationManager...") - manager = ConversationManager(nlu_model_repo=repo_id) - - print("\nScheduleBOT+ is active! Type 'exit' to quit.") - print("--------------------------------------------------") - - while True: - user_input = input("You: ") - if user_input.lower() == "exit": - break - - bot_response = manager.get_response(user_input) - print(f"Bot: {bot_response}") - - -if __name__ == "__main__": - main() diff --git a/src/schedulebot/nlg/rule_based.py b/src/schedulebot/nlg/rule_based.py index d8fed88..2f8ce83 100644 --- a/src/schedulebot/nlg/rule_based.py +++ b/src/schedulebot/nlg/rule_based.py @@ -33,8 +33,8 @@ def __init__(self): "To confirm, we are rescheduling appointment {appointment_id} to {time}. Is this correct?", ], "confirm_cancellation": [ - "Are you sure you want to cancel appointment {appointment_id}?", - "Just to double-check, you'd like to cancel your appointment with ID {appointment_id}. Is that right?", + "Are you sure you want to cancel appointment #{appointment_id}?", + "Just to double-check, you'd like to cancel your appointment with ID #{appointment_id}. Is that right?", ], "request_information": [ "To proceed, I'll need a bit more information. Could you please provide the {missing_slots}?", @@ -44,21 +44,31 @@ def __init__(self): "Okay, I've cancelled that request. Is there anything else I can help you with?", "No problem, that action has been cancelled. What else can I do for you?", ], - "execute_booking": [ - "All set! Your appointment is booked. Your appointment ID is {appointment_id}.", - "Great, you are confirmed. Your new appointment ID is {appointment_id}.", + # --- Templates for successful tool actions --- + "respond_execute_booking": [ + "All set! Your appointment is booked. {result}", + "Great, you are confirmed. {result}", ], - "execute_cancellation": [ - "Okay, your appointment with ID {appointment_id} has been successfully cancelled.", - "I have now cancelled appointment {appointment_id} for you.", + "respond_execute_cancellation": [ + "Okay, I've processed that for you. {result}", + "Done. {result}", ], - "execute_reschedule": [ - "Done! Your appointment has been successfully rescheduled to {time}.", - "I've updated your appointment. The new time is {time}.", + "respond_execute_reschedule": [ + "The appointment has been updated. {result}", + "All set. {result}", ], - "execute_query_avail": [ - "Let me check the schedule. It looks like we have an opening at 4 PM tomorrow.", - "Checking the calendar now... Yes, there is an available slot at 4 PM tomorrow.", + "respond_execute_query_avail": [ + "Here is the availability I found: {result}", + "Let's see... {result}", + ], + # --- Templates for suggestions and failures --- + "suggest_slots": [ + "I'm sorry, but that time is unavailable because: {reason}. However, here are some open slots for that day: {suggestions}.", + "Unfortunately, that time won't work ({reason}). You could try one of these times instead: {suggestions}.", + ], + "inform_failure": [ + "I'm sorry, I was unable to complete your request. Reason: {message}", + "Apologies, but I ran into an issue: {message}", ], "fallback": [ "I'm sorry, I didn't quite understand that. Could you please rephrase?", @@ -86,7 +96,8 @@ def generate_response(self, action: dict) -> str: if action_type == "execute_booking": details["appointment_id"] = generate_appointment_id() - return template.format(**details) + # Use .get() to avoid errors if a key is missing + return template.format(**{k: details.get(k, f"{{{k}}}") for k in details}) else: # If the action is unknown, use the fallback return random.choice(self.templates["fallback"]) diff --git a/src/schedulebot/nlg/slm_based.py b/src/schedulebot/nlg/slm_based.py index 908d7ae..719b9da 100644 --- a/src/schedulebot/nlg/slm_based.py +++ b/src/schedulebot/nlg/slm_based.py @@ -2,6 +2,8 @@ from transformers import AutoModelForCausalLM, AutoTokenizer from data.appointment_id_generator import generate_appointment_id +# This version is outdated and was discarded due to low resources available to run better models + class NLGModule: """ diff --git a/tests/test_dialogue_manager.py b/tests/test_dialogue_manager.py new file mode 100644 index 0000000..a30ae9e --- /dev/null +++ b/tests/test_dialogue_manager.py @@ -0,0 +1,104 @@ +import unittest +import sys +import os + +# Add the 'src' directory to the Python path to allow for imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.schedulebot.core.dialogue_manager import DialogueManager + + +class TestDialogueManager(unittest.TestCase): + def setUp(self): + """This method is called before each test.""" + self.manager = DialogueManager() + + def test_01_handle_greeting(self): + """Tests if the manager returns a simple 'greet' action.""" + nlu_output = {"intent": {"name": "greeting"}} + action = self.manager.get_next_action(nlu_output) + self.assertEqual(action["action"], "greet") + + def test_02_schedule_request_missing_info(self): + """Tests if the manager asks for missing info when scheduling.""" + nlu_output = { + "intent": {"name": "schedule"}, + "entities": [{"entity": "practitioner_name", "value": "Dr. Smith"}], + } + action = self.manager.get_next_action(nlu_output) + self.assertEqual(action["action"], "request_information") + # The DM should identify that 'time' and 'appointment_type' are missing + self.assertIn("time", action["missing_slots"]) + self.assertIn("appointment_type", action["missing_slots"]) + + def test_03_schedule_request_all_info_present(self): + """Tests if the manager moves to confirmation when all info is provided.""" + nlu_output = { + "intent": {"name": "schedule"}, + "entities": [ + {"entity": "practitioner_name", "value": "Dr. Smith"}, + {"entity": "time", "value": "2025-08-01T14:00:00-07:00"}, + {"entity": "appointment_type", "value": "check-up"}, + ], + } + action = self.manager.get_next_action(nlu_output) + self.assertEqual(action["action"], "confirm_booking") + self.assertEqual(self.manager.state["pending_action"], "confirm_booking") + self.assertIn("Dr. Smith", self.manager.state["pending_details"].values()) + + def test_04_user_confirms_pending_action(self): + """Tests if the manager executes the action after a positive reply.""" + # First, set up a pending action + self.manager.state["pending_action"] = "confirm_booking" + self.manager.state["pending_details"] = {"practitioner_name": "Dr. Smith"} + + # Now, simulate the user saying "yes" + nlu_output = {"intent": {"name": "positive_reply"}} + action = self.manager.get_next_action(nlu_output) + + self.assertEqual(action["action"], "execute_booking") + self.assertEqual(action["details"]["practitioner_name"], "Dr. Smith") + # Ensure the state was reset after the action + self.assertIsNone(self.manager.state["pending_action"]) + + def test_05_user_cancels_pending_action(self): + """Tests if the manager cancels the action after a negative reply.""" + # Set up a pending action + self.manager.state["pending_action"] = "confirm_cancellation" + + # Simulate the user saying "no" + nlu_output = {"intent": {"name": "negative_reply"}} + action = self.manager.get_next_action(nlu_output) + + self.assertEqual(action["action"], "cancel_action") + # Ensure the state was reset + self.assertIsNone(self.manager.state["pending_action"]) + + def test_06_handle_multi_turn_request(self): + """Tests the full flow of asking for and receiving missing info.""" + # Turn 1: User asks to cancel, but gives no ID + nlu_turn1 = {"intent": {"name": "cancel"}} + action1 = self.manager.get_next_action(nlu_turn1) + + self.assertEqual(action1["action"], "request_information") + self.assertEqual(action1["missing_slots"], ["appointment_id"]) + # Check that the manager is now waiting for an appointment_id + self.assertEqual(self.manager.state["awaiting_slot"], "appointment_id") + self.assertEqual(self.manager.state["pending_action"], "cancel") + + # Turn 2: User provides the missing appointment ID + nlu_turn2 = { + "intent": {"name": "inform"}, + "entities": [{"entity": "appointment_id", "value": "#12345"}], + } + action2 = self.manager.get_next_action(nlu_turn2) + + # The manager should now have all info and move to confirmation + self.assertEqual(action2["action"], "confirm_cancellation") + self.assertEqual(action2["details"]["appointment_id"], "#12345") + # Ensure the await state is cleared + self.assertIsNone(self.manager.state["awaiting_slot"]) + + +if __name__ == "__main__": + unittest.main()